Repository: mlemgroup/mlem Branch: dev Commit: 5c6773c6ff89 Files: 1229 Total size: 3.5 MB Directory structure: gitextract__gplox9q/ ├── .git-hooks/ │ └── pre-commit ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ └── improvement-proposal.yml │ ├── actions/ │ │ └── ci_xcodebuild/ │ │ └── action.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── ci_build_26.yml │ └── ci_lint.yml ├── .gitignore ├── .gitmodules ├── .periphery.yml ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── Additional Documents/ │ ├── EULA.md │ └── Privacy.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── Mlem/ │ ├── App/ │ │ ├── Actions/ │ │ │ ├── ActionSeed+Extensions.swift │ │ │ ├── ActionSeedSwipeConfiguration.swift │ │ │ ├── ActionSheet/ │ │ │ │ ├── ActionSheet.swift │ │ │ │ └── CustomizableContextMenu.swift │ │ │ ├── AppointAdminAction.swift │ │ │ ├── AppointModeratorAction.swift │ │ │ ├── BanAction.swift │ │ │ ├── BlockAction.swift │ │ │ ├── CollapseAction.swift │ │ │ ├── CollapseParentAction.swift │ │ │ ├── CollapseToTopAction.swift │ │ │ ├── ContextMenu+Comment.swift │ │ │ ├── ContextMenu+Community.swift │ │ │ ├── ContextMenu+InboxNotification.swift │ │ │ ├── ContextMenu+Instance.swift │ │ │ ├── ContextMenu+Message.swift │ │ │ ├── ContextMenu+Person.swift │ │ │ ├── ContextMenu+Post.swift │ │ │ ├── CopyNameAction.swift │ │ │ ├── CreateImageAction.swift │ │ │ ├── CrosspostAction.swift │ │ │ ├── DeleteAction.swift │ │ │ ├── EditAction.swift │ │ │ ├── EditNoteAction.swift │ │ │ ├── FavoriteAction.swift │ │ │ ├── GoToInstanceAction.swift │ │ │ ├── HideAction.swift │ │ │ ├── LockAction.swift │ │ │ ├── LogInAction.swift │ │ │ ├── MarkNsfwAction.swift │ │ │ ├── MarkReadAction.swift │ │ │ ├── NewPostAction.swift │ │ │ ├── OpenInBrowserAction.swift │ │ │ ├── OpenModlogAction.swift │ │ │ ├── PinAction.swift │ │ │ ├── PurgeAction.swift │ │ │ ├── RemoveAction.swift │ │ │ ├── ReplyAction.swift │ │ │ ├── ReportAction.swift │ │ │ ├── ResolveAction.swift │ │ │ ├── SaveAction.swift │ │ │ ├── SelectTextAction.swift │ │ │ ├── SendMessageAction.swift │ │ │ ├── ShareAction.swift │ │ │ ├── SignUpAction.swift │ │ │ ├── SubscribeAction.swift │ │ │ ├── ViewVotesAction.swift │ │ │ ├── VisitAction.swift │ │ │ └── VoteAction.swift │ │ ├── Configuration/ │ │ │ ├── Colors/ │ │ │ │ ├── Palette+Dracula.swift │ │ │ │ ├── Palette+Monochrome.swift │ │ │ │ ├── Palette+Oled.swift │ │ │ │ └── Palette+Solarized.swift │ │ │ ├── Constants/ │ │ │ │ ├── Constants.swift │ │ │ │ └── Platform Constants/ │ │ │ │ ├── PadConstants.swift │ │ │ │ ├── PhoneConstants.swift │ │ │ │ └── PlatformConstants.swift │ │ │ ├── Icons.swift │ │ │ └── User Settings/ │ │ │ ├── PinnedSortTracker.swift │ │ │ ├── SettingPropertyWrapper.swift │ │ │ ├── Settings.swift │ │ │ └── SettingsValues.swift │ │ ├── Data/ │ │ │ ├── Document.swift │ │ │ ├── EULA.swift │ │ │ ├── Licenses.swift │ │ │ └── Privacy Policy.swift │ │ ├── Enums/ │ │ │ ├── AnimatedAvatarBehavior.swift │ │ │ ├── AvatarType.swift │ │ │ ├── CommentJumpButtonLocation.swift │ │ │ ├── InstanceSort.swift │ │ │ ├── Interaction/ │ │ │ │ ├── ActionSeedSections.swift │ │ │ │ ├── CommentBarConfiguration+Types.swift │ │ │ │ ├── CommentBarConfiguration.swift │ │ │ │ ├── CommunityActionConfiguration.swift │ │ │ │ ├── ContextMenuConfiguration.swift │ │ │ │ ├── InteractionBarConfiguration.swift │ │ │ │ ├── PostBarConfiguration+Types.swift │ │ │ │ ├── PostBarConfiguration.swift │ │ │ │ ├── ReplyBarConfiguration+Types.swift │ │ │ │ ├── ReplyBarConfiguration.swift │ │ │ │ └── SwipeActionConfiguration.swift │ │ │ ├── MlemError.swift │ │ │ ├── NsfwBlurBehavior.swift │ │ │ ├── PersonFlair.swift │ │ │ ├── PostViewLinkType.swift │ │ │ ├── ReadPostIndicator.swift │ │ │ ├── TabBarLongPressAction.swift │ │ │ └── ZoomSliderLocation.swift │ │ ├── Globals/ │ │ │ ├── Definitions/ │ │ │ │ ├── AccountsTracker.swift │ │ │ │ ├── AppState+transition.swift │ │ │ │ ├── AppState.swift │ │ │ │ ├── ErrorsTracker.swift │ │ │ │ ├── FiltersTracker.swift │ │ │ │ ├── PaletteOption.swift │ │ │ │ ├── PersistenceRepository.swift │ │ │ │ └── TabReselectTracker.swift │ │ │ └── Dependencies/ │ │ │ └── PersistenceRepository+Dependency.swift │ │ ├── Legacy/ │ │ │ └── LegacySettings.swift │ │ ├── Logic/ │ │ │ ├── Animations.swift │ │ │ ├── HandleError.swift │ │ │ ├── ImageFunctions.swift │ │ │ ├── ImageSaver.swift │ │ │ └── Networking/ │ │ │ └── InternetConnectionManager.swift │ │ ├── Models/ │ │ │ ├── Account/ │ │ │ │ ├── Account.swift │ │ │ │ ├── AccountType.swift │ │ │ │ ├── GuestAccount.swift │ │ │ │ └── UserAccount.swift │ │ │ ├── Action/ │ │ │ │ ├── Action.swift │ │ │ │ ├── ActionAppearance+StaticValues.swift │ │ │ │ ├── ActionAppearance.swift │ │ │ │ ├── ActionBuilder.swift │ │ │ │ ├── ActionGroup.swift │ │ │ │ ├── ActionType.swift │ │ │ │ ├── BasicAction.swift │ │ │ │ ├── Counter.swift │ │ │ │ ├── CounterAppearance.swift │ │ │ │ ├── CounterApperance+StaticValues.swift │ │ │ │ ├── Readout.swift │ │ │ │ └── ShareActivity.swift │ │ │ ├── CommentTreeNode.swift │ │ │ ├── ErrorDetails.swift │ │ │ ├── Events/ │ │ │ │ ├── Event+Extension.swift │ │ │ │ └── EventsTracker.swift │ │ │ ├── FeedContext.swift │ │ │ ├── FeedbackType.swift │ │ │ ├── ImageUploadHistoryManager.swift │ │ │ ├── ImageUploadManager.swift │ │ │ ├── MlemStats/ │ │ │ │ ├── InstanceSummary.swift │ │ │ │ └── MlemStats.swift │ │ │ ├── SeededRandomNumberGenerator.swift │ │ │ ├── Session/ │ │ │ │ ├── GuestSession.swift │ │ │ │ ├── Session.swift │ │ │ │ └── UserSession.swift │ │ │ └── Settings/ │ │ │ └── Options/ │ │ │ ├── InternetSpeed.swift │ │ │ ├── PostSize.swift │ │ │ └── ThumbnailLocation.swift │ │ ├── Protocols/ │ │ │ ├── AccountSortMode.swift │ │ │ └── AssociatedColor.swift │ │ ├── Utility/ │ │ │ └── Extensions/ │ │ │ ├── ApiClient+Extensions.swift │ │ │ ├── Array+Extensions.swift │ │ │ ├── BackendClient+Extensions.swift │ │ │ ├── Binding+Extensions.swift │ │ │ ├── Blockable+Extensions.swift │ │ │ ├── Bundle+Extensions.swift │ │ │ ├── CGFloat+Extensions.swift │ │ │ ├── CGPoint+Extensions.swift │ │ │ ├── CGSize+Extensions.swift │ │ │ ├── Calendar+Extensions.swift │ │ │ ├── CaptchaDifficulty+Extensions.swift │ │ │ ├── Color+Extensions.swift │ │ │ ├── CommentSortType+Extensions.swift │ │ │ ├── Content Models/ │ │ │ │ ├── ActorIdentifiable+Extensions.swift │ │ │ │ ├── Captcha+Extensions.swift │ │ │ │ ├── Comment/ │ │ │ │ │ ├── Comment+Actions.swift │ │ │ │ │ └── Comment+Extensions.swift │ │ │ │ ├── Community/ │ │ │ │ │ └── Community+Extensions.swift │ │ │ │ ├── CommunityOrPersonStub+Extensions.swift │ │ │ │ ├── DeletableProviding+Extensions.swift │ │ │ │ ├── InboxItemProviding+Extensions.swift │ │ │ │ ├── InboxItemType+Extensions.swift │ │ │ │ ├── Instance+Extensions.swift │ │ │ │ ├── Instance3+Extensions.swift │ │ │ │ ├── Interactable/ │ │ │ │ │ ├── InteractableProviding+Actions.swift │ │ │ │ │ ├── InteractableProviding+Extensions.swift │ │ │ │ │ └── InteractableProviding+Toggles.swift │ │ │ │ ├── Message1Providing+Extensions.swift │ │ │ │ ├── ModlogEntryContent+Extensions.swift │ │ │ │ ├── ModlogEntryType+Extensions.swift │ │ │ │ ├── Person/ │ │ │ │ │ ├── Person+Actions.swift │ │ │ │ │ └── Person+Extensions.swift │ │ │ │ ├── PersonContent+Extensions.swift │ │ │ │ ├── Post/ │ │ │ │ │ ├── Post+Actions.swift │ │ │ │ │ ├── Post+Extensions.swift │ │ │ │ │ └── Post+Toggles.swift │ │ │ │ ├── ProfileProviding+Extensions.swift │ │ │ │ ├── PurgableProviding+Extensions.swift │ │ │ │ ├── RegistrationApplication+Extensions.swift │ │ │ │ ├── RemovableProviding+Extensions.swift │ │ │ │ ├── Reply1Providing+Extensions.swift │ │ │ │ ├── Report+Extensions.swift │ │ │ │ ├── ReportableProviding+Extensions.swift │ │ │ │ ├── ScoringOperation+Extensions.swift │ │ │ │ ├── SelectableContentProviding+Extensions.swift │ │ │ │ ├── Sharable+Extensions.swift │ │ │ │ ├── SiteSoftware+Extensions.swift │ │ │ │ ├── SiteSoftwareType+Extensions.swift │ │ │ │ ├── UnreadCount+Extensions.swift │ │ │ │ └── VotesModel+Extensions.swift │ │ │ ├── Data+Extensions.swift │ │ │ ├── Date+Extensions.swift │ │ │ ├── DateComponents+Extensions.swift │ │ │ ├── EnvironmentValues+Extensions.swift │ │ │ ├── FederationMode+Extensions.swift │ │ │ ├── GetContentFilter+Extensions.swift │ │ │ ├── HapticLevel+Extensions.swift │ │ │ ├── InstanceSummarySoftware+Extensions.swift │ │ │ ├── Int+Extensions.swift │ │ │ ├── ListingType+Extensions.swift │ │ │ ├── MarkdownConfiguration+Extensions.swift │ │ │ ├── MlemMiddleware Mock/ │ │ │ │ ├── ActorIdentifier+Mock.swift │ │ │ │ ├── Comment+Mock.swift │ │ │ │ ├── CommentMockType.swift │ │ │ │ ├── Community+Mock.swift │ │ │ │ ├── CommunityMockType+Realistic.swift │ │ │ │ ├── CommunityMockType.swift │ │ │ │ ├── MockApiClient+Realistic.swift │ │ │ │ ├── Person+Mock.swift │ │ │ │ ├── PersonMockType+Realistic.swift │ │ │ │ ├── PersonMockType.swift │ │ │ │ ├── Post+Mock.swift │ │ │ │ ├── PostMockType+Realistic.swift │ │ │ │ └── PostMockType.swift │ │ │ ├── PersonContentFeedLoader+Extensions.swift │ │ │ ├── PostSortType+Extensions.swift │ │ │ ├── PostType+Extensions.swift │ │ │ ├── PrefetchingConfiguration+Extensions.swift │ │ │ ├── QuickSwipeAction+Actions.swift │ │ │ ├── QuickSwipeAction+Extensions.swift │ │ │ ├── RegistrationMode+Extensions.swift │ │ │ ├── SearchSortType+Extensions.swift │ │ │ ├── Set+Extensions.swift │ │ │ ├── SortTimeRange+Extensions.swift │ │ │ ├── String+Extensions.swift │ │ │ ├── String+extension.swift │ │ │ ├── SwipeConfiguration+Extensions.swift │ │ │ ├── UIApplication+Extensions.swift │ │ │ ├── UIDevice+Extensions.swift │ │ │ ├── UIImage+Extensions.swift │ │ │ ├── UITextView+Extensions.swift │ │ │ ├── UIUserInterfaceStyle+Extensions.swift │ │ │ ├── UIViewController+Extensions.swift │ │ │ ├── UsernameValidity+Extensions.swift │ │ │ ├── Views/ │ │ │ │ ├── Label+Profile1.swift │ │ │ │ ├── NavigationLink+NavigationPage.swift │ │ │ │ ├── PreviewModifier+SampleEnvironment.swift │ │ │ │ └── View Modifiers/ │ │ │ │ ├── PopupAnchorModel.swift │ │ │ │ ├── View+AccountSwitcherGesture.swift │ │ │ │ ├── View+Background.swift │ │ │ │ ├── View+ConditionalNavigationTitle.swift │ │ │ │ ├── View+ContentMenu.swift │ │ │ │ ├── View+ContextMenu.swift │ │ │ │ ├── View+DynamicBlur.swift │ │ │ │ ├── View+ExternalApiWarning.swift │ │ │ │ ├── View+HiddenNavigationTitle.swift │ │ │ │ ├── View+IsAtTopSubscriber.swift │ │ │ │ ├── View+LoadFeed.swift │ │ │ │ ├── View+MarkReadOnScroll.swift │ │ │ │ ├── View+NavigationTransition.swift │ │ │ │ ├── View+NavigtionStackPreview.swift │ │ │ │ ├── View+OutdatedFeedPopup.swift │ │ │ │ ├── View+PaletteBorder.swift │ │ │ │ ├── View+PopupAnchor.swift │ │ │ │ ├── View+QuickSwipes.swift │ │ │ │ ├── View+Refreshable.swift │ │ │ │ ├── View+ReloadOnAccountSwitch.swift │ │ │ │ ├── View+SafeAreaBar.swift │ │ │ │ ├── View+TabBarPreview.swift │ │ │ │ ├── View+TabReselectConsumer.swift │ │ │ │ └── View+WidthReader.swift │ │ │ └── [BlockNode]+Extensions.swift │ │ └── Views/ │ │ ├── Pages/ │ │ │ ├── Community/ │ │ │ │ ├── AdvancedSortView+SortButton.swift │ │ │ │ ├── AdvancedSortView.swift │ │ │ │ ├── CommunityAboutView.swift │ │ │ │ ├── CommunityDetailsView.swift │ │ │ │ ├── CommunitySearchSortPicker.swift │ │ │ │ ├── CommunityStubResolutionPage.swift │ │ │ │ ├── CommunityView+Logic.swift │ │ │ │ ├── CommunityView.swift │ │ │ │ ├── FeedSortPicker.swift │ │ │ │ └── TopSortPicker.swift │ │ │ ├── DeleteAccountView.swift │ │ │ ├── Editors/ │ │ │ │ ├── CommentEditor/ │ │ │ │ │ ├── CommentEditorView+Context.swift │ │ │ │ │ ├── CommentEditorView+Logic.swift │ │ │ │ │ ├── CommentEditorView.swift │ │ │ │ │ └── PostEditor/ │ │ │ │ │ ├── LinkEditorView.swift │ │ │ │ │ ├── PostEditorTargetView.swift │ │ │ │ │ ├── PostEditorView+ImageView.swift │ │ │ │ │ ├── PostEditorView+LinkView.swift │ │ │ │ │ ├── PostEditorView+Logic.swift │ │ │ │ │ ├── PostEditorView+Toolbar.swift │ │ │ │ │ ├── PostEditorView+Views.swift │ │ │ │ │ ├── PostEditorView.swift │ │ │ │ │ └── PostEditorWebsitePreviewView.swift │ │ │ │ ├── CommunityDescriptionEditorView.swift │ │ │ │ ├── ContentPurgeEditorView.swift │ │ │ │ ├── ContentRemovalEditorView.swift │ │ │ │ ├── FilterViolationWarning.swift │ │ │ │ ├── NoteEditorView.swift │ │ │ │ ├── PersonBanEditorView+Logic.swift │ │ │ │ ├── PersonBanEditorView.swift │ │ │ │ ├── RegistrationApplicationDenialEditorView.swift │ │ │ │ └── ReportEditorView.swift │ │ │ ├── ExternalApiInfoView.swift │ │ │ ├── ImageViewer+Views.swift │ │ │ ├── ImageViewer.swift │ │ │ ├── Instance/ │ │ │ │ ├── Fediseer.swift │ │ │ │ ├── FediseerInfoView.swift │ │ │ │ ├── FediseerOpinionListView.swift │ │ │ │ ├── FediseerOpinionView.swift │ │ │ │ ├── InstanceCommunityListView.swift │ │ │ │ ├── InstanceDetailsView.swift │ │ │ │ ├── InstanceSafetyView.swift │ │ │ │ ├── InstanceStubResolutionPage.swift │ │ │ │ ├── InstanceUptimeView+Logic.swift │ │ │ │ ├── InstanceUptimeView+Views.swift │ │ │ │ ├── InstanceUptimeView.swift │ │ │ │ ├── InstanceView+About.swift │ │ │ │ ├── InstanceView+Logic.swift │ │ │ │ ├── InstanceView.swift │ │ │ │ └── UptimeData.swift │ │ │ ├── MessageFeedView/ │ │ │ │ ├── MessageBubbleView.swift │ │ │ │ ├── MessageFeedView+Logic.swift │ │ │ │ └── MessageFeedView.swift │ │ │ ├── Modlog/ │ │ │ │ ├── ModlogEntryView.swift │ │ │ │ ├── ModlogView+Filters.swift │ │ │ │ ├── ModlogView+Logic.swift │ │ │ │ └── ModlogView.swift │ │ │ ├── Person/ │ │ │ │ ├── PersonStubResolutionPage.swift │ │ │ │ ├── PersonView+Logic.swift │ │ │ │ └── PersonView.swift │ │ │ ├── UploadConfirmationView.swift │ │ │ └── VotesListView.swift │ │ ├── Root/ │ │ │ ├── AppDelegate.swift │ │ │ ├── ContentView+Logic.swift │ │ │ ├── ContentView+Tab.swift │ │ │ ├── ContentView.swift │ │ │ ├── Login/ │ │ │ │ ├── LoginCredentialsView.swift │ │ │ │ ├── LoginInstancePickerView.swift │ │ │ │ ├── LoginTotpView.swift │ │ │ │ ├── Onboarding/ │ │ │ │ │ ├── OnboardingEmailView.swift │ │ │ │ │ ├── OnboardingModel.swift │ │ │ │ │ ├── OnboardingRecommendInstanceView.swift │ │ │ │ │ ├── OnboardingUsernameView.swift │ │ │ │ │ └── OnboardingView.swift │ │ │ │ ├── SignUpView+EmailConfirmationView.swift │ │ │ │ ├── SignUpView+Logic.swift │ │ │ │ ├── SignUpView+Views.swift │ │ │ │ └── SignUpView.swift │ │ │ ├── MlemApp.swift │ │ │ ├── Tabs/ │ │ │ │ ├── Feeds/ │ │ │ │ │ ├── Components/ │ │ │ │ │ │ ├── FeedWelcomeView.swift │ │ │ │ │ │ ├── HiddenReadBannerView.swift │ │ │ │ │ │ ├── TileScoreView.swift │ │ │ │ │ │ └── UpdateBannerView.swift │ │ │ │ │ ├── Feed Comments/ │ │ │ │ │ │ ├── FeedCommentView.swift │ │ │ │ │ │ └── TileCommentView.swift │ │ │ │ │ ├── Feed Header/ │ │ │ │ │ │ ├── FeedDescription.swift │ │ │ │ │ │ ├── FeedHeaderView.swift │ │ │ │ │ │ └── FeedIconView.swift │ │ │ │ │ ├── Feed Posts/ │ │ │ │ │ │ ├── CompactPostView.swift │ │ │ │ │ │ ├── Feed Post Components/ │ │ │ │ │ │ │ ├── CrossPostListView.swift │ │ │ │ │ │ │ ├── PostLinkHostView.swift │ │ │ │ │ │ │ └── PostTag.swift │ │ │ │ │ │ ├── FeedPostView.swift │ │ │ │ │ │ ├── HeadlinePostBodyView.swift │ │ │ │ │ │ ├── HeadlinePostView.swift │ │ │ │ │ │ ├── LargePostBodyView.swift │ │ │ │ │ │ ├── LargePostView.swift │ │ │ │ │ │ ├── PostPollView.swift │ │ │ │ │ │ └── TilePostView.swift │ │ │ │ │ ├── FeedsView.swift │ │ │ │ │ ├── SectionIndexTitles.swift │ │ │ │ │ ├── SubscriptionListItemView.swift │ │ │ │ │ ├── SubscriptionListNavigationButton.swift │ │ │ │ │ ├── SubscriptionListView.swift │ │ │ │ │ └── VisitAgainView.swift │ │ │ │ ├── Inbox/ │ │ │ │ │ ├── InboxView+Types.swift │ │ │ │ │ ├── InboxView+Views.swift │ │ │ │ │ ├── InboxView.swift │ │ │ │ │ └── MarkAllAsReadButton.swift │ │ │ │ ├── Profile/ │ │ │ │ │ └── Profile View.swift │ │ │ │ └── Settings/ │ │ │ │ ├── AboutMlemView.swift │ │ │ │ ├── AccessibilitySettingsView.swift │ │ │ │ ├── AccountAdvancedSettingsView.swift │ │ │ │ ├── AccountAgeVisibilitySettingsView.swift │ │ │ │ ├── AccountContentSettingsView.swift │ │ │ │ ├── AccountEmailSettingsView.swift │ │ │ │ ├── AccountListSettingsView.swift │ │ │ │ ├── AccountLocalSettingsView.swift │ │ │ │ ├── AccountNicknameFieldView.swift │ │ │ │ ├── AccountSettingsView.swift │ │ │ │ ├── AccountSignInSettingsView.swift │ │ │ │ ├── AdvancedSettingsView.swift │ │ │ │ ├── AnimatedAvatarSettingsView.swift │ │ │ │ ├── BlockListView.swift │ │ │ │ ├── ChangePasswordView.swift │ │ │ │ ├── CommentJumpButtonSettingsView.swift │ │ │ │ ├── CommentMaximumDepthSettingsView.swift │ │ │ │ ├── CommentSettingsView.swift │ │ │ │ ├── CommunitySettingsView.swift │ │ │ │ ├── Components/ │ │ │ │ │ ├── ConditionalLabelStyleViewModifier.swift │ │ │ │ │ ├── DevicePickerItem.swift │ │ │ │ │ ├── SettingsDeviceView.swift │ │ │ │ │ ├── SettingsHeaderView.swift │ │ │ │ │ ├── SettingsInteractionBarSummaryView.swift │ │ │ │ │ ├── SquircleLabelStyle.swift │ │ │ │ │ └── ThemeLabel.swift │ │ │ │ ├── ContextMenuSettingsView.swift │ │ │ │ ├── DefaultFeedSettingsView.swift │ │ │ │ ├── DeveloperSettingsView.swift │ │ │ │ ├── Discussion Languages/ │ │ │ │ │ ├── DiscussionLanguageSettingsView.swift │ │ │ │ │ ├── LanguageListRowBody.swift │ │ │ │ │ └── LanguagePickerSheetView.swift │ │ │ │ ├── DiscussionLanguageSettingsView.swift │ │ │ │ ├── EmbeddingSettingsView.swift │ │ │ │ ├── ErrorLogView.swift │ │ │ │ ├── ExternalLinkSettingsView.swift │ │ │ │ ├── FiltersSettingsView.swift │ │ │ │ ├── GeneralSettingsView.swift │ │ │ │ ├── HapticSettingsView.swift │ │ │ │ ├── Icon/ │ │ │ │ │ ├── AlternateIcon.swift │ │ │ │ │ ├── AlternateIconCell.swift │ │ │ │ │ ├── AlternateIconLabel.swift │ │ │ │ │ └── IconSettingsView.swift │ │ │ │ ├── ImageViewerDismissSettingsView.swift │ │ │ │ ├── ImageViewerSettingsView.swift │ │ │ │ ├── ImageViewerShowControlsSettingsView.swift │ │ │ │ ├── ImportExportSettingsView.swift │ │ │ │ ├── InboxBadgeSettingsView.swift │ │ │ │ ├── InboxSettingsView.swift │ │ │ │ ├── InteractionBarEditor/ │ │ │ │ │ ├── InteractionBarEditorView+Logic.swift │ │ │ │ │ ├── InteractionBarEditorView+Views.swift │ │ │ │ │ ├── InteractionBarEditorView.swift │ │ │ │ │ └── InteractionBarWidgetPickerView.swift │ │ │ │ ├── LinkSettingsView.swift │ │ │ │ ├── LongPressActionSettingsView.swift │ │ │ │ ├── ModMailInteractionBarSettingsView.swift │ │ │ │ ├── ModeratorActionSeparationSettingsView.swift │ │ │ │ ├── ModeratorSettingsView.swift │ │ │ │ ├── PostReadIndicatorSettingsView.swift │ │ │ │ ├── PostSettingsView+PostSizePicker.swift │ │ │ │ ├── PostSettingsView.swift │ │ │ │ ├── PostSubscriptionIndicatorSettingsView.swift │ │ │ │ ├── PostThumbnailSettingsView.swift │ │ │ │ ├── PrivacyBypassImageProxySettingsView.swift │ │ │ │ ├── PrivacySettingsView.swift │ │ │ │ ├── ProfileSettingsView.swift │ │ │ │ ├── SafetyBlurNsfwSettingsView.swift │ │ │ │ ├── SafetySettingsView.swift │ │ │ │ ├── SafetyWarningsSettingsView.swift │ │ │ │ ├── SettingsView.swift │ │ │ │ ├── SharingLinksSettingsView.swift │ │ │ │ ├── SortingSettingsView.swift │ │ │ │ ├── SubscriptionListSettingsView.swift │ │ │ │ ├── SwipeActionEditorView.swift │ │ │ │ ├── TabBarSettingsView.swift │ │ │ │ ├── TappableLinksSettingsView.swift │ │ │ │ ├── ThemeSettingsView.swift │ │ │ │ └── ZoomSliderSettingsView.swift │ │ │ └── TransitionView.swift │ │ └── Shared/ │ │ ├── AccountPickerMenu.swift │ │ ├── Accounts/ │ │ │ ├── AccountListView+Logic.swift │ │ │ ├── AccountListView.swift │ │ │ └── QuickSwitcherView.swift │ │ ├── Avatar/ │ │ │ ├── AvatarBannerView.swift │ │ │ ├── AvatarStackView.swift │ │ │ ├── AvatarView.swift │ │ │ ├── ProfileHeaderView.swift │ │ │ └── SimpleAvatarView.swift │ │ ├── Bubble Picker/ │ │ │ ├── BubblePickerView.swift │ │ │ └── ChildSizeReader.swift │ │ ├── BypassProxyWarningSheet.swift │ │ ├── CommentBodyView.swift │ │ ├── CommentView.swift │ │ ├── ContentLoader.swift │ │ ├── CustomTabBarController.swift │ │ ├── CustomTabItem.swift │ │ ├── CustomTabView.swift │ │ ├── CustomTabViewHostingController.swift │ │ ├── EllipsisMenu.swift │ │ ├── EndOfFeedView.swift │ │ ├── ErrorView.swift │ │ ├── ExpandedPost/ │ │ │ ├── CommentPage.swift │ │ │ ├── CommentStubResolutionPage.swift │ │ │ ├── CommentTreeTracker.swift │ │ │ ├── ExpandedPostHistoryTracker.swift │ │ │ ├── ExpandedPostView+Logic.swift │ │ │ ├── ExpandedPostView+Views.swift │ │ │ ├── ExpandedPostView.swift │ │ │ ├── MoreRepliesButton.swift │ │ │ └── PostStubResolutionPage.swift │ │ ├── ExpectedViews/ │ │ │ ├── ExpectedTextView.swift │ │ │ ├── ExpectedView.swift │ │ │ └── String+Placeholders.swift │ │ ├── ExportableViews/ │ │ │ ├── ExportableCommentEditorView.swift │ │ │ ├── ExportableCommentLoader.swift │ │ │ ├── ExportableCommentView.swift │ │ │ ├── ExportablePostEditorView.swift │ │ │ ├── ExportablePostView.swift │ │ │ └── ExportableViewComponents.swift │ │ ├── FancyScrollView.swift │ │ ├── FeedFilterButtonStyle.swift │ │ ├── FeedToolbarOptions.swift │ │ ├── FooterLinkView.swift │ │ ├── Form/ │ │ │ ├── ActiveUserCountView.swift │ │ │ ├── CollapsibleSection.swift │ │ │ ├── FormReadout.swift │ │ │ └── FormSection.swift │ │ ├── HandleThreadiverseLinksModifier.swift │ │ ├── ImageUploadMenu.swift │ │ ├── Images/ │ │ │ ├── Core/ │ │ │ │ ├── MediaView+Logic.swift │ │ │ │ ├── MediaView+Views.swift │ │ │ │ └── MediaView.swift │ │ │ ├── Helpers/ │ │ │ │ ├── AnimationControlLayer.swift │ │ │ │ ├── NsfwOverlayView.swift │ │ │ │ ├── PlayButton.swift │ │ │ │ ├── SmallOverlayButtonLabel.swift │ │ │ │ └── ZoomRecognizer/ │ │ │ │ ├── BridgeDragValue.swift │ │ │ │ ├── CachedComputation.swift │ │ │ │ ├── GestureRecognizers.swift │ │ │ │ ├── MomentumStatus.swift │ │ │ │ ├── ZoomCurves.swift │ │ │ │ ├── ZoomRecognizer.swift │ │ │ │ ├── ZoomRecognizerCoordinator+GestureRecognition.swift │ │ │ │ ├── ZoomRecognizerCoordinator+Logic.swift │ │ │ │ └── ZoomRecognizerCoordinator.swift │ │ │ └── Wrappers/ │ │ │ ├── CircleCroppedImageView.swift │ │ │ ├── SimpleAvatarView.swift │ │ │ ├── ThumbnailImageView.swift │ │ │ └── ZoomableImageView.swift │ │ ├── InfoStackView.swift │ │ ├── InteractionBar/ │ │ │ ├── InteractionBarActionLabelView.swift │ │ │ └── InteractionBarView.swift │ │ ├── JumpButtonView.swift │ │ ├── Labels/ │ │ │ ├── FullyQualifiedLabelView.swift │ │ │ ├── FullyQualifiedLinkView.swift │ │ │ └── FullyQualifiedNameView.swift │ │ ├── LinkHostView.swift │ │ ├── ListRow/ │ │ │ ├── AccountListRow.swift │ │ │ └── AccountListRowBody.swift │ │ ├── MarkdownEditorToolbarView.swift │ │ ├── MarkdownTextEditor.swift │ │ ├── MarkdownWithLinkList.swift │ │ ├── MenuButton.swift │ │ ├── MessageView.swift │ │ ├── ModlogButtonView.swift │ │ ├── MultiplatformView.swift │ │ ├── Navigation/ │ │ │ ├── LoginPage.swift │ │ │ ├── NavigationLayer.swift │ │ │ ├── NavigationLayerView.swift │ │ │ ├── NavigationModel.swift │ │ │ ├── NavigationPage+PresentationDetents.swift │ │ │ ├── NavigationPage+View.swift │ │ │ ├── NavigationPage.swift │ │ │ ├── NavigationRootView.swift │ │ │ ├── NavigationSearchType.swift │ │ │ ├── SettingsPage.swift │ │ │ └── View+NavigationSheetModifiers.swift │ │ ├── Palette Components/ │ │ │ ├── Button.swift │ │ │ ├── Divider.swift │ │ │ ├── Form.swift │ │ │ └── Section.swift │ │ ├── PersonContentGridView+FeedLoaderType.swift │ │ ├── PersonContentGridView.swift │ │ ├── PostEllipsisMenus.swift │ │ ├── PostGridView.swift │ │ ├── ProfileDateView.swift │ │ ├── ReadCheck.swift │ │ ├── ReasonShortcutView.swift │ │ ├── RefreshPopupView.swift │ │ ├── RegistrationApplicationView.swift │ │ ├── ReplyView.swift │ │ ├── ReportView.swift │ │ ├── RulesListView.swift │ │ ├── RulesPickerView.swift │ │ ├── Search/ │ │ │ ├── Home/ │ │ │ │ ├── EventRowView.swift │ │ │ │ ├── SearchHomeCategoryLabelStyle.swift │ │ │ │ ├── SearchHomeLabelStyle.swift │ │ │ │ ├── SearchHomeListView.swift │ │ │ │ ├── SearchHomeView.swift │ │ │ │ ├── TopCommunitiesListView.swift │ │ │ │ ├── TopInstancesListView.swift │ │ │ │ └── TopPeopleListView.swift │ │ │ ├── PasteLinkButtonView.swift │ │ │ ├── Results/ │ │ │ │ ├── CommunityListRow.swift │ │ │ │ ├── CommunityListRowBody.swift │ │ │ │ ├── InstanceListRow.swift │ │ │ │ ├── InstanceListRowBody.swift │ │ │ │ ├── PersonListRow.swift │ │ │ │ └── PersonListRowBody.swift │ │ │ ├── SearchBar/ │ │ │ │ ├── DefaultTextInputType.swift │ │ │ │ ├── SearchBar+NavigationView.swift │ │ │ │ ├── SearchBar.swift │ │ │ │ ├── SearchBarExtensions.swift │ │ │ │ ├── View+WithSheetSearch.swift │ │ │ │ └── _assignIfNotEqual.swift │ │ │ ├── SearchResultsView.swift │ │ │ ├── SearchSheetView.swift │ │ │ ├── SearchView+CreatorPicker.swift │ │ │ ├── SearchView+FilterModels.swift │ │ │ ├── SearchView+FiltersView.swift │ │ │ ├── SearchView+InstancePicker.swift │ │ │ ├── SearchView+LocationPicker.swift │ │ │ ├── SearchView+Logic.swift │ │ │ ├── SearchView+Views.swift │ │ │ ├── SearchView.swift │ │ │ ├── Searchable.swift │ │ │ ├── VisitHistory+CodedData.swift │ │ │ └── VisitHistory.swift │ │ ├── SelectTextView.swift │ │ ├── ShareInstancePickerView.swift │ │ ├── ShieldsBadgeView/ │ │ │ ├── ShieldsBadgeView+Logic.swift │ │ │ └── ShieldsBadgeView.swift │ │ ├── Toast/ │ │ │ ├── Toast.swift │ │ │ ├── ToastLocation.swift │ │ │ ├── ToastModel.swift │ │ │ ├── ToastOverlayView.swift │ │ │ ├── ToastType.swift │ │ │ └── ToastView.swift │ │ ├── ToolbarEllipsisMenu.swift │ │ ├── WarningOverlayView.swift │ │ ├── WarningView.swift │ │ ├── WebView.swift │ │ └── WebsitePreviewView.swift │ ├── Assets.xcassets/ │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Default Community.imageset/ │ │ │ └── Contents.json │ │ ├── Icon Previews/ │ │ │ ├── Contents.json │ │ │ ├── icon.eric.lemmy.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.alien.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.green.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.ocean.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.orange.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.pink.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.pride.preview.imageset/ │ │ │ │ └── Contents.json │ │ │ └── icon.sjmarf.silver.preview.imageset/ │ │ │ └── Contents.json │ │ ├── Icons/ │ │ │ ├── Contents.json │ │ │ ├── icon.eric.lemmy.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.alien.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.green.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.ocean.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.orange.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.pink.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── icon.sjmarf.pride.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── icon.sjmarf.silver.appiconset/ │ │ │ └── Contents.json │ │ ├── Symbols/ │ │ │ ├── Contents.json │ │ │ ├── discord.logo.symbolset/ │ │ │ │ └── Contents.json │ │ │ ├── github.logo.symbolset/ │ │ │ │ └── Contents.json │ │ │ ├── lemmy.logo.symbolset/ │ │ │ │ └── Contents.json │ │ │ ├── mastodon.logo.symbolset/ │ │ │ │ └── Contents.json │ │ │ └── matrix.logo.symbolset/ │ │ │ └── Contents.json │ │ ├── background.earth.imageset/ │ │ │ └── Contents.json │ │ ├── background.trees.imageset/ │ │ │ └── Contents.json │ │ ├── logo.imageset/ │ │ │ └── Contents.json │ │ └── nsfw.symbolset/ │ │ └── Contents.json │ ├── Info.plist │ ├── Localizable.xcstrings │ ├── Mlem.entitlements │ ├── Mlem.xcdatamodeld/ │ │ ├── .xccurrentversion │ │ └── Mlem.xcdatamodel/ │ │ └── contents │ ├── Packages/ │ │ ├── Actions/ │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── Actions/ │ │ │ ├── Action.swift │ │ │ ├── ActionLabel.swift │ │ │ ├── ActionSeed.swift │ │ │ ├── ActionVisiblity.swift │ │ │ ├── BasicAction.swift │ │ │ ├── SimpleLabelAction.swift │ │ │ └── Views/ │ │ │ ├── ActionButtonWithVisibilityControl.swift │ │ │ ├── ActionButtons.swift │ │ │ ├── Button+Extensions.swift │ │ │ └── Label+Extensions.swift │ │ ├── ComponentViews/ │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── ComponentViews/ │ │ │ ├── ButtonStyle/ │ │ │ │ ├── CapsuleButtonStyle.swift │ │ │ │ ├── ChevronButtonStyle.swift │ │ │ │ └── EmptyButtonStyle.swift │ │ │ ├── Checkbox.swift │ │ │ ├── CloseButtonToolbarItem.swift │ │ │ ├── CloseButtonView.swift │ │ │ ├── CollapsibleSheetView.swift │ │ │ ├── FitContentPresentationDetentViewModifier.swift │ │ │ ├── FormChevron.swift │ │ │ ├── KeyboardAwarePadding.swift │ │ │ ├── Line.swift │ │ │ ├── MockTextView.swift │ │ │ ├── OptimalHeightLayout.swift │ │ │ ├── View+GlassProminentButtonStyle.swift │ │ │ └── View+VersionAwareDialog.swift │ │ ├── FediverseEvents/ │ │ │ ├── .gitignore │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── FediverseEvents/ │ │ │ ├── Event.swift │ │ │ ├── EventsClient+Requests.swift │ │ │ ├── EventsClient.swift │ │ │ └── Requests/ │ │ │ └── ListEventsRequest.swift │ │ ├── Haptics/ │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── Haptics/ │ │ │ ├── Haptic.swift │ │ │ ├── HapticError.swift │ │ │ ├── HapticLevel.swift │ │ │ ├── HapticManager.swift │ │ │ ├── Resources/ │ │ │ │ ├── Failure/ │ │ │ │ │ └── Failure.ahap │ │ │ │ ├── Info/ │ │ │ │ │ ├── Firm Info.ahap │ │ │ │ │ ├── Gentle Info.ahap │ │ │ │ │ ├── Mushy Info.ahap │ │ │ │ │ └── Rigid Info.ahap │ │ │ │ └── Success/ │ │ │ │ ├── Destructive Success.ahap │ │ │ │ ├── Light Success.ahap │ │ │ │ ├── Success.ahap │ │ │ │ └── Violent Success.ahap │ │ │ └── View+Haptic.swift │ │ ├── Icons/ │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── Icons/ │ │ │ ├── Icon+Fediseer.swift │ │ │ ├── Icon+General.swift │ │ │ ├── Icon+Lemmy.swift │ │ │ ├── Icon+Markdown.swift │ │ │ ├── Icon+Settings.swift │ │ │ ├── Icon+Uptime.swift │ │ │ ├── Icon.swift │ │ │ └── ViewExtensions/ │ │ │ ├── Button+Extensions.swift │ │ │ ├── Image+Extensions.swift │ │ │ ├── Label+Extensions.swift │ │ │ ├── Menu+Extensions.swift │ │ │ ├── Picker+Extensions.swift │ │ │ ├── Toggle+Extensions.swift │ │ │ └── UIImage+Extensions.swift │ │ ├── Media/ │ │ │ ├── .gitignore │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── Media/ │ │ │ └── Resources/ │ │ │ ├── Core/ │ │ │ │ ├── Animated/ │ │ │ │ │ ├── AnimatedImageView.swift │ │ │ │ │ └── VideoView.swift │ │ │ │ ├── CoreMediaView+Logic.swift │ │ │ │ ├── CoreMediaView.swift │ │ │ │ ├── MediaControlState.swift │ │ │ │ └── MediaLoader.swift │ │ │ ├── Decoders/ │ │ │ │ ├── MlemVideoDecoder.swift │ │ │ │ └── NukeWebpBridgeDecoder.swift │ │ │ └── Extensions/ │ │ │ ├── AVPlayer+Extensions.swift │ │ │ └── CGSize+Extensions.swift │ │ ├── MlemBackend/ │ │ │ ├── .gitignore │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── MlemBackend/ │ │ │ ├── BackendClient+Requests.swift │ │ │ ├── BackendClient.swift │ │ │ ├── BackendClientError.swift │ │ │ ├── BackendHealthCheck.swift │ │ │ ├── InstanceSummary.swift │ │ │ ├── InstanceSummarySoftware.swift │ │ │ ├── JSONDecoder+Extensions.swift │ │ │ ├── MlemDeveloper.swift │ │ │ ├── MlemFlairs.swift │ │ │ ├── Requests/ │ │ │ │ ├── BackendGetTestflightUpdateRequest.swift │ │ │ │ ├── BackendHealthCheckRequest.swift │ │ │ │ ├── BackendListFlairsRequest.swift │ │ │ │ └── BackendListInstancesRequest.swift │ │ │ └── TestflightUpdate.swift │ │ ├── MlemLogger/ │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── MlemLogger/ │ │ │ └── MlemLogger.swift │ │ ├── MlemMiddleware/ │ │ │ ├── .gitignore │ │ │ ├── CODEOWNERS │ │ │ ├── LICENSE │ │ │ ├── NOTICE.md │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ ├── Sources/ │ │ │ │ └── MlemMiddleware/ │ │ │ │ ├── API Client/ │ │ │ │ │ ├── ApiClient+Caches.swift │ │ │ │ │ ├── ApiClient+Comment.swift │ │ │ │ │ ├── ApiClient+Community.swift │ │ │ │ │ ├── ApiClient+General.swift │ │ │ │ │ ├── ApiClient+Image.swift │ │ │ │ │ ├── ApiClient+Inbox.swift │ │ │ │ │ ├── ApiClient+Instance.swift │ │ │ │ │ ├── ApiClient+Mock.swift │ │ │ │ │ ├── ApiClient+Person.swift │ │ │ │ │ ├── ApiClient+Post.swift │ │ │ │ │ ├── ApiClient+RegistrationApplication.swift │ │ │ │ │ ├── ApiClient+Report.swift │ │ │ │ │ ├── ApiClient.swift │ │ │ │ │ ├── ApiClientError.swift │ │ │ │ │ ├── ApiErrorResponse.swift │ │ │ │ │ ├── ApiSession.swift │ │ │ │ │ ├── Caching/ │ │ │ │ │ │ ├── ApiTypeBackedCache.swift │ │ │ │ │ │ ├── Atomic.swift │ │ │ │ │ │ ├── Caches/ │ │ │ │ │ │ │ ├── CommentCaches.swift │ │ │ │ │ │ │ ├── CommunityCache.swift │ │ │ │ │ │ │ ├── ImageUploadCaches.swift │ │ │ │ │ │ │ ├── InstanceCache.swift │ │ │ │ │ │ │ ├── MessageCaches.swift │ │ │ │ │ │ │ ├── NotificationCaches.swift │ │ │ │ │ │ │ ├── PersonCache.swift │ │ │ │ │ │ │ ├── PersonVoteCaches.swift │ │ │ │ │ │ │ ├── PostCache.swift │ │ │ │ │ │ │ ├── RegistrationApplicationCaches.swift │ │ │ │ │ │ │ └── ReportCaches.swift │ │ │ │ │ │ ├── Conformance/ │ │ │ │ │ │ │ ├── ImageUpload+CacheExtensions.swift │ │ │ │ │ │ │ ├── InboxNotification+CacheExtensions.swift │ │ │ │ │ │ │ ├── Message+CacheExtensions.swift │ │ │ │ │ │ │ ├── RegistrationApplication+CacheExtensions.swift │ │ │ │ │ │ │ └── Report+CacheExtensions.swift │ │ │ │ │ │ └── CoreCache.swift │ │ │ │ │ └── Helpers/ │ │ │ │ │ ├── MarkReadQueue.swift │ │ │ │ │ └── SharedTaskManager.swift │ │ │ │ ├── API Repository/ │ │ │ │ │ ├── ApiRepository+Comment.swift │ │ │ │ │ ├── ApiRepository+Community.swift │ │ │ │ │ ├── ApiRepository+General.swift │ │ │ │ │ ├── ApiRepository+Image.swift │ │ │ │ │ ├── ApiRepository+Inbox.swift │ │ │ │ │ ├── ApiRepository+Instance.swift │ │ │ │ │ ├── ApiRepository+Mock.swift │ │ │ │ │ ├── ApiRepository+Person.swift │ │ │ │ │ ├── ApiRepository+Post.swift │ │ │ │ │ ├── ApiRepository+RegistrationApplication.swift │ │ │ │ │ ├── ApiRepository+Report.swift │ │ │ │ │ ├── ApiRepository.swift │ │ │ │ │ └── ConnectionMultiplexer.swift │ │ │ │ ├── Bridges/ │ │ │ │ │ ├── LemmyBlockBridge.swift │ │ │ │ │ ├── LemmyInstanceWithFederationStateBridge.swift │ │ │ │ │ ├── LemmySortTypeBridge.swift │ │ │ │ │ └── LemmyVoteShowBridge.swift │ │ │ │ ├── Constants/ │ │ │ │ │ └── MiddlewareConstants.swift │ │ │ │ ├── Content Models/ │ │ │ │ │ ├── ActiveUserCount.swift │ │ │ │ │ ├── ActorIdentifiable.swift │ │ │ │ │ ├── ActorIdentifier.swift │ │ │ │ │ ├── BlockList.swift │ │ │ │ │ ├── BlockListSnapshot.swift │ │ │ │ │ ├── CanModerateProviding.swift │ │ │ │ │ ├── Captcha.swift │ │ │ │ │ ├── CaptchaDifficulty.swift │ │ │ │ │ ├── Comment/ │ │ │ │ │ │ ├── Comment+Conformance.swift │ │ │ │ │ │ ├── Comment+Mock.swift │ │ │ │ │ │ ├── Comment.swift │ │ │ │ │ │ ├── CommentProducing.swift │ │ │ │ │ │ ├── CommentProperties.swift │ │ │ │ │ │ └── CommentStub.swift │ │ │ │ │ ├── Community/ │ │ │ │ │ │ ├── Community+Conformance.swift │ │ │ │ │ │ ├── Community+Mock.swift │ │ │ │ │ │ ├── Community.swift │ │ │ │ │ │ ├── CommunityProperties.swift │ │ │ │ │ │ └── CommunityStub.swift │ │ │ │ │ ├── CommunityOrPersonStub.swift │ │ │ │ │ ├── ContentModel.swift │ │ │ │ │ ├── ContentModelUrlType.swift │ │ │ │ │ ├── ContentType.swift │ │ │ │ │ ├── DeletableProviding.swift │ │ │ │ │ ├── Feature.swift │ │ │ │ │ ├── FederationPolicy.swift │ │ │ │ │ ├── FederationStatus.swift │ │ │ │ │ ├── GetContentFilter.swift │ │ │ │ │ ├── ImageUpload/ │ │ │ │ │ │ ├── ImageUpload1.swift │ │ │ │ │ │ └── ImageUpload1Providing.swift │ │ │ │ │ ├── InboxItemProviding.swift │ │ │ │ │ ├── Instance/ │ │ │ │ │ │ ├── Instance+Conformance.swift │ │ │ │ │ │ ├── Instance.swift │ │ │ │ │ │ ├── InstanceProperties.swift │ │ │ │ │ │ └── InstanceStub.swift │ │ │ │ │ ├── InstanceBanType.swift │ │ │ │ │ ├── InstanceUrlBlockRecord.swift │ │ │ │ │ ├── Interactable/ │ │ │ │ │ │ └── InteractableProviding.swift │ │ │ │ │ ├── Internal/ │ │ │ │ │ │ ├── LemmyURL.swift │ │ │ │ │ │ ├── ScoringOperation.swift │ │ │ │ │ │ ├── SiteSoftware/ │ │ │ │ │ │ │ ├── SiteSoftware.swift │ │ │ │ │ │ │ └── SiteSoftwareType.swift │ │ │ │ │ │ └── SiteVersion/ │ │ │ │ │ │ ├── SiteVersion+EndpointVersion.swift │ │ │ │ │ │ └── SiteVersion.swift │ │ │ │ │ ├── LemmyExtensions/ │ │ │ │ │ │ ├── BlockListSnapshot+Lemmy.swift │ │ │ │ │ │ ├── Comment1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Comment2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── CommentSortType+Lemmy.swift │ │ │ │ │ │ ├── Community1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Community2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Community3Snapshot+Lemmy.swift │ │ │ │ │ │ ├── FederationMode+Lemmy.swift │ │ │ │ │ │ ├── ImageUpload1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── InboxNotificationSnapshot+Lemmy.swift │ │ │ │ │ │ ├── Instance1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Instance2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Instance3Snapshot+Lemmy.swift │ │ │ │ │ │ ├── LegacySortTimeRangeLimit+Lemmy.swift │ │ │ │ │ │ ├── LemmyPersonSavedCombinedView+Extensions.swift │ │ │ │ │ │ ├── ListingType+Lemmy.swift │ │ │ │ │ │ ├── Locale.Language+Lemmy.swift │ │ │ │ │ │ ├── Message1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Message2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── ModlogEntryContentSnapshot+Lemmy.swift │ │ │ │ │ │ ├── ModlogEntrySnapshot+Lemmy.swift │ │ │ │ │ │ ├── PagedResponseUnion+Extensions.swift │ │ │ │ │ │ ├── Person1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Person2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Person3Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Person4Snapshot+Lemmy.swift │ │ │ │ │ │ ├── PersonVoteSnapshot+Lemmy.swift │ │ │ │ │ │ ├── PersonalUnreadCountSnapshot+Lemmy.swift │ │ │ │ │ │ ├── Post1Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Post2Snapshot+Lemmy.swift │ │ │ │ │ │ ├── Post3Snapshot+Lemmy.swift │ │ │ │ │ │ ├── PostFeatureType+Lemmy.swift │ │ │ │ │ │ ├── PostSortType+Lemmy.swift │ │ │ │ │ │ ├── RegistrationApplicationSnapshot+Lemmy.swift │ │ │ │ │ │ ├── RegistrationMode+Lemmy.swift │ │ │ │ │ │ ├── ReportSnapshot+Lemmy.swift │ │ │ │ │ │ ├── ResolvedContent+Lemmy.swift │ │ │ │ │ │ ├── SearchSortType+Lemmy.swift │ │ │ │ │ │ └── SortTimeRange+Lemmy.swift │ │ │ │ │ ├── ListingType.swift │ │ │ │ │ ├── Message/ │ │ │ │ │ │ ├── Message1.swift │ │ │ │ │ │ ├── Message1Providing+Snapshots.swift │ │ │ │ │ │ ├── Message1Providing.swift │ │ │ │ │ │ ├── Message2.swift │ │ │ │ │ │ ├── Message2Providing+Snapshots.swift │ │ │ │ │ │ └── Message2Providing.swift │ │ │ │ │ ├── Modlog/ │ │ │ │ │ │ ├── ModlogEntry.swift │ │ │ │ │ │ └── ModlogEntryContent.swift │ │ │ │ │ ├── ModlogEntryType.swift │ │ │ │ │ ├── Notification/ │ │ │ │ │ │ ├── InboxNotification+Snapshots.swift │ │ │ │ │ │ ├── InboxNotification.swift │ │ │ │ │ │ └── InboxNotificationContent.swift │ │ │ │ │ ├── OwnershipProviding.swift │ │ │ │ │ ├── Person/ │ │ │ │ │ │ ├── Person+Conformance.swift │ │ │ │ │ │ ├── Person.swift │ │ │ │ │ │ ├── Person1+Mock.swift │ │ │ │ │ │ ├── PersonProperties.swift │ │ │ │ │ │ └── PersonStub.swift │ │ │ │ │ ├── PersonVote/ │ │ │ │ │ │ ├── PersonVote+CacheExtensions.swift │ │ │ │ │ │ └── PersonVote.swift │ │ │ │ │ ├── PersonalUnreadCountSnapshot.swift │ │ │ │ │ ├── PieFedExtensions/ │ │ │ │ │ │ ├── BlockListSnapshot+PieFed.swift │ │ │ │ │ │ ├── Comment1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Comment2Snapshot+PieFed.swift │ │ │ │ │ │ ├── CommentSortType+PieFed.swift │ │ │ │ │ │ ├── Community1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Community2Snapshot+PieFed.swift │ │ │ │ │ │ ├── Community3Snapshot+PieFed.swift │ │ │ │ │ │ ├── ImageUpload1Snapshot+PieFed.swift │ │ │ │ │ │ ├── InboxNotificationSnapshot+PieFed.swift │ │ │ │ │ │ ├── Instance1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Instance2Snapshot+PieFed.swift │ │ │ │ │ │ ├── Instance3Snapshot+PieFed.swift │ │ │ │ │ │ ├── LegacySortTimeRangeLimit+PieFed.swift │ │ │ │ │ │ ├── ListingType+PieFed.swift │ │ │ │ │ │ ├── Locale.Language+PieFed.swift │ │ │ │ │ │ ├── Message1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Message2Snapshot+PieFed.swift │ │ │ │ │ │ ├── Person1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Person2Snapshot+PieFed.swift │ │ │ │ │ │ ├── Person3Snapshot+PieFed.swift │ │ │ │ │ │ ├── Person4Snapshot+PieFed.swift │ │ │ │ │ │ ├── PersonVoteSnapshot+PieFed.swift │ │ │ │ │ │ ├── PersonalUnreadCountSnapshot+PieFed.swift │ │ │ │ │ │ ├── Post1Snapshot+PieFed.swift │ │ │ │ │ │ ├── Post2Snapshot+PieFed.swift │ │ │ │ │ │ ├── Post3Snapshot+PieFed.swift │ │ │ │ │ │ ├── PostFeatureType+PieFed.swift │ │ │ │ │ │ ├── PostPoll+PieFed.swift │ │ │ │ │ │ ├── PostSortType+PieFed.swift │ │ │ │ │ │ ├── RegistrationMode+PieFed.swift │ │ │ │ │ │ ├── ReportSnapshot+PieFed.swift │ │ │ │ │ │ ├── ResolvedContent+PieFed.swift │ │ │ │ │ │ ├── SearchSortType+PieFed.swift │ │ │ │ │ │ └── SortTimeRange+PieFed.swift │ │ │ │ │ ├── Post/ │ │ │ │ │ │ ├── Post+Conformance.swift │ │ │ │ │ │ ├── Post+Mock.swift │ │ │ │ │ │ ├── Post.swift │ │ │ │ │ │ ├── PostPoll.swift │ │ │ │ │ │ ├── PostProperties.swift │ │ │ │ │ │ └── PostStub.swift │ │ │ │ │ ├── PostFeatureType.swift │ │ │ │ │ ├── PostFeedViewMode.swift │ │ │ │ │ ├── PostLink.swift │ │ │ │ │ ├── PostType.swift │ │ │ │ │ ├── Profile/ │ │ │ │ │ │ └── ProfileProviding.swift │ │ │ │ │ ├── PurgableProviding.swift │ │ │ │ │ ├── ReadableProviding.swift │ │ │ │ │ ├── RegistrationApplication/ │ │ │ │ │ │ └── RegistrationApplication.swift │ │ │ │ │ ├── RegistrationMode.swift │ │ │ │ │ ├── RemovableProviding.swift │ │ │ │ │ ├── Report/ │ │ │ │ │ │ ├── Report.swift │ │ │ │ │ │ └── ReportTarget.swift │ │ │ │ │ ├── ReportUnreadCountSnapshot.swift │ │ │ │ │ ├── ReportableProviding.swift │ │ │ │ │ ├── Resolvable.swift │ │ │ │ │ ├── ResolvedContent.swift │ │ │ │ │ ├── SelectableContentProviding.swift │ │ │ │ │ ├── Sharable.swift │ │ │ │ │ ├── Shared/ │ │ │ │ │ │ ├── UnifiedModelProviding.swift │ │ │ │ │ │ ├── UnifiedPropertiesProviding.swift │ │ │ │ │ │ └── ValueProviding/ │ │ │ │ │ │ ├── ExpectedValue.swift │ │ │ │ │ │ ├── RealizedValue.swift │ │ │ │ │ │ ├── SyntheticExpectedValue.swift │ │ │ │ │ │ ├── SyntheticRealizedValue.swift │ │ │ │ │ │ ├── ValueProviding.swift │ │ │ │ │ │ └── ValueSynthesizer.swift │ │ │ │ │ ├── SignUpResponse.swift │ │ │ │ │ ├── Sort Types/ │ │ │ │ │ │ ├── CommentSortType.swift │ │ │ │ │ │ ├── LegacySortTimeRange.swift │ │ │ │ │ │ ├── PostSortType.swift │ │ │ │ │ │ ├── SearchSortType.swift │ │ │ │ │ │ └── SortTimeRange.swift │ │ │ │ │ ├── StateManager.swift │ │ │ │ │ ├── SubscriptionList.swift │ │ │ │ │ ├── SubscriptionModel.swift │ │ │ │ │ ├── UnreadCount.swift │ │ │ │ │ ├── UpdateQueues/ │ │ │ │ │ │ ├── InboxNotificationUpdateQueue.swift │ │ │ │ │ │ ├── Queue.swift │ │ │ │ │ │ ├── UnifiedUpdateQueue.swift │ │ │ │ │ │ └── UpdateStatus.swift │ │ │ │ │ └── VotesModel.swift │ │ │ │ ├── Custom API Models/ │ │ │ │ │ ├── ApiPictrsFile.swift │ │ │ │ │ ├── ApiPictrsUploadResponse.swift │ │ │ │ │ ├── Extensions/ │ │ │ │ │ │ ├── APIComment+Extensions.swift │ │ │ │ │ │ ├── APIPost+Extensions.swift │ │ │ │ │ │ ├── APISubscribedType+Extensions.swift │ │ │ │ │ │ ├── ApiCommentAggregates+Extensions.swift │ │ │ │ │ │ ├── ApiCommunityAggregates+Extensions.swift │ │ │ │ │ │ ├── ApiCommunityFollowerState+Extensions.swift │ │ │ │ │ │ ├── ApiGetModlogResponse+Extensions.swift │ │ │ │ │ │ ├── ApiPersonAggregates+Extensions.swift │ │ │ │ │ │ ├── ApiPostAggregates+Extensions.swift │ │ │ │ │ │ ├── ApiPrivateMessageReportView+Extensions.swift │ │ │ │ │ │ ├── ApiSiteAggregates+Extensions.swift │ │ │ │ │ │ ├── PieFedCommentAggregates+Extensions.swift │ │ │ │ │ │ ├── PieFedPostAggregates+Extensions.swift │ │ │ │ │ │ └── PieFedSubscribedType+Extensions.swift │ │ │ │ │ └── LemmyPagedResponse.swift │ │ │ │ ├── Extensions/ │ │ │ │ │ ├── Array+Extensions.swift │ │ │ │ │ ├── Bool+Extensions.swift │ │ │ │ │ ├── InstanceSummarySoftware+Extensions.swift │ │ │ │ │ ├── RandomAccessCollection+Extensions.swift │ │ │ │ │ ├── RangeReplaceableCollection+Extensions.swift │ │ │ │ │ ├── String+Extensions.swift │ │ │ │ │ └── URL+Extensions.swift │ │ │ │ ├── FeedLoaders/ │ │ │ │ │ ├── Comment/ │ │ │ │ │ │ └── SearchCommentFeedLoader.swift │ │ │ │ │ ├── Community/ │ │ │ │ │ │ └── CommunityFeedLoader.swift │ │ │ │ │ ├── DualSourceMixed/ │ │ │ │ │ │ ├── CommentChildFeedLoader.swift │ │ │ │ │ │ ├── DualSourceMixedFeedLoader.swift │ │ │ │ │ │ └── PostChildFeedLoader.swift │ │ │ │ │ ├── Filtering/ │ │ │ │ │ │ ├── FilterContext.swift │ │ │ │ │ │ ├── FilterProviding.swift │ │ │ │ │ │ ├── Filterable.swift │ │ │ │ │ │ ├── Filters/ │ │ │ │ │ │ │ ├── CommentFilter.swift │ │ │ │ │ │ │ ├── DedupeFilter.swift │ │ │ │ │ │ │ ├── InboxDedupeFilter.swift │ │ │ │ │ │ │ ├── Post/ │ │ │ │ │ │ │ │ ├── PostKeywordFilter.swift │ │ │ │ │ │ │ │ └── PostLiteralFilter.swift │ │ │ │ │ │ │ ├── ReadFilter.swift │ │ │ │ │ │ │ └── UserContentFilter.swift │ │ │ │ │ │ ├── InboxItemFilter.swift │ │ │ │ │ │ ├── ModMailItemFilter.swift │ │ │ │ │ │ ├── ModlogItemFilter.swift │ │ │ │ │ │ ├── MultiFilter.swift │ │ │ │ │ │ └── PostFilter.swift │ │ │ │ │ ├── Generics/ │ │ │ │ │ │ ├── ChildFeedLoader.swift │ │ │ │ │ │ ├── FeedLoaderItem.swift │ │ │ │ │ │ ├── FeedLoaderSort.swift │ │ │ │ │ │ ├── FeedLoading.swift │ │ │ │ │ │ ├── FeedLoadingState.swift │ │ │ │ │ │ ├── Fetcher.swift │ │ │ │ │ │ ├── LoadingActor.swift │ │ │ │ │ │ ├── MultiFetcher.swift │ │ │ │ │ │ ├── PrefetchingFeedLoader.swift │ │ │ │ │ │ └── StandardFeedLoader.swift │ │ │ │ │ ├── Helpers/ │ │ │ │ │ │ └── Thresholds.swift │ │ │ │ │ ├── Inbox/ │ │ │ │ │ │ ├── InboxFeedLoader/ │ │ │ │ │ │ │ ├── InboxChildFeedLoader.swift │ │ │ │ │ │ │ ├── InboxFeedLoader.swift │ │ │ │ │ │ │ ├── InboxFetcher.swift │ │ │ │ │ │ │ ├── MentionChildFeedLoader.swift │ │ │ │ │ │ │ ├── MessageChildFeedLoader.swift │ │ │ │ │ │ │ ├── MessageFeedLoader.swift │ │ │ │ │ │ │ └── ReplyChildFeedLoader.swift │ │ │ │ │ │ ├── InboxFeedLoading.swift │ │ │ │ │ │ ├── InboxIdentifiable.swift │ │ │ │ │ │ └── ModMailFeedLoader/ │ │ │ │ │ │ ├── ApplicationChildFeedLoader.swift │ │ │ │ │ │ ├── CommentReportChildFeedLoader.swift │ │ │ │ │ │ ├── MessageReportChildFeedLoader.swift │ │ │ │ │ │ ├── ModMailChildFeedLoader.swift │ │ │ │ │ │ ├── ModMailFeedLoader.swift │ │ │ │ │ │ ├── ModMailFetcher.swift │ │ │ │ │ │ ├── ModMailItem.swift │ │ │ │ │ │ ├── PostReportChildFeedLoader.swift │ │ │ │ │ │ └── ReportChildFeedLoader.swift │ │ │ │ │ ├── Modlog/ │ │ │ │ │ │ ├── ModlogChildFeedLoader.swift │ │ │ │ │ │ ├── ModlogChildFetcher+SharedCache.swift │ │ │ │ │ │ ├── ModlogChildFetcher.swift │ │ │ │ │ │ └── ModlogFeedLoader.swift │ │ │ │ │ ├── Person/ │ │ │ │ │ │ └── PersonFeedLoader.swift │ │ │ │ │ ├── Post Feed Loaders/ │ │ │ │ │ │ ├── AggregatePostFeedLoader.swift │ │ │ │ │ │ ├── CommunityPostFeedLoader.swift │ │ │ │ │ │ ├── CorePostFeedLoader.swift │ │ │ │ │ │ └── SearchPostFeedLoader.swift │ │ │ │ │ ├── Prefetching/ │ │ │ │ │ │ ├── ImagePrefetchProviding.swift │ │ │ │ │ │ └── PrefetchingConfiguration.swift │ │ │ │ │ └── SingleSourceMixedFeedLoader/ │ │ │ │ │ ├── PersonContent.swift │ │ │ │ │ ├── PersonContentProviding.swift │ │ │ │ │ ├── PersonContentStream.swift │ │ │ │ │ └── SingleSourceMixedFeedLoader.swift │ │ │ │ ├── InstanceConnection/ │ │ │ │ │ ├── InstanceConnection.swift │ │ │ │ │ ├── LemmyConnection/ │ │ │ │ │ │ ├── LemmyConnection+Comment.swift │ │ │ │ │ │ ├── LemmyConnection+Community.swift │ │ │ │ │ │ ├── LemmyConnection+Context.swift │ │ │ │ │ │ ├── LemmyConnection+Feature.swift │ │ │ │ │ │ ├── LemmyConnection+General.swift │ │ │ │ │ │ ├── LemmyConnection+Image.swift │ │ │ │ │ │ ├── LemmyConnection+Inbox.swift │ │ │ │ │ │ ├── LemmyConnection+Instance.swift │ │ │ │ │ │ ├── LemmyConnection+Person.swift │ │ │ │ │ │ ├── LemmyConnection+Post.swift │ │ │ │ │ │ ├── LemmyConnection+RegistrationApplication.swift │ │ │ │ │ │ ├── LemmyConnection+Report.swift │ │ │ │ │ │ └── LemmyConnection.swift │ │ │ │ │ └── PiefedConnection/ │ │ │ │ │ ├── PieFedConnection+Comment.swift │ │ │ │ │ ├── PieFedConnection+Community.swift │ │ │ │ │ ├── PieFedConnection+Feature.swift │ │ │ │ │ ├── PieFedConnection+General.swift │ │ │ │ │ ├── PieFedConnection+Image.swift │ │ │ │ │ ├── PieFedConnection+Inbox.swift │ │ │ │ │ ├── PieFedConnection+Instance.swift │ │ │ │ │ ├── PieFedConnection+Person.swift │ │ │ │ │ ├── PieFedConnection+Post.swift │ │ │ │ │ ├── PieFedConnection+RegistrationApplication.swift │ │ │ │ │ ├── PieFedConnection+Report.swift │ │ │ │ │ ├── PieFedConnection.swift │ │ │ │ │ └── PieFedLemmyCompatible/ │ │ │ │ │ └── PieFedLemmyCompatibleSite.swift │ │ │ │ ├── Protocols/ │ │ │ │ │ ├── APIContentAggregatesProtocol.swift │ │ │ │ │ └── UpgradableProtocol.swift │ │ │ │ ├── Snapshot/ │ │ │ │ │ ├── Comment/ │ │ │ │ │ │ ├── Comment1Snapshot.swift │ │ │ │ │ │ ├── Comment2Snapshot.swift │ │ │ │ │ │ └── CommentSnapshotProviding.swift │ │ │ │ │ ├── Community/ │ │ │ │ │ │ ├── Community1Snapshot.swift │ │ │ │ │ │ ├── Community2Snapshot.swift │ │ │ │ │ │ └── Community3Snapshot.swift │ │ │ │ │ ├── ImageUpload/ │ │ │ │ │ │ └── ImageUpload1Snapshot.swift │ │ │ │ │ ├── Instance/ │ │ │ │ │ │ ├── Instance1Snapshot.swift │ │ │ │ │ │ ├── Instance2Snapshot.swift │ │ │ │ │ │ └── Instance3Snapshot.swift │ │ │ │ │ ├── Message/ │ │ │ │ │ │ ├── Message1Snapshot.swift │ │ │ │ │ │ └── Message2Snapshot.swift │ │ │ │ │ ├── ModlogEntry/ │ │ │ │ │ │ ├── ModlogEntryContentSnapshot.swift │ │ │ │ │ │ └── ModlogEntrySnapshot.swift │ │ │ │ │ ├── Notification/ │ │ │ │ │ │ └── InboxNotificationSnapshot.swift │ │ │ │ │ ├── Person/ │ │ │ │ │ │ ├── Person1Snapshot.swift │ │ │ │ │ │ ├── Person2Snapshot.swift │ │ │ │ │ │ ├── Person3Snapshot.swift │ │ │ │ │ │ └── Person4Snapshot.swift │ │ │ │ │ ├── PersonVote/ │ │ │ │ │ │ └── PersonVoteSnapshot.swift │ │ │ │ │ ├── Post/ │ │ │ │ │ │ ├── Post1Snapshot.swift │ │ │ │ │ │ ├── Post2Snapshot.swift │ │ │ │ │ │ ├── Post3Snapshot.swift │ │ │ │ │ │ └── PostSnapshotProviding.swift │ │ │ │ │ ├── ProfileDetails.swift │ │ │ │ │ ├── RegistrationApplication/ │ │ │ │ │ │ └── RegistrationApplicationSnapshot.swift │ │ │ │ │ └── Report/ │ │ │ │ │ └── ReportSnapshot.swift │ │ │ │ └── UsernameValidity.swift │ │ │ └── Tests/ │ │ │ └── MlemMiddlewareTests/ │ │ │ └── MlemMiddlewareTests.swift │ │ ├── QuickSwipes/ │ │ │ ├── .gitignore │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ └── QuickSwipes/ │ │ │ ├── Array+Extensions.swift │ │ │ ├── EnvironmentValues+Extensions.swift │ │ │ ├── PanGesture.swift │ │ │ ├── QuickSwipeAction.swift │ │ │ ├── QuickSwipeThresholdSet.swift │ │ │ ├── QuickSwipesViewModifier.swift │ │ │ ├── SwipeConfiguration.swift │ │ │ ├── View+EdgeBorders.swift │ │ │ ├── View+Extensions.swift │ │ │ └── View+QuickSwipes.swift │ │ ├── Rest/ │ │ │ ├── .gitignore │ │ │ ├── Package.resolved │ │ │ ├── Package.swift │ │ │ └── Sources/ │ │ │ ├── Rest/ │ │ │ │ ├── ApiRequest.swift │ │ │ │ ├── ImageUploadDelegate.swift │ │ │ │ ├── JSONDecoder+Extensions.swift │ │ │ │ ├── JSONEncoder+Extensions.swift │ │ │ │ ├── MlemUrlRequest.swift │ │ │ │ ├── MultiPartForm.swift │ │ │ │ ├── RestClient.swift │ │ │ │ ├── RestError.swift │ │ │ │ └── URLRequest+Extensions.swift │ │ │ └── URLEncoder/ │ │ │ ├── InternalQueryItemEncoder.swift │ │ │ ├── RetrievalEncoder.swift │ │ │ ├── RetrievalSingleValueContainer.swift │ │ │ ├── String+Extensions.swift │ │ │ ├── ThrowingKeyedContainer.swift │ │ │ ├── ThrowingSingleValueContainer.swift │ │ │ ├── ThrowingUnkeyedContainer.swift │ │ │ ├── TopLevelKeyedContainer.swift │ │ │ ├── URLQueryItemEncoder.swift │ │ │ └── URLQueryItemEncoderSettings.swift │ │ └── Theming/ │ │ ├── Package.swift │ │ └── Sources/ │ │ └── Theming/ │ │ ├── Color+Extensions.swift │ │ ├── EnvironmentValues+Extensions.swift │ │ ├── Palette+Default.swift │ │ ├── Palette.swift │ │ ├── ThemedColor.swift │ │ └── View+Tint.swift │ ├── Preview Content/ │ │ ├── Preview Assets.xcassets/ │ │ │ ├── Contents.json │ │ │ ├── image.droplets.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── image.meguro_river.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── image.yorkshire_dales.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.balloon.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.circuit.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.firework.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.fish.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.flowers.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.goose.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.lakeside.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.news.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── pfp.person.imageset/ │ │ │ │ └── Contents.json │ │ │ └── pfp.shower.imageset/ │ │ │ └── Contents.json │ │ └── PreviewLocalizable.xcstrings │ └── Settings.bundle/ │ ├── Root.plist │ └── en.lproj/ │ └── Root.strings ├── Mlem.xcodeproj/ │ └── project.pbxproj ├── MlemTests/ │ ├── DateTests.swift │ └── MlemTests.swift ├── MlemUITests/ │ └── MlemUITests.swift ├── OpenInMlem/ │ ├── Action.js │ ├── ActionRequestHandler.swift │ ├── Info.plist │ ├── InfoPlist.xcstrings │ └── Media.xcassets/ │ ├── ActionIcon.appiconset/ │ │ └── Contents.json │ └── Contents.json ├── PrivacyInfo.xcprivacy ├── README.md └── brewfile ================================================ FILE CONTENTS ================================================ ================================================ FILE: .git-hooks/pre-commit ================================================ #!/bin/bash export PATH="$PATH:/opt/homebrew/bin" regex="\(Mlem/.*\).swift$" formatter=$(which swiftformat) check_for_swiftformat() { if [ ! -x "$formatter" ] then 1>&2 echo "Unable to find swiftformat - no formatting will take place" exit 0 fi } format_staged_files() { git diff --diff-filter=d --staged --name-only | grep -e '\(.*\).swift$' | while read line; do # format the stages changes in a file temporary_file="${line}.tmp.swift" git show ":$line" > "$temporary_file" $formatter "$temporary_file" $formatter "$line" blob=`git hash-object -w "$temporary_file"` git update-index --add --cacheinfo 100644 $blob "$line" rm "$temporary_file" done } main() { check_for_swiftformat format_staged_files } main ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: Bug Report description: Report unexpected behavior. type: "Bug" body: - type: checkboxes attributes: label: Requirements description: Please check whether an issue already exists for the bug you encountered. options: - label: There are no existing issues for this bug report. required: true - type: textarea attributes: label: "Description" description: "Please describe the issue. If possible, give step-by-step instructions to reproduce the bug." - type: input attributes: label: "Mlem Version" description: "You can find this under `Settings` -> `About Mlem`. Example: `Mlem 2.0 (184)`." ================================================ FILE: .github/ISSUE_TEMPLATE/improvement-proposal.yml ================================================ name: Improvement Proposal description: Propose an improvement for Mlem. type: "Feature" body: - type: checkboxes attributes: label: Requirements description: Before you create this issue, please check the following. options: - label: There are no existing issues for this feature. required: true - label: This is a request for a **single** feature (create multiple issues for multiple feature requests). required: true - type: textarea attributes: label: "Description" description: "Please describe how you would like Mlem to be improved." ================================================ FILE: .github/actions/ci_xcodebuild/action.yml ================================================ name: xcodebuild inputs: xcode_version: required: true xcodebuild_destination: required: true xcodebuild_action: required: true runs: using: "composite" steps: - uses: maxim-lobanov/setup-xcode@v1 name: Set Xcode Version with: xcode-version: "${{ inputs.xcode_version }}" - name: "Test SDK versions" shell: bash run: | xcodebuild -showsdks - name: "Xcode Build" uses: sersoft-gmbh/xcodebuild-action@v3 with: project: Mlem.xcodeproj scheme: Mlem configuration: Release destination: "${{ inputs.xcodebuild_destination }}" action: "${{ inputs.xcodebuild_action }}" result-bundle-path: build_results.xcresult ================================================ FILE: .github/pull_request_template.md ================================================ ## Issues - closes #issue - progress towards #issue ## Description ## Implementation Notes ================================================ FILE: .github/workflows/ci_build_26.yml ================================================ name: CI - Build - Xcode 26 on: push: branches: - master - dev pull_request: branches: - master - dev jobs: Build: permissions: write-all runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@v3 with: submodules: 'true' - name: Build uses: "./.github/actions/ci_xcodebuild" with: xcode_version: "26.4" xcodebuild_destination: "platform=iOS Simulator,name=iPhone 17,OS=26.4.1" xcodebuild_action: "build" ================================================ FILE: .github/workflows/ci_lint.yml ================================================ name: CI - Lint on: # Run in master as CI push: branches: - master pull_request: branches: - master - dev jobs: SwiftLint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: dorny/paths-filter@v4 id: changes with: filters: | src: - '.github/workflows/swiftlint.yml' - '.swiftlint.yml' - '**/*.swift' - 'Mlem.xcodeproj/project.pbxproj' - name: GitHub Action for SwiftLint uses: norio-nomura/action-swiftlint@3.2.1 with: args: --strict ================================================ FILE: .gitignore ================================================ # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,macos # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,macos *.orig ### macOS ### # General .DS_Store .AppleDouble .LSOverride # https://stackoverflow.com/a/65429032/17629371 Icon? ![iI]con[_a-zA-Z0-9] # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### macOS Patch ### # iCloud generated files *.icloud ### Swift ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings xcuserdata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Obj-C/Swift specific *.hmap ## App packaging *.ipa *.dSYM.zip *.dSYM ## Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm .build/ # CocoaPods # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # Pods/ # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace # Carthage # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts Carthage/Build/ # fastlane # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output # Code Injection # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ ### Xcode ### ## Xcode 8 and earlier ### Xcode Patch ### *.xcodeproj/* !*.xcodeproj/project.pbxproj !*.xcodeproj/xcshareddata/ !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings # End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos # Brew Brewfile.lock.json ================================================ FILE: .gitmodules ================================================ [submodule "Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/MlemApiTypes"] path = Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/MlemApiTypes url = https://github.com/mlemgroup/MlemApiTypes.git branch = master ================================================ FILE: .periphery.yml ================================================ project: Mlem.xcodeproj schemes: - MarkdownUI - Mlem - Semaphore targets: - Mlem ================================================ FILE: .swift-version ================================================ 5.8 ================================================ FILE: .swiftformat ================================================ --wraparguments before-first --commas inline --disable wrapMultilineStatementBraces --trimwhitespace nonblank-lines --stripunusedargs closure-only --header ignore --disable self --disable headerFileName ================================================ FILE: .swiftlint.yml ================================================ disabled_rules: - trailing_whitespace # disabling this check until swiftformat is in place, which will catch the majority. - todo line_length: warning: 140 error: 160 ignores_comments: true ignores_urls: true identifier_name: excluded: # excluded via string array - id - op - w - h - x - y allowed_symbols: ["_"] # these are allowed in type names as we use them in API body arguments excluded: - "**/.build" - "**/build" - Mlem/Packages/MlemMiddleware ================================================ FILE: Additional Documents/EULA.md ================================================ # End User License Agreement Welcome to Mlem! Before you proceed, please carefully read the following Terms of Service ("Terms") governing your use of our app. By accessing or using Mlem, you acknowledge that you have read, understood, and agreed to be bound by these Terms. If you do not agree with any part of these Terms, please refrain from using Mlem. ## 1. User Responsibilities 1.1 Reporting Content: Questionable content or content in violation of the rules and guidelines of the Lemmy instance or community on which it is hosted can be reported to the Lemmy community moderators using Mlem's built-in report function or on the instance website. We are not responsible for moderating or enforcing Lemmy instance or community rules. 1.2 Blocking Users and Instances: Mlem provides you with the ability to block individual users, communities, or entire instances. We encourage you to use these blocking features to create a safe and enjoyable Mlem experience. 1.3 Following Instance Rules: You are required to follow the rules of the instance(s) that you access using Mlem. Instance terms of use can be found on the instance website. Failure to comply with the rules of an instance may result in suspension or termination of your account with that instance as dictated by the instance rules. 1.4 No Misuse: The misuse of Mlem will result in termination of all services--to the furthest of our ability--with us. We reserve the right to terminate--to the furthest of our ability--any services that we provide. 1.5 Adult Content: Some Lemmy instances that Mlem accesses may host adult content. Lemmy blocks this content by default; if you wish to view it, in-app or otherwise, you can enable the option on the website of the instance where your account is registered. We take reasonable measures to prevent the display of explicit or adult content within Mlem, but we cannot guarantee that all instances or communities will follow the appropriate procedures to label adult content as such. It is your responsibility to exercise caution while accessing external content. 1.6 No Abusive, Unlawful, or Offensive Content: You may not use Mlem to produce or distribute any abusive, unlawful, or offensive content. This includes, but is not limited to: content that is unlawful, libelous, defamatory, or tortious; harmful, threatening, abusive, invasive, or harassing; or hateful or racially, ethnically, or otherwise discriminatory. The content you produce will not harm minors in any way. You will not impersonate any person or entity. You will not upload or post any content that you do not have the right to make available under any US or foreign laws. You will not produce content that interferes with or disrupts the app, the Lemmy instances that you use, other Lemmy instances, or any other service or person. You will not transmit misinformation in any capacity to any individual. ## 2. Limitation of Liability 2.1 No Liability: To the fullest extent permitted by applicable law, we disclaim any liability for any direct, indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, arising from your use of Mlem or any interactions within the Lemmy instances that you access thereby. This includes, but is not limited to, any damages resulting from the content, actions, or conduct of other Mlem or Lemmy users. ## 3. General Provisions 3.1 Modifications: We reserve the right to modify, suspend, or terminate Mlem or these Terms, at our sole discretion, at any time and without prior notice. Your continued use of Mlem after any modifications to these Terms shall constitute your acceptance of the modified Terms. 3.2 Governing Law: These Terms shall be governed by and construed in accordance with the laws of the United States, without regard to its conflict of laws principles. 3.3 Entire Agreement: These Terms constitute the entire agreement between you and us regarding your use of Mlem and supersede any prior or contemporaneous agreements, communications, or proposals, whether oral or written, between you and us. By using Mlem, you affirm that you have read, understood, and agreed to these revised Terms of Service. If you have any questions or concerns, please contact us at mlemappofficial@gmail.com. Thank you for using Mlem! ================================================ FILE: Additional Documents/Privacy.md ================================================ # Privacy Policy for Mlem Mobile Application Effective Date: July 15th, 2023 Thank you for using Mlem! This Privacy Policy outlines how your personal information is collected, used, and protected when you use the Mlem mobile application ("App"). Please read this Privacy Policy carefully to understand our practices regarding your personal information. By using the Mlem mobile application, you acknowledge that you have read and understood this Privacy Policy and agree to the collection, use, and disclosure of your information as described herein. ## Information We Collect Mlem does not collect or store any user data. We do not collect any personally identifiable information or track your activities within the App. ## Servers and Data Control Mlem connects to servers that we do not host or have control over. While we strive to ensure the security and privacy of your data within our App, we cannot guarantee the security or privacy practices of the external servers. Any data stored or processed on these servers is subject to the respective privacy policies and terms of service of those servers. ## Third-Party Services Mlem does not integrate any third-party services, advertising networks, or analytics tools that collect personal information or track your activities within the App. We prioritize user privacy and do not engage in any data sharing or tracking practices. ## Children's Privacy Mlem is not intended for use by individuals under the age of 13. We do not knowingly collect personal information from children under the age of 13. If we become aware that we have inadvertently collected personal information from a child under the age of 13, we will take steps to delete the information as soon as possible. If you believe that we may have collected information from a child under the age of 13, please contact us using the information provided in the "Contact Us" section below. ## Changes to this Privacy Policy We reserve the right to modify or update this Privacy Policy at any time. Any changes will be effective immediately upon posting the revised Privacy Policy. ## Contact Us If you have any questions, concerns, or requests regarding this Privacy Policy or the privacy practices of Mlem, please contact us at mlemappofficial@gmail.com. ================================================ FILE: CODEOWNERS ================================================ * @mlemgroup/mlem-dev-team ================================================ FILE: CONTRIBUTING.md ================================================ Hello! If you're reading this, you probably want to contribute to Mlem. Welcome! We're happy to have you on board. You may wish to join our [Matrix room](https://matrix.to/#/#mlemappspace:matrix.org) if you haven't already. ## Getting Started ### Cloning and Building Mlem is built using the latest stable version of Xcode. Install it from the App Store or the Apple Developer downloads page, along with the command line tools. Mlem employs submodules to integrate generated code into the main project. To clone the project, execute the following: `git clone git@github.com:mlemgroup/mlem.git --recurse-submodules` If you encounter missing `Api...` types when building, this can usually be resolved by updating the submodules: `git submodule update --recursive` ### Additional Tools This project makes use of the following tools: - Xcode 15 - [SwiftLint](https://github.com/realm/SwiftLint#swiftlint). This runs as part of the Xcode build phases. - [Swiftformat](https://github.com/nicklockwood/SwiftFormat#what-is-this). This runs as a pre-commit hook. In order to benefit please ensure you have [Homebrew](https://brew.sh) installed on your system and then run the following commands inside the project directory: ``` cd /path/to/this/repo brew update brew bundle git config --local --add core.hooksPath .git-hooks ``` With these steps completed each time you build your code will be linted, and each time you commit your code will be formatted. ### Claiming Issues 1. Go to our [project board](https://github.com/orgs/mlemgroup/projects/1/views/1). 2. Find an unassigned issue under the "Todo" section that you'd like to work on. 3. Comment that you would like to work on the issue. If the issue doesn't conflict with any in-flight work, a maintainer will assign it to you. 4. Fork the repository (see Cloning and Building) and develop the changes on your fork. It is important that you create your development branch using the upstream `dev` branch as the source, not the `master` branch. 5. Open a Pull Request for your changes. ## Merge Protocol When your code is approved, it can be merged into the `dev` branch by a member of the development team. If you need to tinker with your changes post-approval, please make a comment that you are doing so. PRs that sit approved for more than 12 hours with no input from the dev may be merged if they are blocking other work. ## Coding Conventions ### General Principles - Files should be named according to the following patterns: - All files: `TitleCase`. If the file contains extensions, it should be named `BaseEntity+Extensions`. - `View` files: file name must end in `View` (e.g., `FeedsView`) - If you can reuse code, do. Prefer abstracting common components to a generic struct and common logic to a generic function. ### Views - Only one `View` struct should be defined per file - Within reason, any complex of views that renders a single component of a larger view should be placed in a descriptively named function, computed property or `@ViewBuilder` variable beneath the body of the View. This keeps pyramids from piling up and makes our accessibility experts' work easier. - All `View` structs should be organized according to the following template: ``` struct SomeView: View { @AppStorage values @Setting values @Environment entities @Binding variables @State variables @Namespace variables Normal variables Computed properties // if necessary init() { ... } var body: some View { ... } // if necessary var content: some View { ... } Helper views } ``` - If the view has modifiers that are attached to the entire body, place the view definition in `content` and attach these modifiers to it in `body` (see `ContentView.swift` for an example). - Prefer `var helper: some View` to `func helper() -> some View` unless the helper view takes in parameters. - Helper views should always appear lower in the file than the view they help. ### Global Objects There are several objects (e.g., `AppState`) that need to be available anywhere in the app. Normally this is handled with `@Environment`, but this is not available outside of the context of a `View`. To address this, globals that need to be available outside of a `View` define a `static var main: GlobalObject = .init()`, allowing them to be referenced as `GlobalObject.main`. This definition should be placed immediately above the initializer. This pattern should be used only where necessary, and should not be blindly applied to any global object. Likewise, if possible, these objects should be referenced via `@Environment(GlobalObject.self) var globalObject`; the static singleton should be considered a last resort. ### Colors Colors are managed using our custom `Theming` package, which enables color themes. The following conventions apply: - Avoid referencing `Color` directly; always use a `Themed` color. These can be referenced the same way normal colors are referenced (e.g., `.fill(.themedSecondary)`) - Prefer semantic over literal colors (e.g., `.themedUpvote` over `.blue`). The `Theming` package requires the environmental `Palette` object. In certain rare cases, this is not implicitly accessible; if absolutely necessary, a themed color can be generated by explicitly passing in a palette: `ThemedColor..resolve(with: palette)` Avoid using this invocation unless absolutely necessary. ### Main Actor To run code on the main actor, use either: - `@MainActor` annotated method - `Task { @MainActor in ... }` If you need to execute code after a delay, use `DispatchQueue.main.asyncAfter`. ### Hashable Explicit `hash` functions for `enum`s should, in the absence of associated values, use a descriptive string to identify each case rather than an integer. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ---- The Mlem iOS developers are aware that the terms of service that apply to apps distributed via Apple's App Store services may conflict with rights granted under the Mlem iOS license, the "GNU GPLv3". We have committed not to pursue any license violation that results solely from the conflict between the "GNU GPLv3" or any later version and the Apple App Store terms of service. ================================================ FILE: Mlem/App/Actions/ActionSeed+Extensions.swift ================================================ // // ActionSeed+Extensions.swift // Mlem // // Created by Sjmarf on 2026-04-02. // import Actions extension ActionSeed { private static let moderatorActions: Set = [ .pin, .lock, .markNsfw, .viewVotes, .remove, .banCreator, .purge, .purgeCreator, .resolveReport ] var isModeratorAction: Bool { Self.moderatorActions.contains(self) } var isBasicAction: Bool { !Self.moderatorActions.contains(self) } } ================================================ FILE: Mlem/App/Actions/ActionSeedSwipeConfiguration.swift ================================================ // // ActionSeedSwipeConfiguration.swift // Mlem // // Created by Sjmarf on 2026-03-04. // import Actions import Foundation struct ActionSeedSwipeConfiguration: Encodable, Equatable { var leading: [ActionSeed] var trailing: [ActionSeed] enum CodingKeys: CodingKey { case leading, trailing } func filter(allowed seeds: [ActionSeed]) -> ActionSeedSwipeConfiguration { let keys = Set(seeds.lazy.map(\.key)) return .init( leading: leading.filter { keys.contains($0.key) }, trailing: trailing.filter { keys.contains($0.key) } ) } } extension ActionSeedSwipeConfiguration { init(from container: KeyedDecodingContainer, availableActions: [ActionSeed]) throws { let leading = try container.decode([String].self, forKey: .leading) self.leading = leading.compactMap { key in availableActions.first(where: {$0.key == key}) } let trailing = try container.decode([String].self, forKey: .trailing) self.trailing = trailing.compactMap { key in availableActions.first(where: {$0.key == key}) } } } ================================================ FILE: Mlem/App/Actions/ActionSheet/ActionSheet.swift ================================================ // // ActionSheet.swift // Mlem // // Created by Sjmarf on 2025-11-12. // import Actions import MlemMiddleware import SwiftUI struct ActionSheetSection { let actions: [any Actions.Action] } struct ActionSheet: View { @Environment(\.dismiss) var dismiss @Environment(NavigationLayer.self) var navigation let sections: [ActionSheetSection] let environment: EnvironmentValues let configuration: ContextMenuSettingsPage? @State var popupAnchorModel: PopupAnchorModel = .init() var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { content .padding(16) if let configuration { Button("Customize", icon: .general.edit) { navigation.replace(.settings(.contextMenu(configuration))) } .font(.footnote) .padding(.horizontal, 32) .padding(.top, -5) } } } .presentationBackground(.themedGroupedBackground) .presentationDragIndicator(.hidden) .presentationBackgroundInteraction(.enabled) } var content: some View { ForEach(Array(sections.enumerated()), id: \.offset) { _, section in let frames = frames(for: section.actions) if !frames.isEmpty { VStack(spacing: 0) { ForEach(Array(frames.enumerated()), id: \.offset) { index, frame in actionRow(frame, showDivider: ![frames.startIndex, frames.endIndex].contains(index)) .compositingGroup() } } .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 25)) } } .labelStyle(ActionSheetLabelStyle()) .buttonStyle(ActionSheetButtonStyle()) .onChange(of: popupAnchorModel.outcome) { if popupAnchorModel.outcome == .confirmed, !navigation.rootChangePending { dismiss() } } } private func frames(for actions: [any Actions.Action]) -> [ActionFrame] { actions.compactMap { let label = $0.createLabel(environment: environment) if label.visibility == .hidden { return nil } return .init(action: $0, label: label) } } @ViewBuilder private func actionRow(_ frame: ActionFrame, showDivider: Bool) -> some View { if showDivider { Divider() .padding(.horizontal, 15) } ActionSheetButton(action: frame.action, label: frame.label) .popupAnchor(model: popupAnchorModel) .environment(navigation) .environment(\.self, environment) } } private struct ActionSheetButton: View { @Environment(\.dismiss) var dismiss @Environment(NavigationLayer.self) var navigation @Environment(PopupAnchorModel.self) var popupAnchorModel @Environment(\.self) var environment let action: any Actions.Action // Lable passed separately for performance reasons let label: ActionLabel var body: some View { Button(label) { action.execute(environment: environment) if !navigation.rootChangePending, popupAnchorModel.data == nil { dismiss() } } .disabled(label.visibility == .disabled) } } private struct ActionFrame { let action: any Actions.Action let label: ActionLabel } private struct ActionSheetButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(configuration.role == .destructive ? .themedWarning : .themedPrimary) } } private struct ActionSheetLabelStyle: LabelStyle { @ScaledMetric(relativeTo: .body) var rowHeight = 40 func makeBody(configuration: Configuration) -> some View { HStack { configuration.title Spacer() configuration.icon .font(.title2) } .padding(.horizontal, 25) .frame(height: rowHeight) .padding(.vertical, 8) .contentShape(.rect) } } ================================================ FILE: Mlem/App/Actions/ActionSheet/CustomizableContextMenu.swift ================================================ // // CustomizableActionMenu.swift // Mlem // // Created by Sjmarf on 2026-04-02. // import Actions import SwiftUI struct CustomizableActionMenu: View { @Environment(NavigationLayer.self) var navigation @Environment(\.self) var environment let configurationKeyPathGenerator: (EnvironmentValues) -> ReferenceWritableKeyPath let createAction: (ActionSeed, EnvironmentValues) -> (any Actions.Action)? let customizable: Bool fileprivate init( customizable: Bool = true, configuration keyPathGenerator: @escaping (EnvironmentValues) -> ReferenceWritableKeyPath, createAction: @escaping (ActionSeed, EnvironmentValues) -> (any Actions.Action)?, ) { self.configurationKeyPathGenerator = keyPathGenerator self.customizable = customizable self.createAction = createAction } var configuration: Configuration { Settings.get(configurationKeyPathGenerator(environment)) } var body: some View { ActionButtons { _ in self.createActions(seeds: configuration.contextMenu) } .environment(\.isContextMenu, true) if customizable { Section { Button("More...", icon: .general.menu) { navigation.openSheet(.actionSheet( sheetSections, environment: environment, configuration: configurationKeyPathGenerator(environment) )) } .symbolVariant(.circle) } } } var sheetSections: [ActionSheetSection] { Configuration.availableActions.sections.map { seeds in .init(actions: self.createActions(seeds: seeds)) } } func createActions(seeds: [ActionSeed]) -> [any Actions.Action] { seeds.compactMap { self.createAction($0, environment) } } } extension CustomizableActionMenu { init( entity: Any, configuration keyPath: ReferenceWritableKeyPath, customizable: Bool = true ) { self.init(configuration: keyPath, customizable: customizable) { seed, _ in seed.createAction(entity) } } init( configuration keyPath: ReferenceWritableKeyPath, customizable: Bool = true, createAction: @escaping (ActionSeed, EnvironmentValues) -> (any Actions.Action)?, ) { self.configurationKeyPathGenerator = { _ in keyPath } self.customizable = customizable self.createAction = createAction } init( entity: Any, configuration: ReferenceWritableKeyPath, modMailConfiguration: ReferenceWritableKeyPath, customizable: Bool = true, _ filter: @escaping (ActionSeed) -> Bool = { _ in true } ) { self.init( customizable: customizable, configuration: { environment in if environment.reportContext != nil && Settings.get(\.interactionBar_alternateReportLayout) { configuration } else { modMailConfiguration } }, createAction: { seed, environment in if !filter(seed) { return nil } if let report = environment.reportContext { if let action = seed.createAction(report) { return action } } return seed.createAction(entity) }) } } ================================================ FILE: Mlem/App/Actions/AppointAdminAction.swift ================================================ // // AppointAdminAction.swift // Mlem // // Created by Sjmarf on 2025-10-13. // import Actions import MlemMiddleware import SwiftUI struct AppointAdminAction: Actions.Action { let entity: Person } // MARK: - Configurability extension ActionSeed { static let appointAdmin = ActionSeed("appointAdmin", label: AppointAdminAction.appointLabel) { entity in switch entity { case let entity as Person: AppointAdminAction(entity: entity) default: nil } } } // MARK: - Appearance extension AppointAdminAction { static let appointLabel: ActionLabel = .init( "Appoint Administrator", icon: .lemmy.addAdministrator, color: .themedPositive ) static let demoteLabel: ActionLabel = .init( "Remove Administrator", icon: .lemmy.removeAdministrator, color: .themedNegative ) func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let isAdmin = entity.isAdmin.value else { return Self.demoteLabel.withVisibility(.hidden) } let label: ActionLabel if isAdmin { label = Self.demoteLabel } else { label = Self.appointLabel } return label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard let entityIsAdmin = entity.isAdmin.value else { return .hidden } if entity.api.canInteract(appState: environment.appState), entity.api.isAdmin, !entityIsAdmin, entity.api.isHigherAdmin(than: entity), entity.apiIsLocal { return .enabled } else { return .hidden } } } // MARK: - Behavior extension AppointAdminAction { func execute(environment: EnvironmentValues) { guard let message = self.popupMessage(environment: environment) else { assertionFailure() return } environment.popupModel?.showPopup(message: message, [ .init(title: "Yes", isDestructive: true) { confirm(environment: environment) } ]) } private func popupMessage(environment: EnvironmentValues) -> LocalizedStringResource? { guard let isAdmin = self.entity.isAdmin.value else { return nil } if isAdmin { return "Really remove administrator \(entity.displayName) from \(self.entity.api.host)?" } else { return "Really appoint \(entity.displayName) as an administrator of \(self.entity.api.host)?" } } private func confirm(environment: EnvironmentValues) { guard let instance = entity.api.myInstance, let isAdmin = entity.isAdmin.value else { assertionFailure() return } instance.addAdmin(personId: self.entity.id, added: !isAdmin) } } ================================================ FILE: Mlem/App/Actions/AppointModeratorAction.swift ================================================ // // AppointModeratorAction.swift // Mlem // // Created by Sjmarf on 2025-10-13. // import Actions import MlemMiddleware import SwiftUI struct AppointModeratorAction: Actions.Action { let entity: Person } // MARK: - Configurability extension ActionSeed { static let appointModerator = ActionSeed("appointModerator", label: AppointModeratorAction.appointLabel) { entity in switch entity { case let entity as Person: AppointModeratorAction(entity: entity) default: nil } } } // MARK: - Appearance extension AppointModeratorAction { static let appointLabel: ActionLabel = .init( "Appoint Moderator", icon: .lemmy.addModerator, color: .themedPositive ) static let demoteLabel: ActionLabel = .init( "Remove Moderator", icon: .lemmy.removeModerator, color: .themedNegative ) func createLabel(environment: EnvironmentValues) -> ActionLabel { let label: ActionLabel if isModerator(environment: environment) ?? false { label = Self.demoteLabel } else { label = Self.appointLabel } return label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if let communityModerators = environment.communityContext?.moderators.value, let myPerson = entity.api.myPerson, entity.api.canInteract(appState: environment.appState), myPerson.canModerate(entity, communityModerators: communityModerators) { .enabled } else { .hidden } } } // MARK: - Behavior extension AppointModeratorAction { func isModerator(environment: EnvironmentValues) -> Bool? { if let communityModerators = environment.communityContext?.moderators.value { return communityModerators.contains(where: { $0.id == entity.id }) } else { return nil } } func execute(environment: EnvironmentValues) { guard let message = self.popupMessage(environment: environment) else { assertionFailure() return } environment.popupModel?.showPopup(message: message, [ .init(title: "Yes", isDestructive: true) { confirm(environment: environment) } ]) } private func popupMessage(environment: EnvironmentValues) -> LocalizedStringResource? { guard let community = environment.communityContext else { return nil } if self.isModerator(environment: environment) ?? false { return "Really remove moderator \(entity.displayName) from \(community.displayName)?" } else { return "Really appoint \(entity.displayName) as a moderator of \(community.displayName)?" } } private func confirm(environment: EnvironmentValues) { guard let isModerator = self.isModerator(environment: environment) else { assertionFailure() return } Task { do { try await environment.communityContext?.addModerator(self.entity, added: !isModerator) } catch { handleError(error) } } } } ================================================ FILE: Mlem/App/Actions/BanAction.swift ================================================ // // BanAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI private enum BanScope { case community case instance } private extension Set { static let communityOnly: Set = [.community] static let instanceOnly: Set = [.instance] static let both: Set = [.community, .instance] static let none: Set = [] } private struct BanScopePattern { let closure: (Set) -> Bool static func ~= (lhs: BanScopePattern, rhs: Set) -> Bool { lhs.closure(rhs) } } private extension BanScopePattern { static func anyContaining(_ value: BanScope) -> BanScopePattern { BanScopePattern { $0.contains(value) } } static func anyNotContaining(_ value: BanScope) -> BanScopePattern { BanScopePattern { !$0.contains(value) } } } struct BanAction: SimpleLabelAction { let entity: Person var canBanFromInstance: Bool { entity.api.isAdmin && entity.api.supports(.banFromInstance, defaultValue: false) } func canBanFromCommunity(community: Community?) -> Bool { let supportedByApi = entity.api.supports(.banFromCommunity, defaultValue: false) && ( entity.apiIsLocal || entity.api.supports(.banFromNonLocalCommunity, defaultValue: false) ) guard supportedByApi else { return false } guard let community else { return entity.api.isAdmin } guard let myPerson = entity.api.myPerson, let myPersonModerates = myPerson.moderates else { return false } return myPersonModerates(.community(community)) || entity.api.isAdmin } } // MARK: - Configurability extension ActionSeed { static let ban = ActionSeed("ban") { entity in switch entity { case let entity as Person: BanAction(entity: entity) default: nil } } static let banCreator = ActionSeed("banCreator") { entity in switch entity { case let entity as Comment: if let creator = entity.creator.value { BanAction(entity: creator) } else { nil } case let entity as Post: if let creator = entity.creator.value { BanAction(entity: creator) } else { nil } default: nil } } } // MARK: - Appearance extension BanAction { static let label: ActionLabel = .init( "Ban", icon: .lemmy.banFromCommunity, color: .themedNegative, isDestructive: true ) func createLabel(environment: EnvironmentValues) -> ActionLabel { let label: ActionLabel let appliedBanScopes = getAppliedBanScopes(environment: environment) let actionableBanScopes = getActionableBanScopes(environment: environment) switch (bannedFrom: appliedBanScopes, canBanFrom: actionableBanScopes) { case (bannedFrom: .none, canBanFrom: .both), (bannedFrom: .anyNotContaining(.instance), canBanFrom: .instanceOnly): label = .init( "Ban", icon: .lemmy.banFromInstance, color: .themedNegative, isDestructive: true ) case (bannedFrom: .anyContaining(.instance), canBanFrom: .instanceOnly), (bannedFrom: .both, canBanFrom: .both): label = .init( "Unban", icon: .lemmy.unbanFromInstance, color: .themedPositive ) case (bannedFrom: .instanceOnly, canBanFrom: .both), (bannedFrom: .communityOnly, canBanFrom: .both): label = .init( "Ban...", icon: .lemmy.banFromInstance, color: .themedNegative, isDestructive: true ) case (bannedFrom: .anyContaining(.community), canBanFrom: .communityOnly): label = .init( "Unban", icon: .lemmy.unbanFromCommunity, color: .themedPositive ) case (bannedFrom: .anyNotContaining(.community), canBanFrom: .communityOnly): label = Self.label default: return Self.label.withVisibility(.hidden) } return label.withVisibility(visibility(environment)) } /// Get the scopes that the target is current banned within. private func getAppliedBanScopes(environment: EnvironmentValues) -> Set { var output: Set = [] if isBannedFromCommunity(environment: environment) { output.insert(.community) } if entity.bannedFromInstance { output.insert(.instance) } return output } /// Get the set of ban scopes that the authorized user is able to apply to the target. private func getActionableBanScopes(environment: EnvironmentValues) -> Set { var output: Set = [] if canBanFromCommunity(community: environment.communityContext) { output.insert(.community) } if entity.api.isAdmin { output.insert(.instance) } return output } private func isBannedFromCommunity(environment: EnvironmentValues) -> Bool { guard let communityContext = environment.communityContext else { return false } return entity.isBannedFromCommunity(communityContext) ?? false } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPersonId = entity.api.myPerson?.id else { return .hidden } return entity.id == myPersonId ? .hidden : .enabled } } // MARK: - Behavior extension BanAction { // If `nil` is returned, a modal should be shown asking whether the user wants to ban or unban private func shouldBan(environment: EnvironmentValues) -> Bool? { let bannedFromCommunity = isBannedFromCommunity(environment: environment) let bannedFromInstance = entity.bannedFromInstance if canBanFromInstance { switch (bannedFromCommunity, bannedFromInstance) { case (false, false): return true case (true, true): return false default: return nil } } else { return !bannedFromCommunity } } @MainActor func execute(environment: EnvironmentValues) { if let shouldBan = shouldBan(environment: environment) { showBanSheet(environment: environment, shouldBan: shouldBan) } else { showAlert(environment: environment) } } @MainActor private func showAlert(environment: EnvironmentValues) { var actions: [PopupAnchorModel.Action] = [] if entity.bannedFromInstance { actions.append( .init(title: "Unban from Instance", isDestructive: false) { showBanSheet(environment: environment, shouldBan: false) } ) } else { actions.append( .init(title: "Ban from Instance", isDestructive: true) { showBanSheet(environment: environment, shouldBan: true) } ) } if isBannedFromCommunity(environment: environment) { actions.append( .init(title: "Unban from Community", isDestructive: false) { showBanSheet(environment: environment, shouldBan: false) } ) } else { actions.append( .init(title: "Ban from Community", isDestructive: true) { showBanSheet(environment: environment, shouldBan: true) } ) } environment.popupModel?.showPopup(message: "Choose an action...", actions) } @MainActor private func showBanSheet(environment: EnvironmentValues, shouldBan: Bool) { environment.navigation?.openSheet(.ban( entity, isBannedFromCommunity: isBannedFromCommunity(environment: environment), shouldBan: shouldBan, community: environment.communityContext )) } } ================================================ FILE: Mlem/App/Actions/BlockAction.swift ================================================ // // BlockAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI import MlemBackend struct BlockAction: Actions.Action { enum Relationship { case direct, indirect } enum ContentType { case personOnly, communityOnly, instanceOnly, multi, other } let content: [any Blockable] let relationship: Relationship var availableContent: [any Blockable] { content.filter { item in switch item { case let entity as Person: guard let myPersonId = entity.api.myPerson?.id else { return true } return entity.id != myPersonId default: return true } } } } private extension [Blockable] { var contentType: BlockAction.ContentType { if self.count > 1 { return .multi } guard let first = self.first else { return .other } return switch first { case _ as Person: .personOnly case _ as Community: .communityOnly case _ as Instance: .instanceOnly default: .other } } } // MARK: - Configurability extension ActionSeed { static let block = ActionSeed( "block", label: BlockAction.createLabel(relationship: .direct, mode: .block, contentType: .multi) ) { entity in switch entity { case let entity as any Blockable: BlockAction(content: [entity], relationship: .direct) default: nil } } static let blockCreator = ActionSeed( "blockCreator", label: BlockAction.createLabel(relationship: .indirect, mode: .block, contentType: .multi) ) { entity in switch entity { case let entity as Comment: if let creator = entity.creator.value { BlockAction( content: [creator], relationship: .indirect) } else { nil } case let entity as Post: if let creator = entity.creator.value, let community = entity.community.value { BlockAction( content: [creator, community], relationship: .indirect) } else { nil } default: nil } } } // MARK: - Appearance extension BlockAction { enum Mode { case block, unblock } // swiftlint:disable:next cyclomatic_complexity static func createLabel(relationship: Relationship, mode: Mode, contentType: ContentType) -> ActionLabel { let label: LocalizedStringResource = switch (relationship, mode, contentType) { case (.direct, .block, _): "Block" case (.direct, .unblock, _): "Unblock" case (.indirect, .block, .personOnly): "Block User" case (.indirect, .unblock, .personOnly): "Unblock User" case (.indirect, .block, .communityOnly): "Block Community" case (.indirect, .unblock, .communityOnly): "Unblock Community" case (.indirect, .block, .instanceOnly): "Block Instance" case (.indirect, .unblock, .instanceOnly): "Unblock Instance" case (.indirect, .block, .multi): "Block..." case (.indirect, .unblock, .multi): "Unblock..." case (_, _, .other): "Block..." } return switch mode { case .block: .init( label, icon: .lemmy.block, color: .themedNegative, isDestructive: true ) case .unblock: .init( label, icon: .lemmy.unblock, color: .themedPositive ) } } func createLabel(environment: EnvironmentValues) -> ActionLabel { return Self.createLabel( relationship: self.relationship, mode: content.first!.blocked(environment: environment) ? .unblock : .block, contentType: availableContent.contentType ).withVisibility(visibility(environment)) } // swiftlint:disable:next cyclomatic_complexity private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { let canInteract = content.allSatisfy { if $0 is any InstanceActionProviding { return true } else if let contentModel = $0 as? ContentModel { return contentModel.api.canInteract(appState: environment.appState) && $0.updateBlocked != nil } return false } guard canInteract else { return .hidden } for item in content { switch item { case let person as Person: guard let myPersonId = person.api.myPerson?.id else { return .hidden } guard person.id != myPersonId else { return .hidden } case let instance as any InstanceActionProviding: let api = environment.appState.firstApi guard api.supports(.blockInstances, defaultValue: false) else { return .hidden } guard api.actorId != instance.actorId else { return .hidden } default: break } } return .enabled } } // MARK: - Behavior extension BlockAction { @MainActor func execute(environment: EnvironmentValues) { if availableContent.count > 1 { executeMulti(environment: environment) return } guard let first = availableContent.first else { assertionFailure() return } execute(entity: first, environment: environment) } @MainActor func executeMulti(environment: EnvironmentValues) { let actions: [PopupAnchorModel.Action] = content.map { item in let callback = { submit(entity: item, environment: environment) } let label = Self.createLabel( relationship: .indirect, mode: item.blocked(environment: environment) ? .unblock : .block, contentType: item is Person ? .personOnly: .communityOnly ) return .init( title: label.title, isDestructive: label.isDestructive, callback: callback ) } environment.popupModel?.showPopup(message: "User or community?", actions) } @MainActor func execute(entity: any Blockable, environment: EnvironmentValues) { if entity.blocked(environment: environment) { submit(entity: entity, environment: environment) return } let label: String switch entity { case _ as Person: label = .init(localized: "Really block this user?") case _ as Community: label = .init(localized: "Really block this community?") case _ as any InstanceActionProviding: label = .init(localized: "Really block this instance?") default: assertionFailure() label = "Really block?" } environment.popupModel?.showPopup(message: label, [ .init(title: "Yes", isDestructive: true) { submit(entity: entity, environment: environment) } ]) } private func submit(entity: any Blockable, environment: EnvironmentValues) { let shouldBlock = !entity.blocked(environment: environment) if let updateBlocked = entity.updateBlocked { updateBlocked(shouldBlock) { didSucceed in let toast = createToast(didBlock: shouldBlock, didSucceed: didSucceed) { updateBlocked(!shouldBlock, nil) } environment.toastModel?.add(toast) } } else if entity is any InstanceActionProviding, let session = (environment.appState.firstSession as? UserSession) { session.updateInstanceBlock(actorId: entity.actorId, shouldBlock: shouldBlock) { didSucceed in let toast = createToast(didBlock: shouldBlock, didSucceed: didSucceed) { session.updateInstanceBlock(actorId: entity.actorId, shouldBlock: !shouldBlock) } environment.toastModel?.add(toast) } } else { assertionFailure("Failed to block entity") } } private func createToast( didBlock: Bool, didSucceed: Bool, undo: @escaping () -> Void ) -> ToastType { switch (didBlock, didSucceed) { case (true, true): .undoable( "Blocked", icon: .lemmy.block, callback: undo, color: .themedNegative ) case (true, false): .failure("Failed to block!") case (false, true): .basic("Unblocked", icon: .lemmy.unblock) case (false, false): .failure("Failed to unblock!") } } } ================================================ FILE: Mlem/App/Actions/CollapseAction.swift ================================================ // // CollapseAction.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import MlemMiddleware import SwiftUI struct CollapseAction: SimpleLabelAction { let entity: Comment } // MARK: - Configurability extension ActionSeed { static let collapse = ActionSeed("collapse") { entity in switch entity { case let entity as Comment: CollapseAction(entity: entity) default: nil } } } // MARK: - Appearance extension CollapseAction { static let collapseLabel: ActionLabel = .init( "Collapse", icon: .general.collapse, color: .themedColorfulAccent(0) ) static let expandLabel: ActionLabel = .init( "Expand", icon: .general.expand, color: .themedColorfulAccent(0) ) static var label: ActionLabel { collapseLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let node = environment.commentTreeTracker?.getNode(actorId: entity.actorId) else { return Self.label.withVisibility(.hidden) } if node.collapsed { return Self.expandLabel } else { return Self.collapseLabel } } } // MARK: - Behavior extension CollapseAction { @MainActor func execute(environment: EnvironmentValues) { if let node = environment.commentTreeTracker?.getNode(actorId: entity.actorId) { withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { node.collapsed.toggle() } } } } ================================================ FILE: Mlem/App/Actions/CollapseParentAction.swift ================================================ // // CollapseParentAction.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import MlemMiddleware import SwiftUI struct CollapseParentAction: SimpleLabelAction { let entity: Comment } // MARK: - Configurability extension ActionSeed { static let collapseParent = ActionSeed("collapseParent") { entity in switch entity { case let entity as Comment: CollapseParentAction(entity: entity) default: nil } } } // MARK: - Appearance extension CollapseParentAction { static let label: ActionLabel = .init( "Collapse Parent", icon: .lemmy.collapseParent, color: .themedColorfulAccent(0) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { return Self.label.withVisibility(visibility(environment: environment)) } func visibility(environment: EnvironmentValues) -> ActionVisiblity { if environment.commentTreeTracker?.hasNode(actorId: entity.actorId) ?? false { .enabled } else { .hidden } } } // MARK: - Behavior extension CollapseParentAction { @MainActor func execute(environment: EnvironmentValues) { if let node = environment.commentTreeTracker?.getNode(actorId: entity.actorId) { withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { (node.parent ?? node).collapsed.toggle() } } } } ================================================ FILE: Mlem/App/Actions/CollapseToTopAction.swift ================================================ // // CollapseToTopAction.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import MlemMiddleware import SwiftUI struct CollapseToTopAction: SimpleLabelAction { let entity: Comment } // MARK: - Configurability extension ActionSeed { static let collapseToTop = ActionSeed("collapseToTop") { entity in switch entity { case let entity as Comment: CollapseToTopAction(entity: entity) default: nil } } } // MARK: - Appearance extension CollapseToTopAction { static let label: ActionLabel = .init( "Collapse to Top", icon: .lemmy.collapseToTop, color: .themedColorfulAccent(0) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { return Self.label.withVisibility(visibility(environment: environment)) } func visibility(environment: EnvironmentValues) -> ActionVisiblity { if environment.commentTreeTracker?.hasNode(actorId: entity.actorId) ?? false { .enabled } else { .hidden } } } // MARK: - Behavior extension CollapseToTopAction { @MainActor func execute(environment: EnvironmentValues) { if let node = environment.commentTreeTracker?.getNode(actorId: entity.actorId) { withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { node.topParent.collapsed.toggle() } } } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Comment.swift ================================================ // // ContextMenu+Comment.swift // Mlem // // Created by Sjmarf on 2025-10-17. // import Actions import Icons import MlemMiddleware import SwiftUI private let seeds: [ActionSeed] = [ .upvote, .downvote, .save, .reply, .selectText, .share, .createImage, .report, .blockCreator, .edit, .delete ] private let moderationSeeds: [ActionSeed] = [ .viewVotes, .remove, .banCreator, .purge, .purgeCreator ] extension View { func contextMenu(comment: Comment) -> some View { contextMenu { CustomizableActionMenu( entity: comment, configuration: \.interactionBar_comment, modMailConfiguration: \.interactionBar_commentReport, customizable: true ) } .popupAnchor() } @ViewBuilder func quickSwipes(comment: Comment, configuration: CommentBarConfiguration) -> some View { quickSwipes( leading: configuration.swipes.leading.compactMap { $0.createAction(comment) }, trailing: configuration.swipes.trailing.compactMap { $0.createAction(comment) } ) } } extension EllipsisMenu { init( icon: Icon = .general.menu, size: CGFloat, comment: Comment, type: Set = [.basic, .moderator] ) where Content == CustomizableActionMenu { self.icon = icon self.size = size self.content = CustomizableActionMenu( entity: comment, configuration: \.interactionBar_comment, modMailConfiguration: \.interactionBar_commentReport, customizable: true ) { seed in if seed.isModeratorAction { return type.contains(.moderator) } else { return type.contains(.basic) } } } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Community.swift ================================================ // // ContextMenu+Community.swift // Mlem // // Created by Sjmarf on 2026-02-08. // import Actions import MlemMiddleware import SwiftUI extension ActionButtons { init(community: Community) { self.init { _ in CommunityActionConfiguration.availableActions.all.compactMap { $0.createAction(community) } } } } extension View { func contextMenu(community: Community) -> some View { contextMenu { ActionButtons(community: community) } } @ViewBuilder func quickSwipes(community: Community, configuration: CommunityActionConfiguration) -> some View { quickSwipes( leading: configuration.swipes.leading.compactMap { $0.createAction(community) }, trailing: configuration.swipes.trailing.compactMap { $0.createAction(community) } ) } } ================================================ FILE: Mlem/App/Actions/ContextMenu+InboxNotification.swift ================================================ // // ContextMenu+InboxNotification.swift // Mlem // // Created by Sjmarf on 2025-11-07. // import Actions import Icons import MlemMiddleware import SwiftUI extension View { func contextMenu(notification: InboxNotification) -> some View { contextMenu { CustomizableActionMenu(configuration: \.interactionBar_reply) { seed, _ in seed.createAction(notification) ?? seed.createAction(notification.content.wrappedValue) } } } func contextMenu(notification: InboxNotification?, message: any Message, report: Report?) -> some View { contextMenu { CustomizableActionMenu(configuration: \.interactionBar_reply) { seed, _ in if let notification { if let action = seed.createAction(notification) { return action } } if let report { if let action = seed.createAction(report) { return action } } return seed.createAction(message) } } } @ViewBuilder func quickSwipes(notification: InboxNotification, configuration: ReplyBarConfiguration) -> some View { quickSwipes( leading: configuration.swipes.leading.compactMap { seed in seed.createAction(notification) ?? notification.content.comment.map { seed.createAction($0) } ?? nil }, trailing: configuration.swipes.trailing.compactMap { seed in seed.createAction(notification) ?? notification.content.comment.map { seed.createAction($0) } ?? nil } ) } } private extension InboxNotificationContent { var comment: Comment? { switch self { case let .reply(comment), let .mention(comment): comment default: nil } } } extension EllipsisMenu { init( icon: Icon = .general.menu, size: CGFloat, notification: InboxNotification, type: Set = [.basic, .moderator] ) where Content == CustomizableActionMenu { self.icon = icon self.size = size self.content = CustomizableActionMenu(configuration: \.interactionBar_reply) { seed, _ in if seed.isModeratorAction { if !type.contains(.moderator) { return nil } } else { if !type.contains(.basic) { return nil } } return seed.createAction(notification) ?? seed.createAction(notification.content.wrappedValue) } } } extension EllipsisMenu { init( icon: Icon = .general.menu, size: CGFloat, message: any Message, report: Report, type: Set = [.basic, .moderator] ) where Content == CustomizableActionMenu { self.icon = icon self.size = size self.content = CustomizableActionMenu(configuration: \.interactionBar_reply) { seed, _ in if seed.isModeratorAction { if !type.contains(.moderator) { return nil } } else { if !type.contains(.basic) { return nil } } return seed.createAction(report) ?? seed.createAction(message) } } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Instance.swift ================================================ // // ContextMenu+Instance.swift // Mlem // // Created by Sjmarf on 2026-01-15. // import Actions import MlemMiddleware import SwiftUI import MlemBackend private let seeds: [ActionSeed] = [ .visit, .logIn, .signUp, .openInBrowser, .share, .block ] extension View { @ViewBuilder func contextMenu(instance: (any InstanceActionProviding)?) -> some View { if let instance { contextMenu { ActionButtons { _ in seeds.compactMap { $0.createAction(instance) } } } } else { self } } } extension ToolbarEllipsisMenu { init(instance: any InstanceActionProviding) where Content == ActionButtons { self.init { ActionButtons { _ in seeds.compactMap { $0.createAction(instance) } } } } } extension View { @ViewBuilder func contextMenu(instance: any InstanceActionProviding) -> some View { contextMenu { ActionButtons { _ in seeds.compactMap { $0.createAction(instance) } } } } } // MARK: - InstanceActionProviding public protocol InstanceActionProviding: Sharable, Blockable { var instanceStub: InstanceStub { get } } extension Instance: InstanceActionProviding { public var instanceStub: InstanceStub { .init(api: api, actorId: actorId) } } extension InstanceSummary: @retroactive Sharable {} extension InstanceSummary: @retroactive ActorIdentifiable {} extension InstanceSummary: @retroactive Blockable {} extension InstanceSummary: InstanceActionProviding { public var actorId: ActorIdentifier { instanceStub.actorId } public func url() -> URL { actorId.url } public var blocked: any RealizedValueProviding { RealizedValue(false) } public var updateBlocked: ((Bool, ((Bool) -> Void)?) -> Void)? { nil } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Message.swift ================================================ // // ContextMenu+Message.swift // Mlem // // Created by Sjmarf on 2025-10-17. // import Actions import MlemMiddleware import SwiftUI private let seeds: [ActionSeed] = [ .reply, .selectText, .report, .edit, .delete ] extension View { func contextMenu(message: any Message1Providing) -> some View { contextMenu { ActionButtons { _ in seeds.compactMap { $0.createAction(message) } } } } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Person.swift ================================================ // // ContextMenu+Person.swift // Mlem // // Created by Sjmarf on 2025-10-17. // import Actions import MlemMiddleware import SwiftUI private let seeds: [ActionSeed] = [ .goToInstance, .copyName, .share, .sendMessage, .block, .editNote, .openModlog, .ban, .purge, .appointModerator, .appointAdmin ] extension ActionButtons { init(person: Person) { self.init { _ in seeds.compactMap { $0.createAction(person) } } } } extension View { func contextMenu(person: Person) -> some View { contextMenu { ActionButtons(person: person) } } } ================================================ FILE: Mlem/App/Actions/ContextMenu+Post.swift ================================================ // // ContextMenu+Post.swift // Mlem // // Created by Sjmarf on 2025-12-23. // import Actions import Icons import MlemMiddleware import SwiftUI extension View { func contextMenu(post: Post) -> some View { contextMenu { CustomizableActionMenu( entity: post, configuration: \.interactionBar_post, modMailConfiguration: \.interactionBar_postReport, customizable: true ) } .popupAnchor() } @ViewBuilder func quickSwipes(post: Post, configuration: PostBarConfiguration) -> some View { quickSwipes( leading: configuration.swipes.leading.compactMap { $0.createAction(post) }, trailing: configuration.swipes.trailing.compactMap { $0.createAction(post) } ) } } enum EllipsisMenuType { case basic, moderator } extension EllipsisMenu { init( icon: Icon = .general.menu, size: CGFloat, post: Post, type: Set = [.basic, .moderator] ) where Content == CustomizableActionMenu { self.icon = icon self.size = size self.content = CustomizableActionMenu( entity: post, configuration: \.interactionBar_post, modMailConfiguration: \.interactionBar_postReport, customizable: true ) { seed in if seed.isModeratorAction { return type.contains(.moderator) } else { return type.contains(.basic) } } } } ================================================ FILE: Mlem/App/Actions/CopyNameAction.swift ================================================ // // CopyNameAction.swift // Mlem // // Created by Sjmarf on 2025-10-13. // import Actions import MlemMiddleware import SwiftUI struct CopyNameAction: Actions.Action { enum Relationship { case identity, author } let text: String let relationship: Relationship } // MARK: - Configurability extension ActionSeed { static let copyName = ActionSeed( "copyName", label: CopyNameAction.createLabel(relationship: .identity) ) { entity in switch entity { case let entity as Person: CopyNameAction(text: entity.fullNameWithPrefix, relationship: .identity) case let entity as Community: CopyNameAction(text: entity.fullNameWithPrefix, relationship: .identity) default: nil } } static let copyAuthorName = ActionSeed( "copyAuthorName", label: CopyNameAction.createLabel(relationship: .author) ) { entity in switch entity { case let entity as any InteractableProviding: if let creator = entity.creator.value { CopyNameAction(text: creator.fullNameWithPrefix, relationship: .author) } else { nil } default: nil } } } // MARK: - Appearance extension CopyNameAction { static func createLabel(relationship: Relationship) -> ActionLabel { .init( relationship == .identity ? "Copy Name" : "Copy Username", icon: .general.copy, color: .themedColorfulAccent(4) ) } func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.createLabel(relationship: self.relationship) } } // MARK: - Behavior extension CopyNameAction { func execute(environment: EnvironmentValues) { environment.toastModel?.add(.success("Copied")) UIPasteboard.general.string = self.text } } ================================================ FILE: Mlem/App/Actions/CreateImageAction.swift ================================================ // // CreateImageAction.swift // Mlem // // Created by Eric Andrews on 2025-12-06. // import Actions import MlemMiddleware import SwiftUI struct CreateImageAction: SimpleLabelAction { enum Content { case comment(Comment) case post(Post) } let content: Content } // MARK: - Configurability extension ActionSeed { static let createImage = ActionSeed("createImage") { entity in switch entity { case let entity as Post: CreateImageAction(content: .post(entity)) case let entity as Comment: CreateImageAction(content: .comment(entity)) default: nil } } } // MARK: - Appearance extension CreateImageAction { static let label: ActionLabel = .init( "Create Image", icon: .general.createImage, color: .themedColorfulAccent(5) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if environment.isContextMenu && environment.feedContext != .post { .hidden } else { .enabled } } } // MARK: - Behavior extension CreateImageAction { @MainActor func execute(environment: EnvironmentValues) { guard let navigation = environment.navigation else { assertionFailure() return } switch self.content { case let .post(post): navigation.openSheet(.exportPostImage(post)) case let .comment(comment): navigation.openSheet(.exportCommentImage(comment, tracker: environment.commentTreeTracker)) } } } ================================================ FILE: Mlem/App/Actions/CrosspostAction.swift ================================================ // // CrosspostAction.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import MlemMiddleware import SwiftUI struct CrosspostAction: SimpleLabelAction { let entity: Post } // MARK: - Configurability extension ActionSeed { static let crosspost = ActionSeed("crosspost") { entity in switch entity { case let entity as Post: CrosspostAction(entity: entity) default: nil } } } // MARK: - Appearance extension CrosspostAction { static let label: ActionLabel = .init( "Crosspost", icon: .lemmy.crosspost, color: .themedColorfulAccent(5) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if entity.api.canInteract(appState: environment.appState) { .enabled } else { .hidden } } } // MARK: - Behavior extension CrosspostAction { @MainActor func execute(environment: EnvironmentValues) { var crossPostContent: String let crossPostedLabel = String(localized: "Crossposted from \(entity.actorId.description)") if let content = entity.content, !content.isEmpty { crossPostContent = "\(crossPostedLabel)\n-----\n\(content)" } else { crossPostContent = crossPostedLabel } environment.navigation?.openSheet(.createPost( community: nil, title: entity.title, content: crossPostContent, type: entity.type, nsfw: entity.nsfw, feedLoader: .init(wrappedValue: nil) )) } } ================================================ FILE: Mlem/App/Actions/DeleteAction.swift ================================================ // // DeleteAction.swift // Mlem // // Created by Sjmarf on 2025-10-17. // import Actions import MlemMiddleware import SwiftUI struct DeleteAction: SimpleLabelAction { let entity: any DeletableProviding } // MARK: - Configurability extension ActionSeed { static let delete = ActionSeed("delete") { entity in switch entity { case let entity as any DeletableProviding: DeleteAction(entity: entity) default: nil } } } // MARK: - Appearance extension DeleteAction { static let deleteLabel: ActionLabel = .init( "Delete", icon: .general.delete, color: .themedNegative, isDestructive: true ) static let restoreLabel: ActionLabel = .init( "Restore", icon: .lemmy.restore, color: .themedPositive ) static var label: ActionLabel { deleteLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.deleted { Self.restoreLabel.withVisibility(visibility(environment)) } else { Self.deleteLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPersonId = entity.api.myPerson?.id else { return .hidden } guard entity.isOwnContent(myPersonId: myPersonId) else { return .hidden } guard !entity.deleted || canUndelete else { return .hidden } return .enabled } } // MARK: - Behavior extension DeleteAction { @MainActor func execute(environment: EnvironmentValues) { environment.popupModel?.showPopup(message: "Really delete?", [ .init(title: "Yes", isDestructive: true) { entity.toggleDeleted { status in let toast = createToast(didDelete: entity.deleted, requestStatus: status) environment.toastModel?.add(toast) } } ]) } var canUndelete: Bool { switch entity { case is any Message1Providing: entity.api.supports(.undeletePrivateMessages, defaultValue: true) default: true } } private func createToast(didDelete: Bool, requestStatus: UpdateStatus) -> ToastType { switch (didDelete, requestStatus) { case (true, .success): createConfirmationToast() case (true, .failure): .failure("Failed to delete!") case (false, .success): .success("Restored") case (false, .failure): .failure("Failed to restore!") } } private func createConfirmationToast() -> ToastType { if canUndelete { .undoable( "Deleted", icon: .general.delete, callback: { entity.updateDeleted(false, callback: nil) }, color: .themedNegative ) } else { .basic( "Deleted", icon: .general.delete, color: .themedNegative ) } } } ================================================ FILE: Mlem/App/Actions/EditAction.swift ================================================ // // EditAction.swift // Mlem // // Created by Sjmarf on 2025-10-17. // import Actions import MlemMiddleware import SwiftUI struct EditAction: SimpleLabelAction { enum Content { case post(Post) case comment(Comment) case message(any Message1Providing) var value: any OwnershipProviding { switch self { case let .post(post): post case let .comment(comment): comment case let .message(message): message } } } let content: Content } // MARK: - Configurability extension ActionSeed { static let edit = ActionSeed("edit") { entity in switch entity { case let entity as any Message1Providing: EditAction(content: .message(entity)) case let entity as Comment: EditAction(content: .comment(entity)) case let entity as Post: EditAction(content: .post(entity)) default: nil } } } // MARK: - Appearance extension EditAction { static let label: ActionLabel = .init("Edit", icon: .general.edit) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard content.value.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPersonId = content.value.api.myPerson?.id else { return .hidden } return content.value.isOwnContent(myPersonId: myPersonId) ? .enabled : .hidden } } // MARK: - Behavior extension EditAction { @MainActor func execute(environment: EnvironmentValues) { switch content { case let .comment(comment): environment.navigation?.openSheet(.editComment(comment, context: nil)) case let .post(post): environment.navigation?.openSheet(.editPost(post)) case let .message(message): if let message = message as? any Message2Providing { if let editMessage = environment.editMessage { editMessage(message.message2) } else { let otherPerson = message.isOwnMessage ? message.recipient : message.creator environment.navigation?.push(.messageFeed(otherPerson, focusTextField: true, editing: message)) } } else { assertionFailure() } } } } ================================================ FILE: Mlem/App/Actions/EditNoteAction.swift ================================================ // // EditNoteAction.swift // Mlem // // Created by Sjmarf on 2025-12-13. // import Actions import MlemMiddleware import SwiftUI struct EditNoteAction: Actions.Action { let entity: Person } // MARK: - Configurability extension ActionSeed { static let editNote = ActionSeed( "editNote", label: EditNoteAction.createLabel(noteExists: true) ) { entity in switch entity { case let entity as Person: EditNoteAction(entity: entity) default: nil } } } // MARK: - Appearance extension EditNoteAction { static func createLabel(noteExists: Bool) -> ActionLabel { if noteExists { .init("Edit Note", icon: .lemmy.editNote) } else { .init("Add Note", icon: .lemmy.editNote) } } func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.createLabel(noteExists: entity.note != nil) .withVisibility(entity.api.supports(.userNotes, defaultValue: false) ? .enabled : .hidden) } } // MARK: - Behavior extension EditNoteAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.editNote(entity)) } } ================================================ FILE: Mlem/App/Actions/FavoriteAction.swift ================================================ // // FavoriteAction.swift // Mlem // // Created by Sjmarf on 2026-02-08. // import Actions import MlemMiddleware import SwiftUI struct FavoriteAction: SimpleLabelAction { let entity: Community } // MARK: - Configurability extension ActionSeed { static let favorite = ActionSeed("favorite") { entity in switch entity { case let entity as Community: FavoriteAction(entity: entity) default: nil } } } // MARK: - Appearance extension FavoriteAction { static let favoriteLabel: ActionLabel = .init( "Favorite", icon: .lemmy.favorite, color: .themedFavorite ) static let unfavoriteLabel: ActionLabel = .init( "Unfavorite", icon: .lemmy.unfavorite, color: .themedFavorite ) static var label: ActionLabel { favoriteLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.favorited { return Self.unfavoriteLabel.withVisibility(visibility(environment)) } else { return Self.favoriteLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState), entity.updateFavorite != nil else { return .hidden } return .enabled } } // MARK: - Behavior extension FavoriteAction { @MainActor func execute(environment: EnvironmentValues) { guard let updateFavorite = entity.updateFavorite else { return } environment.hapticManager.play(haptic: .lightSuccess, tier: .low) if entity.favorited { environment.toastModel?.add( .undoable( "Unfavorited", icon: .lemmy.unfavorite, callback: { updateFavorite(true) }, color: .themedFavorite ) ) } else { environment.toastModel?.add( .basic("Favorited", icon: .lemmy.favorite, color: .themedFavorite) ) } updateFavorite(!entity.favorited) } } ================================================ FILE: Mlem/App/Actions/GoToInstanceAction.swift ================================================ // // GoToInstanceAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct GoToInstanceAction: SimpleLabelAction { let entity: any ActorIdentifiable } // MARK: - Configurability extension ActionSeed { static let goToInstance = ActionSeed("goToInstance") { entity in switch entity { case let entity as any ActorIdentifiable: GoToInstanceAction(entity: entity) default: nil } } } // MARK: - Appearance extension GoToInstanceAction { static let label: ActionLabel = .init( "Go to Instance", icon: .lemmy.instance, color: .themedColorfulAccent(1) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withTitle(entity.host) } } // MARK: - Behavior extension GoToInstanceAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.push(.hostInstance(of: self.entity)) } } ================================================ FILE: Mlem/App/Actions/HideAction.swift ================================================ // // HideAction.swift // Mlem // // Created by Sjmarf on 2025-12-23. // import Actions import MlemMiddleware import SwiftUI struct HideAction: SimpleLabelAction { let entity: Post } // MARK: - Configurability extension ActionSeed { static let hide = ActionSeed("hide") { entity in switch entity { case let entity as Post: HideAction(entity: entity) default: nil } } } // MARK: - Appearance extension HideAction { static let hideLabel: ActionLabel = .init( "Hide", icon: .general.hide, color: .themedColorfulAccent(4) ) static let showLabel: ActionLabel = .init( "Show", icon: .general.show, color: .themedColorfulAccent(4) ) static var label: ActionLabel { hideLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let hidden = entity.hidden.value else { return Self.showLabel.withVisibility(.hidden) } if hidden { return Self.showLabel.withVisibility(visibility(environment)) } else { return Self.hideLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if entity.api.canInteract(appState: environment.appState), entity.api.supports(.hidePosts, defaultValue: false) { .enabled } else { .hidden } } } // MARK: - Behavior extension HideAction { @MainActor func execute(environment: EnvironmentValues) { guard let hidden = entity.hidden.value, let toggleHidden = entity.toggleHidden else { return } toggleHidden([]) environment.hapticManager.play(haptic: .lightSuccess, tier: .low) if !hidden { environment.toastModel?.add( .undoable( "Hidden", icon: .general.hide, callback: { entity.updateHidden(false) } ) ) } else { environment.toastModel?.add(.success("Shown")) } } } ================================================ FILE: Mlem/App/Actions/LockAction.swift ================================================ // // LockAction.swift // Mlem // // Created by Sjmarf on 2025-12-23. // import Actions import MlemMiddleware import SwiftUI struct LockAction: SimpleLabelAction { let entity: Post } // MARK: - Configurability extension ActionSeed { static let lock = ActionSeed("lock") { entity in switch entity { case let entity as Post: LockAction(entity: entity) default: nil } } } // MARK: - Appearance extension LockAction { static let lockLabel: ActionLabel = .init( "Lock", icon: .lemmy.addLock, color: .themedLockAccent ) static let unlockLabel: ActionLabel = .init( "Unlock", icon: .lemmy.removeLock, color: .themedLockAccent ) static var label: ActionLabel { lockLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.locked { Self.unlockLabel.withVisibility(visibility(environment)) } else { Self.lockLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if entity.api.canInteract(appState: environment.appState), entity.canModerate { return .enabled } else { return .hidden } } } // MARK: - Behavior extension LockAction { @MainActor func execute(environment: EnvironmentValues) { environment.popupModel?.showPopup( message: entity.locked ? "Really unlock this post?" : "Really lock this post?", [ .init(title: "Yes", isDestructive: true) { let shouldLock = !entity.locked entity.toggleLocked([]) { status in self.handleResult( status: status, shouldLock: shouldLock, environment: environment ) } } ]) } @MainActor func handleResult( status: UpdateStatus, shouldLock: Bool, environment: EnvironmentValues ) { switch status { case .success: environment.hapticManager.play(haptic: .lightSuccess, tier: .low) case .failure: environment.toastModel?.add( .failure(shouldLock ? "Failed to lock post" : "Failed to unlock post") ) } } } ================================================ FILE: Mlem/App/Actions/LogInAction.swift ================================================ // // LogInAction.swift // Mlem // // Created by Sjmarf on 2026-01-16. // import Actions import MlemMiddleware import SwiftUI struct LogInAction: SimpleLabelAction { let instance: any InstanceActionProviding } // MARK: - Configurability extension ActionSeed { static let logIn = ActionSeed("logIn") { entity in switch entity { case let entity as any InstanceActionProviding: LogInAction(instance: entity) default: nil } } } // MARK: - Appearance extension LogInAction { static let label: ActionLabel = .init("Log In", icon: .lemmy.logIn) } // MARK: - Behavior extension LogInAction { @MainActor func execute(environment: EnvironmentValues) { if let instance = instance as? Instance { environment.navigation?.openSheet(.logIn(.instance(instance))) } else { environment.navigation?.openSheet(.instanceStub(instance.instanceStub) { .logIn(.instance($0)) }) } } } ================================================ FILE: Mlem/App/Actions/MarkNsfwAction.swift ================================================ // // MarkNsfwAction.swift // Mlem // // Created by Sjmarf on 2025-12-23. // import Actions import MlemMiddleware import SwiftUI struct MarkNsfwAction: SimpleLabelAction { let entity: Post } // MARK: - Configurability extension ActionSeed { static let markNsfw = ActionSeed("markNsfw") { entity in switch entity { case let entity as Post: MarkNsfwAction(entity: entity) default: nil } } } // MARK: - Appearance extension MarkNsfwAction { static let addLabel: ActionLabel = .init( "Add NSFW Tag", icon: .settings.blurNsfw, color: .themedNegative ) static let removeLabel: ActionLabel = .init( "Remove NSFW Tag", icon: .settings.blurNsfw, color: .themedNegative ) static var label: ActionLabel { addLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.nsfw { Self.removeLabel.withVisibility(visibility(environment)) } else { Self.addLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if entity.api.canInteract(appState: environment.appState), entity.canModerate, let community = entity.community.value, community.apiIsLocal, // Setting NSFW doesn't work on non-local communities at the time of writing entity.api.supports(.moderatorSetNsfw, defaultValue: false) { return .enabled } else { return .hidden } } } // MARK: - Behavior extension MarkNsfwAction { @MainActor func execute(environment: EnvironmentValues) { environment.popupModel?.showPopup( message: entity.nsfw ? "Really remove NSFW tag?" : "Really add NSFW tag?", [ .init(title: "Yes", isDestructive: true) { entity.toggleNsfw { status in switch status { case .success: environment.hapticManager.play(haptic: .lightSuccess, tier: .low) case .failure: environment.toastModel?.add(.failure("Failed to set NSFW status")) } } } ]) } } ================================================ FILE: Mlem/App/Actions/MarkReadAction.swift ================================================ // // MarkReadAction.swift // Mlem // // Created by Sjmarf on 2025-11-07. // import Actions import MlemMiddleware import SwiftUI struct MarkReadAction: SimpleLabelAction { let notification: InboxNotification } // MARK: - Configurability extension ActionSeed { static let markRead = ActionSeed("markRead") { entity in switch entity { case let entity as InboxNotification: MarkReadAction(notification: entity) default: nil } } } // MARK: - Appearance extension MarkReadAction { static let markReadLabel: ActionLabel = .init( "Mark Read", icon: .lemmy.markRead, color: .themedRead ) static let markUnreadLabel: ActionLabel = .init( "Mark Unread", icon: .lemmy.markUnread, color: .themedRead ) static var label: ActionLabel { markReadLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if notification.read { Self.markUnreadLabel.withVisibility(visibility(environment)) } else { Self.markReadLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard notification.api.canInteract(appState: environment.appState) else { return .hidden } return .enabled } } // MARK: - Behavior extension MarkReadAction { @MainActor func execute(environment: EnvironmentValues) { notification.toggleRead() environment.hapticManager.play(haptic: .lightSuccess, tier: .low) } } ================================================ FILE: Mlem/App/Actions/NewPostAction.swift ================================================ // // NewPostAction.swift // Mlem // // Created by Sjmarf on 2026-02-08. // import Actions import MlemMiddleware import SwiftUI struct NewPostAction: SimpleLabelAction { let entity: Community } // MARK: - Configurability extension ActionSeed { static let newPost = ActionSeed("newPost") { entity in switch entity { case let entity as Community: NewPostAction(entity: entity) default: nil } } } // MARK: - Appearance extension NewPostAction { static let label: ActionLabel = .init("New Post", icon: .lemmy.send) func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.api.canInteract(appState: environment.appState) { Self.label.withVisibility(.enabled) } else { Self.label.withVisibility(.disabled) } } } // MARK: - Behavior extension NewPostAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.createPost(community: self.entity, type: nil, feedLoader: environment.feedLoader)) } } ================================================ FILE: Mlem/App/Actions/OpenInBrowserAction.swift ================================================ // // OpenInBrowserAction.swift // Mlem // // Created by Sjmarf on 2026-01-16. // import Actions import MlemMiddleware import SwiftUI struct OpenInBrowserAction: SimpleLabelAction { let url: URL } // MARK: - Configurability extension ActionSeed { static let openInBrowser = ActionSeed("openInBrowser") { entity in switch entity { case let entity as any Sharable: OpenInBrowserAction(url: entity.url()) case let entity as any InstanceActionProviding: OpenInBrowserAction(url: entity.actorId.url) default: nil } } } // MARK: - Appearance extension OpenInBrowserAction { static let label: ActionLabel = .init("Open in Browser", icon: .general.browser) } // MARK: - Behavior extension OpenInBrowserAction { @MainActor func execute(environment: EnvironmentValues) { openLinkAsWebsite(url: url) } } ================================================ FILE: Mlem/App/Actions/OpenModlogAction.swift ================================================ // // OpenModlogAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct OpenModlogAction: Actions.Action { enum Content { case person(Person) } enum Relationship { case identity, author } let content: Content let relationship: Relationship } // MARK: - Configurability extension ActionSeed { static let openModlog = ActionSeed( "openModlog", label: OpenModlogAction.createLabel(relationship: .identity) ) { entity in switch entity { case let entity as Person: OpenModlogAction(content: .person(entity), relationship: .identity) default: nil } } static let openCreatorModlog = ActionSeed( "openCreatorModlog", label: OpenModlogAction.createLabel(relationship: .author) ) { entity in switch entity { case let entity as any InteractableProviding: if let creator = entity.creator.value { OpenModlogAction(content: .person(creator), relationship: .author) } else { nil } default: nil } } } // MARK: - Appearance extension OpenModlogAction { static func createLabel(relationship: Relationship) -> ActionLabel { .init( relationship == .identity ? "Modlog" : "User Modlog", icon: .lemmy.modlog, color: .themedModeration ) } func createLabel(environment: EnvironmentValues) -> ActionLabel { return Self.createLabel(relationship: relationship) } } // MARK: - Behavior extension OpenModlogAction { @MainActor func execute(environment: EnvironmentValues) { switch content { case let .person(person): execute(person: person, environment: environment) } } @MainActor private func execute(person: Person, environment: EnvironmentValues) { environment.popupModel?.showPopup(message: "Filter as...", [ .init(title: "Subject") { environment.navigation?.push(.modlog(targetPerson: .init(person), moderatorPerson: nil)) }, .init(title: "Moderator") { environment.navigation?.push(.modlog(targetPerson: nil, moderatorPerson: .init(person))) } ]) } } ================================================ FILE: Mlem/App/Actions/PinAction.swift ================================================ // // PinAction.swift // Mlem // // Created by Sjmarf on 2025-12-23. // import Actions import MlemMiddleware import SwiftUI struct PinAction: SimpleLabelAction { let entity: Post } // MARK: - Configurability extension ActionSeed { static let pin = ActionSeed("pin") { entity in switch entity { case let entity as Post: PinAction(entity: entity) default: nil } } } // MARK: - Appearance extension PinAction { static let pinLabel: ActionLabel = .init( "Pin", icon: .lemmy.addPin, color: .themedModeration ) static let unpinLabel: ActionLabel = .init( "Unpin", icon: .lemmy.removePin, color: .themedModeration ) static let pinDetailsLabel: ActionLabel = .init( "Pin...", icon: .lemmy.addPin, color: .themedModeration ) static var label: ActionLabel { pinLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { let label: ActionLabel = if entity.api.isAdmin { switch (entity.pinnedInstance, entity.pinnedCommunity) { case (true, true): Self.unpinLabel case (true, false), (false, true): Self.pinDetailsLabel case (false, false): Self.pinLabel } } else { if entity.pinnedCommunity { Self.unpinLabel } else { Self.pinLabel } } return label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if entity.api.canInteract(appState: environment.appState), entity.canModerate { .enabled } else { .hidden } } } // MARK: - Behavior extension PinAction { @MainActor func execute(environment: EnvironmentValues) { if entity.api.isAdmin { executeAsAdmin(environment: environment) } else { executeAsModerator(environment: environment) } } @MainActor func executeAsModerator(environment: EnvironmentValues) { environment.popupModel?.showPopup( message: entity.pinnedCommunity ? "Really unpin this post?" : "Really pin this post?", [ .init(title: "Yes", isDestructive: false) { togglePinnedCommunity(environment: environment) } ]) } @MainActor func executeAsAdmin(environment: EnvironmentValues) { environment.popupModel?.showPopup( message: "Choose target...", [ .init(title: entity.pinnedCommunity ? "Unpin from community" : "Pin to community") { togglePinnedCommunity(environment: environment) }, .init(title: entity.pinnedInstance ? "Unpin from instance" : "Pin to instance") { togglePinnedInstance(environment: environment) } ] ) } @MainActor func togglePinnedCommunity(environment: EnvironmentValues) { let shouldPin = entity.pinnedCommunity entity.togglePinnedCommunity { status in handleResult( status: status, shouldPin: shouldPin, environment: environment ) } } @MainActor func togglePinnedInstance(environment: EnvironmentValues) { let shouldPin = entity.pinnedInstance entity.togglePinnedInstance { status in handleResult( status: status, shouldPin: shouldPin, environment: environment ) } } @MainActor func handleResult( status: UpdateStatus, shouldPin: Bool, environment: EnvironmentValues ) { switch status { case .success: environment.hapticManager.play(haptic: .lightSuccess, tier: .low) case .failure: environment.toastModel?.add( .failure(shouldPin ? "Failed to pin post" : "Failed to unpin post") ) } } } ================================================ FILE: Mlem/App/Actions/PurgeAction.swift ================================================ // // PurgeAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct PurgeAction: Actions.Action { enum Relationship { case identity, author } let entity: any PurgableProviding let relationship: Relationship } // MARK: - Configurability extension ActionSeed { static let purge = ActionSeed("purge", label: PurgeAction.createLabel(relationship: .identity)) { entity in switch entity { case let entity as any PurgableProviding: PurgeAction(entity: entity, relationship: .identity) default: nil } } static let purgeCreator = ActionSeed("purgeCreator", label: PurgeAction.createLabel(relationship: .author)) { entity in switch entity { case let entity as any InteractableProviding: if let creator = entity.creator.value { PurgeAction(entity: creator, relationship: .author) } else { nil } default: nil } } } // MARK: - Appearance extension PurgeAction { static func createLabel(relationship: Relationship) -> ActionLabel { .init( relationship == .identity ? "Purge" : "Purge User", icon: .lemmy.purge, color: .themedNegative, isDestructive: true ) } func createLabel(environment: EnvironmentValues) -> ActionLabel { return Self.createLabel(relationship: self.relationship).withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard entity.api.supports(.purgeContent, defaultValue: false) else { return .hidden } guard entity.api.isAdmin else { return .hidden } return .enabled } } // MARK: - Behavior extension PurgeAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.purge(entity)) } } ================================================ FILE: Mlem/App/Actions/RemoveAction.swift ================================================ // // RemoveAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct RemoveAction: SimpleLabelAction { let entity: any RemovableProviding } // MARK: - Configurability extension ActionSeed { static let remove = ActionSeed("remove") { entity in switch entity { case let entity as any RemovableProviding: RemoveAction(entity: entity) default: nil } } } // MARK: - Appearance extension RemoveAction { static let removeLabel: ActionLabel = .init( "Remove", icon: .lemmy.remove, color: .themedNegative, isDestructive: true ) static let restoreLabel: ActionLabel = .init( "Restore", icon: .lemmy.restore, color: .themedPositive ) static var label: ActionLabel { removeLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { if entity.removed { Self.restoreLabel.withVisibility(visibility(environment)) } else { Self.removeLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPerson = entity.api.myPerson else { return .hidden } guard entity.canModerate else { return .hidden } if let entity = entity as? any OwnershipProviding { guard !entity.isOwnContent(myPersonId: myPerson.id) else { return .hidden } } return .enabled } } // MARK: - Behavior extension RemoveAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.remove(entity)) } } ================================================ FILE: Mlem/App/Actions/ReplyAction.swift ================================================ // // ReplyAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct ReplyAction: SimpleLabelAction { enum Content { case post(Post) case comment(Comment) case message(any Message2Providing) var value: any OwnershipProviding { switch self { case let .post(post): post case let .comment(comment): comment case let .message(message): message } } } let content: Content } // MARK: - Configurability extension ActionSeed { static let reply = ActionSeed("reply") { entity in switch entity { case let entity as Post: ReplyAction(content: .post(entity)) case let entity as Comment: ReplyAction(content: .comment(entity)) case let entity as any Message2Providing: ReplyAction(content: .message(entity)) default: nil } } } // MARK: - Appearance extension ReplyAction { static let label: ActionLabel = .init("Reply", icon: .lemmy.reply) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard content.value.api.canInteract(appState: environment.appState) else { return .hidden } // Don't show the reply action for messages in the message feed if case .message = self.content, case .messageFeed = environment.navigation?.path.last { return .hidden } return .enabled } } // MARK: - Behavior extension ReplyAction { @MainActor func execute(environment: EnvironmentValues) { guard let navigation = environment.navigation else { assertionFailure() return } switch self.content { case let .post(post): navigation.openSheet(.createComment(.post(post), commentTreeTracker: environment.commentTreeTracker)) case let .comment(comment): navigation.openSheet(.createComment(.comment(comment), commentTreeTracker: environment.commentTreeTracker)) case let .message(message): navigation.push(.messageFeed(message.creator, focusTextField: true)) } } } ================================================ FILE: Mlem/App/Actions/ReportAction.swift ================================================ // // ReportAction.swift // Mlem // // Created by Sjmarf on 2025-10-13. // import Actions import MlemMiddleware import SwiftUI struct ReportAction: SimpleLabelAction { let entity: any ReportableProviding } // MARK: - Configurability extension ActionSeed { static let report = ActionSeed("report") { entity in switch entity { case let entity as any ReportableProviding: ReportAction(entity: entity) default: nil } } } // MARK: - Appearance extension ReportAction { static let label: ActionLabel = .init( "Report", icon: .lemmy.report, color: .themedNegative, isDestructive: true ) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPersonId = entity.api.myPerson?.id else { return .hidden } if entity.isOwnContent(myPersonId: myPersonId) { return .hidden } return .enabled } } // MARK: - Behavior extension ReportAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.report(entity, community: environment.communityContext)) } } ================================================ FILE: Mlem/App/Actions/ResolveAction.swift ================================================ // // ResolveAction.swift // Mlem // // Created by Eric Andrews on 2025-12-24. // import Actions import MlemMiddleware import SwiftUI struct ResolveAction: SimpleLabelAction { let entity: Report } // MARK: - Configurability extension ActionSeed { static let resolveReport = ActionSeed("resolveReport") { entity in switch entity { case let entity as Report: ResolveAction(entity: entity) default: nil } } } // MARK: - Appearance extension ResolveAction { static let resolveLabel: ActionLabel = .init( "Resolve", icon: .init("checkmark.circle"), color: .themedPositive ) static let unresolveLabel: ActionLabel = .init( "Unresolve", icon: .init("xmark.circle"), color: .themedNegative ) static var label: ActionLabel { resolveLabel } func createLabel(environment: EnvironmentValues) -> Actions.ActionLabel { entity.resolved ? Self.unresolveLabel : Self.resolveLabel } func execute(environment: EnvironmentValues) { entity.toggleResolved() } } ================================================ FILE: Mlem/App/Actions/SaveAction.swift ================================================ // // SaveAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct SaveAction: SimpleLabelAction { let entity: any InteractableProviding } // MARK: - Configurability extension ActionSeed { static let save = ActionSeed("save") { entity in switch entity { case let entity as any InteractableProviding: SaveAction(entity: entity) default: nil } } } // MARK: - Appearance extension SaveAction { static let saveLabel: ActionLabel = .init( "Save", icon: .lemmy.saved.representingState(active: false), color: .themedSave ) static let unsaveLabel: ActionLabel = .init( "Saved", icon: .lemmy.saved.representingState(active: true), color: .themedSave ) static var label: ActionLabel { saveLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let saved = entity.saved.value else { return Self.saveLabel.withVisibility(.hidden) } if saved { return Self.unsaveLabel.withVisibility(visibility(environment)) } else { return Self.saveLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } return .enabled } } // MARK: - Behavior extension SaveAction { @MainActor func execute(environment: EnvironmentValues) { guard let toggleSaved = entity.toggleSaved else { return } toggleSaved([.haptic]) } } ================================================ FILE: Mlem/App/Actions/SelectTextAction.swift ================================================ // // SelectTextAction.swift // Mlem // // Created by Sjmarf on 2025-10-13. // import Actions import MlemMiddleware import SwiftUI struct SelectTextAction: Actions.SimpleLabelAction { static let label: ActionLabel = .init("Select Text", icon: .general.select) let text: String func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.selectText(text)) } } extension ActionSeed { static let selectText = ActionSeed("selectText") { entity in switch entity { case let entity as any Message1Providing: SelectTextAction(text: entity.content) case let entity as Comment: SelectTextAction(text: entity.content) case let entity as Post: SelectTextAction(text: entity.selectableContent ?? "") default: nil } } } ================================================ FILE: Mlem/App/Actions/SendMessageAction.swift ================================================ // // SendMessageAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct SendMessageAction: SimpleLabelAction { enum Relationship { case identity, author } let entity: Person let relationship: Relationship } // MARK: - Configurability extension ActionSeed { static let sendMessage = ActionSeed("sendMessage") { entity in switch entity { case let entity as Person: SendMessageAction(entity: entity, relationship: .identity) default: nil } } static let sendCreatorMessage = ActionSeed("sendCreatorMessage") { entity in switch entity { case let entity as any InteractableProviding: if let creator = entity.creator.value { SendMessageAction(entity: creator, relationship: .author) } else { nil } default: nil } } } // MARK: - Appearance extension SendMessageAction { static let label: ActionLabel = .init("Send Message", icon: .lemmy.message) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { if environment.appState.firstPerson?.actorId == entity.actorId { return .hidden } if environment.isInMessageFeed { return .hidden } if !entity.api.canInteract(appState: environment.appState) { return .disabled } return .enabled } } // MARK: - Behavior extension SendMessageAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.messageFeed(self.entity, focusTextField: true)) } } ================================================ FILE: Mlem/App/Actions/ShareAction.swift ================================================ // // ShareAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct ShareAction: SimpleLabelAction { let entity: any Sharable } // MARK: - Configurability extension ActionSeed { static let share = ActionSeed("share") { entity in switch entity { case let entity as any Sharable: ShareAction(entity: entity) default: nil } } } // MARK: - Appearance extension ShareAction { static let label: ActionLabel = .init( "Share...", icon: .general.share, color: .themedColorfulAccent(3) ) } // MARK: - Behavior extension ShareAction { @MainActor func execute(environment: EnvironmentValues) { let url: URL? = switch Settings.get(\.links_shareMode) { case .myInstance: entity.url() case .originalInstance: entity.actorId.url case .lemmyverse: entity.lemmyverseUrl case .askEveryTime: nil } if let url, let navigation = environment.navigation { if case .actionSheet = navigation.root { navigation.dismissSheet() DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NavigationModel.main.shareInfo = .init(url: url, actions: entity.shareSheetActions()) } } else { navigation.model?.shareInfo = .init(url: url, actions: entity.shareSheetActions()) } } else { environment.navigation?.openSheet(.shareInstancePicker(entity)) } } } ================================================ FILE: Mlem/App/Actions/SignUpAction.swift ================================================ // // SignUpAction.swift // Mlem // // Created by Sjmarf on 2026-01-16. // import Actions import MlemMiddleware import SwiftUI struct SignUpAction: SimpleLabelAction { let instance: InstanceStub } // MARK: - Configurability extension ActionSeed { static let signUp = ActionSeed("signUp") { entity in switch entity { case let entity as any InstanceActionProviding: SignUpAction(instance: entity.instanceStub) default: nil } } } // MARK: - Appearance extension SignUpAction { static let label: ActionLabel = .init("Sign Up", icon: .lemmy.signUp) } // MARK: - Behavior extension SignUpAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.signUp(instance)) } } ================================================ FILE: Mlem/App/Actions/SubscribeAction.swift ================================================ // // SubscribeAction.swift // Mlem // // Created by Sjmarf on 2026-02-08. // import Actions import MlemMiddleware import SwiftUI struct SubscribeAction: SimpleLabelAction { let entity: Community } // MARK: - Configurability extension ActionSeed { static let subscribe = ActionSeed("subscribe") { entity in switch entity { case let entity as Community: SubscribeAction(entity: entity) default: nil } } } // MARK: - Appearance extension SubscribeAction { static let subscribeLabel: ActionLabel = .init( "Subscribe", icon: .lemmy.subscribe, color: .themedPositive ) static let unsubscribeLabel: ActionLabel = .init( "Unsubscribe", icon: .lemmy.unsubscribe, color: .themedNegative ) static var label: ActionLabel { subscribeLabel } func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let subscription = entity.subscription.value else { return Self.subscribeLabel.withVisibility(.hidden) } if subscription.subscribed { return Self.unsubscribeLabel.withVisibility(visibility(environment)) } else { return Self.subscribeLabel.withVisibility(visibility(environment)) } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState), entity.subscription.value != nil, entity.updateSubscribed != nil else { return .hidden } return .enabled } } // MARK: - Behavior extension SubscribeAction { @MainActor func execute(environment: EnvironmentValues) { guard let updateSubscribed = entity.updateSubscribed, let subscription = entity.subscription.value, let updateFavorite = entity.updateFavorite else { return } environment.hapticManager.play(haptic: .lightSuccess, tier: .low) let wasFavorited = entity.favorited if subscription.subscribed { environment.toastModel?.add( .undoable( "Unsubscribed", icon: .lemmy.didUnsubscribe, callback: { if wasFavorited { updateFavorite(true) } else { updateSubscribed(true) } }, color: .themedAccent ) ) } updateSubscribed(!subscription.subscribed) } } ================================================ FILE: Mlem/App/Actions/ViewVotesAction.swift ================================================ // // ViewVotesAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct ViewVotesAction: SimpleLabelAction { let content: VotesListView.Target } // MARK: - Configurability extension ActionSeed { static let viewVotes = ActionSeed("viewVotes") { entity in switch entity { case let entity as Post: ViewVotesAction(content: .post(entity)) case let entity as Comment: ViewVotesAction(content: .comment(entity)) default: nil } } } // MARK: - Appearance extension ViewVotesAction { static let label: ActionLabel = .init( "View Votes", icon: .lemmy.votes, color: .themedColorfulAccent(4) ) func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label.withVisibility(visibility(environment)) } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { let entity = content.model guard entity.api.canInteract(appState: environment.appState) else { return .hidden } guard let myPerson = entity.api.myPerson, let community = entity.community.value, let myPersonModerates = myPerson.moderates, myPersonModerates(.id(community.id)), entity.api.supports(.viewVotes, defaultValue: true) else { return .hidden } return .enabled } } // MARK: - Behavior extension ViewVotesAction { @MainActor func execute(environment: EnvironmentValues) { environment.navigation?.openSheet(.votesList(content)) } } ================================================ FILE: Mlem/App/Actions/VisitAction.swift ================================================ // // VisitAction.swift // Mlem // // Created by Sjmarf on 2026-01-16. // import Actions import MlemMiddleware import SwiftUI struct VisitAction: SimpleLabelAction { let instance: any InstanceActionProviding } // MARK: - Configurability extension ActionSeed { static let visit = ActionSeed("visit") { entity in switch entity { case let entity as any InstanceActionProviding: VisitAction(instance: entity) default: nil } } } // MARK: - Appearance extension VisitAction { static let label: ActionLabel = .init("Visit", icon: .lemmy.visitInstance) func createLabel(environment: EnvironmentValues) -> ActionLabel { let api = environment.appState.firstApi let isVisiting = api.host == instance.actorId.host && api.token == nil return Self.label.withVisibility(isVisiting ? .disabled : .enabled) } } // MARK: - Behavior extension VisitAction { @MainActor func execute(environment: EnvironmentValues) { do { let account = try GuestAccount.getGuestAccount(url: instance.actorId.url) environment.appState.changeAccount(to: account) environment.appState.contentViewTab = .feeds } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Actions/VoteAction.swift ================================================ // // VoteAction.swift // Mlem // // Created by Sjmarf on 2025-10-25. // import Actions import MlemMiddleware import SwiftUI struct VoteAction: Actions.Action { let entity: any InteractableProviding let type: ScoringOperation } // MARK: - Configurability extension ActionSeed { static let upvote = ActionSeed("upvote", label: VoteAction.upvoteLabel) { createVoteAction($0, type: .upvote) } static let downvote = ActionSeed("downvote", label: VoteAction.downvoteLabel) { createVoteAction($0, type: .downvote) } } private func createVoteAction(_ entity: Any, type: ScoringOperation) -> VoteAction? { switch entity { case let entity as any InteractableProviding: VoteAction(entity: entity, type: type) default: nil } } // MARK: - Appearance extension VoteAction { static let upvoteLabel: ActionLabel = .init( "Upvote", icon: .lemmy.upvoted.representingState(active: false), color: .themedUpvote ) static let downvoteLabel: ActionLabel = .init( "Downvote", icon: .lemmy.downvoted.representingState(active: false), color: .themedDownvote ) static let removeUpvoteLabel: ActionLabel = .init( "Upvoted", icon: .lemmy.upvoted.representingState(active: true), color: .themedUpvote ) static let removeDownvoteLabel: ActionLabel = .init( "Downvoted", icon: .lemmy.downvoted.representingState(active: true), color: .themedDownvote ) func createLabel(environment: EnvironmentValues) -> ActionLabel { guard let votes = entity.votes.value else { return Self.upvoteLabel.withVisibility(.hidden) } let hasMatchingVote = votes.myVote == type guard type != .none else { assertionFailure() return Self.upvoteLabel } return switch (type, hasMatchingVote) { case (.upvote, false): Self.upvoteLabel case (.upvote, true): Self.removeUpvoteLabel case (.downvote, false): Self.downvoteLabel case (.downvote, true): Self.removeDownvoteLabel default: Self.upvoteLabel } } private func visibility(_ environment: EnvironmentValues) -> ActionVisiblity { guard entity.api.canInteract(appState: environment.appState) else { return .hidden } let voteFederationMode = entity.api.voteFederationMode switch (self.type, entity is Post) { case (.upvote, true): return voteFederationMode.postUpvote == .all ? .enabled : .hidden case (.downvote, true): return voteFederationMode.postDownvote == .all ? .enabled : .hidden case (.upvote, false): return voteFederationMode.commentUpvote == .all ? .enabled : .hidden case (.downvote, false): return voteFederationMode.commentDownvote == .all ? .enabled : .hidden default: assertionFailure() return .hidden } } } // MARK: - Behavior extension VoteAction { @MainActor func execute(environment: EnvironmentValues) { guard let toggleVote = entity.toggleVote else { return } toggleVote(type, [.haptic]) } } ================================================ FILE: Mlem/App/Configuration/Colors/Palette+Dracula.swift ================================================ // // Palette+Dracula.swift // Mlem // // Created by Sjmarf on 2025-03-08. // import Foundation import SwiftUI import Theming // source: https://draculatheme.com/contribute#color-palette private let _darkBackground: Color = .init(red: 0.07843137255, green: 0.07450980392, blue: 0.1215686275) private let _background: Color = .init(red: 0.1568627450980392, green: 0.16470588235294117, blue: 0.21176470588235294) private let _secondaryBackground: Color = .init(red: 53 / 255, green: 55 / 255, blue: 74 / 255) private let _primary: Color = .init(red: 0.9725490196078431, green: 0.9725490196078431, blue: 0.9490196078431372) private let _secondary: Color = .init(red: 153 / 255, green: 178 / 255, blue: 255 / 255, opacity: 0.7) private let _cyan: Color = .init(red: 0.5450980392156862, green: 0.9137254901960784, blue: 0.9921568627450981) private let _green: Color = .init(red: 0.3137254901960784, green: 0.9803921568627451, blue: 0.4823529411764706) private let _orange: Color = .init(red: 1.0, green: 0.7215686274509804, blue: 0.4235294117647059) private let _pink: Color = .init(red: 1.0, green: 0.4745098039215686, blue: 0.7764705882352941) private let _purple: Color = .init(red: 0.7411764705882353, green: 0.5764705882352941, blue: 0.9764705882352941) private let _red: Color = .init(red: 1.0, green: 0.3333333333333333, blue: 0.3333333333333333) private let _yellow: Color = .init(red: 0.9450980392156862, green: 0.9803921568627451, blue: 0.5490196078431373) extension Palette { static let dracula: Self = .init( bordered: false, label: .init( primary: _primary, secondary: _secondary, tertiary: _secondary ), background: .init( primary: _background, secondary: _secondaryBackground, tertiary: _secondaryBackground ), groupedBackground: .init( primary: _darkBackground, secondary: _background, tertiary: _secondaryBackground ), thumbnailBackground: _secondaryBackground, contrastingLabel: _primary, accent: _purple, neutralAccent: _secondaryBackground, colorfulAccents: [_orange, _pink, _cyan, _green, _purple, _red, _yellow], commentIndentColors: [_cyan, _green, _orange, _pink, _purple, _red], accountAgeColors: [_green, _cyan], positive: _green, negative: _red, warning: _red, caution: _orange, upvote: _cyan, downvote: _red, save: _green, read: _purple, favorite: _cyan, administration: _purple, moderation: _pink, federatedFeed: _pink, localFeed: _purple, subscribedFeed: _red, moderatedFeed: _pink, savedFeed: _green, popularFeed: _cyan, suggestedFeed: _orange, inbox: _purple, fediseerEndorsement: _cyan ) } ================================================ FILE: Mlem/App/Configuration/Colors/Palette+Monochrome.swift ================================================ // // Palette+Monochrome.swift // Mlem // // Created by Sjmarf on 2025-03-08. // import Foundation import SwiftUI import Theming extension Palette { static let monochrome: Self = .init( bordered: false, label: .init( primary: .primary, secondary: .secondary, tertiary: .init(uiColor: .tertiaryLabel) ), background: .init( primary: .init(uiColor: .systemBackground), secondary: .init(uiColor: .secondarySystemBackground), tertiary: .init(uiColor: .tertiarySystemBackground) ), groupedBackground: .init( primary: .init(uiColor: .systemGroupedBackground), secondary: .init(uiColor: .secondarySystemGroupedBackground), tertiary: .init(uiColor: .tertiarySystemGroupedBackground) ), thumbnailBackground: Color(uiColor: .systemGray4), contrastingLabel: Color(uiColor: .systemBackground), accent: .primary, neutralAccent: .gray, colorfulAccents: [.gray], commentIndentColors: [ Color(uiColor: .systemGray), Color(uiColor: .systemGray2), Color(uiColor: .systemGray3), Color(uiColor: .systemGray4), Color(uiColor: .systemGray5), Color(uiColor: .systemGray6) ], accountAgeColors: [.gray], positive: .primary, negative: .primary, warning: .primary, caution: .primary, upvote: .primary, downvote: .primary, save: .primary, read: .primary, favorite: .primary, administration: .primary, moderation: .primary, federatedFeed: Color(uiColor: .darkGray), localFeed: Color(uiColor: .darkGray), subscribedFeed: Color(uiColor: .darkGray), moderatedFeed: Color(uiColor: .darkGray), savedFeed: Color(uiColor: .darkGray), popularFeed: Color(uiColor: .darkGray), suggestedFeed: Color(uiColor: .darkGray), inbox: Color(uiColor: .darkGray), fediseerEndorsement: .gray ) } ================================================ FILE: Mlem/App/Configuration/Colors/Palette+Oled.swift ================================================ // // Palette+Oled.swift // Mlem // // Created by Sjmarf on 2025-03-08. // import Foundation import Theming extension Palette { static let oled: Self = { var palette: Self = .default palette.bordered = true palette.background.primary = .black palette.background.secondary = .black palette.background.tertiary = .black palette.groupedBackground.primary = .black palette.groupedBackground.secondary = .black palette.groupedBackground.tertiary = .black return palette }() } ================================================ FILE: Mlem/App/Configuration/Colors/Palette+Solarized.swift ================================================ // // Palette+Solarized.swift // Mlem // // Created by Sjmarf on 2025-03-08. // import Foundation import SwiftUI import Theming // See https://ethanschoonover.com/solarized/ for details // TODO: I'd love to do this in LAB space, but that involves some ugly manual CGColor work private let base04: Color = .init(red: 0.0, green: 0.0823529412, blue: 0.1137254902) private let base03: Color = .init(red: 0.0, green: 0.16862745098039217, blue: 0.21176470588235294) private let base02: Color = .init(red: 0.027450980392156862, green: 0.21176470588235294, blue: 0.25882352941176473) private let base01: Color = .init(red: 0.34509803921568627, green: 0.43137254901960786, blue: 0.4588235294117647) private let base00: Color = .init(red: 0.396078431372549, green: 0.4823529411764706, blue: 0.5137254901960784) private let base0: Color = .init(red: 0.5137254901960784, green: 0.5803921568627451, blue: 0.5882352941176471) private let base1: Color = .init(red: 0.5764705882352941, green: 0.6313725490196078, blue: 0.6313725490196078) private let base2: Color = .init(red: 0.9333333333333333, green: 0.9098039215686274, blue: 0.8352941176470589) private let base3: Color = .init(red: 0.9921568627450981, green: 0.9647058823529412, blue: 0.8901960784313725) private let yellow: Color = .init(red: 0.7098039215686275, green: 0.5372549019607843, blue: 0.0) private let orange: Color = .init(red: 0.796078431372549, green: 0.29411764705882354, blue: 0.08627450980392157) private let red: Color = .init(red: 0.8627450980392157, green: 0.19607843137254902, blue: 0.1843137254901961) private let magenta: Color = .init(red: 0.8274509803921568, green: 0.21176470588235294, blue: 0.5098039215686274) private let violet: Color = .init(red: 0.4235294117647059, green: 0.44313725490196076, blue: 0.7686274509803922) private let blue: Color = .init(red: 0.14901960784313725, green: 0.5450980392156862, blue: 0.8235294117647058) private let cyan: Color = .init(red: 0.16470588235294117, green: 0.6313725490196078, blue: 0.596078431372549) private let green: Color = .init(red: 0.5215686274509804, green: 0.6, blue: 0.0) extension Palette { static let solarized: Self = .init( bordered: false, label: .init( primary: .init(light: base00, dark: base0), secondary: .init(light: base0, dark: base01), tertiary: .init(light: base1, dark: base01) ), background: .init( primary: .init(light: base3, dark: base03), secondary: .init(light: base2, dark: base02), tertiary: .init(light: base2, dark: base02) ), groupedBackground: .init( primary: .init(light: base2, dark: base04), secondary: .init(light: base3, dark: base03), tertiary: .init(light: base2, dark: base02) ), thumbnailBackground: .init(light: base2, dark: base02), contrastingLabel: base2, accent: blue, neutralAccent: base0, colorfulAccents: [orange, violet, blue, cyan, magenta, green, cyan], commentIndentColors: [red, orange, yellow, cyan, blue, violet], accountAgeColors: [cyan, blue, violet], positive: cyan, negative: red, warning: red, caution: orange, upvote: blue, downvote: red, save: cyan, read: violet, favorite: blue, administration: violet, moderation: green, federatedFeed: blue, localFeed: violet, subscribedFeed: red, moderatedFeed: violet, savedFeed: cyan, popularFeed: magenta, suggestedFeed: orange, inbox: violet, fediseerEndorsement: cyan ) } ================================================ FILE: Mlem/App/Configuration/Constants/Constants.swift ================================================ // // Constants.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // import Foundation import KeychainAccess import MlemMiddleware import SwiftUI class Constants { private var platformConstants: PlatformConstants public static let main: Constants = .init() private init() { if UIDevice.isPhone { self.platformConstants = .phone } else if UIDevice.isPad { self.platformConstants = .pad } else { assertionFailure("Unrecognized UIDevice!") self.platformConstants = .phone } } // MARK: - Common Constants // These constants are used across all platforms, and generally configure backend behavior // MARK: Image Caching /// Size for the image cache (500MB) let cacheSize = 500_000_000 /// URLCache to use for image caching let urlCache: URLCache = .init(memoryCapacity: 500_000_000, diskCapacity: 500_000_000) /// URLSession to use for image caching let urlSession: URLSession = .init(configuration: .default) /// Images are fetched at this resolution when displayed in the feed, and the maximum resolution is only fetched when the image viewer is opened let feedImageResolution: Int = 1024 // MARK: Keychain let keychain: Keychain = .init(service: "com.hanners.Mlem-keychain") // MARK: - Platform Constants // These constants change depending on which platform the app is running on, and so passthrough to the current PlatformConstants. Standard dimensions are used for elements or element types that recur frequently, and are preferred over non-standard to promote a consistent layout structure. Non-standard spacings are used in cases where unique aesthetic considerations warrant deviation from the standards. // MARK: Standard Spacings /// Normal spacing between elements var standardSpacing: CGFloat { platformConstants.standardSpacing } /// Half of standardSpacing var halfSpacing: CGFloat { platformConstants.halfSpacing } /// Twice standardSpacing var doubleSpacing: CGFloat { platformConstants.doubleSpacing } /// Normal pacing between elements in a compact layout var compactSpacing: CGFloat { platformConstants.compactSpacing } // MARK: Standard Corner Radii /// Corner radius of a large item (tile post) var largeItemCornerRadius: CGFloat { platformConstants.largeItemCornerRadius } /// Corner radius of a medium item (website previews, large cards, etc.) var mediumItemCornerRadius: CGFloat { platformConstants.mediumItemCornerRadius } /// Corner radius of a small item (thumbnails, embedded cards, etc.) var smallItemCornerRadius: CGFloat { platformConstants.smallItemCornerRadius } // MARK: Sizes /// Size of a post thumbnail var thumbnailSize: CGFloat { platformConstants.thumbnailSize } /// Size of an avatar in a list context var listRowAvatarSize: CGFloat { platformConstants.listRowAvatarSize } /// Size of an avatar in a large label display var largeAvatarSize: CGFloat { platformConstants.largeAvatarSize } /// Size of an avatar in a medium label display var mediumAvatarSize: CGFloat { platformConstants.mediumAvatarSize } /// Size of an avatar in a compact label display var smallAvatarSize: CGFloat { platformConstants.smallAvatarSize } /// Size of a feed header avatar var feedHeaderSize: CGFloat { platformConstants.feedHeaderSize } // MARK: Non-Standard Dimensions // App Icon /// Size of an app icon var appIconSize: CGFloat { platformConstants.appIconSize } /// Corner radius of an app icon var appIconCornerRadius: CGFloat { platformConstants.appIconCornerRadius } // Settings Icon /// Size of a settings icon var settingsIconSize: CGFloat { platformConstants.settingsIconSize } // Interaction Bar /// Size of an interaction bar icon var barIconSize: CGFloat { platformConstants.barIconSize } /// Corner radius of an interaction bar icon's background var barIconCornerRadius: CGFloat { platformConstants.barIconCornerRadius } /// Size of the visible bar icon background var barIconBackgroundSize: CGFloat { barIconHitbox - (2 * standardSpacing) } /// Tappable area for a bar icon (extends beyond visible background, should be at least 44x44 per Apple HIG) var barIconHitbox: CGFloat { platformConstants.barIconHitbox } } ================================================ FILE: Mlem/App/Configuration/Constants/Platform Constants/PadConstants.swift ================================================ // // PadConstants.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // import Foundation // iPad-specific constants extension PlatformConstants { static let pad: PlatformConstants = .init( standardSpacing: 10, halfSpacing: 5, doubleSpacing: 20, compactSpacing: 6, thumbnailSize: 60, listRowAvatarSize: 46, largeAvatarSize: 32, mediumAvatarSize: 22, smallAvatarSize: 16, feedHeaderSize: 44, largeItemCornerRadius: 16, mediumItemCornerRadius: 8, smallItemCornerRadius: 6, appIconSize: 60, appIconCornerRadius: 10, settingsIconSize: 28, barIconSize: 15.5, barIconCornerRadius: 4, barIconHitbox: 44 ) } ================================================ FILE: Mlem/App/Configuration/Constants/Platform Constants/PhoneConstants.swift ================================================ // // PhoneConstants.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // import Foundation // iPhone-specific constants extension PlatformConstants { static let phone: PlatformConstants = .init( standardSpacing: 10, halfSpacing: 5, doubleSpacing: 20, compactSpacing: 6, thumbnailSize: 60, listRowAvatarSize: 46, largeAvatarSize: 32, mediumAvatarSize: 22, smallAvatarSize: 16, feedHeaderSize: 44, largeItemCornerRadius: 16, mediumItemCornerRadius: 8, smallItemCornerRadius: 6, appIconSize: 60, appIconCornerRadius: 10, settingsIconSize: 28, barIconSize: 15.5, barIconCornerRadius: 4, barIconHitbox: 44 ) } ================================================ FILE: Mlem/App/Configuration/Constants/Platform Constants/PlatformConstants.swift ================================================ // // PlatformConstants.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // import Foundation // Struct enumerating all platform-specific constants struct PlatformConstants { // Standard spacings let standardSpacing: CGFloat let halfSpacing: CGFloat let doubleSpacing: CGFloat let compactSpacing: CGFloat // Standard sizes let thumbnailSize: CGFloat let listRowAvatarSize: CGFloat let largeAvatarSize: CGFloat let mediumAvatarSize: CGFloat let smallAvatarSize: CGFloat let feedHeaderSize: CGFloat // Standard corner radii let largeItemCornerRadius: CGFloat let mediumItemCornerRadius: CGFloat let smallItemCornerRadius: CGFloat // Non-standard dimensions let appIconSize: CGFloat let appIconCornerRadius: CGFloat let settingsIconSize: CGFloat let barIconSize: CGFloat let barIconCornerRadius: CGFloat let barIconHitbox: CGFloat } ================================================ FILE: Mlem/App/Configuration/Icons.swift ================================================ // // Icon.swift // Mlem // // Created by Eric Andrews on 2023-09-13. // import Foundation import SwiftUI // swiftlint:disable type_body_length /// SFSymbol names for icons enum Icons { // votes static let votes: String = "arrow.up.arrow.down" static let votesSquare: String = "arrow.up.arrow.down.square" static let upvote: String = "arrow.up" static let upvoteSquare: String = "arrow.up.square" static let upvoteSquareFill: String = "arrow.up.square.fill" static let downvote: String = "arrow.down" static let downvoteSquare: String = "arrow.down.square" static let downvoteSquareFill: String = "arrow.down.square.fill" static let resetVoteSquare: String = "minus.square" static let resetVoteSquareFill: String = "minus.square.fill" // reply/send static let reply: String = "arrowshape.turn.up.left" static let replyFill: String = "arrowshape.turn.up.left.fill" static let send: String = "paperplane" static let sendFill: String = "paperplane.fill" static let sendMessage: String = "arrow.up.circle.fill" // save static let save: String = "bookmark" static let saveFill: String = "bookmark.fill" static let unsave: String = "bookmark.slash" static let unsaveFill: String = "bookmark.slash.fill" // mark read static let markRead: String = "envelope.open" static let markReadFill: String = "envelope.open.fill" static let markUnread: String = "envelope" static let markUnreadFill: String = "envelope.fill" // moderation static let moderation: String = "shield" static let moderationFill: String = "shield.fill" static let administration: String = "crown" static let administrationFill: String = "crown.fill" static let demoteModerator: String = "shield.slash" static let demoteModeratorFill: String = "shield.slash.fill" static let moderationReport: String = "flag" static let moderationReportFill: String = "flag.fill" static let registrationApplication: String = "list.clipboard" static let modlog: String = "book.pages" static let transferCommunity: String = "arrow.right" static let removeAdministrator: String = "arrowshape.down" static let removeAdministratorFill: String = "arrowshape.down.fill" static let resolve: String = "checkmark.circle" static let resolveFill: String = "checkmark.circle.fill" static let unresolve: String = "xmark.circle" static let unresolveFill: String = "xmark.circle.fill" // inbox static let mention: String = "quote.bubble" static let message: String = "envelope" // misc post static let posts: String = "doc.plaintext" static let replies: String = "bubble.left" static let unreadReplies: String = "text.bubble" static let textPost: String = "text.book.closed" static let titleOnlyPost: String = "character.bubble" static let pin: String = "pin" static let pinFill: String = "pin.fill" static let unpin: String = "pin.slash" static let unpinFill: String = "pin.slash.fill" static let websiteIcon: String = "globe" static let read: String = "book" static let lock: String = "lock" static let lockFill: String = "lock.fill" static let unlock: String = "lock.open" static let unlockFill: String = "lock.open.fill" static let remove: String = "xmark.bin" static let removeFill: String = "xmark.bin.fill" static let restore: String = "arrow.up.bin" static let restoreFill: String = "arrow.up.bin.fill" static let purge: String = "burn" static let scoreCounter: String = "arrow.up.arrow.down.circle" static let upvoteCounter: String = "arrow.up.circle" static let downvoteCounter: String = "arrow.down.circle" static let replyCounter: String = "arrowshape.turn.up.left.circle" // post sizes static let postSizeSetting: String = "rectangle.expand.vertical" static let compactPost: String = "rectangle.grid.1x2" static let compactPostFill: String = "rectangle.grid.1x2.fill" static let tilePost: String = "square.grid.2x2" static let tilePostFill: String = "square.grid.2x2.fill" static let headlinePost: String = "rectangle" static let headlinePostFill: String = "rectangle.fill" static let largePost: String = "text.below.photo" static let largePostFill: String = "text.below.photo.fill" // feeds static let federatedFeed: String = "circle.hexagongrid" static let federatedFeedFill: String = "circle.hexagongrid.fill" static let federatedFeedCircle: String = "circle.hexagongrid.circle.fill" static let instanceFeed: String = "building.2" static let instanceFeedFill: String = "building.2.fill" static let instanceFeedCircle: String = "building.2.crop.circle" static let subscribedFeed: String = "newspaper" static let subscribedFeedFill: String = "newspaper.fill" static let subscribedFeedCircle: String = "newspaper.circle.fill" static let savedFeed: String = "bookmark" static let savedFeedFill: String = "bookmark.fill" static let savedFeedCircle: String = "bookmark.circle.fill" // sort types static let activeSort: String = "popcorn" static let activeSortFill: String = "popcorn.fill" static let hotSort: String = "flame" static let hotSortFill: String = "flame.fill" static let scaledSort: String = "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left" static let scaledSortFill: String = "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left" static let newSort: String = "hare" static let newSortFill: String = "hare.fill" static let oldSort: String = "tortoise" static let oldSortFill: String = "tortoise.fill" static let newCommentsSort: String = "exclamationmark.bubble" static let newCommentsSortFill: String = "exclamationmark.bubble.fill" static let mostCommentsSort: String = "bubble.left.and.bubble.right" static let mostCommentsSortFill: String = "bubble.left.and.bubble.right.fill" static let controversialSort: String = "bolt" static let controversialSortFill: String = "bolt.fill" static let topSortMenu: String = "text.line.first.and.arrowtriangle.forward" static let topSort: String = "trophy" static let topSortFill: String = "trophy.fill" static let timeSort: String = "calendar.day.timeline.leading" static let timeSortFill: String = "calendar.day.timeline.leading" static let alphabeticalSort: String = "textformat" static let scoreSort: String = "star" static let usersSort: String = "person.2" static let versionSort: String = "server.rack" // user flairs static let developerFlair: String = "hammer.fill" static let botFlair: String = "terminal.fill" static let opFlair: String = "person.fill" static let instanceBannedFlair: String = "xmark.square.fill" static let communityBannedFlair: String = "xmark.shield.fill" static let newAccountFlair: String = "leaf.fill" // markdown static let bold: String = "bold" static let italic: String = "italic" static let strikethrough: String = "strikethrough" static let superscript: String = "textformat.superscript" static let `subscript`: String = "textformat.subscript" // Potentially "chevron.left.chevron.right" is better, it's iOS 18+ though static let inlineCode: String = "chevron.left.forwardslash.chevron.right" static let quote: String = "quote.opening" static let heading: String = "textformat.size" static let uploadImage: String = "photo" static let spoiler: String = "eye" static let codeBlock: String = "text.viewfinder" // entities/general Lemmy concepts static let federation: String = "point.3.filled.connected.trianglepath.dotted" static let instance: String = "building.2" static let instanceFill: String = "building.2.fill" static let instanceCircle: String = "building.2.crop.circle" static let instanceCircleFill: String = "building.2.crop.circle.fill" static let person: String = "person" static let personFill: String = "person.fill" static let personCircle: String = "person.crop.circle" static let personCircleFill: String = "person.crop.circle.fill" static let community: String = "house" static let communityFill: String = "house.fill" static let communityCircle: String = "house.circle" static let communityCircleFill: String = "house.circle.fill" // tabs static let feeds: String = "scroll" static let feedsFill: String = "scroll.fill" static let inbox: String = "mail.stack" static let inboxFill: String = "mail.stack.fill" static let search: String = "magnifyingglass" static let searchActive: String = "text.magnifyingglass" static let settings: String = "gear" // information/status static let success: String = "checkmark" static let successCircle: String = "checkmark.circle" static let successCircleFill: String = "checkmark.circle.fill" static let successSquareFill: String = "checkmark.square.fill" static let failure: String = "xmark" static let failureCircle: String = "xmark.circle" static let failureCircleFill: String = "xmark.circle.fill" static let present: String = "circle.fill" // that's present as in "here," not as in "gift" static let absent: String = "circle" static let warning: String = "exclamationmark.triangle" static let warningFill: String = "exclamationmark.triangle.fill" static let hide: String = "eye.slash" static let hideFill: String = "eye.slash.fill" static let block: String = "hand.raised" static let blockFill: String = Icons.privacy static let unblock: String = "hand.raised.slash" static let unblockFill: String = "hand.raised.slash.fill" static let nsfwTag: String = "nsfw" static let show: String = "eye" static let showFill: String = "eye.fill" static let blurNsfw: String = "eye.trianglebadge.exclamationmark" static let noContent: String = "binoculars" static let noPosts: String = "text.bubble" static let time: String = "clock" static let updated: String = "clock.arrow.2.circlepath" static let favorite: String = "star" static let favoriteFill: String = "star.fill" static let unfavorite: String = "star.slash" static let unfavoriteFill: String = "star.slash.fill" static let close: String = "multiply" static let closeCircle: String = "xmark.circle" static let closeCircleFill: String = "xmark.circle.fill" static let addCircleFill: String = "plus.circle.fill" static let cakeDay: String = "birthday.cake" static let cakeDayFill: String = "birthday.cake.fill" static let undoCircleFill: String = "arrow.uturn.backward.circle.fill" static let errorCircleFill: String = "exclamationmark.circle.fill" static let proxy: String = "firewall" // uptime static let uptimeOffline: String = "xmark.circle.fill" static let uptimeOnline: String = "checkmark.circle.fill" static let uptimeOutage: String = "exclamationmark.circle.fill" // end of feed static let endOfFeedHobbit: String = "figure.climbing" static let endOfFeedCartoon: String = "figure.wave" static let endOfFeedTurtle: String = "tortoise" // common operations static let share: String = "square.and.arrow.up" static let subscribe: String = "plus.circle" static let subscribed: String = "checkmark.circle" static let subscribePerson: String = "person.crop.circle.badge.plus" static let subscribePersonFill: String = "person.crop.circle.badge.plus.fill" static let unsubscribe: String = "multiply.circle" static let unsubscribePerson: String = "person.crop.circle.badge.xmark" static let unsubscribePersonFill: String = "person.crop.circle.badge.xmark.fill" static let filter: String = "line.3.horizontal.decrease.circle" static let filterFill: String = "line.3.horizontal.decrease.circle.fill" static let menu: String = "ellipsis" static let menuCircle: String = "ellipsis.circle" static let menuCircleFill: String = "ellipsis.circle.fill" static let `import`: String = "square.and.arrow.down" static let attachment: String = "paperclip" static let edit: String = "pencil" static let delete: String = "trash" static let deleteFill: String = "trash.fill" static let undelete: String = "arrow.up.trash" static let copy: String = "doc.on.doc" static let copyFill: String = "doc.on.doc.fill" static let paste: String = "doc.on.clipboard" static let signOut: String = "minus.circle" static let collapseComment: String = "arrow.down.and.line.horizontal.and.arrow.up" static let expandComment: String = "arrow.up.and.line.horizontal.and.arrow.down" static let refresh: String = "arrow.clockwise" static let select: String = "selection.pin.in.out" static let crossPost: String = "shuffle" static let chooseFile: String = "folder" static let add: String = "plus" static let createImage: String = "scanner" // collapse actions static let collapse: String = "minus" static let collapseSquare: String = "minus.rectangle" static let collapseSquareFill: String = "minus.rectangle.fill" static let collapseParent: String = "chevron.up" static let collapseParentSquare: String = "chevron.up.square" static let collapseParentSquareFill: String = "chevron.up.square.fill" static let collapseToTop: String = "arrow.up.to.line" static let collapseToTopSquare: String = "arrow.up.to.line.square" static let collapseToTopSquareFill: String = "arrow.up.to.line.square.fill" // settings static let upvoteOnSave: String = "arrow.up.heart" static let readIndicatorSetting: String = "book" static let readIndicatorBarSetting: String = "rectangle.leftthird.inset.filled" static let profileTabSettings: String = "person.text.rectangle" static let nicknameField: String = "rectangle.and.pencil.and.ellipsis" static let label: String = "tag" static let unreadBadge: String = "envelope.badge" static let showAvatar: String = "person.fill.questionmark" static let widgetWizard: String = "wand.and.stars" static let thumbnail: String = "photo" static let author: String = "signature" static let websiteAddress: String = "link" static let leftRight: String = "arrow.left.arrow.right" static let leftAndRightCircle: String = "arrow.left.and.right.circle" static let developerMode: String = "wrench.adjustable.fill" static let limitImageHeightSetting: String = "rectangle.compress.vertical" static let appLockSettings: String = "lock.app.dashed" static let banFromInstance: String = "xmark.square" static let unbanFromInstance: String = "checkmark.square" static let banFromCommunity: String = "xmark.shield" static let unbanFromCommunity: String = "checkmark.shield" static let banFromInstanceFill: String = "xmark.square.fill" static let unbanFromInstanceFill: String = "checkmark.square.fill" static let banFromCommunityFill: String = "xmark.shield.fill" static let unbanFromCommunityFill: String = "checkmark.shield.fill" static let logIn: String = "person.text.rectangle" static let signUp: String = "pencil.and.list.clipboard" static let sidebar: String = "sidebar.left" static let infiniteScroll: String = "infinity" static let confirmImageUploads: String = "photo.badge.checkmark" static let swipeActions: String = "inset.filled.leadinghalf.rectangle" static let swipeAnywhere: String = "arrow.left" static let importSettings: String = "folder.badge.gearshape" static let inApp: String = "house" static let reader: String = "text.page" static let keywordFilter: String = "rectangle.and.text.magnifyingglass" static let saveSettings: String = "document.badge.gearshape" static let restoreSettings: String = "gearshape.arrow.trianglehead.2.clockwise.rotate.90" static let menuItems: String = "filemenu.and.selection" static let systemMode: String = "circle.lefthalf.filled" static let lightMode: String = "sun.max" static let darkMode: String = "moon" static let compactComments: String = "rectangle.compress.vertical" static let interactionBar: String = "square.and.line.vertical.and.square.fill" static let commentDepth: String = "text.append" static let qualifiedLabel: String = "at" static let right: String = "arrow.right.circle" static let left: String = "arrow.left.circle" static let center: String = "dot.circle" static let zoomSlider: String = "arrow.up.and.down.and.sparkles" static let language: String = "globe" // fediseer static let fediseer: String = "shield.checkered" static let fediseerGuarantee: String = "checkmark.seal.fill" static let fediseerUnguarantee: String = "xmark.seal.fill" static let fediseerEndorsement: String = "signature" static let fediseerHesitation: String = "exclamationmark.triangle.fill" static let fediseerCensure: String = "exclamationmark.octagon.fill" // media static let play: String = "play.fill" static let playCircle: String = "play.circle" static let pause: String = "pause.fill" static let muted: String = "speaker.slash.fill" static let unmuted: String = "speaker.wave.2.fill" static let embedding: String = "app.connected.to.app.below.fill" static let movie: String = "film" // misc static let `private`: String = "lock" static let email: String = "envelope" static let photo: String = "photo" static let action: String = "diamond" static let switchUser: String = "person.crop.circle.badge.plus" static let missing: String = "questionmark.square.dashed" static let connection: String = "antenna.radiowaves.left.and.right" static let haptics: String = "circle.dotted.and.circle" static let transparency: String = "square.on.square.intersection.dashed" static let icon: String = "fleuron" static let banner: String = "flag" static let noWifi: String = "wifi.slash" static let easterEgg: String = "gift.fill" static let jumpButton: String = "chevron.down" static let jumpButtonCircle: String = "chevron.down.circle" static let jumpToLastPositionButton: String = "chevron.down.2" static let browser: String = "safari" static let emptySquare: String = "square" static let dropDown: String = "chevron.down" static let dropDownCircleFill: String = "chevron.down.circle.fill" static let noFile: String = "questionmark.folder" static let forward: String = "chevron.forward" static let backward: String = "chevron.backward" static let imageDetails: String = "doc.badge.ellipsis" static let accountSwitchReload: String = "arrow.2.circlepath" static let accountSwitchKeepPlace: String = "checkmark.diamond" static let security: String = "key" static let securityFill: String = "key.fill" static let privacy: String = "hand.raised.fill" } // swiftlint:enable type_body_length ================================================ FILE: Mlem/App/Configuration/User Settings/PinnedSortTracker.swift ================================================ // // PinnedSortTracker.swift // Mlem // // Created by Sjmarf on 2024-12-12. // import Dependencies import Foundation import MlemMiddleware import Observation @Observable class PinnedSortTracker { @ObservationIgnored @Dependency(\.persistenceRepository) private var persistenceRepository var pinnedSortTypes: Set { didSet { Task.detached { try await self.persistenceRepository.savePinnedSortTypes(self.pinnedSortTypes) } } } init() { self.pinnedSortTypes = PersistenceRepository.liveValue.loadPinnedSortTypes() } public static let main: PinnedSortTracker = .init() } ================================================ FILE: Mlem/App/Configuration/User Settings/SettingPropertyWrapper.swift ================================================ // // SettingPropertyWrapper.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // Adapted from https://fatbobman.com/en/posts/appstorage/ // import Foundation import SwiftUI @propertyWrapper struct Setting: DynamicProperty { private let keyPath: ReferenceWritableKeyPath public init(_ keyPath: ReferenceWritableKeyPath) { self.keyPath = keyPath } public var wrappedValue: T { get { Settings.get(keyPath) } nonmutating set { Settings.set(keyPath, to: newValue) } } public var projectedValue: Binding { Binding( get: { Settings.get(keyPath) }, set: { Settings.set(keyPath, to: $0) } ) } } ================================================ FILE: Mlem/App/Configuration/User Settings/Settings.swift ================================================ // // CodableSettings.swift // Mlem // // Created by Eric Andrews on 2024-09-05. // import Foundation import MlemMiddleware import UIKit import Dependencies import SwiftUI /// Responsible for managing settings logic. /// /// There should only ever be one instance of this class, the private `main`. To enforce this, interaction with the class /// is entirely abstracted to behind a static API. /// /// To access a settings value, it is recommended to use the `@Setting` property wrapper. In contexts where this is not available, /// use `Settings.get(\.keypath)`. class Settings { @Dependency(\.persistenceRepository) var persistenceRepository private let values: SettingsValues private static let main: Settings = .init() // MARK: - API static func get(_ keyPath: ReferenceWritableKeyPath) -> T { main.values[keyPath: keyPath] } static func set(_ keyPath: ReferenceWritableKeyPath, to newValue: T) { main.values[keyPath: keyPath] = newValue main._save() } static func mutate(_ keyPath: ReferenceWritableKeyPath, mutation: (T) -> T) { main.values[keyPath: keyPath] = mutation(main.values[keyPath: keyPath]) main._save() } static func mutate(_ keyPath: ReferenceWritableKeyPath, mutation: (inout T) -> Void) { mutation(&main.values[keyPath: keyPath]) main._save() } static func save(to systemSetting: SystemSetting) async { await main._save(to: systemSetting) } @MainActor static func restore(from systemSetting: SystemSetting) { main._restore(from: systemSetting) } @MainActor static func reinit(with values: SettingsValues) { main._reinit(with: values) } static func encoded() throws -> Data { try JSONEncoder().encode(main.values) } // MARK: - Logic fileprivate func _save() { Task { try await persistenceRepository.saveSystemSettings(values, setting: .v2_system) } } private func _save(to systemSetting: SystemSetting) async { do { try await persistenceRepository.saveSystemSettings(values, setting: systemSetting) ToastModel.main.add(.success("Saved Settings")) } catch { handleError(error) } } @MainActor private func _restore(from systemSetting: SystemSetting) { if let savedSettings = persistenceRepository.loadSystemSettings(systemSetting) { _reinit(with: savedSettings) ToastModel.main.add(.success("Restored Settings")) } else { ToastModel.main.add(.failure("Could not find settings")) } } @MainActor private func _reinit(with newValues: SettingsValues) { // values needs to be re-initialized memberwise rather than simply reassigned in order for the changes to publish correctly values.reinit(from: newValues) _save() } private init() { @Dependency(\.persistenceRepository) var persistenceRepository if let savedSettings = persistenceRepository.loadSystemSettings(.v2_system) { values = savedSettings } else { values = .init(from: .main, filteredKeywords: persistenceRepository.loadFilteredKeywords()) Task { do { try await persistenceRepository.saveSystemSettings(values, setting: .v2_system) } catch { handleError(error) } } } } } // MARK: Legacy Keyword Loading private extension PersistencePath { static var filteredKeywords = root.appendingPathComponent("Blocked Keywords", conformingTo: .json) } private extension PersistenceRepository { func loadFilteredKeywords() -> Set { load(Set.self, from: PersistencePath.filteredKeywords) ?? .init() } } ================================================ FILE: Mlem/App/Configuration/User Settings/SettingsValues.swift ================================================ // // SettingsValues.swift // Mlem // // Created by Eric Andrews on 2025-04-07. // import Dependencies import Haptics import MlemMiddleware import UIKit // swiftlint:disable line_length function_body_length file_length /// Values backing the Settings class. /// - Note: when adding a new settings, be sure to add relevant entries to `init`, `reinit`, and `CodingKeys`. @Observable class SettingsValues: Codable { // swiftlint:disable:this type_body_length var a11y_readPostIndicator: ReadPostIndicator var a11y_readOutlineThickness: Int var a11y_showSettingsIcons: Bool var a11y_websiteThumbnailIcon: Bool var a11y_zoomSliderLocation: ZoomSliderLocation var a11y_showInteractionBarButtonBackground: Bool var accounts_defaultId: Int? var accounts_grouped: Bool var accounts_sort: AccountSortMode var accounts_keepPlace: Bool var accounts_preferredListRowComplication: PreferredAccountListRowComplication var appearance_interfaceStyle: UIUserInterfaceStyle var appearance_palette: PaletteOption var markdown_wrapCodeBlockLines: Bool var behavior_biometricUnlock: Bool var behavior_confirmImageUploads: Bool var behavior_enableQuickSwipes: Bool var behavior_hapticLevel: HapticTier? var behavior_internetSpeed: InternetSpeed var behavior_upvoteOnSave: Bool var behavior_autoplayMedia: Bool var behavior_muteVideos: Bool var behavior_infiniteScroll: Bool var comment_behaviors_collapseChildren: Bool var comment_compact: Bool var comment_defaultSort: LemmyCommentSortType var comment_gestures_tapToCollapse: Bool var comment_jumpButton: CommentJumpButtonLocation var comment_showCreatorInstance: Bool var comment_maxDepth: Int var comment_createImage_showPost: Bool var comment_createImage_showCreator: Bool var comment_createImage_showStats: Bool var comment_createImage_colorScheme: UIUserInterfaceStyle var comment_showDownvotesCompact: Bool var community_showAvatar: Bool var community_showBanner: Bool var community_showInstance: Bool var dev_developerMode: Bool var dev_errorTimeout: Double var feed_default: ListingType var feed_markReadOnScroll: Bool var feed_showRead: Bool var inbox_showRead: Bool var links_displayMode: TappableLinksDisplayMode var links_openInBrowser: Bool var links_readerMode: Bool var links_shareMode: LinkSharingMode var links_embedLoops: Bool var imageViewer_showControls: ShowImageViewerControls var imageViewer_showCloseButton: Bool var imageViewer_showZoomIndicator: Bool var imageViewer_dismissThreshold: Int var media_animatedAvatars: AnimatedAvatarBehavior var menus_allModActions: Bool var menus_modActionGrouping: ModeratorActionGrouping var post_defaultSort: LemmySortType var post_fallbackSort: LemmySortType var post_limitImageHeight: Bool var post_showCreator: Bool var post_showCreatorInstance: Bool var post_showSubscribedStatus: Bool var post_showWebsitePreview: Bool var post_size: PostSize var post_allowMultipleColumns: Bool var post_thumbnailLocation: ThumbnailLocation var post_webPreview_showHost: Bool var post_webPreview_showIcon: Bool var post_showDownvotesCompact: Bool var post_gestures_tapToCollapse: Bool var post_createImage_showCommunity: Bool var post_createImage_showCreator: Bool var post_createImage_showStats: Bool var post_createImage_colorScheme: UIUserInterfaceStyle var profile_showBanner: Bool var privacy_autoBypassImageProxy: Bool var privacy_showFavicons: Bool var safety_blurNsfw: NsfwBlurBehavior var safety_enableModlogWarning: Bool var safety_enableNsfwCommunityWarning: Bool var tab_gestures_enableLongPress: Bool var tab_gestures_enableSwipeUp: Bool var tab_gestures_longPressAction: TabBarLongPressAction var tab_profile_labelType: ProfileTabLabel var tab_profile_showAvatar: Bool var tab_inbox_badgeIncludedTypes: Set var tab_showNames: Bool var tip_feedWelcomePrompt: Bool var person_showAvatar: Bool var person_showInstance: Bool var person_ageVisibility: AccountAgeFlairVisibility var status_bypassImageProxyShown: Bool var subscriptions_instanceLocation: InstanceLocation var subscriptions_sort: SubscriptionListSort var navigation_sidebarVisibleByDefault: Bool var navigation_swipeAnywhere: Bool var filters_keywordFilterEnabled: Bool var filters_keywords: Set var filters_literalFilterEnabled: Bool var filters_literals: Set var interactionBar_post: PostBarConfiguration var interactionBar_comment: CommentBarConfiguration var interactionBar_reply: ReplyBarConfiguration var interactionBar_community: CommunityActionConfiguration var interactionBar_postReport: PostBarConfiguration var interactionBar_commentReport: CommentBarConfiguration var interactionBar_alternateReportLayout: Bool var events_showEvents: Bool // These are included in the encoding, but are synthesized into tab_inbox_badgeIncludedTypes at decoding @ObservationIgnored var inbox_badge_includeApplications: Bool = false @ObservationIgnored var inbox_badge_includeMessageReports: Bool = false @ObservationIgnored var inbox_badge_includeMod: Bool = false @ObservationIgnored var inbox_badge_includePersonal: Bool = false // This was only used in a 2.5 beta; remove me var imageViewer_showOverlayByDefault: Bool = true required init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.a11y_readPostIndicator = try container.decodeIfPresent(ReadPostIndicator.self, forKey: ._a11y_readPostIndicator) ?? .checkmark self.a11y_readOutlineThickness = try container.decodeIfPresent(Int.self, forKey: ._a11y_readOutlineThickness) ?? 3 self.a11y_showSettingsIcons = try container.decodeIfPresent(Bool.self, forKey: ._a11y_showSettingsIcons) ?? true self.a11y_websiteThumbnailIcon = try container.decodeIfPresent(Bool.self, forKey: ._a11y_websiteThumbnailIcon) ?? false self.a11y_zoomSliderLocation = try container.decodeIfPresent(ZoomSliderLocation.self, forKey: ._a11y_zoomSliderLocation) ?? .none self.a11y_showInteractionBarButtonBackground = try container.decodeIfPresent(Bool.self, forKey: ._a11y_showInteractionBarButtonBackground) ?? false self.accounts_defaultId = try container.decodeIfPresent(Int?.self, forKey: ._accounts_defaultId) ?? nil self.accounts_grouped = try container.decodeIfPresent(Bool.self, forKey: ._accounts_grouped) ?? false self.accounts_sort = try container.decodeIfPresent(AccountSortMode.self, forKey: ._accounts_sort) ?? .name self.accounts_keepPlace = try container.decodeIfPresent(Bool.self, forKey: ._accounts_keepPlace) ?? false self.accounts_preferredListRowComplication = try container.decodeIfPresent(PreferredAccountListRowComplication.self, forKey: ._accounts_preferredListRowComplication) ?? .lastUsed self.appearance_interfaceStyle = try container.decodeIfPresent(UIUserInterfaceStyle.self, forKey: ._appearance_interfaceStyle) ?? .unspecified self.appearance_palette = try container.decodeIfPresent(PaletteOption.self, forKey: ._appearance_palette) ?? .standard self.markdown_wrapCodeBlockLines = try container.decodeIfPresent(Bool.self, forKey: ._markdown_wrapCodeBlockLines) ?? true self.behavior_biometricUnlock = try container.decodeIfPresent(Bool.self, forKey: ._behavior_biometricUnlock) ?? false self.behavior_confirmImageUploads = try container.decodeIfPresent(Bool.self, forKey: ._behavior_confirmImageUploads) ?? true self.behavior_enableQuickSwipes = try container.decodeIfPresent(Bool.self, forKey: ._behavior_enableQuickSwipes) ?? true do { self.behavior_hapticLevel = try container.decodeIfPresent(HapticTier.self, forKey: ._behavior_hapticLevel) } catch DecodingError.dataCorrupted { // Decodes the 'sentinel' value, which was replaced with `nil` in Mlem 2.2 self.behavior_hapticLevel = nil } self.behavior_internetSpeed = try container.decodeIfPresent(InternetSpeed.self, forKey: ._behavior_internetSpeed) ?? .fast self.behavior_autoplayMedia = try container.decodeIfPresent(Bool.self, forKey: ._behavior_autoplayMedia) ?? false self.behavior_muteVideos = try container.decodeIfPresent(Bool.self, forKey: ._behavior_muteVideos) ?? true self.behavior_upvoteOnSave = try container.decodeIfPresent(Bool.self, forKey: ._behavior_upvoteOnSave) ?? false self.behavior_infiniteScroll = try container.decodeIfPresent(Bool.self, forKey: ._behavior_infiniteScroll) ?? true self.comment_behaviors_collapseChildren = try container.decodeIfPresent(Bool.self, forKey: ._comment_behaviors_collapseChildren) ?? false self.comment_compact = try container.decodeIfPresent(Bool.self, forKey: ._comment_compact) ?? false self.comment_defaultSort = try container.decodeIfPresent(LemmyCommentSortType.self, forKey: ._comment_defaultSort) ?? .hot self.comment_gestures_tapToCollapse = try container.decodeIfPresent(Bool.self, forKey: ._comment_gestures_tapToCollapse) ?? true self.comment_jumpButton = try container.decodeIfPresent(CommentJumpButtonLocation.self, forKey: ._comment_jumpButton) ?? .bottomTrailing self.comment_showCreatorInstance = try container.decodeIfPresent(Bool.self, forKey: ._comment_showCreatorInstance) ?? true self.comment_showDownvotesCompact = try container.decodeIfPresent(Bool.self, forKey: ._comment_showDownvotesCompact) ?? false if let value = try container.decodeIfPresent(Int.self, forKey: ._comment_maxDepth) { self.comment_maxDepth = value } else if let value = try container.decodeIfPresent(Bool.self, forKey: ._comment_behaviors_collapseChildren) { self.comment_maxDepth = value ? 1 : 8 } else { self.comment_maxDepth = 8 } self.community_showAvatar = try container.decodeIfPresent(Bool.self, forKey: ._community_showAvatar) ?? true self.community_showBanner = try container.decodeIfPresent(Bool.self, forKey: ._community_showBanner) ?? true self.community_showInstance = try container.decodeIfPresent(Bool.self, forKey: ._community_showInstance) ?? true self.comment_createImage_showPost = try container.decodeIfPresent(Bool.self, forKey: ._comment_createImage_showPost) ?? true self.comment_createImage_showCreator = try container.decodeIfPresent(Bool.self, forKey: ._comment_createImage_showCreator) ?? true self.comment_createImage_showStats = try container.decodeIfPresent(Bool.self, forKey: ._comment_createImage_showStats) ?? true self.comment_createImage_colorScheme = try container.decodeIfPresent(UIUserInterfaceStyle.self, forKey: ._comment_createImage_colorScheme) ?? .unspecified self.dev_developerMode = try container.decodeIfPresent(Bool.self, forKey: ._dev_developerMode) ?? false self.dev_errorTimeout = try container.decodeIfPresent(Double.self, forKey: ._dev_errorTimeout) ?? 1.5 self.feed_default = try container.decodeIfPresent(ListingType.self, forKey: ._feed_default) ?? .subscribed self.feed_markReadOnScroll = try container.decodeIfPresent(Bool.self, forKey: ._feed_markReadOnScroll) ?? false self.feed_showRead = try container.decodeIfPresent(Bool.self, forKey: ._feed_showRead) ?? true if let tab_inbox_badgeIncludedTypes = try container.decodeIfPresent(Set.self, forKey: ._tab_inbox_badgeIncludedTypes) { self.tab_inbox_badgeIncludedTypes = tab_inbox_badgeIncludedTypes } else { let inbox_badge_includeApplications: Bool? = try container.decodeIfPresent(Bool.self, forKey: .inbox_badge_includeApplications) let inbox_badge_includeMessageReports: Bool? = try container.decodeIfPresent(Bool.self, forKey: .inbox_badge_includeMessageReports) let inbox_badge_includeMod: Bool? = try container.decodeIfPresent(Bool.self, forKey: .inbox_badge_includeMod) let inbox_badge_includePersonal: Bool? = try container.decodeIfPresent(Bool.self, forKey: .inbox_badge_includePersonal) var includedTypes: Set = [] if inbox_badge_includePersonal ?? true { includedTypes.formUnion([.reply, .mention, .message]) } if inbox_badge_includeMod ?? true { includedTypes.formUnion([.postReport, .commentReport]) } if inbox_badge_includeMessageReports ?? true { includedTypes.formUnion([.messageReport]) } if inbox_badge_includeApplications ?? true { includedTypes.insert(.registrationApplication) } self.tab_inbox_badgeIncludedTypes = includedTypes } self.inbox_showRead = try container.decodeIfPresent(Bool.self, forKey: ._inbox_showRead) ?? true self.links_displayMode = try container.decodeIfPresent(TappableLinksDisplayMode.self, forKey: ._links_displayMode) ?? .contextual self.links_openInBrowser = try container.decodeIfPresent(Bool.self, forKey: ._links_openInBrowser) ?? false self.links_readerMode = try container.decodeIfPresent(Bool.self, forKey: ._links_readerMode) ?? false self.links_shareMode = try container.decodeIfPresent(LinkSharingMode.self, forKey: ._links_shareMode) ?? .myInstance self.links_embedLoops = try container.decodeIfPresent(Bool.self, forKey: ._links_embedLoops) ?? true // This was only used in a 2.5 beta; remove me let showOverlayByDefault = try container.decodeIfPresent(Bool.self, forKey: ._imageViewer_showOverlayByDefault) ?? true let showControlsOldValue: ShowImageViewerControls = showOverlayByDefault ? .immediately : .onTap self.imageViewer_showControls = try container.decodeIfPresent(ShowImageViewerControls.self, forKey: ._imageViewer_showControls) ?? showControlsOldValue self.imageViewer_showCloseButton = try container.decodeIfPresent(Bool.self, forKey: ._imageViewer_showCloseButton) ?? true self.imageViewer_showZoomIndicator = try container.decodeIfPresent(Bool.self, forKey: ._imageViewer_showZoomIndicator) ?? true self.imageViewer_dismissThreshold = try container.decodeIfPresent(Int.self, forKey: ._imageViewer_dismissThreshold) ?? 10 self.media_animatedAvatars = try container.decodeIfPresent(AnimatedAvatarBehavior.self, forKey: ._media_animatedAvatars) ?? (UIAccessibility.isReduceMotionEnabled ? .never : .always) self.menus_allModActions = try container.decodeIfPresent(Bool.self, forKey: ._menus_allModActions) ?? false self.menus_modActionGrouping = try container.decodeIfPresent(ModeratorActionGrouping.self, forKey: ._menus_modActionGrouping) ?? .divider self.post_defaultSort = try container.decodeIfPresent(LemmySortType.self, forKey: ._post_defaultSort) ?? .hot self.post_fallbackSort = try container.decodeIfPresent(LemmySortType.self, forKey: ._post_fallbackSort) ?? .hot self.post_limitImageHeight = try container.decodeIfPresent(Bool.self, forKey: ._post_limitImageHeight) ?? true self.post_showCreator = try container.decodeIfPresent(Bool.self, forKey: ._post_showCreator) ?? true self.post_showCreatorInstance = try container.decodeIfPresent(Bool.self, forKey: ._post_showCreatorInstance) ?? true self.post_showSubscribedStatus = try container.decodeIfPresent(Bool.self, forKey: ._post_showSubscribedStatus) ?? false self.post_showWebsitePreview = try container.decodeIfPresent(Bool.self, forKey: ._post_showWebsitePreview) ?? true self.post_showDownvotesCompact = try container.decodeIfPresent(Bool.self, forKey: ._post_showDownvotesCompact) ?? false self.post_size = try container.decodeIfPresent(PostSize.self, forKey: ._post_size) ?? .large self.post_allowMultipleColumns = try container.decodeIfPresent(Bool.self, forKey: ._post_allowMultipleColumns) ?? true self.post_thumbnailLocation = try container.decodeIfPresent(ThumbnailLocation.self, forKey: ._post_thumbnailLocation) ?? .left self.post_webPreview_showHost = try container.decodeIfPresent(Bool.self, forKey: ._post_webPreview_showHost) ?? true self.post_webPreview_showIcon = try container.decodeIfPresent(Bool.self, forKey: ._post_webPreview_showIcon) ?? true self.post_gestures_tapToCollapse = try container.decodeIfPresent(Bool.self, forKey: ._post_gestures_tapToCollapse) ?? true self.post_createImage_showCommunity = try container.decodeIfPresent(Bool.self, forKey: ._post_createImage_showCommunity) ?? true self.post_createImage_showCreator = try container.decodeIfPresent(Bool.self, forKey: ._post_createImage_showCreator) ?? true self.post_createImage_showStats = try container.decodeIfPresent(Bool.self, forKey: ._post_createImage_showStats) ?? true self.post_createImage_colorScheme = try container.decodeIfPresent(UIUserInterfaceStyle.self, forKey: ._post_createImage_colorScheme) ?? .unspecified self.privacy_autoBypassImageProxy = try container.decodeIfPresent(Bool.self, forKey: ._privacy_autoBypassImageProxy) ?? false self.privacy_showFavicons = try container.decodeIfPresent(Bool.self, forKey: ._privacy_showFavicons) ?? true self.profile_showBanner = try container.decodeIfPresent(Bool.self, forKey: ._profile_showBanner) ?? true self.safety_blurNsfw = try container.decodeIfPresent(NsfwBlurBehavior.self, forKey: ._safety_blurNsfw) ?? .always self.safety_enableModlogWarning = try container.decodeIfPresent(Bool.self, forKey: ._safety_enableModlogWarning) ?? true self.safety_enableNsfwCommunityWarning = try container.decodeIfPresent(Bool.self, forKey: ._safety_enableNsfwCommunityWarning) ?? true self.tab_gestures_enableLongPress = try container.decodeIfPresent(Bool.self, forKey: ._tab_gestures_enableLongPress) ?? true self.tab_gestures_enableSwipeUp = try container.decodeIfPresent(Bool.self, forKey: ._tab_gestures_enableSwipeUp) ?? true self.tab_gestures_longPressAction = try container.decodeIfPresent(TabBarLongPressAction.self, forKey: ._tab_gestures_longPressAction) ?? .openAccountSwitcher self.tab_profile_labelType = try container.decodeIfPresent(ProfileTabLabel.self, forKey: ._tab_profile_labelType) ?? .nickname self.tab_profile_showAvatar = try container.decodeIfPresent(Bool.self, forKey: ._tab_profile_showAvatar) ?? true self.tab_showNames = try container.decodeIfPresent(Bool.self, forKey: ._tab_showNames) ?? true self.tip_feedWelcomePrompt = try container.decodeIfPresent(Bool.self, forKey: ._tip_feedWelcomePrompt) ?? true self.person_showAvatar = try container.decodeIfPresent(Bool.self, forKey: ._person_showAvatar) ?? true self.person_showInstance = try container.decodeIfPresent(Bool.self, forKey: ._person_showInstance) ?? true self.person_ageVisibility = try container.decodeIfPresent(AccountAgeFlairVisibility.self, forKey: ._person_ageVisibility) ?? .newAccountsOnly self.status_bypassImageProxyShown = try container.decodeIfPresent(Bool.self, forKey: ._status_bypassImageProxyShown) ?? false self.subscriptions_instanceLocation = try container.decodeIfPresent(InstanceLocation.self, forKey: ._subscriptions_instanceLocation) ?? (UIDevice.isPad ? .bottom : .trailing) self.subscriptions_sort = try container.decodeIfPresent(SubscriptionListSort.self, forKey: ._subscriptions_sort) ?? .alphabetical self.navigation_sidebarVisibleByDefault = try container.decodeIfPresent(Bool.self, forKey: ._navigation_sidebarVisibleByDefault) ?? true self.navigation_swipeAnywhere = try container.decodeIfPresent(Bool.self, forKey: ._navigation_swipeAnywhere) ?? false self.filters_keywordFilterEnabled = try container.decodeIfPresent(Bool.self, forKey: ._filters_keywordFilterEnabled) ?? true self.filters_keywords = try container.decodeIfPresent(Set.self, forKey: ._filters_keywords) ?? .init() self.filters_literalFilterEnabled = try container.decodeIfPresent(Bool.self, forKey: ._filters_literalFilterEnabled) ?? true self.filters_literals = try container.decodeIfPresent(Set.self, forKey: ._filters_literals) ?? .init() self.interactionBar_post = try container.decodeIfPresent(PostBarConfiguration.self, forKey: ._interactionBar_post) ?? .default self.interactionBar_comment = try container.decodeIfPresent(CommentBarConfiguration.self, forKey: ._interactionBar_comment) ?? .default self.interactionBar_reply = try container.decodeIfPresent(ReplyBarConfiguration.self, forKey: ._interactionBar_reply) ?? .default self.interactionBar_community = try container.decodeIfPresent(CommunityActionConfiguration.self, forKey: ._interactionBar_community) ?? .init() self.interactionBar_postReport = try container.decodeIfPresent(PostBarConfiguration.self, forKey: ._interactionBar_postReport) ?? .reportDefault_ self.interactionBar_commentReport = try container.decodeIfPresent(CommentBarConfiguration.self, forKey: ._interactionBar_commentReport) ?? .reportDefault_ self.interactionBar_alternateReportLayout = try container.decodeIfPresent(Bool.self, forKey: ._interactionBar_alternateReportLayout) ?? false self.events_showEvents = try container.decodeIfPresent(Bool.self, forKey: ._events_showEvents) ?? true } func reinit(from otherValues: SettingsValues) { a11y_readPostIndicator = otherValues.a11y_readPostIndicator a11y_readOutlineThickness = otherValues.a11y_readOutlineThickness a11y_showSettingsIcons = otherValues.a11y_showSettingsIcons a11y_websiteThumbnailIcon = otherValues.a11y_websiteThumbnailIcon a11y_zoomSliderLocation = otherValues.a11y_zoomSliderLocation accounts_defaultId = otherValues.accounts_defaultId accounts_grouped = otherValues.accounts_grouped accounts_sort = otherValues.accounts_sort accounts_keepPlace = otherValues.accounts_keepPlace accounts_preferredListRowComplication = otherValues.accounts_preferredListRowComplication appearance_interfaceStyle = otherValues.appearance_interfaceStyle appearance_palette = otherValues.appearance_palette markdown_wrapCodeBlockLines = otherValues.markdown_wrapCodeBlockLines behavior_biometricUnlock = otherValues.behavior_biometricUnlock behavior_confirmImageUploads = otherValues.behavior_confirmImageUploads behavior_enableQuickSwipes = otherValues.behavior_enableQuickSwipes behavior_hapticLevel = otherValues.behavior_hapticLevel behavior_internetSpeed = otherValues.behavior_internetSpeed behavior_upvoteOnSave = otherValues.behavior_upvoteOnSave behavior_autoplayMedia = otherValues.behavior_autoplayMedia behavior_muteVideos = otherValues.behavior_muteVideos behavior_infiniteScroll = otherValues.behavior_infiniteScroll comment_behaviors_collapseChildren = otherValues.comment_behaviors_collapseChildren comment_compact = otherValues.comment_compact comment_defaultSort = otherValues.comment_defaultSort comment_gestures_tapToCollapse = otherValues.comment_gestures_tapToCollapse comment_jumpButton = otherValues.comment_jumpButton comment_showCreatorInstance = otherValues.comment_showCreatorInstance comment_maxDepth = otherValues.comment_maxDepth comment_createImage_showPost = otherValues.comment_createImage_showPost comment_createImage_showCreator = otherValues.comment_createImage_showCreator comment_createImage_showStats = otherValues.comment_createImage_showStats comment_createImage_colorScheme = otherValues.comment_createImage_colorScheme comment_showDownvotesCompact = otherValues.comment_showDownvotesCompact community_showAvatar = otherValues.community_showAvatar community_showBanner = otherValues.community_showBanner community_showInstance = otherValues.community_showInstance dev_developerMode = otherValues.dev_developerMode feed_default = otherValues.feed_default feed_markReadOnScroll = otherValues.feed_markReadOnScroll feed_showRead = otherValues.feed_showRead inbox_showRead = otherValues.inbox_showRead links_displayMode = otherValues.links_displayMode links_openInBrowser = otherValues.links_openInBrowser links_readerMode = otherValues.links_readerMode links_shareMode = otherValues.links_shareMode links_embedLoops = otherValues.links_embedLoops imageViewer_showControls = otherValues.imageViewer_showControls imageViewer_showCloseButton = otherValues.imageViewer_showCloseButton imageViewer_showZoomIndicator = otherValues.imageViewer_showZoomIndicator imageViewer_dismissThreshold = otherValues.imageViewer_dismissThreshold media_animatedAvatars = otherValues.media_animatedAvatars menus_allModActions = otherValues.menus_allModActions menus_modActionGrouping = otherValues.menus_modActionGrouping post_defaultSort = otherValues.post_defaultSort post_fallbackSort = otherValues.post_fallbackSort post_limitImageHeight = otherValues.post_limitImageHeight post_showCreator = otherValues.post_showCreator post_showCreatorInstance = otherValues.post_showCreatorInstance post_showSubscribedStatus = otherValues.post_showSubscribedStatus post_showWebsitePreview = otherValues.post_showWebsitePreview post_size = otherValues.post_size post_allowMultipleColumns = otherValues.post_allowMultipleColumns post_thumbnailLocation = otherValues.post_thumbnailLocation post_webPreview_showHost = otherValues.post_webPreview_showHost post_webPreview_showIcon = otherValues.post_webPreview_showIcon post_showDownvotesCompact = otherValues.post_showDownvotesCompact post_gestures_tapToCollapse = otherValues.post_gestures_tapToCollapse post_createImage_showCommunity = otherValues.post_createImage_showCommunity post_createImage_showCreator = otherValues.post_createImage_showCreator post_createImage_showStats = otherValues.post_createImage_showStats post_createImage_colorScheme = otherValues.post_createImage_colorScheme profile_showBanner = otherValues.profile_showBanner privacy_autoBypassImageProxy = otherValues.privacy_autoBypassImageProxy privacy_showFavicons = otherValues.privacy_showFavicons safety_blurNsfw = otherValues.safety_blurNsfw safety_enableModlogWarning = otherValues.safety_enableModlogWarning safety_enableNsfwCommunityWarning = otherValues.safety_enableNsfwCommunityWarning tab_gestures_enableLongPress = otherValues.tab_gestures_enableLongPress tab_gestures_enableSwipeUp = otherValues.tab_gestures_enableSwipeUp tab_profile_labelType = otherValues.tab_profile_labelType tab_profile_showAvatar = otherValues.tab_profile_showAvatar tab_inbox_badgeIncludedTypes = otherValues.tab_inbox_badgeIncludedTypes tab_showNames = otherValues.tab_showNames tip_feedWelcomePrompt = otherValues.tip_feedWelcomePrompt person_showAvatar = otherValues.person_showAvatar person_showInstance = otherValues.person_showInstance person_ageVisibility = otherValues.person_ageVisibility status_bypassImageProxyShown = otherValues.status_bypassImageProxyShown subscriptions_instanceLocation = otherValues.subscriptions_instanceLocation subscriptions_sort = otherValues.subscriptions_sort navigation_sidebarVisibleByDefault = otherValues.navigation_sidebarVisibleByDefault navigation_swipeAnywhere = otherValues.navigation_swipeAnywhere filters_keywordFilterEnabled = otherValues.filters_keywordFilterEnabled filters_keywords = otherValues.filters_keywords filters_literalFilterEnabled = otherValues.filters_literalFilterEnabled filters_literals = otherValues.filters_literals interactionBar_post = otherValues.interactionBar_post interactionBar_comment = otherValues.interactionBar_comment interactionBar_reply = otherValues.interactionBar_reply interactionBar_community = otherValues.interactionBar_community interactionBar_postReport = otherValues.interactionBar_postReport interactionBar_commentReport = otherValues.interactionBar_commentReport interactionBar_alternateReportLayout = otherValues.interactionBar_alternateReportLayout inbox_badge_includeApplications = otherValues.inbox_badge_includeApplications inbox_badge_includeMessageReports = otherValues.inbox_badge_includeMessageReports inbox_badge_includeMod = otherValues.inbox_badge_includeMod inbox_badge_includePersonal = otherValues.inbox_badge_includePersonal } enum CodingKeys: String, CodingKey { case _a11y_readPostIndicator = "a11y_readPostIndicator" case _a11y_readOutlineThickness = "a11y_readOutlineThickness" case _a11y_showSettingsIcons = "a11y_showSettingsIcons" case _a11y_websiteThumbnailIcon = "a11y_websiteThumbnailIcon" case _a11y_zoomSliderLocation = "a11y_zoomSliderLocation" case _a11y_showInteractionBarButtonBackground = "a11y_showInteractionBarButtonBackground" case _accounts_defaultId = "accounts_defaultId" case _accounts_grouped = "accounts_grouped" case _accounts_sort = "accounts_sort" case _accounts_keepPlace = "accounts_keepPlace" case _accounts_preferredListRowComplication case _appearance_interfaceStyle = "appearance_interfaceStyle" case _appearance_palette = "appearance_palette" case _markdown_wrapCodeBlockLines = "markdown_wrapCodeBlockLines" case _behavior_biometricUnlock = "behavior_biometricUnlock" case _behavior_confirmImageUploads = "behavior_confirmImageUploads" case _behavior_enableQuickSwipes = "behavior_enableQuickSwipes" case _behavior_hapticLevel = "behavior_hapticLevel" case _behavior_internetSpeed = "behavior_internetSpeed" case _behavior_upvoteOnSave = "behavior_upvoteOnSave" case _behavior_autoplayMedia = "behavior_autoplayMedia" case _behavior_muteVideos = "behavior_muteVideos" case _behavior_infiniteScroll = "behavior_infiniteScroll" case _comment_behaviors_collapseChildren = "comment_behaviors_collapseChildren" case _comment_compact = "comment_compact" case _comment_defaultSort = "comment_defaultSort" case _comment_gestures_tapToCollapse = "comment_gestures_tapToCollapse" case _comment_jumpButton = "comment_jumpButton" case _comment_showCreatorInstance = "comment_showCreatorInstance" case _comment_maxDepth = "comment_maxDepth" case _comment_createImage_showPost = "comment_createImage_showPost" case _comment_createImage_showCreator = "comment_createImage_showCreator" case _comment_createImage_showStats = "comment_createImage_showStats" case _comment_createImage_colorScheme = "comment_createImage_colorScheme" case _comment_showDownvotesCompact = "comment_showDownvotesCompact" case _community_showAvatar = "community_showAvatar" case _community_showBanner = "community_showBanner" case _community_showInstance = "community_showInstance" case _dev_developerMode = "dev_developerMode" case _dev_errorTimeout = "dev_errorTimeout" case _feed_default = "feed_default" case _feed_markReadOnScroll = "feed_markReadOnScroll" case _feed_showRead = "feed_showRead" case _inbox_showRead = "inbox_showRead" case _links_displayMode = "links_displayMode" case _links_openInBrowser = "links_openInBrowser" case _links_readerMode = "links_readerMode" case _links_shareMode = "links_shareMode" case _links_embedLoops = "links_embedLoops" case _imageViewer_showControls = "imageViewer_showControls" // This was only used in a 2.5 beta, remove me case _imageViewer_showOverlayByDefault = "imageViewer_showOverlayByDefault" case _imageViewer_showCloseButton = "imageViewer_showCloseButton" case _imageViewer_showZoomIndicator = "imageViewer_showZoomIndicator" case _imageViewer_dismissThreshold = "imageViewer_dismissThreshold" case _media_animatedAvatars = "media_animatedAvatars" case _menus_allModActions = "menus_allModActions" case _menus_modActionGrouping = "menus_modActionGrouping" case _post_defaultSort = "post_defaultSort" case _post_fallbackSort = "post_fallbackSort" case _post_limitImageHeight = "post_limitImageHeight" case _post_showCreator = "post_showCreator" case _post_showCreatorInstance = "post_showCreatorInstance" case _post_showSubscribedStatus = "post_showSubscribedStatus" case _post_showWebsitePreview = "post_showWebsitePreview" case _post_size = "post_size" case _post_allowMultipleColumns = "post_allowMultipleColumns" case _post_thumbnailLocation = "post_thumbnailLocation" case _post_webPreview_showHost = "post_webPreview_showHost" case _post_webPreview_showIcon = "post_webPreview_showIcon" case _post_showDownvotesCompact = "post_showDownvotesCompact" case _post_gestures_tapToCollapse = "post_gestures_tapToCollapse" case _post_createImage_showCommunity = "post_createImage_showCommunity" case _post_createImage_showCreator = "post_createImage_showCreator" case _post_createImage_showStats = "post_createImage_showStats" case _post_createImage_colorScheme = "post_createImage_colorScheme" case _profile_showBanner = "profile_showBanner" case _privacy_autoBypassImageProxy = "privacy_autoBypassImageProxy" case _privacy_showFavicons = "privacy_showFavicons" case _safety_blurNsfw = "safety_blurNsfw" case _safety_enableModlogWarning = "safety_enableModlogWarning" case _safety_enableNsfwCommunityWarning = "safety_enableNsfwCommunityWarning" case _tab_gestures_enableLongPress = "tab_gestures_enableLongPress" case _tab_gestures_enableSwipeUp = "tab_gestures_enableSwipeUp" case _tab_gestures_longPressAction = "tab_gestures_longPressAction" case _tab_profile_labelType = "tab_profile_labelType" case _tab_profile_showAvatar = "tab_profile_showAvatar" case _tab_inbox_badgeIncludedTypes = "tab_inbox_badgeIncludedTypes" case _tab_showNames = "tab_showNames" case _tip_feedWelcomePrompt = "tip_feedWelcomePrompt" case _person_showAvatar = "person_showAvatar" case _person_showInstance = "person_showInstance" case _person_ageVisibility = "person_ageVisibility" case _status_bypassImageProxyShown = "status_bypassImageProxyShown" case _subscriptions_instanceLocation = "subscriptions_instanceLocation" case _subscriptions_sort = "subscriptions_sort" case _navigation_sidebarVisibleByDefault = "navigation_sidebarVisibleByDefault" case _navigation_swipeAnywhere = "navigation_swipeAnywhere" case _filters_keywordFilterEnabled = "filters_keywordFilterEnabled" case _filters_keywords = "filters_keywords" case _filters_literalFilterEnabled = "filters_literalFilterEnabled" case _filters_literals = "filters_literals" case _interactionBar_post = "interactionBar_post" case _interactionBar_comment = "interactionBar_comment" case _interactionBar_reply = "interactionBar_reply" case _interactionBar_community = "interactionBar_community" case _interactionBar_postReport = "interactionBar_postReport" case _interactionBar_commentReport = "interactionBar_commentReport" case _interactionBar_alternateReportLayout = "interactionBar_alternateReportLayout" case _events_showEvents = "events_showEvents" case inbox_badge_includeApplications case inbox_badge_includeMessageReports case inbox_badge_includeMod case inbox_badge_includePersonal } init(from settings: LegacySettings, filteredKeywords: Set) { @Dependency(\.persistenceRepository) var persistenceRepository self.a11y_readPostIndicator = settings.readPostIndicator self.a11y_readOutlineThickness = settings.readOutlineThickness self.a11y_showSettingsIcons = settings.showSettingsIcons self.a11y_websiteThumbnailIcon = settings.websiteThumbnailIcon self.a11y_zoomSliderLocation = settings.zoomSliderLocation self.a11y_showInteractionBarButtonBackground = false self.accounts_defaultId = nil // In 2.0, the last used account is now activated when the app is opened self.accounts_grouped = settings.groupAccountSort self.accounts_sort = settings.accountSort self.accounts_keepPlace = settings.keepPlaceOnAccountSwitch self.accounts_preferredListRowComplication = .lastUsed self.appearance_interfaceStyle = settings.interfaceStyle self.appearance_palette = settings.colorPalette self.markdown_wrapCodeBlockLines = settings.wrapCodeBlockLines self.behavior_biometricUnlock = false // Removed in 2.0 self.behavior_confirmImageUploads = settings.confirmImageUploads self.behavior_enableQuickSwipes = settings.quickSwipesEnabled self.behavior_hapticLevel = settings.hapticLevel self.behavior_internetSpeed = settings.internetSpeed self.behavior_upvoteOnSave = settings.upvoteOnSave self.behavior_autoplayMedia = settings.autoplayMedia self.behavior_muteVideos = settings.muteVideos self.behavior_infiniteScroll = settings.infiniteScroll self.comment_behaviors_collapseChildren = false // Replaced by comment_maxDepth in 2.0 self.comment_compact = settings.compactComments self.comment_defaultSort = settings.commentSort self.comment_gestures_tapToCollapse = settings.tapCommentsToCollapse self.comment_jumpButton = settings.jumpButton self.comment_showCreatorInstance = true // Removed in 2.0 self.comment_maxDepth = settings.maxCommentDepth self.comment_createImage_showPost = true // Added in 2.4 self.comment_createImage_showCreator = true // Added in 2.4 self.comment_createImage_showStats = true // Added in 2.4 self.comment_createImage_colorScheme = .unspecified // Added in 2.4 self.comment_showDownvotesCompact = false // Added in 2.5 self.community_showAvatar = settings.showCommunityAvatar self.community_showBanner = true // Removed in 2.0 self.community_showInstance = true // Removed in 2.0 self.dev_developerMode = settings.developerMode self.dev_errorTimeout = 1.5 // Added in 2.5 self.feed_default = settings.defaultFeed self.feed_markReadOnScroll = settings.markReadOnScroll self.feed_showRead = settings.showReadInFeed self.inbox_showRead = settings.showReadInInbox self.links_displayMode = settings.tappableLinksDisplayMode self.links_openInBrowser = settings.openLinksInBrowser self.links_readerMode = settings.openLinksInReaderMode self.links_shareMode = settings.linkSharingMode self.links_embedLoops = settings.embedLoops self.imageViewer_showControls = .immediately // Added in 2.5 self.imageViewer_showCloseButton = true // Added in 2.5 self.imageViewer_showZoomIndicator = true // Added in 2.5 self.imageViewer_dismissThreshold = 10 // Added in 2.5 self.media_animatedAvatars = settings.animatedAvatars self.menus_allModActions = settings.showAllModActions self.menus_modActionGrouping = settings.moderatorActionGrouping self.post_defaultSort = settings.defaultPostSort self.post_fallbackSort = settings.fallbackPostSort self.post_limitImageHeight = true // Removed in 2.0 self.post_showCreator = settings.showPostCreator self.post_showCreatorInstance = true // Removed in 2.0 self.post_showSubscribedStatus = settings.showSubscribedStatus self.post_showWebsitePreview = true // Removed in 2.0 self.post_size = settings.postSize self.post_allowMultipleColumns = settings.allowMultiplePostColumns self.post_thumbnailLocation = settings.thumbnailLocation self.post_webPreview_showHost = true // Removed in 2.0 self.post_webPreview_showIcon = settings.showFavicons self.post_showDownvotesCompact = settings.showDownvotesCompact self.post_gestures_tapToCollapse = true self.post_createImage_showCommunity = true // Added in 2.4 self.post_createImage_showCreator = true // Added in 2.4 self.post_createImage_showStats = true // Added in 2.4 self.post_createImage_colorScheme = .unspecified // Added in 2.4 self.profile_showBanner = true // Removed in 2.0 self.safety_blurNsfw = settings.blurNsfw self.safety_enableNsfwCommunityWarning = settings.showNsfwCommunityWarning self.safety_enableModlogWarning = settings.showModlogWarning self.tab_gestures_enableLongPress = true // Removed in 2.0 self.tab_gestures_enableSwipeUp = true // Removed in 2.0 self.tab_gestures_longPressAction = .openAccountSwitcher // Added in 2.2 self.tab_profile_labelType = settings.tabProfileLabelType self.tab_profile_showAvatar = settings.tabProfileShowAvatar self.tab_inbox_badgeIncludedTypes = settings.tabInboxBadgeIncludedTypes self.tab_showNames = true // Removed in 2.0 self.tip_feedWelcomePrompt = settings.showFeedWelcomePrompt self.person_showAvatar = settings.showPersonAvatar self.person_showInstance = true // Removed in 2.0 self.person_ageVisibility = .newAccountsOnly // Added in 2.2 self.privacy_autoBypassImageProxy = settings.autoBypassImageProxy self.privacy_showFavicons = settings.showFavicons // TODO: unused? self.status_bypassImageProxyShown = settings.bypassImageProxyShown self.subscriptions_instanceLocation = settings.subscriptionInstanceLocation self.subscriptions_sort = settings.subscriptionSort self.navigation_sidebarVisibleByDefault = settings.sidebarVisibleByDefault self.navigation_swipeAnywhere = settings.swipeAnywhereToNavigate self.filters_keywordFilterEnabled = settings.keywordFilterEnabled self.filters_keywords = filteredKeywords self.filters_literalFilterEnabled = true // Added in 2.4 self.filters_literals = .init() // Added in 2.4 self.interactionBar_alternateReportLayout = settings.alternateInteractionBarLayoutForReports let interactionBarConfigurations = persistenceRepository.loadInteractionBarConfigurations() self.interactionBar_post = interactionBarConfigurations.post self.interactionBar_comment = interactionBarConfigurations.comment self.interactionBar_reply = interactionBarConfigurations.reply self.interactionBar_community = .init() // Added in 2.5 self.interactionBar_postReport = interactionBarConfigurations.postReport self.interactionBar_commentReport = interactionBarConfigurations.commentReport self.events_showEvents = true // Added in 2.5 } } // swiftlint:enable line_length function_body_length file_length ================================================ FILE: Mlem/App/Data/Document.swift ================================================ // // Document.swift // Mlem // // Created by Eric Andrews on 2023-07-09. // import Foundation struct Document: Identifiable, Hashable { let title: String let body: String var id: Int { var hasher = Hasher() hasher.combine(body) return hasher.finalize() } } ================================================ FILE: Mlem/App/Data/EULA.swift ================================================ // // EULA.swift // Mlem // // Created by Eric Andrews on 2023-07-09. // import Foundation // swiftlint:disable line_length extension Document { static let eula: Document = .init( title: "EULA", body: """ # EULA Welcome to Mlem! Before you proceed, please carefully read the following Terms of Service ("Terms") governing your use of our app. By accessing or using Mlem, you acknowledge that you have read, understood, and agreed to be bound by these Terms. If you do not agree with any part of these Terms, please refrain from using Mlem. ## 1. User Responsibilities 1.1 Reporting Content: Questionable content or content in violation of the rules and guidelines of the Lemmy instance or community on which it is hosted can be reported to the Lemmy community moderators using Mlem's built-in report function or on the instance website. We are not responsible for moderating or enforcing Lemmy instance or community rules. 1.2 Blocking Users and Instances: Mlem provides you with the ability to block individual users, communities, or entire instances. We encourage you to use these blocking features to create a safe and enjoyable Mlem experience. 1.3 Following Instance Rules: You are required to follow the rules of the instance(s) that you access using Mlem. Instance terms of use can be found on the instance website. Failure to comply with the rules of an instance may result in suspension or termination of your account with that instance as dictated by the instance rules. 1.4 No Misuse: The misuse of Mlem will result in termination of all services--to the furthest of our ability--with us. We reserve the right to terminate--to the furthest of our ability--any services that we provide. 1.5 Adult Content: Some Lemmy instances that Mlem accesses may host adult content. Lemmy blocks this content by default; if you wish to view it, in-app or otherwise, you can enable the option on the website of the instance where your account is registered. We take reasonable measures to prevent the display of explicit or adult content within Mlem, but we cannot guarantee that all instances or communities will follow the appropriate procedures to label adult content as such. It is your responsibility to exercise caution while accessing external content. 1.6 No Abusive, Unlawful, or Offensive Content: You may not use Mlem to produce or distribute any abusive, unlawful, or offensive content. This includes, but is not limited to: content that is unlawful, libelous, defamatory, or tortious; harmful, threatening, abusive, invasive, or harassing; or hateful or racially, ethnically, or otherwise discriminatory. The content you produce will not harm minors in any way. You will not impersonate any person or entity. You will not upload or post any content that you do not have the right to make available under any US or foreign laws. You will not produce content that interferes with or disrupts the app, the Lemmy instances that you use, other Lemmy instances, or any other service or person. You will not transmit misinformation in any capacity to any individual. ## 2. Limitation of Liability 2.1 No Liability: To the fullest extent permitted by applicable law, we disclaim any liability for any direct, indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, arising from your use of Mlem or any interactions within the Lemmy instances that you access thereby. This includes, but is not limited to, any damages resulting from the content, actions, or conduct of other Mlem or Lemmy users. ## 3. General Provisions 3.1 Modifications: We reserve the right to modify, suspend, or terminate Mlem or these Terms, at our sole discretion, at any time and without prior notice. Your continued use of Mlem after any modifications to these Terms shall constitute your acceptance of the modified Terms. 3.2 Governing Law: These Terms shall be governed by and construed in accordance with the laws of the United States, without regard to its conflict of laws principles. 3.3 Entire Agreement: These Terms constitute the entire agreement between you and us regarding your use of Mlem and supersede any prior or contemporaneous agreements, communications, or proposals, whether oral or written, between you and us. By using Mlem, you affirm that you have read, understood, and agreed to these revised Terms of Service. If you have any questions or concerns, please contact us at mlemappofficial@gmail.com. Thank you for using Mlem! """ ) } // swiftlint:enable line_length ================================================ FILE: Mlem/App/Data/Licenses.swift ================================================ // // Licenses.swift // Mlem // // Created by Weston Hanners on 7/12/23. // import Foundation // swiftlint:disable file_length line_length extension Document { static let allLicenses: [Document] = [ .cmarkLicense, .keychainAccessLicense, .nukeLicense, .semaphoreLicense, .swiftDependenciesLicense, .swiftUiFlowLicense, .swiftUiIntrospectLicence, .swiftUiXLicense, .solarizedThemeLicense, .draculaThemeLicense ] static let keychainAccessLicense = Document( title: "Keychain Access", body: """ The MIT License (MIT) Copyright (c) 2014 kishikawa katsumi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let nukeLicense = Document( title: "Nuke", body: """ The MIT License (MIT) Copyright (c) 2015-2023 Alexander Grebenyuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let swiftDependenciesLicense = Document( title: "Swift Dependencies", body: """ MIT License Copyright (c) 2022 Point-Free, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let swiftUiXLicense = Document( title: "SwiftUIX", body: """ Copyright © 2020 Vatsal Manot Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let cmarkLicense = Document( title: "cmark-gfm", body: """ Copyright (c) 2014, John MacFarlane All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----- houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c derive from https://github.com/vmg/houdini (with some modifications) Copyright (C) 2012 Vicent Martí Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- buffer.h, buffer.c, chunk.h are derived from code (C) 2012 Github, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- utf8.c and utf8.c are derived from utf8proc (), (C) 2009 Public Software Group e. V., Berlin, Germany. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The normalization code in normalize.py was derived from the markdowntest project, Copyright 2013 Karl Dubost: The MIT License (MIT) Copyright (c) 2013 Karl Dubost Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----- The CommonMark spec (test/spec.txt) is Copyright (C) 2014-15 John MacFarlane Released under the Creative Commons CC-BY-SA 4.0 license: . ----- The test software in test/ is Copyright (c) 2014, John MacFarlane All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ ) static let swiftUiFlowLicense = Document( title: "SwiftUI-Flow", body: """ MIT License Copyright (c) 2023 Laszlo Teveli Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let semaphoreLicense = Document( title: "Semaphore", body: """ MIT License Copyright (c) 2022 Gwendal Roué Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let swiftUiIntrospectLicence = Document( title: "swiftui-introspect", body: """ Copyright 2019 Timber Software Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let solarizedThemeLicense = Document( title: "Solarized Theme", body: """ Copyright (c) 2011 Ethan Schoonover Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) static let draculaThemeLicense = Document( title: "Dracula Theme", body: """ The MIT License (MIT) Copyright (c) 2023 Dracula Theme Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ ) } // swiftlint:enable file_length line_length ================================================ FILE: Mlem/App/Data/Privacy Policy.swift ================================================ // // Privacy Policy.swift // Mlem // // Created by Eric Andrews on 2023-07-09. // import Foundation // swiftlint:disable line_length extension Document { static let privacyPolicy: Document = .init( title: "Privacy Policy", body: """ # Privacy Policy Effective Date: July 15th, 2023 Thank you for using Mlem! This Privacy Policy outlines how your personal information is collected, used, and protected when you use the Mlem mobile application ("App"). Please read this Privacy Policy carefully to understand our practices regarding your personal information. By using the Mlem mobile application, you acknowledge that you have read and understood this Privacy Policy and agree to the collection, use, and disclosure of your information as described herein. ## Information We Collect Mlem does not collect or store any user data. We do not collect any personally identifiable information or track your activities within the App. ## Servers and Data Control Mlem connects to servers that we do not host or have control over. While we strive to ensure the security and privacy of your data within our App, we cannot guarantee the security or privacy practices of the external servers. Any data stored or processed on these servers is subject to the respective privacy policies and terms of service of those servers. ## Third-Party Services Mlem does not integrate any third-party services, advertising networks, or analytics tools that collect personal information or track your activities within the App. We prioritize user privacy and do not engage in any data sharing or tracking practices. ## Children's Privacy Mlem is not intended for use by individuals under the age of 13. We do not knowingly collect personal information from children under the age of 13. If we become aware that we have inadvertently collected personal information from a child under the age of 13, we will take steps to delete the information as soon as possible. If you believe that we may have collected information from a child under the age of 13, please contact us using the information provided in the "Contact Us" section below. ## Changes to this Privacy Policy We reserve the right to modify or update this Privacy Policy at any time. Any changes will be effective immediately upon posting the revised Privacy Policy. ## Contact Us If you have any questions, concerns, or requests regarding this Privacy Policy or the privacy practices of Mlem, please contact us at mlemappofficial@gmail.com. """ ) } // swiftlint:enable line_length ================================================ FILE: Mlem/App/Enums/AnimatedAvatarBehavior.swift ================================================ // // AnimatedAvatarBehavior.swift // Mlem // // Created by Eric Andrews on 2025-03-15. // import Foundation import Icons enum AnimatedAvatarBehavior: String, CaseIterable, Codable { case always, profile, never var label: LocalizedStringResource { switch self { case .always: "Always" case .profile: "Only in Profile" case .never: "Never" } } var icon: Icon { switch self { case .always: .general.success case .profile: .lemmy.person case .never: .general.failure } } } ================================================ FILE: Mlem/App/Enums/AvatarType.swift ================================================ // // AvatarType.swift // Mlem // // Created by Eric Andrews on 2023-10-02. // import Foundation import Icons // TODO: move into DefaultAvatarView? /// Enum of things that can have avatars enum AvatarType { case person, community, instance } ================================================ FILE: Mlem/App/Enums/CommentJumpButtonLocation.swift ================================================ // // CommentJumpButtonLocation.swift // Mlem // // Created by Sjmarf on 24/08/2024. // import Icons import SwiftUI enum CommentJumpButtonLocation: String, CaseIterable, Codable { case bottomLeading, bottomTrailing, bottomCenter, none var alignment: Alignment { switch self { case .bottomLeading: .bottomLeading case .bottomTrailing: .bottomTrailing case .bottomCenter: .bottom case .none: .bottomTrailing } } var label: LocalizedStringResource { switch self { case .bottomLeading: "Left" case .bottomTrailing: "Right" case .bottomCenter: "Center" case .none: "Hidden" } } var icon: Icon { switch self { case .bottomLeading: .general.backward case .bottomTrailing: .general.forward default: .settings.center } } } ================================================ FILE: Mlem/App/Enums/InstanceSort.swift ================================================ // // InstanceSort.swift // Mlem // // Created by Sjmarf on 09/09/2024. // import Foundation import Icons enum InstanceSort: CaseIterable { case alphabetical, score, users, version // TODO: Add "New", "Old", "Active Users"? Requires MlemStats update // We could add a _lot_ of sort modes here if we wanted to once we get a HTTP server (https://github.com/mlemgroup/mlem/issues/1313) var label: LocalizedStringResource { switch self { case .alphabetical: "Alphabetical" case .score: "Score" case .users: "Users" case .version: "Version" } } var icon: Icon { switch self { case .alphabetical: .lemmy.alphabeticalSort case .score: .lemmy.scoreSort case .users: .lemmy.usersSort case .version: .lemmy.versionSort } } } ================================================ FILE: Mlem/App/Enums/Interaction/ActionSeedSections.swift ================================================ // // ActionSeedSections.swift // Mlem // // Created by Sjmarf on 2026-02-28. // import Actions struct ActionSeedSections { let sections: [[ActionSeed]] init(sections: [[ActionSeed]]) { self.sections = sections } var all: [ActionSeed] { sections.reduce([], +) } } ================================================ FILE: Mlem/App/Enums/Interaction/CommentBarConfiguration+Types.swift ================================================ // // CommentBarConfiguration+Types.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import Foundation import MlemMiddleware import SwiftUI extension CommentBarConfiguration { enum ActionType: String, ActionTypeProviding { typealias Configuration = CommentBarConfiguration // swiftlint:disable:this nesting case upvote case downvote case save case reply case share case selectText case report case resolve case remove case ban case collapse case collapseParent case collapseToTop static var defaultWidgets: [ActionType] { [ .upvote, .downvote, .save, .reply, .share ] } static var defaultReportWidgets: [ActionType] { [ .share, .resolve, .remove, .ban ] } var appearance: ActionAppearance { switch self { case .upvote: .upvote(isOn: false) case .downvote: .downvote(isOn: false) case .save: .save(isOn: false) case .reply: .reply() case .share: .share() case .selectText: .selectText() case .report: .report() case .resolve: .resolve(isOn: false) case .remove: .remove(isOn: false) case .ban: .banFromCommunity(isOn: false) case .collapse: .collapse() case .collapseParent: .collapseParent() case .collapseToTop: .collapseToTop() } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .save: [.saved] case .reply, .share, .selectText, .report, .resolve, .remove, .ban: [] case .collapse, .collapseParent, .collapseToTop: [] } } var actionSeed: ActionSeed { switch self { case .upvote: .upvote case .downvote: .downvote case .save: .save case .reply: .reply case .share: .share case .selectText: .selectText case .report: .report case .resolve: .resolveReport case .remove: .remove case .ban: .ban case .collapse: .collapse case .collapseParent: .collapseParent case .collapseToTop: .collapseToTop } } } enum CounterType: String, CounterTypeProviding { typealias Configuration = CommentBarConfiguration // swiftlint:disable:this nesting case score case upvote case downvote case reply static var defaultWidgets: [CounterType] { allCases } var appearance: CounterAppearance { switch self { case .score: .score() case .upvote: .upvote() case .downvote: .downvote() case .reply: .reply() } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .score: [.upvote, .downvote, .score] case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .reply: [] } } } enum ReadoutType: String, ReadoutTypeProviding { case created case score case upvote case downvote case comment case saved var appearance: MockReadoutAppearance { switch self { case .created: .init(icon: .general.time, label: "18h") case .score: .init(icon: .lemmy.votes, label: "7") case .upvote: .init(icon: .lemmy.upvoted, label: "9") case .downvote: .init(icon: .lemmy.downvoted, label: "2") case .comment: .init(icon: .lemmy.replies, label: "1") case .saved: .init(icon: .lemmy.saved, label: "") } } func compatibleWith(otherReadouts: Set) -> Bool { switch self { case .score: otherReadouts.isDisjoint(with: [.upvote, .downvote]) case .upvote, .downvote: !otherReadouts.contains(.score) default: true } } } } ================================================ FILE: Mlem/App/Enums/Interaction/CommentBarConfiguration.swift ================================================ // // CommentInteraction.swift // Mlem // // Created by Sjmarf on 14/06/2024. // import Actions import Foundation import MlemMiddleware import SwiftUI struct CommentBarConfiguration: InteractionBarConfiguration, SwipeActionConfiguration { var leading: [Item] var trailing: [Item] var readouts: [ReadoutType] var savedContextMenu: [ActionSeed]? public var savedSwipes: ActionSeedSwipeConfiguration? static var defaultSwipes: ActionSeedSwipeConfiguration { .init(leading: [.downvote, .upvote], trailing: [.save, .reply]) } static var defaultContextMenu: [ActionSeed] { [.selectText, .share, .blockCreator, .report, .edit, .delete, .remove, .banCreator, .resolveReport] } var availableWidgets: Set func widgetPickerPage(_ configuration: Binding) -> SettingsPage { .commentBarWidgetPicker(configuration) } init( leading: [Item], trailing: [Item], savedSwipes: ActionSeedSwipeConfiguration?, readouts: [ReadoutType], availableWidgets: Set, savedContextMenu: [ActionSeed]? ) { self.leading = leading self.trailing = trailing self.savedSwipes = savedSwipes self.readouts = readouts self.availableWidgets = availableWidgets self.savedContextMenu = savedContextMenu } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.leading = try container.decodeIfPresent([Item].self, forKey: .leading) ?? [.counter(.score)] self.trailing = try container.decodeIfPresent([Item].self, forKey: .trailing) ?? [.action(.save), .action(.reply)] self.readouts = try container.decodeIfPresent([ReadoutType].self, forKey: .readouts) ?? [.created, .comment] self.availableWidgets = try container.decodeIfPresent(Set.self, forKey: .availableWidgets) ?? .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }) if let contextMenuKeys = try container.decodeIfPresent([String].self, forKey: .savedContextMenu) { let allActions = Self.availableActions.all self.savedContextMenu = contextMenuKeys.compactMap { key in allActions.first(where: {$0.key == key}) } } else { self.savedContextMenu = nil } let swipeConfigurationContainer = try? container.nestedContainer( keyedBy: ActionSeedSwipeConfiguration.CodingKeys.self, forKey: .swipes ) if let swipeConfigurationContainer { self.savedSwipes = try .init(from: swipeConfigurationContainer, availableActions: Self.availableActions.all) } else { // Convert from Mlem 2.4 -> 2.5 format let leadingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .leadingSwipes) ?? [.upvote, .downvote] let trailingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .trailingSwipes) ?? [.save, .reply] let swipes = ActionSeedSwipeConfiguration( leading: leadingSwipes.map(\.actionSeed), trailing: trailingSwipes.map(\.actionSeed) ) if swipes == Self.defaultSwipes { self.savedSwipes = nil } else { self.savedSwipes = swipes } } } enum CodingKeys: CodingKey { case leading case trailing case readouts case availableWidgets case savedContextMenu case swipes // Used for conversion from Mlem 2.4 -> 2.5 format case leadingSwipes case trailingSwipes } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.leading, forKey: .leading) try container.encode(self.trailing, forKey: .trailing) try container.encode(self.readouts, forKey: .readouts) try container.encode(self.availableWidgets, forKey: .availableWidgets) try container.encode(self.savedContextMenu, forKey: .savedContextMenu) try container.encode(self.savedSwipes, forKey: .swipes) } static var `default`: Self { .init( leading: [.counter(.score)], trailing: [.action(.save), .action(.reply)], savedSwipes: nil, readouts: [.created, .comment], availableWidgets: .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }), savedContextMenu: nil ) } static var reportDefault_: Self { .init( leading: [.action(.resolve), .action(.share)], trailing: [.action(.ban), .action(.remove)], savedSwipes: nil, readouts: [.upvote, .downvote, .created, .comment], availableWidgets: .init(ActionType.defaultReportWidgets.map { .action($0) }), savedContextMenu: nil ) } static var availableActions: ActionSeedSections { .init(sections: [ [ .upvote, .downvote, .save, .reply, .selectText, .share, .createImage, .report, .edit, .delete ], [ .collapse, .collapseParent, .collapseToTop ], [ .blockCreator, .copyAuthorName, .openCreatorModlog, .sendCreatorMessage ], [ .viewVotes, .remove, .banCreator, .purge, .purgeCreator, .resolveReport ] ]) } static var reportDefault: Self? { reportDefault_ } } ================================================ FILE: Mlem/App/Enums/Interaction/CommunityActionConfiguration.swift ================================================ // // CommunityActionConfiguration.swift // Mlem // // Created by Sjmarf on 2026-03-04. // import Actions import Foundation struct CommunityActionConfiguration: Codable, SwipeActionConfiguration { var savedSwipes: ActionSeedSwipeConfiguration? static var availableActions: ActionSeedSections { .init(sections: [ [ .newPost, .subscribe, .favorite, .goToInstance, .copyName, .share ], [ .block, .remove, .purge ] ]) } static var defaultSwipes: ActionSeedSwipeConfiguration { .init(leading: [], trailing: [.subscribe, .favorite]) } enum CodingKeys: CodingKey { case swipes } init() { self.savedSwipes = nil } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let swipeConfigurationContainer = try? container.nestedContainer( keyedBy: ActionSeedSwipeConfiguration.CodingKeys.self, forKey: .swipes ) if let swipeConfigurationContainer { self.savedSwipes = try .init(from: swipeConfigurationContainer, availableActions: Self.availableActions.all) } else { self.savedSwipes = nil } } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.savedSwipes, forKey: .swipes) } } ================================================ FILE: Mlem/App/Enums/Interaction/ContextMenuConfiguration.swift ================================================ // // ContextMenuConfiguration.swift // Mlem // // Created by Sjmarf on 2026-03-21. // import Actions import Foundation protocol ContextMenuConfiguration { var savedContextMenu: [ActionSeed]? { get set } var contextMenu: [ActionSeed] { get set } static var availableActions: ActionSeedSections { get } static var defaultContextMenu: [ActionSeed] { get } } extension ContextMenuConfiguration { var contextMenu: [ActionSeed] { get { savedContextMenu ?? Self.defaultContextMenu } set { savedContextMenu = newValue } } } ================================================ FILE: Mlem/App/Enums/Interaction/InteractionBarConfiguration.swift ================================================ // // InteractionConfiguration.swift // Mlem // // Created by Sjmarf on 15/08/2024. // // swiftlint:disable line_length import Actions import Foundation import Icons import MlemMiddleware import SwiftUI protocol InteractionBarConfiguration: Codable, Equatable, SwipeActionConfiguration, ContextMenuConfiguration { associatedtype ActionType: ActionTypeProviding associatedtype CounterType: CounterTypeProviding associatedtype ReadoutType: ReadoutTypeProviding typealias Item = InteractionConfigurationItem var leading: [Item] { get set } var trailing: [Item] { get set } var readouts: [ReadoutType] { get set } var availableWidgets: Set { get set } func widgetPickerPage(_ configuration: Binding) -> SettingsPage /// Default configuration for this type static var `default`: Self { get } /// Default report configuration for this type. `nil` if inapplicable. static var reportDefault: Self? { get } static var availableActions: ActionSeedSections { get } init( leading: [Item], trailing: [Item], savedSwipes: ActionSeedSwipeConfiguration?, readouts: [ReadoutType], availableWidgets: Set, savedContextMenu: [ActionSeed]? ) } extension InteractionBarConfiguration { /// Convert the `InteractionBarConfiguration` to another type of `InteractionBarConfiguration`. This is done by finding cases with /// matching `rawValue` in the new type. If one cannot be found, the item is omitted. func applying(other: some InteractionBarConfiguration, types: Set) -> Self { .init( leading: types.contains(.bar) ? other.leading.compactMap { $0.convert() } : leading, trailing: types.contains(.bar) ? other.trailing.compactMap { $0.convert() } : trailing, savedSwipes: types.contains(.swipe) ? other.savedSwipes?.filter(allowed: Self.availableActions.all) : savedSwipes, readouts: types.contains(.bar) ? other.readouts.compactMap { .init(rawValue: $0.rawValue) } : readouts, availableWidgets: types.contains(.bar) ? .init(other.availableWidgets.compactMap { $0.convert() }) : availableWidgets, savedContextMenu: types.contains(.contextMenu) ? other.savedContextMenu.map { $0.filter { Self.availableActions.all.contains($0) } } : savedContextMenu ) } var all: [Item] { leading + trailing } func associatedReadouts(context: any InteractableProviding) -> Set { all.reduce(into: Set()) { result, element in result.formUnion(element.associatedReadouts(context: context)) } } } // swiftlint:disable:next type_name enum InteractionBarConfigurationConversionType { case swipe, bar, contextMenu } enum InteractionConfigurationItem< ActionType: ActionTypeProviding, CounterType: CounterTypeProviding, ReadoutType: ReadoutTypeProviding >: Codable, Hashable { case action(ActionType) case counter(CounterType) static var allCases: [InteractionConfigurationItem] { CounterType.allCases.map { .counter($0) } + ActionType.allCases.map { .action($0) } } fileprivate func convert< A: ActionTypeProviding, C: CounterTypeProviding, R: ReadoutTypeProviding >() -> InteractionConfigurationItem? { switch self { case let .action(action): if let value = A(rawValue: action.rawValue) { return .action(value) } else { return nil } case let .counter(counter): if let value = C(rawValue: counter.rawValue) { return .counter(value) } else { return nil } } } // This is used to determine when an interaction bar configuration is considered "full" var score: Int { switch self { case .action: 1 case let .counter(counter): counter.appearance.leading == nil || counter.appearance.trailing == nil ? 2 : 3 } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case let .action(actionType): guard let ret = actionType.associatedReadouts(context: context) as? Set else { assertionFailure("Could not cast to ReadoutType") return [] } return ret case let .counter(counterType): guard let ret = counterType.associatedReadouts(context: context) as? Set else { assertionFailure("Could not cast to ReadoutType") return [] } return ret } } } protocol ActionTypeProviding: Codable, CaseIterable, Hashable, RawRepresentable where RawValue == String { associatedtype Configuration: InteractionBarConfiguration var appearance: ActionAppearance { get } static var defaultWidgets: [Self] { get } func associatedReadouts(context: any InteractableProviding) -> Set } protocol CounterTypeProviding: Codable, CaseIterable, Hashable, RawRepresentable where RawValue == String { associatedtype Configuration: InteractionBarConfiguration var appearance: CounterAppearance { get } static var defaultWidgets: [Self] { get } func associatedReadouts(context: any InteractableProviding) -> Set } protocol ReadoutTypeProviding: Codable, CaseIterable, Hashable, RawRepresentable where RawValue == String { var appearance: MockReadoutAppearance { get } func compatibleWith(otherReadouts: Set) -> Bool } struct InteractionBarConfigurations: Codable { var post: PostBarConfiguration var comment: CommentBarConfiguration var reply: ReplyBarConfiguration var postReport: PostBarConfiguration var commentReport: CommentBarConfiguration static var `default`: Self { .init( post: .default, comment: .default, reply: .default, postReport: .reportDefault_, commentReport: .reportDefault_ ) } init( post: PostBarConfiguration, comment: CommentBarConfiguration, reply: ReplyBarConfiguration, postReport: PostBarConfiguration, commentReport: CommentBarConfiguration ) { self.post = post self.comment = comment self.reply = reply self.postReport = postReport self.commentReport = commentReport } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.post = try container.decodeIfPresent(PostBarConfiguration.self, forKey: .post) ?? .default self.comment = try container.decodeIfPresent(CommentBarConfiguration.self, forKey: .comment) ?? .default self.reply = try container.decodeIfPresent(ReplyBarConfiguration.self, forKey: .reply) ?? .default self.postReport = try container.decodeIfPresent(PostBarConfiguration.self, forKey: .postReport) ?? .reportDefault_ self.commentReport = try container.decodeIfPresent(CommentBarConfiguration.self, forKey: .commentReport) ?? .reportDefault_ } } struct MockReadoutAppearance { let icon: Icon let label: String } // swiftlint:enable line_length ================================================ FILE: Mlem/App/Enums/Interaction/PostBarConfiguration+Types.swift ================================================ // // PostBarConfiguration+Types.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import Foundation import MlemMiddleware import SwiftUI extension PostBarConfiguration { enum ActionType: String, ActionTypeProviding { typealias Configuration = PostBarConfiguration // swiftlint:disable:this nesting case upvote case downvote case save case reply case share case selectText case hide case block case report case crossPost case lock case pin case resolve case remove case ban static var defaultWidgets: [ActionType] { [ .upvote, .downvote, .save, .reply, .share ] } static var defaultReportWidgets: [ActionType] { [ .share, .lock, .pin, .resolve, .remove, .ban ] } var appearance: ActionAppearance { switch self { case .upvote: .upvote(isOn: false) case .downvote: .downvote(isOn: false) case .save: .save(isOn: false) case .reply: .reply() case .share: .share() case .selectText: .selectText() case .hide: .hide(isOn: false) case .block: .block(isOn: false) case .report: .report() case .crossPost: .crossPost() case .lock: .lock(isOn: false) case .pin: .pin(isOn: false) case .resolve: .resolve(isOn: false) case .remove: .remove(isOn: false) case .ban: .banFromCommunity(isOn: false) } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .save: [.saved] case .reply, .share, .selectText, .hide, .block, .report, .crossPost, .lock, .pin, .resolve, .remove, .ban: [] } } func associatedReadouts(context: Post) -> Set { switch self { case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .save: [.saved] case .reply, .share, .selectText, .hide, .block, .report, .crossPost, .lock, .pin, .resolve, .remove, .ban: [] } } var actionSeed: ActionSeed { switch self { case .upvote: .upvote case .downvote: .downvote case .save: .save case .reply: .reply case .share: .share case .selectText: .selectText case .hide: .hide case .block: .blockCreator case .report: .report case .crossPost: .crosspost case .lock: .lock case .pin: .pin case .resolve: .resolveReport case .remove: .remove case .ban: .banCreator } } } enum CounterType: String, CounterTypeProviding { typealias Configuration = PostBarConfiguration // swiftlint:disable:this nesting case score case upvote case downvote case reply static var defaultWidgets: [CounterType] { allCases } var appearance: CounterAppearance { switch self { case .score: .score() case .upvote: .upvote() case .downvote: .downvote() case .reply: .reply() } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .score: [.upvote, .downvote, .score] case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .reply: [] } } func associatedReadouts(context: Post) -> Set { switch self { case .score: [.upvote, .downvote, .score] case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .reply: [] } } } enum ReadoutType: String, ReadoutTypeProviding { case created case score case upvote case downvote case comment case saved var appearance: MockReadoutAppearance { switch self { case .created: .init(icon: .general.time, label: "18h") case .score: .init(icon: .lemmy.votes, label: "7") case .upvote: .init(icon: .lemmy.upvoted, label: "9") case .downvote: .init(icon: .lemmy.downvoted, label: "2") case .comment: .init(icon: .lemmy.replies, label: "1") case .saved: .init(icon: .lemmy.saved, label: "") } } func compatibleWith(otherReadouts: Set) -> Bool { switch self { case .score: otherReadouts.isDisjoint(with: [.upvote, .downvote]) case .upvote, .downvote: !otherReadouts.contains(.score) default: true } } } } ================================================ FILE: Mlem/App/Enums/Interaction/PostBarConfiguration.swift ================================================ // // PostInteraction.swift // Mlem // // Created by Sjmarf on 14/06/2024. // import Actions import Foundation import MlemMiddleware import SwiftUI struct PostBarConfiguration: InteractionBarConfiguration, SwipeActionConfiguration { var leading: [Item] var trailing: [Item] var readouts: [ReadoutType] var savedContextMenu: [ActionSeed]? var savedSwipes: ActionSeedSwipeConfiguration? static var defaultSwipes: ActionSeedSwipeConfiguration { .init(leading: [.downvote, .upvote], trailing: [.save, .reply]) } static var defaultContextMenu: [ActionSeed] { [.selectText, .share, .blockCreator, .report, .edit, .delete, .remove, .banCreator, .resolveReport] } var availableWidgets: Set func widgetPickerPage(_ configuration: Binding) -> SettingsPage { .postBarWidgetPicker(configuration) } init( leading: [Item], trailing: [Item], savedSwipes: ActionSeedSwipeConfiguration?, readouts: [ReadoutType], availableWidgets: Set, savedContextMenu: [ActionSeed]? ) { self.leading = leading self.trailing = trailing self.savedSwipes = savedSwipes self.readouts = readouts self.availableWidgets = availableWidgets self.savedContextMenu = savedContextMenu } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.leading = try container.decodeIfPresent([Item].self, forKey: .leading) ?? [.counter(.score)] self.trailing = try container.decodeIfPresent([Item].self, forKey: .trailing) ?? [.action(.save), .action(.reply)] self.readouts = try container.decodeIfPresent([ReadoutType].self, forKey: .readouts) ?? [.created, .comment] self.availableWidgets = try container.decodeIfPresent(Set.self, forKey: .availableWidgets) ?? .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }) if let contextMenuKeys = try container.decodeIfPresent([String].self, forKey: .savedContextMenu) { let allActions = Self.availableActions.all self.savedContextMenu = contextMenuKeys.compactMap { key in allActions.first(where: {$0.key == key}) } } else { self.savedContextMenu = nil } let swipeConfigurationContainer = try? container.nestedContainer( keyedBy: ActionSeedSwipeConfiguration.CodingKeys.self, forKey: .swipes ) if let swipeConfigurationContainer { self.savedSwipes = try .init(from: swipeConfigurationContainer, availableActions: Self.availableActions.all) } else { // Convert from Mlem 2.4 -> 2.5 format let leadingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .leadingSwipes) ?? [.upvote, .downvote] let trailingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .trailingSwipes) ?? [.save, .reply] let swipes = ActionSeedSwipeConfiguration( leading: leadingSwipes.map(\.actionSeed), trailing: trailingSwipes.map(\.actionSeed) ) if swipes == Self.defaultSwipes { self.savedSwipes = nil } else { self.savedSwipes = swipes } } } enum CodingKeys: CodingKey { case leading case trailing case readouts case availableWidgets case savedContextMenu case swipes // Used for conversion from Mlem 2.4 -> 2.5 format case leadingSwipes case trailingSwipes } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.leading, forKey: .leading) try container.encode(self.trailing, forKey: .trailing) try container.encode(self.readouts, forKey: .readouts) try container.encode(self.availableWidgets, forKey: .availableWidgets) try container.encode(self.savedContextMenu, forKey: .savedContextMenu) try container.encode(self.savedSwipes, forKey: .swipes) } static var `default`: Self { .init( leading: [.counter(.score)], trailing: [.action(.save), .action(.reply)], savedSwipes: nil, readouts: [.created, .comment], availableWidgets: .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }), savedContextMenu: nil ) } static var reportDefault_: Self { .init( leading: [.action(.resolve), .action(.lock)], trailing: [.action(.ban), .action(.remove)], savedSwipes: nil, readouts: [.upvote, .downvote, .created, .comment], availableWidgets: .init(ActionType.defaultReportWidgets.map { .action($0) }), savedContextMenu: nil ) } static var availableActions: ActionSeedSections { .init(sections: [ [ .upvote, .downvote, .save, .reply, .selectText, .share, .crosspost, .hide, .createImage, .report, .edit, .delete ], [ .blockCreator, .copyAuthorName, .openCreatorModlog, .sendCreatorMessage ], [ .pin, .lock, .markNsfw, .viewVotes, .remove, .banCreator, .purge, .purgeCreator, .resolveReport ] ]) } static var reportDefault: Self? { .reportDefault_ } } ================================================ FILE: Mlem/App/Enums/Interaction/ReplyBarConfiguration+Types.swift ================================================ // // ReplyBarConfiguration+Types.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import Foundation import MlemMiddleware import SwiftUI extension ReplyBarConfiguration { enum ActionType: String, ActionTypeProviding { typealias Configuration = ReplyBarConfiguration // swiftlint:disable:this nesting case upvote case downvote case save case reply case markRead case selectText case report static var defaultWidgets: [ActionType] { [ .upvote, .downvote, .save, .reply, .markRead ] } var appearance: ActionAppearance { switch self { case .upvote: .upvote(isOn: false) case .downvote: .downvote(isOn: false) case .save: .save(isOn: false) case .reply: .reply() case .markRead: .markRead(isOn: false) case .selectText: .selectText() case .report: .report() } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .save: [.saved] case .reply, .markRead, .selectText, .report: [] } } var actionSeed: ActionSeed { switch self { case .upvote: .upvote case .downvote: .downvote case .save: .save case .reply: .reply case .markRead: .markRead case .selectText: .selectText case .report: .report } } } enum CounterType: String, CounterTypeProviding { typealias Configuration = ReplyBarConfiguration // swiftlint:disable:this nesting case score case upvote case downvote case reply static var defaultWidgets: [CounterType] { allCases } var appearance: CounterAppearance { switch self { case .score: .score() case .upvote: .upvote() case .downvote: .downvote() case .reply: .reply() } } func associatedReadouts(context: any InteractableProviding) -> Set { switch self { case .score: [.upvote, .downvote, .score] case .upvote: context.votes.value?.myVote ?? .none == .upvote ? [.upvote, .score] : [.upvote] case .downvote: context.votes.value?.myVote ?? .none == .downvote ? [.downvote, .score] : [.downvote] case .reply: [] } } } enum ReadoutType: String, ReadoutTypeProviding { case created case score case upvote case downvote case comment case saved var appearance: MockReadoutAppearance { switch self { case .created: .init(icon: .general.time, label: "18h") case .score: .init(icon: .lemmy.votes, label: "7") case .upvote: .init(icon: .lemmy.upvoted, label: "9") case .downvote: .init(icon: .lemmy.downvoted, label: "2") case .comment: .init(icon: .lemmy.replies, label: "1") case .saved: .init(icon: .lemmy.saved, label: "") } } func compatibleWith(otherReadouts: Set) -> Bool { switch self { case .score: otherReadouts.isDisjoint(with: [.upvote, .downvote]) case .upvote, .downvote: !otherReadouts.contains(.score) default: true } } } } ================================================ FILE: Mlem/App/Enums/Interaction/ReplyBarConfiguration.swift ================================================ // // InboxInteraction.swift // Mlem // // Created by Sjmarf on 14/06/2024. // import Actions import Foundation import MlemMiddleware import SwiftUI struct ReplyBarConfiguration: InteractionBarConfiguration, SwipeActionConfiguration { var leading: [Item] var trailing: [Item] var readouts: [ReadoutType] var savedContextMenu: [ActionSeed]? var savedSwipes: ActionSeedSwipeConfiguration? static var defaultSwipes: ActionSeedSwipeConfiguration { .init(leading: [.downvote, .upvote], trailing: [.save, .reply]) } static var defaultContextMenu: [ActionSeed] { [.markRead, .share, .blockCreator, .report] } var availableWidgets: Set func widgetPickerPage(_ configuration: Binding) -> SettingsPage { .replyBarWidgetPicker(configuration) } init( leading: [Item], trailing: [Item], savedSwipes: ActionSeedSwipeConfiguration?, readouts: [ReadoutType], availableWidgets: Set, savedContextMenu: [ActionSeed]? ) { self.leading = leading self.trailing = trailing self.savedSwipes = savedSwipes self.readouts = readouts self.availableWidgets = availableWidgets self.savedContextMenu = savedContextMenu } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.leading = try container.decodeIfPresent([Item].self, forKey: .leading) ?? [.counter(.score)] self.trailing = try container.decodeIfPresent([Item].self, forKey: .trailing) ?? [.action(.save), .action(.reply)] self.readouts = try container.decodeIfPresent([ReadoutType].self, forKey: .readouts) ?? [.created, .comment] self.availableWidgets = try container.decodeIfPresent(Set.self, forKey: .availableWidgets) ?? .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }) if let contextMenuKeys = try container.decodeIfPresent([String].self, forKey: .savedContextMenu) { let allActions = Self.availableActions.all self.savedContextMenu = contextMenuKeys.compactMap { key in allActions.first(where: {$0.key == key}) } } else { self.savedContextMenu = nil } let swipeConfigurationContainer = try? container.nestedContainer( keyedBy: ActionSeedSwipeConfiguration.CodingKeys.self, forKey: .swipes ) if let swipeConfigurationContainer { self.savedSwipes = try .init(from: swipeConfigurationContainer, availableActions: Self.availableActions.all) } else { // Convert from Mlem 2.4 -> 2.5 format let leadingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .leadingSwipes) ?? [.upvote, .downvote] let trailingSwipes = try container.decodeIfPresent([ActionType].self, forKey: .trailingSwipes) ?? [.save, .reply] let swipes = ActionSeedSwipeConfiguration( leading: leadingSwipes.map(\.actionSeed), trailing: trailingSwipes.map(\.actionSeed) ) if swipes == Self.defaultSwipes { self.savedSwipes = nil } else { self.savedSwipes = swipes } } } enum CodingKeys: CodingKey { case leading case trailing case readouts case availableWidgets case savedContextMenu case swipes // Used for conversion from Mlem 2.4 -> 2.5 format case leadingSwipes case trailingSwipes } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.leading, forKey: .leading) try container.encode(self.trailing, forKey: .trailing) try container.encode(self.readouts, forKey: .readouts) try container.encode(self.availableWidgets, forKey: .availableWidgets) try container.encode(self.savedContextMenu, forKey: .savedContextMenu) try container.encode(self.savedSwipes, forKey: .swipes) } static var `default`: Self { .init( leading: [.counter(.score)], trailing: [.action(.save), .action(.reply)], savedSwipes: nil, readouts: [.created, .comment], availableWidgets: .init(CounterType.defaultWidgets.map { .counter($0) } + ActionType.defaultWidgets.map { .action($0) }), savedContextMenu: nil ) } static var availableActions: ActionSeedSections { .init(sections: [ [ .upvote, .downvote, .save, .reply, .markRead, .selectText, .share, .createImage, .report, .edit, .delete ], [ .blockCreator, .copyAuthorName, .openCreatorModlog, .sendCreatorMessage ], [ .viewVotes, .remove, .banCreator, .purge, .purgeCreator, .resolveReport ] ]) } static var reportDefault: Self? { nil } } ================================================ FILE: Mlem/App/Enums/Interaction/SwipeActionConfiguration.swift ================================================ // // SwipeActionConfiguration.swift // Mlem // // Created by Sjmarf on 2026-03-15. // import Actions import Foundation protocol SwipeActionConfiguration { var savedSwipes: ActionSeedSwipeConfiguration? { get set } static var availableActions: ActionSeedSections { get } static var defaultSwipes: ActionSeedSwipeConfiguration { get } } extension SwipeActionConfiguration { var swipes: ActionSeedSwipeConfiguration { get { savedSwipes ?? Self.defaultSwipes } set { savedSwipes = newValue } } } ================================================ FILE: Mlem/App/Enums/MlemError.swift ================================================ // // MlemError.swift // Mlem // // Created by Eric Andrews on 2025-02-06. // enum MlemError: Error { case modelError(String) case navigationError(String) case unexpectedValue case cannotAccessSecurityScopedResource case mediaError(String) } extension MlemError: CustomStringConvertible { public var description: String { switch self { case let .modelError(string): return "Model Error: \(string)" case let .navigationError(string): return "Navigation Error: \(string)" case .cannotAccessSecurityScopedResource: return "Cannot access security-scoped resource" case .unexpectedValue: return "Encountered unexpected value" case let .mediaError(string): return "Media Error: \(string)" } } } ================================================ FILE: Mlem/App/Enums/NsfwBlurBehavior.swift ================================================ // // NsfwBlurBehavior.swift // Mlem // // Created by Eric Andrews on 2024-08-22. // import Icons import Foundation enum NsfwBlurBehavior: String, CaseIterable, Codable { case always, outsideCommunity, never var label: LocalizedStringResource { switch self { case .always: "Always" case .outsideCommunity: "Outside NSFW Communities" case .never: "Never" } } var icon: Icon { switch self { case .always: .general.success case .outsideCommunity: .lemmy.community case .never: .general.failure } } } ================================================ FILE: Mlem/App/Enums/PersonFlair.swift ================================================ // // UserFlair.swift // Mlem // // Created by Sjmarf on 07/10/2023. // import Icons import MlemMiddleware import SwiftUI import Theming enum PersonFlair: Hashable { case admin case moderator case developer case bot case op case cakeDay case bannedFromInstance case bannedFromCommunity case accountAge(Date) // this defines the order in which flairs appear var sortVal: Int { switch self { case .admin: 0 case .moderator: 1 case .developer: 2 case .bot: 3 case .op: 4 case .cakeDay: 5 case .bannedFromInstance: 6 case .bannedFromCommunity: 7 case .accountAge: 8 } } var text: String { switch self { case let .accountAge(created): let components = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: created, to: .now ).roundingDownToMostSignificantComponent() let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 1 formatter.allowedUnits = [.year, .month, .day, .hour, .minute, .second] return formatter.string(from: components) ?? "" default: return "" } } var color: ThemedColor { switch self { case .admin: .themedAdministration case .moderator: .themedModeration case .op: .themedColorfulAccent(0) case .bot: .themedColorfulAccent(5) case .bannedFromInstance, .bannedFromCommunity: .themedNegative case .developer: .themedColorfulAccent(4) case .cakeDay: .themedColorfulAccent(1) case let .accountAge(date): AccountAgeBracket(date: date).color } } var icon: Icon { switch self { case .admin: .lemmy.administration case .moderator: .lemmy.moderation case .op: .lemmy.opFlair case .bot: .lemmy.botFlair case .bannedFromInstance: .lemmy.bannedFromInstance case .bannedFromCommunity: .lemmy.bannedFromCommunity case .developer: .lemmy.developerFlair case .cakeDay: .lemmy.cakeDay case let .accountAge(date): AccountAgeBracket(date: date).icon } } var label: LocalizedStringResource { switch self { case .admin: "Administrator" case .bot: "Bot Account" case .bannedFromInstance: "Banned from Instance" case .bannedFromCommunity: "Banned from Community" case .moderator: "Moderator" case .developer: "Mlem Developer" case .op: "Original Poster" case .cakeDay: "Cake Day" case let .accountAge(date): "Account Created \(date.formatted(date: .abbreviated, time: .omitted))" } } var textView: Text { (Text(Image(icon: icon)) + Text(text).fontWeight(.semibold)) .foregroundStyle(color) } } private enum AccountAgeBracket: CaseIterable { case upToOneMonth case upToOneYear case upToTwoYears // In future we could increase this to three years? case other case beforeInflux init(date: Date) { if date < Date(timeIntervalSince1970: 1_685_617_200) { // 2023-06-01 self = .beforeInflux return } let intervalSinceCreation = Date.now.timeIntervalSince(date) let day: TimeInterval = 24 * 60 * 60 if intervalSinceCreation < 30 * day { self = .upToOneMonth } else if intervalSinceCreation < 365 * day { self = .upToOneYear } else if intervalSinceCreation < 2 * 365 * day { self = .upToTwoYears } else { self = .other } } var icon: Icon { .lemmy.accountAgeFlair(bracket: self) } var color: ThemedColor { .themedAccountAgeColor(Self.allCases.firstIndex(of: self)!) } } extension [PersonFlair] { var textView: Text { if isEmpty { Text(verbatim: "") } else { reduce(Text(verbatim: "")) { $0 + $1.textView } + Text(verbatim: " ") } } } private extension Icon.LemmyIcons { func accountAgeFlair(bracket: AccountAgeBracket) -> Icon { switch bracket { case .upToOneMonth: .lemmy.newAccountFlair case .upToOneYear: .init("camera.macro") case .upToTwoYears: .init("tree.fill") case .other: .init("mountain.2.fill") case .beforeInflux: .init("fossil.shell.fill") } } } ================================================ FILE: Mlem/App/Enums/PostViewLinkType.swift ================================================ // // PostViewLinkType.swift // Mlem // // Created by Sjmarf on 2024-12-17. // import Foundation enum PostViewNavigationLink { case creator, community } ================================================ FILE: Mlem/App/Enums/ReadPostIndicator.swift ================================================ // // ReadPostIndicator.swift // Mlem // // Created by Eric Andrews on 2024-12-25. // import Foundation enum ReadPostIndicator: String, CaseIterable, Codable { case outline, checkmark, none var label: LocalizedStringResource { switch self { case .outline: "Outline" case .checkmark: "Checkmark" case .none: "None" } } } ================================================ FILE: Mlem/App/Enums/TabBarLongPressAction.swift ================================================ // // TabBarLongPressAction.swift // Mlem // // Created by Bedir Ekim on 21.05.2025. // import Foundation import Icons enum TabBarLongPressAction: String, CaseIterable, Codable { case openAccountSwitcher, switchToMostRecentAccount var label: LocalizedStringResource { switch self { case .openAccountSwitcher: "Open Account Switcher" case .switchToMostRecentAccount: "Switch to Most Recent Account" } } var icon: Icon { switch self { case .openAccountSwitcher: .lemmy.openAccountSwitcher case .switchToMostRecentAccount: .lemmy.switchAccount } } } ================================================ FILE: Mlem/App/Enums/ZoomSliderLocation.swift ================================================ // // ZoomSliderLocation.swift // Mlem // // Created by Eric Andrews on 2025-02-02. // import Foundation import Icons enum ZoomSliderLocation: String, CaseIterable, Codable { case left, right, either, none var label: LocalizedStringResource { switch self { case .left: "Left" case .right: "Right" case .either: "Either" case .none: "Disabled" } } var icon: Icon { switch self { case .left: .general.backward case .right: .general.forward case .either: .settings.leftRight case .none: .general.circle } } var leftEnabled: Bool { switch self { case .left, .either: true default: false } } var rightEnabled: Bool { switch self { case .right, .either: true default: false } } } ================================================ FILE: Mlem/App/Globals/Definitions/AccountsTracker.swift ================================================ // // AccountsTracker.swift // Mlem // // Created by David Bureš on 05.05.2023. // import Combine import Dependencies import Foundation import MlemMiddleware import Observation private let defaultInstanceGroupKey = "Other" @Observable class AccountsTracker { enum SaveType { case user, guest, all } static let main: AccountsTracker = .init() @ObservationIgnored @Dependency(\.persistenceRepository) private var persistenceRepository var userAccounts: [UserAccount] = .init() var guestAccounts: [GuestAccount] = .init() var allAccounts: [any Account] { userAccounts + guestAccounts } // Used on startup to determine which account should be made active func mostRecentAccount() -> any Account { let allAccounts: [any Account] = userAccounts + guestAccounts if let activeAccount = allAccounts.first(where: { $0.activityState == .active }) { return activeAccount } let sorted = allAccounts.sorted(by: { $0.activityState.lastUsed ?? .distantPast < $1.activityState.lastUsed ?? .distantPast }) if let lastUsedAccount = sorted.last { return lastUsedAccount } return userAccounts.first ?? defaultGuestAccount } var defaultGuestAccount: GuestAccount { // This will never fail because we're passing a literal URL that is known to always succeed // swiftlint:disable:next force_try try! GuestAccount.getGuestAccount(url: URL(string: "https://lemmy.world/")!) } var isEmpty: Bool { userAccounts.isEmpty && guestAccounts.isEmpty } private var cancellables = Set() private init() { self.userAccounts = persistenceRepository.loadUserAccounts() self.guestAccounts = persistenceRepository.loadGuestAccounts() } func addAccount(account: any Account) { if let account = account as? UserAccount { guard !userAccounts.contains(where: { $0 === account }) else { assertionFailure("Tried to add a duplicate account to the tracker") return } userAccounts.append(account) saveAccounts(ofType: .user) } else if let account = account as? GuestAccount { guard !guestAccounts.contains(where: { $0 === account }) else { assertionFailure("Tried to add a duplicate account to the tracker") return } guestAccounts.append(account) saveAccounts(ofType: .guest) } else { assertionFailure() } } func removeAccount(account: any Account) { if let account = account as? UserAccount { guard let index = userAccounts.firstIndex(where: { $0 === account }) else { assertionFailure("Tried to remove an account that does not exist") return } userAccounts.remove(at: index) saveAccounts(ofType: .user) account.deleteTokenFromKeychain() } else if let account = account as? GuestAccount { guard let index = guestAccounts.firstIndex(where: { $0 === account }) else { assertionFailure("Tried to remove an account that does not exist") return } guestAccounts.remove(at: index) account.resetStoredSettings(withSave: false) saveAccounts(ofType: .guest) } else { assertionFailure() } AppState.main.deactivate(account: account) do { try PersistenceRepository.liveValue.deleteAccountSettings(for: account) try PersistenceRepository.liveValue.deleteVisitHistory(for: account) } catch { handleError(error, silent: true) } GuestAccountCache.main.clean() } @discardableResult func logIn( client unauthenticatedApi: ApiClient, usernameOrEmail: String, password: String, totpToken: String? = nil ) async throws -> UserAccount { let token = try await unauthenticatedApi.getAccountToken( usernameOrEmail: usernameOrEmail, password: password, totpToken: totpToken ) let username = try await unauthenticatedApi.getUsernameFromToken(token: token) return try await logIn( username: username, url: unauthenticatedApi.baseUrl, token: token ) } @discardableResult func logIn( username: String, url: URL, token: String ) async throws -> UserAccount { let authenticatedApiClient = ApiClient.getApiClient(url: url, username: username) authenticatedApiClient.updateToken(token) // Check if account exists already if let account = userAccounts.first(where: { $0.name.caseInsensitiveCompare(username) == .orderedSame && $0.api.baseUrl == url }) { account.updateToken(token) saveAccounts(ofType: .user) return account } else { let response = try await authenticatedApiClient.getMyPerson() guard let person = response.person else { throw ApiClientError.unsuccessful } let software = try await authenticatedApiClient.software let account = UserAccount(person: person, siteSoftware: software) addAccount(account: account) return account } } func saveAccounts(ofType type: SaveType) { Task { if type != .guest { try await self.persistenceRepository.saveUserAccounts(userAccounts) } if type != .user { try await self.persistenceRepository.saveGuestAccounts(guestAccounts) } } } var highestLevelAccountType: AccountType { userAccounts.lazy.map(\.accountType).max() ?? .guest } } ================================================ FILE: Mlem/App/Globals/Definitions/AppState+transition.swift ================================================ // // AppState+Transition.swift // Mlem // // Created by Sjmarf on 05/06/2024. // import SwiftUI extension AppState { func transition(_ account: any Account) { Task { @MainActor in // Close all sheets NavigationModel.main.clear() let transition = TransitionView(account: account) guard let transitionView = UIHostingController(rootView: transition).view, let window = UIApplication.shared.firstKeyWindow else { return } transitionView.overrideUserInterfaceStyle = Settings.get(\.appearance_interfaceStyle) transitionView.alpha = 0 window.addSubview(transitionView) UIView.animate(withDuration: 0.15) { transitionView.alpha = 1 } transitionView.translatesAutoresizingMaskIntoConstraints = false transitionView.heightAnchor.constraint(equalTo: window.heightAnchor).isActive = true transitionView.widthAnchor.constraint(equalTo: window.widthAnchor).isActive = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { UIView.animate(withDuration: 0.3) { transitionView.alpha = 0 } completion: { _ in transitionView.removeFromSuperview() } } } } } ================================================ FILE: Mlem/App/Globals/Definitions/AppState.swift ================================================ // // AppState.swift // Mlem // // Created by Sjmarf on 17/02/2024. // import Dependencies import Foundation import MlemMiddleware import SwiftUI @Observable class AppState { @ObservationIgnored @Namespace var namespace private(set) var guestSession: GuestSession! { didSet { if oldValue != guestSession { oldValue?.deactivate() } } } private(set) var activeSessions: [UserSession] = [] { didSet { if oldValue != activeSessions { for session in Set(oldValue).subtracting(activeSessions) { session.deactivate() } } } } var contentViewTab: ContentView.Tab = .feeds /// ``ContentView`` watches this for changes. When it is toggled, the app is refreshed. var appRefreshToggle: Bool = true private init() { self.guestSession = .init(account: AccountsTracker.main.defaultGuestAccount) setAccount(to: AccountsTracker.main.mostRecentAccount()) } // TODO: updated mocks // #if DEBUG // private init(api: MockApiClient) { // self.guestSession = .init(account: .mock(api: api)) // } // // static func mock(api: MockApiClient) -> AppState { .init(api: api) } // #endif /// If `keepPlace` is `nil`, use the value from `UserDefaults`. func changeAccount(to account: any Account, keepPlace: Bool? = nil, showAvatarPopup: Bool = true) { @Setting(\.accounts_keepPlace) var keepPlaceSetting let keepPlace = keepPlace ?? keepPlaceSetting if firstAccount is UserAccount { Task { do { try await firstAccount.api.flushPostReadQueue() } catch { handleError(error) } } } if keepPlace { if showAvatarPopup { ToastModel.main.add(.account(account)) } setAccount(to: account) } else { transition(account) // The delays between these events are necessary to stop SwiftUIIntrospect from causing a lag spike. // That library seems to not like us adding subviews to the window directly. For some reason adding // these delays fixes that. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.appRefreshToggle = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { self.setAccount(to: account) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { self.appRefreshToggle = true } } } private func setAccount(to account: any Account) { // Save because we updated `lastUsed` in the above `deactivate()` calls AccountsTracker.main.saveAccounts(ofType: .all) if let account = account as? UserAccount { let activeAccount = UserSession(account: account) if activeSessions.isEmpty { guestSession.deactivate() } activeSessions = [activeAccount] } else if let account = account as? GuestAccount { activeSessions = [] guestSession = .init(account: account) GuestAccountCache.main.clean() } else { assertionFailure() } } func deactivate(account: any Account) { if let account = account as? UserAccount { if let index = AppState.main.activeSessions.firstIndex(where: { $0.account === account }) { activeSessions[index].deactivate() activeSessions.remove(at: index) } else { return } } else if let account = account as? GuestAccount { guard account == guestSession.account else { return } guestSession = .init(account: AccountsTracker.main.defaultGuestAccount) } changeAccount(to: AccountsTracker.main.mostRecentAccount()) } var firstSession: any Session { activeSessions.first ?? guestSession } var firstAccount: any Account { firstSession.account } var firstApi: ApiClient { firstSession.api } var firstPerson: Person? { (firstSession as? UserSession)?.person } var isModOrAdmin: Bool { firstApi.isAdmin || !(firstPerson?.moderatedCommunities.value?.isEmpty ?? true) } func accountThatModerates(actorId: ActorIdentifier) -> UserSession? { activeSessions.first(where: { session in session.person?.moderatedCommunities.value_?.contains { $0.actorId == actorId } ?? false }) } func cleanCaches() { for session in activeSessions { session.api.cleanCaches() } } func switchToMostRecentAccount() -> Bool { let mostRecentAccount = AccountsTracker.main.allAccounts .filter { $0.actorId != firstAccount.actorId } .min { ($0.activityState.lastUsed ?? .distantPast) > ($1.activityState.lastUsed ?? .distantPast) } guard let mostRecentAccount else { return false } changeAccount(to: mostRecentAccount) return true } var initialFeedSortType: PostSortType { get async throws { // In future, we should be storing `PostSortType` in `Settings` rather than `LemmySortType` let defaultSort: PostSortType = .init(Settings.get(\.post_defaultSort)) if try await firstApi.supports(.postSortType(defaultSort)) { return defaultSort } return .init(Settings.get(\.post_fallbackSort)) } } static var main: AppState = .init() } ================================================ FILE: Mlem/App/Globals/Definitions/ErrorsTracker.swift ================================================ // // ErrorsTracker.swift // Mlem // // Created by Eric Andrews on 2024-12-29. // import Observation @Observable class ErrorsTracker { private(set) var errors: [ErrorDetails] = .init() @MainActor func addError(_ error: Error, location: String) { errors.prepend(.init(error: error, location: location)) } static var main: ErrorsTracker = .init() func createErrorLog() -> String { var ret = "" for details in errors { ret += "\(details.when.formatted(.iso8601))\t\(details.title ?? "Error")\t\(details.errorText())\n" } return ret } } ================================================ FILE: Mlem/App/Globals/Definitions/FiltersTracker.swift ================================================ // // FiltersTracker.swift // Mlem // // Created by Eric Andrews on 2024-12-22. // import Dependencies import Foundation import MlemMiddleware import Observation @Observable class FiltersTracker { @ObservationIgnored @Setting(\.filters_keywordFilterEnabled) var keywordFilterEnabled @ObservationIgnored @Setting(\.filters_keywords) var rawKeywords { didSet { (self.keywords, self.phrases) = parseKeywordsAndPhrases(from: rawKeywords) } } @ObservationIgnored @Setting(\.filters_literalFilterEnabled) var literalFilterEnabled @ObservationIgnored @Setting(\.filters_literals) var literals var isAdmin: Bool var moderatedCommunityActorIds: Set /// Single word keywords to filter private(set) var keywords: Set /// Multi-word phrases to filter private(set) var phrases: Set<[String]> var filterContext: FilterContext { .init( isAdmin: isAdmin, moderatedCommunityActorIds: moderatedCommunityActorIds, filteredKeywords: keywordFilterEnabled ? keywords : .init(), filteredPhrases: keywordFilterEnabled ? phrases : .init(), filteredLiterals: literalFilterEnabled ? literals : .init() ) } var changeHash: Int { var hasher = Hasher() hasher.combine(moderatedCommunityActorIds) hasher.combine(rawKeywords) hasher.combine(keywordFilterEnabled) hasher.combine(literals) hasher.combine(literalFilterEnabled) return hasher.finalize() } init() { @Setting(\.filters_keywordFilterEnabled) var keywordFilterEnabled @Setting(\.filters_keywords) var rawKeywords self.isAdmin = AppState.main.firstPerson?.isAdmin.value_ ?? false self.moderatedCommunityActorIds = AppState.main.firstPerson?.moderatedCommunityActorIds ?? .init() (self.keywords, self.phrases) = parseKeywordsAndPhrases(from: rawKeywords) } func addFilteredKeyword(_ keyword: String) async { rawKeywords.insert(keyword) } func removeFilteredKeyword(_ keyword: String) async { assert(rawKeywords.contains(keyword), "Filtered keywords does not contain \(keyword)") rawKeywords = rawKeywords.subtracting([keyword]) } func addFilteredLiteral(_ literal: String) async { literals.insert(literal) } func removeFilteredLiteral(_ literal: String) async { assert(literals.contains(literal), "Filtered literals do not contain \(literal)") literals.remove(literal) } func resetFilteredKeywords(to filteredKeywords: Set) async { rawKeywords = filteredKeywords } func resetFilteredLiterals(to filteredLiterals: Set) { literals = filteredLiterals } func postWouldBeFiltered(_ post: Post) -> Bool { (keywordFilterEnabled && post.title.failsKeywordFilter(keywords: keywords, phrases: phrases)) || (literalFilterEnabled && post.title.failsLiteralFilter(literals: literals)) } static var main: FiltersTracker = .init() } private func parseKeywordsAndPhrases(from rawKeywords: Set) -> (keywords: Set, phrases: Set<[String]>) { var keywords: Set = .init() var phrases: Set<[String]> = .init() for keyword in rawKeywords { if keyword.contains(" ") { phrases.insert(keyword.split(separator: " ").map { $0.lowercased() }) } else { keywords.insert(keyword) } } return (keywords, phrases) } ================================================ FILE: Mlem/App/Globals/Definitions/PaletteOption.swift ================================================ // // PaletteOption.swift // Mlem // // Created by Sjmarf on 2025-03-08. // import Foundation import SwiftUI import Theming enum PaletteOption: String, CaseIterable, Codable { case standard, oled, monochrome, solarized, dracula var palette: Palette { switch self { case .standard: .default case .oled: .oled case .monochrome: .monochrome case .solarized: .solarized case .dracula: .dracula } } var label: LocalizedStringResource { switch self { case .standard: "Default" case .oled: "OLED" case .monochrome: "Monochrome" case .solarized: "Solarized" case .dracula: "Dracula" } } var supportedModes: UIUserInterfaceStyle { switch self { case .oled, .dracula: .dark default: .unspecified } } } ================================================ FILE: Mlem/App/Globals/Definitions/PersistenceRepository.swift ================================================ // // PersistenceRepository.swift // Mlem // // Created by mormaer on 26/07/2023. // // import Dependencies import Foundation import MlemMiddleware enum PersistencePath { static var root = { guard let path = try? FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) else { fatalError("unable to access application support path") } return path }() static var userAccounts = root.appendingPathComponent("Saved Accounts", conformingTo: .json) static var guestAccounts = root.appendingPathComponent("Guest Accounts", conformingTo: .json) static var favoriteCommunities = root.appendingPathComponent("Favorite Communities", conformingTo: .json) static var instanceMetadata = root.appendingPathComponent("Instance Metadata", conformingTo: .json) static var layoutWidgets = root.appendingPathComponent("Layout Widgets", conformingTo: .json) static var pinnedSortTypes = root.appendingPathComponent("Sort Settings", conformingTo: .json) static var systemSettings = root.appendingPathComponent("System Settings", conformingTo: .directory) static func accountSettingsDirectory(for account: any Account) -> URL { root .appendingPathComponent("Account Settings", conformingTo: .directory) .appendingPathComponent(account.uniqueStringId, conformingTo: .directory) } static func accountSettings(for account: any Account) -> URL { accountSettingsDirectory(for: account) .appendingPathComponent("Settings", conformingTo: .json) } static func visitHistory(for account: any Account) -> URL { accountSettingsDirectory(for: account) .appendingPathComponent("Visit History", conformingTo: .json) } } private enum DiskAccess { static func load(from path: URL) throws -> Data { try Data(contentsOf: path, options: .mappedIfSafe) } static func save(_ data: Data, to path: URL) async throws { try await Task(priority: .background) { try FileManager.default.createDirectory( at: path.deletingLastPathComponent(), withIntermediateDirectories: true ) try data.write(to: path, options: .atomic) } .value } } // Enumeration of system-managed settings enum SystemSetting { /// v1 settings manually saved by the user case v1_user /// v2 settings manually saved by the user case v2_user /// v2 settings automatically saved by the app case v2_system var path: String { switch self { case .v1_user: "v1" case .v2_user: "v2" case .v2_system: "v2_system" } } } class PersistenceRepository { enum PersistenceRepositoryError: Error { case noFullName } @Dependency(\.date) private var date private var keychainAccess: (String) -> String? private var read: (URL) throws -> Data private var write: (Data, URL) async throws -> Void private let bundle: Bundle init( keychainAccess: @escaping (String) -> String?, read: @escaping (URL) throws -> Data = { try DiskAccess.load(from: $0) }, write: @escaping (Data, URL) async throws -> Void = { try await DiskAccess.save($0, to: $1) }, bundle: Bundle = Bundle.main ) { self.keychainAccess = keychainAccess self.read = read self.write = write self.bundle = bundle // set up settings directories--if this fails, something has gone _terribly_ wrong do { try FileManager.default.createDirectory(at: PersistencePath.systemSettings, withIntermediateDirectories: true) } catch { fatalError("Could not create settings directories") } } // MARK: - Public methods func deleteAccountSettings(for account: any Account) throws { try FileManager.default.removeItem(at: PersistencePath.accountSettingsDirectory(for: account)) } func deleteVisitHistory(for account: any Account) throws { try FileManager.default.removeItem(at: PersistencePath.visitHistory(for: account)) } func loadUserAccounts() -> [UserAccount] { load([UserAccount].self, from: PersistencePath.userAccounts) ?? [] } func saveUserAccounts(_ value: [UserAccount]) async throws { try await save(value, to: PersistencePath.userAccounts) } func loadGuestAccounts() -> [GuestAccount] { load([GuestAccount].self, from: PersistencePath.guestAccounts) ?? [] } func saveGuestAccounts(_ value: [GuestAccount]) async throws { try await save(value, to: PersistencePath.guestAccounts) } func loadInteractionBarConfigurations() -> InteractionBarConfigurations { if let standard = load(InteractionBarConfigurations.self, from: PersistencePath.layoutWidgets, silentError: true) { return standard } return .default } func saveInteractionBarConfigurations(_ value: InteractionBarConfigurations) async throws { try await save(value, to: PersistencePath.layoutWidgets) } func loadVisitHistory(for account: UserAccount) async throws -> VisitHistory { let path = PersistencePath.visitHistory(for: account) let data = load(VisitHistory.CodedData.self, from: path, silentError: true) ?? .init() return try await .init(data: data, api: account.api) } func saveVisitHistory(_ visitHistory: VisitHistory, for account: UserAccount) async throws { let path = PersistencePath.visitHistory(for: account) try await save(visitHistory.codedData(), to: path) } func loadPinnedSortTypes() -> Set { let apiSortTypes = load(Set.self, from: PersistencePath.pinnedSortTypes) ?? [ .hot, .new, .topSixHour, .topDay, .topWeek, .topMonth, .topYear, .topAll ] return Set(apiSortTypes.map(PostSortType.init)) } func savePinnedSortTypes(_ value: Set) async throws { try await save(value.compactMap(\.v3ApiType), to: PersistencePath.pinnedSortTypes) } /// Saves the given user settings func saveAccountSettings(_ settings: SettingsValues, for account: any Account) async throws { try await save(settings, to: PersistencePath.accountSettings(for: account)) } /// Loads given user settings, if present func loadAccountSttings(for account: any Account) -> SettingsValues? { load(SettingsValues.self, from: PersistencePath.accountSettings(for: account)) } /// Returns true if the given system settings exist, false otherwise func systemSettingsExists(_ setting: SystemSetting) -> Bool { // FileManager does offer fileExists but it always returns false, this way is reliable if loadSystemSettings(setting) != nil { return true } return false } /// Saves the given system settings func saveSystemSettings(_ settings: SettingsValues, setting: SystemSetting) async throws { try await save(settings, to: PersistencePath.systemSettings.appendingPathComponent(setting.path, conformingTo: .json)) } /// Loads given system settings, if present func loadSystemSettings(_ setting: SystemSetting) -> SettingsValues? { load(SettingsValues.self, from: PersistencePath.systemSettings.appendingPathComponent(setting.path, conformingTo: .json)) } // DEV ONLY func deleteAllSystemSettings() throws { try FileManager.default.removeItem(at: PersistencePath.systemSettings) try FileManager.default.createDirectory(at: PersistencePath.systemSettings, withIntermediateDirectories: true) } // // func loadInstanceMetadata() -> TimestampedValue<[InstanceMetadata]> { // let localFile = load(TimestampedValue<[InstanceMetadata]>.self, from: Path.instanceMetadata) // let bundledFile = loadFromBundle(TimestampedValue<[InstanceMetadata]>.self, filename: "instance_metadata") // // if let localFile, localFile.timestamp > bundledFile.timestamp { // return localFile // } // // return bundledFile // } // // func saveInstanceMetadata(_ value: [InstanceMetadata]) async throws { // let timestamped = TimestampedValue(value: value, timestamp: date.now, lifespan: .days(1)) // try await save(timestamped, to: Path.instanceMetadata) // } // MARK: Loading methods func load(_ model: T.Type, from path: URL, silentError: Bool = false) -> T? { do { let data = try read(path) guard !data.isEmpty else { return nil } return try JSONDecoder().decode(T.self, from: data) } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == 260 { // Don't show error toast if file not found return nil } catch { handleError(error, silent: silentError) return nil } } private func loadFromBundle(_ model: T.Type, filename: String, type: String = "json") -> T { do { let path = bundle.path(forResource: filename, ofType: type)! let stringValue = try String(contentsOfFile: path) let data = stringValue.data(using: .utf8)! return try JSONDecoder().decode(T.self, from: data) } catch { fatalError("☠️ failed to load \(filename).\(type) from the application bundle.") } } func save(_ value: some Encodable, to path: URL) async throws { do { let encoder = JSONEncoder() encoder.userInfo[.endpointVersion] = LemmyEndpointVersion.v3 let data = try encoder.encode(value) try await write(data, path) } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Globals/Definitions/TabReselectTracker.swift ================================================ // // TabReselectTracker.swift // Mlem // // Created by Eric Andrews on 2023-11-02. // import Foundation import SwiftUI @Observable class TabReselectTracker { var blockTabSwitch: Bool = false private(set) var flag: Bool = false var consumers: Int = 0 static var main: TabReselectTracker = .init() func signal() { flag = true } func reset() { flag = false } } ================================================ FILE: Mlem/App/Globals/Dependencies/PersistenceRepository+Dependency.swift ================================================ // // PersistenceRepository+Dependency.swift // Mlem // // Created by mormaer on 26/07/2023. // // import Dependencies import Foundation extension PersistenceRepository: DependencyKey { static let liveValue = PersistenceRepository(keychainAccess: { Constants.main.keychain[$0] }) } extension DependencyValues { var persistenceRepository: PersistenceRepository { get { self[PersistenceRepository.self] } set { self[PersistenceRepository.self] = newValue } } } ================================================ FILE: Mlem/App/Legacy/LegacySettings.swift ================================================ // // Settings.swift // Mlem // // Created by Eric Andrews on 2024-08-07. // Adapted from https://fatbobman.com/en/posts/appstorage/ // import Dependencies import Haptics import MlemMiddleware import SwiftUI class LegacySettings: ObservableObject { @Dependency(\.persistenceRepository) var persistenceRepository static let main: LegacySettings = .init() /// Default initializer. Will take current AppStorage values. init() {} @AppStorage("a11y.readPostIndicator") var readPostIndicator: ReadPostIndicator = .checkmark @AppStorage("a11y.readOutlineThickness") var readOutlineThickness: Int = 3 @AppStorage("a11y.showSettingsIcons") var showSettingsIcons: Bool = false @AppStorage("a11y.websiteThumbnailIcon") var websiteThumbnailIcon: Bool = false @AppStorage("a11y.zoomSliderLocation") var zoomSliderLocation: ZoomSliderLocation = .none @AppStorage("post.size") var postSize: PostSize = .large @AppStorage("post.allowMultipleColumns") var allowMultiplePostColumns: Bool = true @AppStorage("post.defaultSort") var defaultPostSort: LemmySortType = .hot @AppStorage("post.fallbackSort") var fallbackPostSort: LemmySortType = .hot @AppStorage("post.thumbnailLocation") var thumbnailLocation: ThumbnailLocation = .left @AppStorage("post.showCreator") var showPostCreator: Bool = false @AppStorage("post.showSubscribedStatus") var showSubscribedStatus: Bool = true @AppStorage("post.showDownvotesCompact") var showDownvotesCompact: Bool = false @AppStorage("post.gestures.tapToCollapse") var tapPostsToCollapse: Bool = true @AppStorage("quickSwipes.enabled") var quickSwipesEnabled: Bool = true @AppStorage("behavior.hapticLevel") var hapticLevel: HapticTier = .high @AppStorage("behavior.upvoteOnSave") var upvoteOnSave: Bool = false @AppStorage("behavior.internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("behavior.autoplayMedia") var autoplayMedia: Bool = false @AppStorage("behavior.muteVideos") var muteVideos: Bool = true @AppStorage("behavior.confirmImageUploads") var confirmImageUploads: Bool = true @AppStorage("behavior.infiniteScroll") var infiniteScroll: Bool = true @AppStorage("accounts.keepPlace") var keepPlaceOnAccountSwitch: Bool = false @AppStorage("accounts.sort") var accountSort: AccountSortMode = .name @AppStorage("accounts.groupSort") var groupAccountSort: Bool = false @AppStorage("appearance.interfaceStyle") var interfaceStyle: UIUserInterfaceStyle = .unspecified @AppStorage("appearance.palette") var colorPalette: PaletteOption = .standard @AppStorage("markdown.wrapCodeBlockLines") var wrapCodeBlockLines: Bool = true @AppStorage("dev.developerMode") var developerMode: Bool = false @AppStorage("safety.blurNsfw") var blurNsfw: NsfwBlurBehavior = .always @AppStorage("safety.showNsfwCommunityWarning") var showNsfwCommunityWarning: Bool = true @AppStorage("safety.showModlogWarning") var showModlogWarning: Bool = true @AppStorage("privacy.autoBypassImageProxy") var autoBypassImageProxy: Bool = false @AppStorage("privacy.showFavicons") var showFavicons: Bool = true @AppStorage("links.openInBrowser") var openLinksInBrowser = false @AppStorage("links.readerMode") var openLinksInReaderMode = false @AppStorage("links.displayMode") var tappableLinksDisplayMode: TappableLinksDisplayMode = .contextual @AppStorage("links.shareMode") var linkSharingMode: LinkSharingMode = .myInstance @AppStorage("links.embedLoops") var embedLoops: Bool = true // swiftlint:disable:next line_length @AppStorage("media.animatedAvatars") var animatedAvatars: AnimatedAvatarBehavior = UIAccessibility.isReduceMotionEnabled ? .never : .always @AppStorage("feed.markReadOnScroll") var markReadOnScroll: Bool = false @AppStorage("feed.showRead") var showReadInFeed: Bool = true @AppStorage("feed.default") var defaultFeed: ListingType = .subscribed @AppStorage("inbox.showRead") var showReadInInbox: Bool = true @AppStorage("subscriptions.instanceLocation") var subscriptionInstanceLocation: InstanceLocation = UIDevice.isPad ? .bottom : .trailing @AppStorage("subscriptions.sort") var subscriptionSort: SubscriptionListSort = .alphabetical @AppStorage("person.showAvatar") var showPersonAvatar: Bool = true @AppStorage("community.showAvatar") var showCommunityAvatar: Bool = true @AppStorage("comment.compact") var compactComments: Bool = false @AppStorage("comment.jumpButton") var jumpButton: CommentJumpButtonLocation = .bottomTrailing @AppStorage("comment.sort") var commentSort: LemmyCommentSortType = .top @AppStorage("comment.maxDepth") var maxCommentDepth: Int = 8 @AppStorage("comment.gestures.tapToCollapse") var tapCommentsToCollapse: Bool = true @AppStorage("status.bypassImageProxyShown") var bypassImageProxyShown: Bool = false @AppStorage("tip.feedWelcomePrompt") var showFeedWelcomePrompt: Bool = true @AppStorage("navigation.sidebarVisibleByDefault") var sidebarVisibleByDefault: Bool = true @AppStorage("navigation.swipeAnywhere") var swipeAnywhereToNavigate: Bool = false @AppStorage("tab.profile.labelType") var tabProfileLabelType: ProfileTabLabel = .nickname @AppStorage("tab.profile.showAvatar") var tabProfileShowAvatar: Bool = true @AppStorage("tab.inbox.badgeIncludedTypes") var tabInboxBadgeIncludedTypes: Set = .all @AppStorage("menus.moderatorActionGrouping") var moderatorActionGrouping: ModeratorActionGrouping = .divider @AppStorage("menus.allModActions") var showAllModActions: Bool = false @AppStorage("interactionBar.alternateReportLayout") var alternateInteractionBarLayoutForReports: Bool = false @AppStorage("filters.keywordFilterEnabled") var keywordFilterEnabled: Bool = true } ================================================ FILE: Mlem/App/Logic/Animations.swift ================================================ // // Animations.swift // Mlem // // Created by Eric Andrews on 2024-12-29. // import SwiftUI import UIKit // https://stackoverflow.com/a/72973172 /// Disables animations on the given action func withoutAnimation(action: @escaping () -> Void) { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { action() } } ================================================ FILE: Mlem/App/Logic/HandleError.swift ================================================ // // HandleError.swift // Mlem // // Created by Sjmarf on 18/05/2024. // import MlemMiddleware import os import SwiftUI func handleError( _ error: Error, silent: Bool = false, file: String = #fileID, function: String = #function, line: Int = #line ) { if !_handleError(error, file: file, function: function, line: line), !silent { ToastModel.main.add(.error(.init(error: error))) } } func handleErrorWithDetails( _ error: Error, file: String = #fileID, function: String = #function, line: Int = #line ) -> ErrorDetails? { if !_handleError(error, file: file, function: function, line: line) { return .init(error: error) } return nil } /// - Returns: true if no further handling is required, false otherwise private func _handleError( _ error: Error, file: String = #fileID, function: String = #function, line: Int = #line ) -> Bool { #if DEBUG let descriptiveString: String if let error = error as? ApiClientError { descriptiveString = " \(String(describing: error))\n" } else { descriptiveString = "" } let statement = """ ☠️ ERROR ☠️ 📝 -> \(error.localizedDescription) \(descriptiveString)📂 -> \(file) | \(function) | line: \(line) """ Logger.universal.error("\(statement)") #endif let location = "\(file), \(function):\(line)" Task { await ErrorsTracker.main.addError(error, location: location) } switch error { // TODO: Modify MlemMiddleware to attach the ApiClient throwing the error to ApiClientError.invalidSession, so that we can access the relevant UserStub in a multi-account context case ApiClientError.invalidSession, ApiClientError.noToken, UserAccount.DecodingError.noTokenInKeychain: Task { @MainActor in showReauthSheet() } return true case ApiClientError.cancelled, is CancellationError: print("Cancellation error") return true default: if (error as NSError).code == NSURLErrorCancelled { print("Timeout error") return true } return false } } @MainActor private func showReauthSheet() { if let user = AppState.main.firstSession.account as? UserAccount, !NavigationModel.main.layers.contains(where: { $0.root == .logIn(.reauth(user)) }) { NavigationModel.main.openSheet(.logIn(.reauth(user))) } } ================================================ FILE: Mlem/App/Logic/ImageFunctions.swift ================================================ // // ImageFunctions.swift // Mlem // // Created by Eric Andrews on 2024-08-25. // import Foundation import MlemMiddleware import Nuke import Photos import Rest import SwiftUI func saveMedia(url: URL) async { do { let (data, _) = try await ImagePipeline.shared.data(for: .init(urlRequest: mlemUrlRequest(url: url))) let imageSaver = ImageSaver() if url.pathExtension.isMovieExtension { try await imageSaver.writeVideoToPhotoAlbum(url: url) ToastModel.main.add(.success("Video Saved")) } else { try await imageSaver.writeImageToPhotoAlbum(imageData: data) ToastModel.main.add(.success("Image Saved")) } } catch { handleError(error, silent: true) ToastModel.main.add(.basic( "Failed to save media", subtitle: "You may need to allow Mlem to access your Photo Library in System Settings.", color: .themedNegative, duration: 5 )) } } @MainActor func createImageFromView(_ view: some View, dimensions: CGSize? = nil) -> UIImage? { let renderer = ImageRenderer(content: view) renderer.scale = 3 // boost resolution to look better on larger devices if let dimensions { renderer.proposedSize = .init(dimensions) } else { // assume screen width renderer.proposedSize.width = UIScreen.main.bounds.width } return renderer.uiImage } func shareImage(url: URL, navigation: NavigationLayer) async { if let fileUrl = await downloadImageToFileSystem(url: url) { navigation.model?.shareInfo = .init(url: fileUrl) } } func fullSizeUrl(url: URL?) -> URL? { if let url, var components = URLComponents(url: url, resolvingAgainstBaseURL: true) { components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } return components.url } return nil } /// Downloads the image at the given URL to the file system, returning the path to the downloaded image func downloadImageToFileSystem(url: URL) async -> URL? { do { let (data, _) = try await ImagePipeline.shared.data(for: .init(urlRequest: mlemUrlRequest(url: url))) var fileName: String // image proxies that use url query param don't have pathExtension so we extract it from the embedded url if url.pathExtension.isEmpty, let components = URLComponents(url: url, resolvingAgainstBaseURL: true), let queryItems = components.queryItems, let baseUrlString = queryItems.first(where: { $0.name == "url" })?.value, let baseUrl = URL(string: baseUrlString) { fileName = baseUrl.lastPathComponent } else { fileName = url.lastPathComponent } if fileName.isEmpty { assertionFailure("Empty fileName!") return nil } return try data.writeToTempFile(fileName: fileName) } catch { handleError(error) return nil } } func downloadTextToFileSystem(fileName: String, text: String) async -> URL? { do { let fileUrl = FileManager.default.temporaryDirectory.appending(path: fileName) if FileManager.default.fileExists(atPath: fileUrl.absoluteString) { try FileManager.default.removeItem(at: fileUrl) } try text.write(to: fileUrl, atomically: true, encoding: String.Encoding.utf8) return fileUrl } catch { handleError(error) return nil } } ================================================ FILE: Mlem/App/Logic/ImageSaver.swift ================================================ // // ImageSaver.swift // Mlem // // Created by Eric Andrews on 2023-11-15. // Adapted from https://www.hackingwithswift.com/books/ios-swiftui/how-to-save-images-to-the-users-photo-library // import Foundation import Photos class ImageSaver: NSObject { func writeVideoToPhotoAlbum(url: URL) async throws { guard let tempFile = await downloadImageToFileSystem(url: url) else { ToastModel.main.add(.error(.init(title: "Failed to save video"))) return } try await PHPhotoLibrary.shared().performChanges { _ = PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: tempFile) } } func writeImageToPhotoAlbum(imageData: Data) async throws { try await PHPhotoLibrary.shared().performChanges { let creationRequest = PHAssetCreationRequest.forAsset() creationRequest.addResource(with: .photo, data: imageData, options: nil) } } } ================================================ FILE: Mlem/App/Logic/Networking/InternetConnectionManager.swift ================================================ // // Reachibility.swift // Mlem // // Created by Sjmarf on 25/08/2023. // import Foundation import SystemConfiguration public enum InternetConnectionManager { public static func isConnectedToNetwork() -> Bool { var zeroAddress = sockaddr_in() zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) zeroAddress.sin_family = sa_family_t(AF_INET) guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { SCNetworkReachabilityCreateWithAddress(nil, $0) } }) else { return false } var flags = SCNetworkReachabilityFlags() if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { return false } let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 return isReachable && !needsConnection } } ================================================ FILE: Mlem/App/Models/Account/Account.swift ================================================ // // NewSavedUser.swift // Mlem // // Created by Sjmarf on 10/02/2024. // import Foundation import KeychainAccess import MlemMiddleware import SwiftUI protocol Account: AnyObject, Codable, ActorIdentifiable, ProfileProviding, Hashable { // Stored var api: ApiClient { get } var name: String { get } var storedNickname: String? { get } var siteSoftware: SiteSoftware? { get } var avatar: URL? { get } var activityState: AccountActivityState { get set } var accountType: AccountType { get } // Computed var nickname: String { get } var nicknameSortKey: String { get } var instanceSortKey: String { get } var isActive: Bool { get } var uniqueStringId: String { get } func setNickname(_ newValue: String) } enum AccountActivityState: Codable, Hashable { case inactive(lastUsed: Date?) case active var lastUsed: Date? { switch self { case let .inactive(lastUsed: lastUsed): lastUsed case .active: nil } } } // ProfileProviding conformance extension Account { var blocked: any RealizedValueProviding { RealizedValue(false) } var displayName: String { name } } extension Account { func hash(into hasher: inout Hasher) { hasher.combine(actorId) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.actorId == rhs.actorId } } extension Account { func signOut() { AccountsTracker.main.removeAccount(account: self) } func activate() { activityState = .active } func deactivate() { activityState = .inactive(lastUsed: .now) } var nickname: String { storedNickname ?? name } } ================================================ FILE: Mlem/App/Models/Account/AccountType.swift ================================================ // // AccountType.swift // Mlem // // Created by Eric Andrews on 2024-10-17. // enum AccountType: String, Codable, Comparable { case guest, user, moderator, admin private var tier: Int { switch self { case .guest: 0 case .user: 1 case .moderator: 2 case .admin: 3 } } static func < (lhs: AccountType, rhs: AccountType) -> Bool { lhs.tier < rhs.tier } } ================================================ FILE: Mlem/App/Models/Account/GuestAccount.swift ================================================ // // GuestAccount.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import Foundation import MlemMiddleware import Observation @Observable class GuestAccount: Account { let actorId: ActorIdentifier let api: ApiClient var storedNickname: String? var siteSoftware: SiteSoftware? var avatar: URL? var activityState: AccountActivityState let accountType: AccountType = .guest fileprivate init(url: URL) throws { guard let host = url.host() else { throw DecodingError.invalidHost } self.actorId = .instance(host: host) self.activityState = .inactive(lastUsed: nil) self.api = .getApiClient(url: url, username: nil) } // TODO: updated mocks // #if DEBUG // private init(api: MockApiClient) { // self.actorId = api.actorId // self.activityState = .inactive(lastUsed: nil) // self.api = api // } // // static func mock(api: MockApiClient) -> GuestAccount { .init(api: api) } // #endif static func getGuestAccount(url: URL) throws -> GuestAccount { try GuestAccountCache.main.getAccount(url: url) } enum CodingKeys: String, CodingKey { // Keys are named this way to be consistent with the `UserAccount.CodingKey` cases case storedNickname, instanceLink, siteVersion, avatarUrl, lastUsed, activityState, siteSoftware } enum DecodingError: Error { case invalidHost } required init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) self.storedNickname = try values.decode(String?.self, forKey: .storedNickname) if let siteSoftware = try values.decodeIfPresent(SiteSoftware.self, forKey: .siteSoftware) { self.siteSoftware = siteSoftware } else if let version = try values.decode(SiteVersion?.self, forKey: .siteVersion) { self.siteSoftware = .init(type: .lemmy, version: version) } else { self.siteSoftware = nil } self.avatar = try values.decode(URL?.self, forKey: .avatarUrl) if let activityState = try values.decodeIfPresent(AccountActivityState.self, forKey: .activityState) { self.activityState = activityState } else { let lastUsed = try values.decodeIfPresent(Date?.self, forKey: .lastUsed) ?? nil self.activityState = .inactive(lastUsed: lastUsed) } let actorId = try values.decode(ActorIdentifier.self, forKey: .instanceLink) self.actorId = actorId self.api = ApiClient.getApiClient(url: actorId.url, username: nil) GuestAccountCache.main.itemCache.put(self) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(storedNickname, forKey: .storedNickname) try container.encode(siteSoftware, forKey: .siteSoftware) try container.encode(avatar, forKey: .avatarUrl) try container.encode(activityState, forKey: .activityState) try container.encode(api.baseUrl, forKey: .instanceLink) } @MainActor func update(instance: Instance, software: SiteSoftware) { var shouldSave = false if avatar != instance.avatar { avatar = instance.avatar shouldSave = true } if siteSoftware != software { siteSoftware = software shouldSave = true } if shouldSave { AccountsTracker.main.saveAccounts(ofType: .guest) } } var name: String { actorId.host } var isActive: Bool { AppState.main.guestSession === self } var isSaved: Bool { AccountsTracker.main.guestAccounts.contains(where: { $0 === self }) } var nicknameSortKey: String { storedNickname ?? name } var instanceSortKey: String { host } var uniqueStringId: String { host } func resetStoredSettings(withSave: Bool = true) { storedNickname = nil if withSave { AccountsTracker.main.saveAccounts(ofType: .guest) } } func setNickname(_ newValue: String) { storedNickname = newValue.isEmpty ? nil : newValue AccountsTracker.main.saveAccounts(ofType: .guest) } var profileCreated: Date? { nil } var description: String? { nil } var banner: URL? { nil } var updated: Date? { nil } } extension GuestAccount: CacheIdentifiable { var cacheId: Int { actorId.hashValue } } class GuestAccountCache: CoreCache { static let main: GuestAccountCache = .init() func getAccount(url: URL) throws -> GuestAccount { if let account = retrieveModel(cacheId: url.hashValue) { return account } let account = try GuestAccount(url: url) itemCache.put(account) return account } } ================================================ FILE: Mlem/App/Models/Account/UserAccount.swift ================================================ // // AuthenticatedAccount.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import Foundation import MlemMiddleware import Observation @Observable class UserAccount: Account, CommunityOrPerson { static var identifierPrefix: String = "@" let actorId: ActorIdentifier let id: Int let api: ApiClient let name: String var storedNickname: String? var siteSoftware: SiteSoftware? var avatar: URL? var activityState: AccountActivityState var favorites: Set var visitHistoryEnabled: Bool var accountType: AccountType var description: String? var banner: URL? var created: Date? var updated: Date? init(person: Person, siteSoftware: SiteSoftware) { self.api = person.api self.id = person.id self.name = person.name self.actorId = person.actorId self.storedNickname = nil self.siteSoftware = siteSoftware self.avatar = person.avatar self.activityState = .inactive(lastUsed: nil) self.favorites = [] self.visitHistoryEnabled = true self.accountType = (person.moderatedCommunities.value_?.isEmpty ?? true) ? .user : .moderator self.description = person.description self.banner = person.banner self.created = person.created self.updated = person.updated } enum CodingKeys: String, CodingKey { // These key names don't match the identifiers of their corresponding properties - this is because these key names must match the property names used in SavedAccount pre-1.3 in order to maintain compatibility case id, username, storedNickname, instanceLink, siteVersion, avatarUrl case lastUsed, favorites, accountType, visitHistoryEnabled, activityState case siteSoftware case description, banner, created, updated } enum DecodingError: Error { case cannotModifyPathComponents, invalidHost, noTokenInKeychain } required init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) // copy simple values self.id = try values.decode(Int.self, forKey: .id) let name = try values.decode(String.self, forKey: .username) self.name = name self.storedNickname = try values.decode(String?.self, forKey: .storedNickname) if let siteSoftware = try values.decodeIfPresent(SiteSoftware.self, forKey: .siteSoftware) { self.siteSoftware = siteSoftware } else if let version = try values.decode(SiteVersion?.self, forKey: .siteVersion) { self.siteSoftware = .init(type: .lemmy, version: version) } else { self.siteSoftware = nil } self.avatar = try values.decode(URL?.self, forKey: .avatarUrl) if let activityState = try values.decodeIfPresent(AccountActivityState.self, forKey: .activityState) { self.activityState = activityState } else { let lastUsed = try values.decodeIfPresent(Date?.self, forKey: .lastUsed) ?? nil self.activityState = .inactive(lastUsed: lastUsed) } self.favorites = try values.decodeIfPresent(Set.self, forKey: .favorites) ?? [] self.visitHistoryEnabled = try values.decodeIfPresent(Bool.self, forKey: .visitHistoryEnabled) ?? true self.accountType = try values.decodeIfPresent(AccountType.self, forKey: .accountType) ?? .user // parse instance link let instanceLink = try values.decode(URL.self, forKey: .instanceLink) // Remove the "api/v3" path that we attached to the instanceLink pre-2.0 var components = URLComponents(url: instanceLink, resolvingAgainstBaseURL: false)! // Adding a slash is important! The API returns instance actor IDs with a trailing slash. components.path = "/" guard let instanceLink = components.url else { throw DecodingError.cannotModifyPathComponents } guard instanceLink.host != nil, let actorId = ActorIdentifier(url: instanceLink.appendingPathComponent("u/\(name)")) else { throw DecodingError.invalidHost } self.actorId = actorId self.api = ApiClient.getApiClient(url: instanceLink, username: name) do { let keychain = Constants.main.keychain let token = try keychain.get(getKeychainId(actorId: actorId)) ?? keychain.get(getKeychainId(id: id)) if let token { api.updateToken(token) } else { handleError(DecodingError.noTokenInKeychain) } } catch { handleError(error) } self.description = try values.decodeIfPresent(String.self, forKey: .description) self.banner = try values.decodeIfPresent(URL.self, forKey: .banner) self.created = try values.decodeIfPresent(Date.self, forKey: .created) self.updated = try values.decodeIfPresent(Date.self, forKey: .updated) } func encode(to encoder: Encoder) throws { saveTokenToKeychain() var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(name, forKey: .username) try container.encode(storedNickname, forKey: .storedNickname) try container.encode(siteSoftware, forKey: .siteSoftware) try container.encode(avatar, forKey: .avatarUrl) try container.encode(activityState, forKey: .activityState) try container.encode(api.baseUrl, forKey: .instanceLink) try container.encode(visitHistoryEnabled, forKey: .visitHistoryEnabled) try container.encode(accountType, forKey: .accountType) try container.encode(favorites, forKey: .favorites) try container.encode(description, forKey: .description) try container.encode(banner, forKey: .banner) try container.encode(created, forKey: .created) try container.encode(updated, forKey: .updated) } var keychainId: String { getKeychainId(actorId: actorId) } @MainActor func update(person: Person, software: SiteSoftware) { var shouldSave = false if avatar != person.avatar { avatar = person.avatar shouldSave = true } if siteSoftware != software { siteSoftware = software shouldSave = true } let newAccountType: AccountType if person.isAdmin.value_ ?? false { newAccountType = .admin } else if !(person.moderatedCommunities.value_?.isEmpty ?? true) { newAccountType = .moderator } else { newAccountType = .user } if accountType != newAccountType { accountType = newAccountType shouldSave = true } if person.description != description { description = person.description shouldSave = true } if person.banner != banner { banner = person.banner shouldSave = true } if person.created != created { created = person.created shouldSave = true } if person.updated != updated { updated = person.updated shouldSave = true } if shouldSave { AccountsTracker.main.saveAccounts(ofType: .user) } } func updateToken(_ newToken: String) { api.updateToken(newToken) } func saveTokenToKeychain() { if let token = api.token { do { try Constants.main.keychain.set(token, key: getKeychainId(actorId: actorId)) } catch { handleError(error) } } } func deleteTokenFromKeychain() { try? Constants.main.keychain.remove(getKeychainId(actorId: actorId)) try? Constants.main.keychain.remove(getKeychainId(id: id)) } var isActive: Bool { AppState.main.activeSessions.contains(where: { $0 === self }) } var nicknameSortKey: String { nickname + actorId.host } var instanceSortKey: String { actorId.host + nickname } var uniqueStringId: String { assert(fullName != nil) return fullName ?? "" } var fullName: String? { "\(name)@\(host)" } var fullNameWithPrefix: String? { "@\(name)@\(host)" } func setNickname(_ newValue: String) { storedNickname = newValue.isEmpty ? nil : newValue AccountsTracker.main.saveAccounts(ofType: .user) } var profileCreated: Date? { created } } private func getKeychainId(actorId: ActorIdentifier) -> String { // localhost sometimes has url "http://localhost:PORT" and sometimes "https://lemmy-alpha/beta/etc" [1], so replace any of that with simple "localhost" // // [1](https://join-lemmy.org/docs/contributors/02-local-development.html#tests) let keychainActorId = actorId.description.replacing( /https?:\/\/(lemmy-(alpha|beta|gamma|delta|epsilon)|127\.0\.0\.1:\d{4}|localhost:\d{4})/, with: "localhost" ) return "\(keychainActorId)_accessToken" } private func getKeychainId(id: Int) -> String { "\(id)_accessToken" } ================================================ FILE: Mlem/App/Models/Action/Action.swift ================================================ // // Action.swift // Mlem // // Created by Sjmarf on 30/03/2024. // import SwiftUI protocol Action: Identifiable { var id: String { get } var appearance: ActionAppearance { get } } ================================================ FILE: Mlem/App/Models/Action/ActionAppearance+StaticValues.swift ================================================ // // ActionAppearance+StaticValues.swift // Mlem // // Created by Sjmarf on 16/08/2024. // import Foundation extension ActionAppearance { static func upvote(isOn: Bool) -> Self { .init( label: isOn ? "Undo Upvote" : "Upvote", isOn: isOn, color: .themedUpvote, icon: Icons.upvote, menuIcon: isOn ? Icons.upvoteSquareFill : Icons.upvoteSquare, swipeIcon1: isOn ? Icons.resetVoteSquare : Icons.upvoteSquare, swipeIcon2: isOn ? Icons.resetVoteSquareFill : Icons.upvoteSquareFill ) } static func downvote(isOn: Bool) -> Self { .init( label: isOn ? "Undo Downvote" : "Downvote", isOn: isOn, color: .themedDownvote, icon: Icons.downvote, menuIcon: isOn ? Icons.downvoteSquareFill : Icons.downvoteSquare, swipeIcon1: isOn ? Icons.resetVoteSquare : Icons.downvoteSquare, swipeIcon2: isOn ? Icons.resetVoteSquareFill : Icons.downvoteSquareFill ) } static func save(isOn: Bool) -> Self { .init( label: isOn ? "Unsave" : "Save", isOn: isOn, color: .themedSave, icon: isOn ? Icons.saveFill : Icons.save, swipeIcon1: isOn ? Icons.unsave : Icons.save, swipeIcon2: isOn ? Icons.unsaveFill : Icons.saveFill ) } static func createImage() -> Self { .init( label: "Create Image", color: .themedAccent, icon: Icons.createImage ) } static func reply() -> Self { .init( label: "Reply", color: .themedAccent, icon: Icons.reply, swipeIcon2: Icons.replyFill ) } static func blockCreator() -> Self { .init( label: "Block User", isOn: false, isDestructive: true, color: .themedNegative, icon: Icons.block, swipeIcon2: Icons.blockFill ) } static func banFromInstance(isOn: Bool, withUserLabel: Bool = false) -> Self { .init( label: getBanLabel(isOn: isOn, withUserLabel: withUserLabel), isOn: isOn, isDestructive: !isOn, color: isOn ? .themedPositive : .themedNegative, icon: isOn ? Icons.unbanFromInstance : Icons.banFromInstance, swipeIcon2: isOn ? Icons.unbanFromInstanceFill : Icons.banFromInstanceFill ) } static func banFromCommunity(isOn: Bool, withUserLabel: Bool = false) -> Self { .init( label: getBanLabel(isOn: isOn, withUserLabel: withUserLabel), isOn: isOn, isDestructive: !isOn, color: isOn ? .themedPositive : .themedNegative, icon: isOn ? Icons.unbanFromCommunity : Icons.banFromCommunity, swipeIcon2: isOn ? Icons.unbanFromCommunityFill : Icons.banFromCommunityFill ) } private static func getBanLabel(isOn: Bool, withUserLabel: Bool) -> LocalizedStringResource { if withUserLabel { isOn ? "Unban User" : "Ban User" } else { isOn ? "Unban" : "Ban" } } static func block(isOn: Bool) -> Self { .init( label: isOn ? "Unblock" : "Block", isOn: isOn, isDestructive: !isOn, color: .themedNegative, icon: isOn ? Icons.unblock : Icons.block, swipeIcon2: isOn ? Icons.unblockFill : Icons.blockFill ) } static func hide(isOn: Bool) -> Self { .init( label: isOn ? "Show" : "Hide", isOn: isOn, color: .themedNeutralAccent, icon: isOn ? Icons.show : Icons.hide ) } static func selectText() -> Self { .init( label: "Select Text", isOn: false, color: .themedAccent, icon: Icons.select ) } static func share() -> Self { .init( label: "Share...", color: .themedNeutralAccent, icon: Icons.share ) } static func report() -> Self { .init( label: "Report", isOn: false, isDestructive: true, color: .themedNegative, icon: Icons.moderationReport, swipeIcon2: Icons.moderationReportFill ) } static func markRead(isOn: Bool) -> Self { .init( label: isOn ? "Mark Unread" : "Mark Read", isOn: isOn, color: .themedRead, icon: isOn ? Icons.markUnread : Icons.markRead, swipeIcon1: isOn ? Icons.markRead : Icons.markUnread, swipeIcon2: isOn ? Icons.markUnreadFill : Icons.markReadFill ) } static func edit() -> Self { .init(label: "Edit", color: .themedAccent, icon: Icons.edit) } static func pin(isOn: Bool, isInProgress: Bool = false) -> Self { .init( label: isOn ? "Unpin" : "Pin", isOn: isOn, isInProgress: isInProgress, color: .themedModeration, icon: isOn ? Icons.unpin : Icons.pin, barIcon: isOn ? Icons.pinFill : Icons.pin, swipeIcon2: isOn ? Icons.unpinFill : Icons.pinFill ) } static func pinToCommunity(isOn: Bool, isInProgress: Bool = false) -> Self { .init( label: isOn ? "Unpin From Community" : "Pin to Community", isOn: isOn, isInProgress: isInProgress, color: .themedModeration, icon: isOn ? Icons.unpin : Icons.pin, barIcon: isOn ? Icons.pinFill : Icons.pin, swipeIcon2: isOn ? Icons.unpinFill : Icons.pinFill ) } static func pinToInstance(isOn: Bool, isInProgress: Bool = false) -> Self { .init( label: isOn ? "Unpin From Instance" : "Pin to Instance", isOn: isOn, isInProgress: isInProgress, color: .themedAdministration, icon: isOn ? Icons.unpin : Icons.pin, barIcon: isOn ? Icons.pinFill : Icons.pin, swipeIcon2: isOn ? Icons.unpinFill : Icons.pinFill ) } static func lock(isOn: Bool, isInProgress: Bool = false) -> Self { .init( label: isOn ? "Unlock" : "Lock", isOn: isOn, isInProgress: isInProgress, color: .themedLockAccent, icon: isOn ? Icons.unlock : Icons.lock, barIcon: isOn ? Icons.lockFill : Icons.lock, swipeIcon2: isOn ? Icons.unlockFill : Icons.lockFill ) } static func remove(isOn: Bool, isInProgress: Bool = false) -> Self { .init( label: isOn ? "Restore" : "Remove", isOn: isOn, isInProgress: isInProgress, isDestructive: !isOn, color: isOn ? .themedPositive : .themedNegative, icon: isOn ? Icons.restore : Icons.remove, swipeIcon2: isOn ? Icons.restoreFill : Icons.removeFill ) } static func toggleNsfw(isOn: Bool) -> Self { .init( label: isOn ? "Remove NSFW Tag" : "Add NSFW Tag", color: .themedNegative, icon: Icons.blurNsfw ) } static func resolve(isOn: Bool) -> Self { .init( label: isOn ? "Unresolve" : "Resolve", isOn: isOn, color: .themedPositive, icon: isOn ? Icons.unresolve : Icons.resolve, barIcon: isOn ? Icons.resolveFill : Icons.resolve, swipeIcon2: isOn ? Icons.unresolveFill : Icons.resolveFill ) } /// Adds or removes a user as administrator /// - Parameter isOn: true when user is admin, false otherwise static func addAdmin(isOn: Bool) -> Self { .init( label: isOn ? "Remove Administrator" : "Appoint Administrator", isDestructive: isOn, color: isOn ? .themedNegative : .themedPositive, icon: isOn ? Icons.removeAdministrator : Icons.administration, swipeIcon1: isOn ? Icons.removeAdministrator : Icons.administration, swipeIcon2: isOn ? Icons.removeAdministratorFill : Icons.administrationFill ) } /// Adds or removes a user as moderator /// - Parameter isOn: true when user is moderator, false otherwise static func addMod(isOn: Bool) -> Self { .init( label: isOn ? "Remove Moderator" : "Appoint Moderator", color: isOn ? .themedNegative : .themedPositive, icon: isOn ? Icons.demoteModerator : Icons.moderation, swipeIcon1: isOn ? Icons.demoteModerator : Icons.moderation, swipeIcon2: isOn ? Icons.demoteModeratorFill : Icons.moderationFill ) } static func purge(isInProgress: Bool = false) -> Self { .init( label: "Purge", isInProgress: isInProgress, isDestructive: true, color: .themedWarning, icon: Icons.purge ) } static func purgePerson(isInProgress: Bool = false) -> Self { .init( label: "Purge User", isInProgress: isInProgress, isDestructive: true, color: .themedWarning, icon: Icons.purge ) } static func crossPost() -> Self { .init(label: "Crosspost", color: .themedAccent, icon: Icons.crossPost) } static func viewVotes() -> Self { .init(label: "View Votes", color: .themedAccent, icon: Icons.votes) } static func collapse() -> Self { .init( label: "Collapse", color: .themedColorfulAccent(4), icon: Icons.collapse, swipeIcon1: Icons.collapseSquare, swipeIcon2: Icons.collapseSquareFill ) } static func collapseParent() -> Self { .init( label: "Collapse Parent", color: .themedColorfulAccent(4), icon: Icons.collapseParent, swipeIcon1: Icons.collapseParentSquare, swipeIcon2: Icons.collapseParentSquareFill ) } static func collapseToTop() -> Self { .init( label: "Collapse to Top", color: .themedColorfulAccent(4), icon: Icons.collapseToTop, swipeIcon1: Icons.collapseToTopSquare, swipeIcon2: Icons.collapseToTopSquareFill ) } } ================================================ FILE: Mlem/App/Models/Action/ActionAppearance.swift ================================================ // // ActionAppearance.swift // Mlem // // Created by Sjmarf on 15/08/2024. // import SwiftUI import Theming struct ActionAppearance { let label: String let isOn: Bool let isInProgress: Bool let isDestructive: Bool let color: ThemedColor let barIcon: String let menuIcon: String let swipeIcon1: String let swipeIcon2: String init( label: LocalizedStringResource, isOn: Bool = false, isInProgress: Bool = false, isDestructive: Bool = false, color: ThemedColor, icon: String, barIcon: String? = nil, menuIcon: String? = nil, swipeIcon1: String? = nil, swipeIcon2: String? = nil ) { self.init( label: .init(localized: label), isOn: isOn, isInProgress: isInProgress, isDestructive: isDestructive, color: color, icon: icon, barIcon: barIcon, menuIcon: menuIcon, swipeIcon1: swipeIcon1, swipeIcon2: swipeIcon2 ) } @_disfavoredOverload init( label: String, isOn: Bool = false, isInProgress: Bool = false, isDestructive: Bool = false, color: ThemedColor, icon: String, barIcon: String? = nil, menuIcon: String? = nil, swipeIcon1: String? = nil, swipeIcon2: String? = nil ) { self.label = label self.isOn = isOn self.isInProgress = isInProgress self.isDestructive = isDestructive self.color = color self.barIcon = barIcon ?? icon self.menuIcon = menuIcon ?? icon self.swipeIcon1 = swipeIcon1 ?? icon self.swipeIcon2 = swipeIcon2 ?? icon } } ================================================ FILE: Mlem/App/Models/Action/ActionBuilder.swift ================================================ // // ActionGroupBuilder.swift // Mlem // // Created by Sjmarf on 07/07/2024. // import Foundation @resultBuilder struct ActionBuilder { static func buildBlock(_ children: [any Action]...) -> [any Action] { children.flatMap { $0 } } static func buildEither(first: [any Action]) -> [any Action] { first } static func buildEither(second: [any Action]) -> [any Action] { second } static func buildExpression(_ expression: any Action) -> [any Action] { [expression] } static func buildExpression(_ expression: [any Action]) -> [any Action] { expression } static func buildOptional(_ action: [any Action]?) -> [any Action] { if let action { return action } return [] } } ================================================ FILE: Mlem/App/Models/Action/ActionGroup.swift ================================================ // // GroupAction.swift // Mlem // // Created by Sjmarf on 31/03/2024. // import SwiftUI struct ActionGroup: Action { enum DisplayMode { case section, compactSection, disclosure, popup } let id: String = UUID().uuidString let appearance: ActionAppearance let prompt: String? let disabled: Bool let children: [any Action] /// Represents how the children of the `ActionGroup` are presented. let displayMode: DisplayMode init( appearance: ActionAppearance = .groupDefault, prompt: LocalizedStringResource? = nil, disabled: Bool? = nil, displayMode: DisplayMode = .section, @ActionBuilder children: () -> [any Action] ) { let stringPrompt: String? if let prompt { stringPrompt = .init(localized: prompt) } else { stringPrompt = nil } self.init( appearance: appearance, prompt: stringPrompt, disabled: disabled, displayMode: displayMode, children: children ) } @_disfavoredOverload init( appearance: ActionAppearance = .groupDefault, prompt: String? = nil, disabled: Bool? = nil, displayMode: DisplayMode = .section, @ActionBuilder children: () -> [any Action] ) { self.appearance = appearance self.prompt = prompt let children = children() self.disabled = disabled ?? !children.allSatisfy { action in if let action = action as? BasicAction { return !action.disabled } else if let action = action as? ActionGroup { return !action.disabled } return true } self.children = children self.displayMode = displayMode } } private extension ActionAppearance { static let groupDefault: Self = .init(label: "More...", color: .themedNeutralAccent, icon: Icons.menuCircle) } ================================================ FILE: Mlem/App/Models/Action/ActionType.swift ================================================ // // ActionType.swift // Mlem // // Created by Sjmarf on 30/03/2024. // import SwiftUI enum ActionType: String { case upvote, downvote, save } ================================================ FILE: Mlem/App/Models/Action/BasicAction.swift ================================================ // // BasicAction.swift // Mlem // // Created by Sjmarf on 31/03/2024. // import Dependencies import MlemMiddleware import SwiftUI struct BasicAction: Action { let id: String let appearance: ActionAppearance let confirmationPrompt: String? /// If this is nil, the BasicAction is disabled var callback: (@MainActor () -> Void)? var disabled: Bool { callback == nil } /// - Parameter id: This must be unique to the action AND contain the model's unique ID. /// If you don't do this, SwiftUI can get confused in a lazy view. init( id: String, appearance: ActionAppearance, confirmationPrompt: LocalizedStringResource? = nil, enabled: Bool = true, callback: (@MainActor () -> Void)? = nil ) { self.id = id self.appearance = appearance if let confirmationPrompt { self.confirmationPrompt = .init(localized: confirmationPrompt) } else { self.confirmationPrompt = nil } self.callback = enabled ? callback : nil } @MainActor func callbackWithConfirmation(popupModel: PopupAnchorModel) { if let callback { if let confirmationPrompt { popupModel.showPopup(ActionGroup( appearance: .init(label: "Confirm", color: .themedNeutralAccent, icon: Icons.success), prompt: confirmationPrompt, children: { BasicAction( id: "", appearance: .init( label: "Yes", isOn: false, isDestructive: true, color: .themedWarning, icon: "" ), callback: callback ) } )) } else { callback() } } } func disabled(_ value: Bool) -> BasicAction { var new = self if value { new.callback = nil } return new } } ================================================ FILE: Mlem/App/Models/Action/Counter.swift ================================================ // // Counter.swift // Mlem // // Created by Sjmarf on 14/06/2024. // import Foundation struct Counter: Identifiable { let id: UUID = .init() let value: Int? let leadingAction: (any Action)? let trailingAction: (any Action)? var appearance: CounterAppearance { .init( value: value, leading: leadingAction?.appearance, trailing: trailingAction?.appearance, label: "Unknown", singleIcon: "" ) } } ================================================ FILE: Mlem/App/Models/Action/CounterAppearance.swift ================================================ // // CounterAppearance.swift // Mlem // // Created by Sjmarf on 17/08/2024. // import Foundation struct CounterAppearance { let value: Int? let leading: ActionAppearance? let trailing: ActionAppearance? let label: LocalizedStringResource let singleIcon: String } ================================================ FILE: Mlem/App/Models/Action/CounterApperance+StaticValues.swift ================================================ // // CounterApperance+StaticValues.swift // Mlem // // Created by Eric Andrews on 2025-01-29. // extension CounterAppearance { static func score(value: Int = 7, upvoteOn: Bool = false, downvoteOn: Bool = false) -> CounterAppearance { .init( value: value, leading: .upvote(isOn: upvoteOn), trailing: .downvote(isOn: downvoteOn), label: "Score Counter", singleIcon: Icons.scoreCounter ) } static func upvote(value: Int = 9, isOn: Bool = false) -> CounterAppearance { .init(value: value, leading: .upvote(isOn: isOn), trailing: nil, label: "Upvote Counter", singleIcon: Icons.upvoteCounter) } static func downvote(value: Int = 2, isOn: Bool = false) -> CounterAppearance { .init(value: value, leading: .downvote(isOn: isOn), trailing: nil, label: "Downvote Counter", singleIcon: Icons.downvoteCounter) } static func reply(value: Int = 3) -> CounterAppearance { .init(value: value, leading: .reply(), trailing: nil, label: "Reply Counter", singleIcon: Icons.replyCounter) } } ================================================ FILE: Mlem/App/Models/Action/Readout.swift ================================================ // // Readout.swift // Mlem // // Created by Sjmarf on 16/06/2024. // import SwiftUI import Theming struct Readout { let id: String let label: String? let icon: String var color: ThemedColor? var value: String? var valueColor: ThemedColor? } ================================================ FILE: Mlem/App/Models/Action/ShareActivity.swift ================================================ // // ShareActivity.swift // Mlem // // Created by Sjmarf on 30/09/2024. // import UIKit class ShareActivity: UIActivity { let appearance: ActionAppearance let action: @MainActor () -> Void init(appearance: ActionAppearance, performAction: @escaping @MainActor () -> Void) { self.appearance = appearance self.action = performAction super.init() } override var activityTitle: String? { appearance.label } override var activityImage: UIImage? { .init(systemName: appearance.menuIcon) } override var activityType: UIActivity.ActivityType { UIActivity.ActivityType(rawValue: "com.hanners.mlem") } override class var activityCategory: UIActivity.Category { .action } override func canPerform(withActivityItems activityItems: [Any]) -> Bool { true } @MainActor override func perform() { action() activityDidFinish(true) } } ================================================ FILE: Mlem/App/Models/CommentTreeNode.swift ================================================ // // CommentWrapper.swift // Mlem // // Created by Sjmarf on 25/06/2024. // import MlemMiddleware import SwiftUI @Observable class CommentTreeNode: Identifiable, Hashable { var comment: Comment private(set) var children: [CommentTreeNode] = [] weak var parent: CommentTreeNode? var collapsed: Bool = false var id: Int { comment.id } init(_ comment: Comment) { self.comment = comment } func addChild(_ child: CommentTreeNode) { child.parent = self children.append(child) } func tree(hideIfCollapsed: Bool = true) -> [CommentTreeNode] { if comment.creator.value_?.blocked_.realizedValue ?? false { return [] } if collapsed, hideIfCollapsed { return [self] } return children.reduce([self]) { $0 + $1.tree() } } var recursiveChildCount: Int { children.reduce(0) { $0 + $1.recursiveChildCount + 1 } } var api: ApiClient { comment.api } var actorId: ActorIdentifier { comment.actorId } /// Returns the top-level parent var topParent: CommentTreeNode { parent?.topParent ?? self } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } static func == (lhs: CommentTreeNode, rhs: CommentTreeNode) -> Bool { lhs === rhs } } extension [CommentTreeNode] { func tree() -> [CommentTreeNode] { reduce([]) { $0 + $1.tree() } } } ================================================ FILE: Mlem/App/Models/ErrorDetails.swift ================================================ // // ErrorDetails.swift // Mlem // // Created by Sjmarf on 31/08/2023. // import Combine import Icons import MlemMiddleware import SwiftUI import UniformTypeIdentifiers struct ErrorDetails: Hashable { var title: String? var body: String? var error: Error? var location: String? var icon: Icon? var buttonText: String? var refresh: (() async -> Bool)? var autoRefresh: Bool = false var when: Date init( title: String? = nil, body: String? = nil, error: Error? = nil, location: String? = nil, icon: Icon? = nil, buttonText: String? = nil, refresh: (() async -> Bool)? = nil, autoRefresh: Bool = false ) { self.title = title self.body = body self.error = error self.location = location self.icon = icon self.buttonText = buttonText self.refresh = refresh self.autoRefresh = autoRefresh self.when = Date.now if let error { switch error { case ApiClientError.imageTooLarge: self.title = self.title ?? "Image too large" default: break } } } func hash(into hasher: inout Hasher) { hasher.combine(title) hasher.combine(body) hasher.combine(error?.localizedDescription) hasher.combine(location) hasher.combine(icon) hasher.combine(buttonText) hasher.combine(refresh == nil) hasher.combine(autoRefresh) } static func == (lhs: ErrorDetails, rhs: ErrorDetails) -> Bool { lhs.hashValue == rhs.hashValue } func errorText(includingLocation: Bool = true) -> String { var output = String(describing: error) if includingLocation, let location { output += " (\(location))" } for account in AccountsTracker.main.userAccounts { if let token = account.api.token { output.replace(token, with: "TOKEN_REDACTED") } } return output } } ================================================ FILE: Mlem/App/Models/Events/Event+Extension.swift ================================================ // // Event+Extension.swift // Mlem // // Created by Sjmarf on 2026-04-24. // import Foundation import FediverseEvents extension Event { var navigationUrl: URL? { if let social = self.social.first(where: { $0.icon == .lemmy }) { return social.url } else { return self.endpoints.open } } } ================================================ FILE: Mlem/App/Models/Events/EventsTracker.swift ================================================ // // EventsTracker.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import Foundation import FediverseEvents @Observable class EventsTracker { private let client = EventsClient() private(set) var events: [Event]? private var lastRefreshedAt: Date? var environment: EventsEnvironment { client.environment } func changeEnvironment(to environment: EventsEnvironment) { self.client.changeEnvironment(to: environment) self.lastRefreshedAt = nil self.events = nil self.refreshIfStale() } private func refresh() async throws { self.events = try await self.client.listEvents() self.lastRefreshedAt = .now } func refreshIfStale() { if self.needsRefresh { Task { do { try await refresh() } catch { handleError(error) } } } } private var needsRefresh: Bool { if let lastRefreshedAt { abs(lastRefreshedAt.timeIntervalSinceNow) > 60 * 60 // 1 hour } else { true } } } ================================================ FILE: Mlem/App/Models/FeedContext.swift ================================================ // // FeedContext.swift // Mlem // // Created by Sjmarf on 2025-08-27. // import Foundation enum FeedContext { case all, local, subscribed, saved, moderated, popular, suggested, community, search, person, post var showSubscriptionIndicator: Bool { switch self { case .all, .local, .popular, .suggested, .saved, .search, .person, .post: return true default: return false } } } ================================================ FILE: Mlem/App/Models/FeedbackType.swift ================================================ // // FeedbackType.swift // Mlem // // Created by Sjmarf on 24/06/2024. // import Foundation enum FeedbackType { case haptic case toast } ================================================ FILE: Mlem/App/Models/ImageUploadHistoryManager.swift ================================================ // // ImageUploadHistoryManager.swift // Mlem // // Created by Sjmarf on 07/09/2024. // import MlemMiddleware import SwiftUI @Observable class ImageUploadHistoryManager { private(set) var uploads: [ImageUpload1] = [] func add(_ upload: ImageUpload1) { uploads.append(upload) } func deleteAll() { for upload in uploads { Task { try await upload.delete() } } } @discardableResult func deleteWhereNotPresent(in text: String) -> [ImageUpload1] { uploads.filter { upload in if !text.contains(upload.url.absoluteString) { Task { try await upload.delete() } return true } return false } } } ================================================ FILE: Mlem/App/Models/ImageUploadManager.swift ================================================ // // ImageUploadManager.swift // Mlem // // Created by Sjmarf on 02/09/2024. // import MlemMiddleware import PhotosUI import SwiftUI @Observable class ImageUploadManager: Hashable { enum UploadState: Hashable { case idle, uploading(progress: Double), done(ImageUpload1) var isDone: Bool { switch self { case .done: true default: false } } } private(set) var state: UploadState = .idle init() {} var image: ImageUpload1? { switch state { case let .done(image): return image default: return nil } } var progress: Double { switch state { case .idle: 0 case let .uploading(progress): progress case .done: 1 } } func uploadPhoto(_ photo: PhotosPickerItem, api: ApiClient) async throws { do { guard let data = try await photo.loadTransferable(type: Data.self) else { throw ApiClientError.unsuccessful } guard let fileExtension = photo.supportedContentTypes.first?.preferredFilenameExtension else { throw ApiClientError.unsuccessful } try await upload(data: data, fileExtension: fileExtension, api: api) } catch { Task { @MainActor in state = .idle } throw error } } func uploadFile(localUrl url: URL, api: ApiClient) async throws { do { guard url.startAccessingSecurityScopedResource() else { throw ApiClientError.insufficientPermissions } let data = try Data(contentsOf: url) url.stopAccessingSecurityScopedResource() try await upload(data: data, fileExtension: url.pathExtension, api: api) } catch { url.stopAccessingSecurityScopedResource() Task { @MainActor in state = .idle } throw error } } func pasteFromClipboard(api: ApiClient) async throws { do { if UIPasteboard.general.hasImages, let content = UIPasteboard.general.image { if let data = content.pngData() { try await upload(data: data, fileExtension: "png", api: api) } } } catch { Task { @MainActor in state = .idle } throw error } } func upload(data: Data, fileExtension: String, api: ApiClient) async throws { do { let image = try await api.uploadImage(data, fileExtension: fileExtension, onProgress: { value in Task { @MainActor in self.state = .uploading(progress: value) } }) Task { @MainActor in state = .done(image) } } catch { Task { @MainActor in state = .idle } throw error } } func hash(into hasher: inout Hasher) { hasher.combine(state) } @MainActor func clear() { state = .idle } func delete() async throws { var imageToDelete: ImageUpload1? if let image { imageToDelete = image } await clear() try await imageToDelete?.delete() } static func == (lhs: ImageUploadManager, rhs: ImageUploadManager) -> Bool { lhs.hashValue == rhs.hashValue } } ================================================ FILE: Mlem/App/Models/MlemStats/InstanceSummary.swift ================================================ // // InstanceSummary.swift // Mlem // // Created by Sjmarf on 25/06/2024. // import Foundation import MlemBackend import MlemMiddleware public extension InstanceSummary { var instanceStub: InstanceStub { .init(api: AppState.main.firstApi, actorId: .instance(host: host)) } } ================================================ FILE: Mlem/App/Models/MlemStats/MlemStats.swift ================================================ // // MlemStats.swift // Mlem // // Created by Sjmarf on 25/06/2024. // import Foundation import MlemMiddleware import MlemBackend /// Class exposing instance search functionality. Instance data is fetched from the Mlem backend. class MlemStats { enum MlemStatsApiClientError: Error { case failed } private(set) var instances: [InstanceSummary]? private(set) var loadingState: LoadingState = .idle private(set) var errorDetails: ErrorDetails? // This set is queried for use in link-handling. // Some of the largest instances are hard-coded just in-case the backend is down. private(set) var hosts: Set = [ "lemm.ee", "lemmy.world", "lemmy.ml", "sh.itjust.works", "beehaw.org", "lemmy.blahaj.zone", "sopuli.xyz", "programming.dev" ] static let main: MlemStats = .init() @MainActor func loadInstances(forceRefresh: Bool = false) async { guard forceRefresh || loadingState == .idle else { return } loadingState = .loading do { let decoder: JSONDecoder = .defaultDecoder decoder.keyDecodingStrategy = .convertFromSnakeCase let instances = try await BackendClient.main.getInstances() self.instances = instances hosts.formUnion(Set(instances.lazy.map(\.host))) loadingState = .done errorDetails = nil } catch { loadingState = .idle if var errorDetails = handleErrorWithDetails(error) { errorDetails.refresh = { await self.loadInstances() return true } self.errorDetails = errorDetails } } } @MainActor func searchInstances(query: String, sort: InstanceSort = .score) async throws -> [InstanceSummary] { await loadInstances() let instances: [InstanceSummary] if query.isEmpty { instances = self.instances ?? [] } else { instances = self.instances?.filter { $0.host.localizedCaseInsensitiveContains(query) || $0.name.localizedCaseInsensitiveContains(query) } ?? [] } let filteredInstances = filterBlockedInstances(instances) switch sort { case .score: return filteredInstances case .users: return filteredInstances.sorted { $0.totalUsers > $1.totalUsers } case .alphabetical: return filteredInstances.sorted { $0.host < $1.host } case .version: return filteredInstances.sorted { $0.software.version > $1.software.version } } } private func filterBlockedInstances(_ instances: [InstanceSummary]) -> [InstanceSummary] { guard let session = AppState.main.firstSession as? UserSession, let blocks = session.blocks else { return instances } return instances.filter { instance in let actorId = ActorIdentifier.instance(host: instance.host) return !blocks.contains(instanceActorId: actorId) } } } ================================================ FILE: Mlem/App/Models/SeededRandomNumberGenerator.swift ================================================ // // SeededRandomNumberGenerator.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation // https://stackoverflow.com/questions/54821659/swift-4-2-seeding-a-random-number-generator struct SeededRandomNumberGenerator: RandomNumberGenerator { init(seed: Int) { srand48(seed) } // swiftlint:disable:next legacy_random func next() -> UInt64 { UInt64(drand48() * Double(UInt64.max)) } } ================================================ FILE: Mlem/App/Models/Session/GuestSession.swift ================================================ // // GuestSession.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import Foundation import MlemMiddleware import Observation @Observable class GuestSession: Session { typealias AccountType = GuestAccount private(set) var account: GuestAccount private(set) var instance: Instance? init(account: GuestAccount) { self.account = account account.activate() Task { let instance = try await self.api.getMyInstance() let software = try await self.api.software await self.account.update(instance: instance, software: software) self.instance = instance } } convenience init(url: URL) throws { try self.init(account: .getGuestAccount(url: url)) } func deactivate() { account.deactivate() api.cleanCaches() } func hash(into hasher: inout Hasher) { hasher.combine(actorId) } static func == (lhs: GuestSession, rhs: GuestSession) -> Bool { lhs.actorId == rhs.actorId } } ================================================ FILE: Mlem/App/Models/Session/Session.swift ================================================ // // Session.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import Foundation import MlemMiddleware import Observation protocol Session: ActorIdentifiable, Hashable { associatedtype AccountType: Account var api: ApiClient { get } var account: AccountType { get } var instance: Instance? { get } func deactivate() } extension Session { var api: ApiClient { account.api } var actorId: ActorIdentifier { account.actorId } } ================================================ FILE: Mlem/App/Models/Session/UserSession.swift ================================================ // // UserSession.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import Foundation import MlemMiddleware import Observation @Observable class UserSession: Session { typealias AccountType = UserAccount private(set) var account: UserAccount private(set) var person: Person? private(set) var instance: Instance? private(set) var subscriptions: SubscriptionList! private(set) var blocks: BlockList? private(set) var unreadCount: UnreadCount? /// This **only** includes requests made by calling `toggleInstanceBlock` on this `UserSession`. private(set) var ongoingInstanceBlockRequests: Set = [] private(set) var visitHistory: VisitHistory? private(set) var subscriptionListErrorDetails: ErrorDetails? init(account: UserAccount) { self.account = account account.activate() self.subscriptions = api.setupSubscriptionList( getFavorites: { account.favorites }, setFavorites: { account.favorites = $0 AccountsTracker.main.saveAccounts(ofType: .user) } ) Task { @MainActor in do { let (person, instance, blocks) = try await self.api.getMyPerson() let software = try await self.api.software if let person { self.account.update(person: person, software: software) self.person = person } self.blocks = blocks self.instance = instance } catch { handleError(error) } do { self.unreadCount = try await api.getUnreadCount() } catch { handleError(error) } do { try await self.api.getSubscriptionList() } catch { self.subscriptionListErrorDetails = handleErrorWithDetails(error) } if account.visitHistoryEnabled { do { self.visitHistory = try await PersistenceRepository.liveValue.loadVisitHistory(for: account) } catch { self.visitHistory = .init() try? await saveVisitHistory() handleError(error, silent: true) } } } } func deactivate() { account.deactivate() api.cleanCaches() } func hash(into hasher: inout Hasher) { hasher.combine(api) } static func == (lhs: UserSession, rhs: UserSession) -> Bool { lhs.api == rhs.api } func updateAccount() async throws { if let person, let instance { try await account.update(person: person, software: api.software) } } func saveVisitHistory() async throws { if let visitHistory { try await PersistenceRepository.liveValue.saveVisitHistory(visitHistory, for: account) } } func updateInstanceBlock(actorId: ActorIdentifier, shouldBlock: Bool, callback: ((Bool) -> Void)? = nil) { Task { guard !ongoingInstanceBlockRequests.contains(actorId) else { callback?(false) return } ongoingInstanceBlockRequests.insert(actorId) do { let instanceId: Int if let id = self.blocks?.instanceIdOfBlockedInstance(actorId: actorId) { instanceId = id } else { instanceId = try await api.getInstanceId(actorId: actorId) } try await api.blockInstance(url: actorId.url, instanceId: instanceId, block: shouldBlock) ongoingInstanceBlockRequests.remove(actorId) callback?(true) } catch { handleError(error) ongoingInstanceBlockRequests.remove(actorId) callback?(false) } } } @MainActor func setVisitHistoryEnabled(_ newValue: Bool) async throws { guard newValue != account.visitHistoryEnabled else { return } account.visitHistoryEnabled = newValue if newValue { visitHistory = .init() } else { visitHistory = nil try await PersistenceRepository.liveValue.saveVisitHistory(.init(), for: account) } AccountsTracker.main.saveAccounts(ofType: .user) } } ================================================ FILE: Mlem/App/Models/Settings/Options/InternetSpeed.swift ================================================ // // InternetSpeed.swift // Mlem // // Created by Eric Andrews on 2023-08-02. // import Foundation enum InternetSpeed: String, Codable { case debug, slow, fast var label: LocalizedStringResource { switch self { case .debug: "Debug" case .slow: "Slow" case .fast: "Fast" } } var id: Self { self } var pageSize: Int { switch self { case .debug: return 11 case .slow: return 25 case .fast: return 50 } } } ================================================ FILE: Mlem/App/Models/Settings/Options/PostSize.swift ================================================ // // PostSize.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import Foundation import Icons import QuickSwipes import SwiftUI enum PostSize: String, CaseIterable, Codable { case compact, tile, headline, large /// Convenience because this check comes up a lot var tiled: Bool { self == .tile } var cornerRadius: CGFloat { switch self { case .tile: Constants.main.largeItemCornerRadius default: Constants.main.standardSpacing } } var quickSwipeIconSize: CGFloat { switch self { case .tile: 18 default: 28 } } var quickSwipeMinimumDrag: CGFloat { switch self { case .tile: 10 default: 20 } } var quickSwipeThresholds: QuickSwipeThresholdSet { switch self { case .tile: .init(primary: 40, secondary: 100, tertiary: 160) default: .default } } var label: LocalizedStringResource { switch self { case .compact: "Compact" case .headline: "Headline" case .large: "Large" case .tile: "Tiled" } } var avatarSize: Int? { switch self { case .compact, .tile: return nil case .headline, .large: return Int(Constants.main.largeAvatarSize * 2) } } var imageSize: CGFloat? { // TODO: Vary this by device? switch self { case .compact, .headline: 128 case .tile: 512 case .large: nil } } var sectionSpacing: CGFloat { switch self { case .compact: Constants.main.halfSpacing default: Constants.main.standardSpacing } } var icon: Icon { switch self { case .compact: .settings.postSizeCompact case .tile: .settings.postSizeTiled case .headline: .settings.postSizeHeadline case .large: .settings.postSizeLarge } } var markReadOffset: Int { switch self { case .compact, .tile: 4 case .headline: 2 case .large: 1 } } } ================================================ FILE: Mlem/App/Models/Settings/Options/ThumbnailLocation.swift ================================================ // // ThumbnailLocation.swift // Mlem // // Created by Eric Andrews on 2024-05-23. // import Foundation import Icons enum ThumbnailLocation: String, CaseIterable, Codable { case left, right, none var label: LocalizedStringResource { switch self { case .none: "None" case .left: "Left" case .right: "Right" } } var icon: Icon { switch self { case .left: .general.backward case .right: .general.forward case .none: .general.hide } } } ================================================ FILE: Mlem/App/Protocols/AccountSortMode.swift ================================================ // // AccountSortMode.swift // Mlem // // Created by Sjmarf on 23/12/2023. // import SwiftUI enum AccountSortMode: String, CaseIterable, Codable { case custom, name, instance, mostRecent var label: LocalizedStringResource { switch self { case .name: return "Name" case .instance: return "Instance" case .mostRecent: return "Most Recent" case .custom: return "Custom Order" } } var systemImage: String { switch self { case .name: return "textformat" case .instance: return "at" case .mostRecent: return "clock" case .custom: return "line.3.horizontal.decrease" } } } ================================================ FILE: Mlem/App/Protocols/AssociatedColor.swift ================================================ // // AssociatedColor.swift // Mlem // // Created by Eric Andrews on 2023-10-31. // import Foundation import SwiftUI protocol AssociatedColor { var color: Color? { get } } ================================================ FILE: Mlem/App/Utility/Extensions/ApiClient+Extensions.swift ================================================ // // ApiClient+Extensions.swift // Mlem // // Created by Sjmarf on 02/06/2024. // import Foundation import MlemMiddleware import OpenGraph import PhotosUI import SwiftUI extension ApiClient { func isActive(appState: AppState) -> Bool { appState.guestSession.api === self || appState.activeSessions.contains(where: { $0.api === self }) } func canInteract(appState: AppState) -> Bool { isActive(appState: appState) && token != nil } func getPostLinkOrUseOpenGraph(url: URL) async throws -> PostLink { if try await self.supports(.fetchLinkMetadata) { return try await self.getPostLink(url: url) } let metadata = try await OpenGraph.fetch(url: url) let thumbnailUrl = metadata[.image].map { URL(string: $0) } ?? nil return .init(content: url, thumbnail: thumbnailUrl, label: metadata[.title] ?? url.absoluteString) } } ================================================ FILE: Mlem/App/Utility/Extensions/Array+Extensions.swift ================================================ // // Array+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-06-10. // import Foundation extension Array { subscript(safeIndex index: Int) -> Element? { guard index >= 0, index < endIndex else { return nil } return self[index] } mutating func appendIfPresent(_ newElement: Element?) { if let newElement { self.append(newElement) } } } extension Sequence where Element: Hashable { func uniqued() -> [Element] { var set = Set() return filter { set.insert($0).inserted } } } ================================================ FILE: Mlem/App/Utility/Extensions/BackendClient+Extensions.swift ================================================ // // MlemBackend+Extensions.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import MlemBackend extension BackendClient { static var main: BackendClient = .init() } ================================================ FILE: Mlem/App/Utility/Extensions/Binding+Extensions.swift ================================================ // // Binding+Extensions.swift // Mlem // // Created by Sjmarf on 06/07/2024. // import SwiftUI extension Binding where Value == Bool { func invert() -> Binding { .init( get: { !wrappedValue }, set: { self.wrappedValue = !$0 } ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Blockable+Extensions.swift ================================================ // // Blockable+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-02-10. // import MlemMiddleware import SwiftUI extension Blockable { func blocked(environment: EnvironmentValues) -> Bool { if self is any InstanceActionProviding, let session = (environment.appState.firstSession as? UserSession) { return session.blocks?.contains(instanceActorId: actorId) ?? self.blocked.realizedValue } return self.blocked.realizedValue } var toggleBlocked: ((Set, ((Bool) -> Void)?) -> Void)? { if let updateBlocked = self.updateBlocked { return { toggleBlocked(updateBlocked: updateBlocked, feedback: $0, callback: $1) } } return nil } private func toggleBlocked( updateBlocked: @escaping (Bool, ((Bool) -> Void)?) -> Void, feedback: Set, callback: ((Bool) -> Void)? = nil) { if feedback.contains(.toast) { if !blocked.realizedValue { ToastModel.main.add( .undoable( "Blocked", icon: .lemmy.block, callback: { updateBlocked(false, callback) }, color: .themedNegative ) ) } else { ToastModel.main.add( .undoable( "Unblocked", icon: .lemmy.unblock, callback: { updateBlocked(true, callback) }, color: .themedPrimary ) ) } } updateBlocked(!blocked.realizedValue, callback) } } ================================================ FILE: Mlem/App/Utility/Extensions/Bundle+Extensions.swift ================================================ // // Bundle+Extensions.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import Foundation extension Bundle { var releaseVersionNumber: String? { infoDictionary?["CFBundleShortVersionString"] as? String } var buildVersionNumber: String? { infoDictionary?["CFBundleVersion"] as? String } var isTestFlight: Bool { // https://stackoverflow.com/a/26113597/17629371 appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" } } ================================================ FILE: Mlem/App/Utility/Extensions/CGFloat+Extensions.swift ================================================ // // CGFloat+Extensions.swift // Mlem // // Created by Eric Andrews on 2025-03-17. // import Foundation extension CGFloat { func bounded(lower: CGFloat, upper: CGFloat) -> CGFloat { if self < lower { return lower } if self > upper { return upper } return self } func stepped(by increment: CGFloat) -> CGFloat { (self / increment).rounded() * increment } /// Returns the value of this CGFloat bounded within the given range. If this float is above softMax, the returned /// value will asymptotically approach hardMax, and likewise for softMin and hardMin func softBounded(softMin: CGFloat, hardMin: CGFloat, softMax: CGFloat, hardMax: CGFloat) -> CGFloat { guard softMin > hardMin, softMax < hardMax, softMin < softMax else { if softMin <= hardMin { assertionFailure("Soft min \(softMin) <= hard min \(hardMin)") } if softMax >= hardMax { assertionFailure("Soft max \(softMax) >= hard max \(hardMax)") } if softMin >= softMax { assertionFailure("Soft min \(softMin) >= soft max \(softMax)") } return self } if self > softMax { let headroom = hardMax - softMax let excess = self - softMax let scaledExcess = headroom - asymptote(x: excess, n: headroom) return softMax + scaledExcess } if self < softMin { let headroom = softMin - hardMin let excess = softMin - self let scaledExcess = asymptote(x: excess, n: headroom) - headroom return softMin + scaledExcess } return self } /// Base asymptotic function used for softBounded, where x is the value to scale and n is the asymptotic bound private func asymptote(x: CGFloat, n: CGFloat) -> CGFloat { // swiftlint:disable:this identifier_name n / (((1 / n) * x) + 1) } } ================================================ FILE: Mlem/App/Utility/Extensions/CGPoint+Extensions.swift ================================================ // // CGPoint+Extensions.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // import Foundation extension CGPoint { static func + (lhs: Self, rhs: Self) -> Self { .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y) } static func += (lhs: inout Self, rhs: Self) { lhs = lhs + rhs } } ================================================ FILE: Mlem/App/Utility/Extensions/CGSize+Extensions.swift ================================================ // // CGSize+Extensions.swift // Mlem // // Created by Eric Andrews on 2025-02-24. // import Foundation extension CGSize { var aspectRatio: Double { height / width } static func + (lhs: Self, rhs: Self) -> Self { .init(width: lhs.width + rhs.width, height: lhs.height + rhs.height) } static func - (lhs: Self, rhs: Self) -> Self { .init(width: lhs.width - rhs.width, height: lhs.height - rhs.height) } static func += (lhs: inout Self, rhs: Self) { lhs = lhs + rhs } func scaled(by factor: CGFloat) -> CGSize { .init(width: width * factor, height: height * factor) } } ================================================ FILE: Mlem/App/Utility/Extensions/Calendar+Extensions.swift ================================================ // // Calendar+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-08-29. // import Foundation extension Calendar { func daysSince(_ from: Date) -> Int? { let fromDate = startOfDay(for: from) let toDate = startOfDay(for: Date.now) let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) return numberOfDays.day } } ================================================ FILE: Mlem/App/Utility/Extensions/CaptchaDifficulty+Extensions.swift ================================================ // // CaptchaDifficulty+Extensions.swift // Mlem // // Created by Sjmarf on 29/07/2024. // import Foundation import MlemMiddleware extension CaptchaDifficulty { var label: LocalizedStringResource { switch self { case .easy: "Easy" case .medium: "Medium" case .hard: "Hard" } } } ================================================ FILE: Mlem/App/Utility/Extensions/Color+Extensions.swift ================================================ // // Color+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-08-29. // import Foundation import SwiftUI extension Color { init(light: UIColor, dark: UIColor) { self.init(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) } init(light: Color, dark: Color) { self.init(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) }) } } ================================================ FILE: Mlem/App/Utility/Extensions/CommentSortType+Extensions.swift ================================================ // // CommentSortType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-03-17. // import Foundation import Icons import MlemMiddleware extension CommentSortType { func label(timeRangeFormat: SortTimeRange.FormatStyle = .timescaleFull) -> String { switch self { case .new: .init(localized: "New") case .old: .init(localized: "Old") case .hot: .init(localized: "Hot") case .controversial: .init(localized: "Controversial") case let .top(timeRange): timeRange.label(name: "Top", prefix: "Top:", format: timeRangeFormat) } } var icon: Icon { switch self { case .new: .lemmy.newSort case .old: .lemmy.oldSort case .hot: .lemmy.hotSort case .controversial: .lemmy.controversialSort case .top: .lemmy.topSort } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ActorIdentifiable+Extensions.swift ================================================ // // ActorIdentifiable+Extensions.swift // Mlem // // Created by Sjmarf on 02/07/2024. // import Foundation import MlemMiddleware import SwiftUI extension ActorIdentifiable { func openInstanceAction(navigation: NavigationLayer?) -> BasicAction { let callback: (@MainActor () -> Void)? if let navigation { callback = { navigation.push(.hostInstance(of: self)) } } else { callback = nil } return .init( id: "instance\(actorId)", appearance: .init(label: host, color: .themedNeutralAccent, icon: Icons.instance), callback: callback ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Captcha+Extensions.swift ================================================ // // Captcha+Extensions.swift // Mlem // // Created by Sjmarf on 06/09/2024. // import MlemMiddleware import SwiftUI extension Captcha { var uiImage: UIImage? { .init(data: imageData) } var image: Image? { if let uiImage { .init(uiImage: uiImage) } else { nil } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Comment/Comment+Actions.swift ================================================ // // Comment+Actions.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-19. // import Haptics import Foundation import MlemMiddleware import SwiftUI extension Comment { // MARK: - Readouts func readout(type: CommentBarConfiguration.ReadoutType, showColor: Bool) -> Readout? { switch type { case .created: createdReadout // swiftlint:disable:next void_function_in_ternary case .score: downvotesEnabled ? scoreReadout(showColor: showColor) : upvoteReadout(showColor: showColor) case .upvote: upvoteReadout(showColor: showColor) case .downvote: downvotesEnabled ? downvoteReadout(showColor: showColor) : nil case .comment: commentReadout case .saved: savedReadout(showColor: showColor) } } func readout(type: ReplyBarConfiguration.ReadoutType, showColor: Bool) -> Readout? { switch type { case .created: createdReadout // swiftlint:disable:next void_function_in_ternary case .score: downvotesEnabled ? scoreReadout(showColor: showColor) : upvoteReadout(showColor: showColor) case .upvote: upvoteReadout(showColor: showColor) case .downvote: downvotesEnabled ? downvoteReadout(showColor: showColor) : nil case .comment: commentReadout case .saved: savedReadout(showColor: showColor) } } // MARK: - Counters func counter( appState: AppState, type: CommentBarConfiguration.CounterType, commentTreeTracker: CommentTreeTracker? = nil ) -> Counter? { switch type { case .score: scoreCounter(appState: appState, downvotesEnabled: downvotesEnabled) case .upvote: upvoteCounter(appState: appState) case .downvote: downvotesEnabled ? downvoteCounter(appState: appState, downvotesEnabled: downvotesEnabled) : nil case .reply: replyCounter(appState: appState, commentTreeTracker: commentTreeTracker) } } func counter( appState: AppState, type: ReplyBarConfiguration.CounterType, commentTreeTracker: CommentTreeTracker? = nil ) -> Counter? { switch type { case .score: scoreCounter(appState: appState, downvotesEnabled: downvotesEnabled) case .upvote: upvoteCounter(appState: appState) case .downvote: downvotesEnabled ? downvoteCounter(appState: appState, downvotesEnabled: downvotesEnabled) : nil case .reply: replyCounter(appState: appState, commentTreeTracker: commentTreeTracker) } } // MARK: - Actions func createImageAction(navigation: NavigationLayer, commentTreeTracker: CommentTreeTracker?) -> BasicAction { .init( id: "exportAsImage\(uid)", appearance: .createImage()) { navigation.openSheet(.exportCommentImage(self, tracker: commentTreeTracker)) } } func editAction(appState: AppState) -> BasicAction { .init( id: "edit\(uid)", appearance: .edit(), callback: api.canInteract(appState: appState) ? { @MainActor in NavigationModel.main.openSheet(.editComment(self, context: nil)) } : nil ) } func viewVotesAction() -> BasicAction { let callback: (@MainActor () -> Void)? = canModerate && api.supports(.viewVotes, defaultValue: true) ? { @MainActor in NavigationModel.main.openSheet(.votesList(.comment(self))) } : nil return .init( id: "viewVotes\(uid)", appearance: .viewVotes(), callback: callback ) } func markReadAction(appState: AppState, notification: InboxNotification, feedback: Set = []) -> BasicAction { .init( id: "markRead\(uid)", appearance: .markRead(isOn: notification.read), callback: api.canInteract(appState: appState) ? { @MainActor in notification.toggleRead() HapticManager.main.play(haptic: .lightSuccess, tier: .low) } : nil ) } func collapseAction(commentTreeTracker: CommentTreeTracker? = nil) -> BasicAction { .init( id: "collapse\(uid)", appearance: .collapse(), callback: { @MainActor in withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { commentTreeTracker?.nodesKeyedByActorId[self.actorId]?.collapsed.toggle() } } ) } func collapseParentAction(commentTreeTracker: CommentTreeTracker? = nil) -> BasicAction { .init( id: "collapseParent\(uid)", appearance: .collapseParent(), callback: { @MainActor in withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { guard let comment = commentTreeTracker?.nodesKeyedByActorId[self.actorId] else { return } (comment.parent ?? comment).collapsed.toggle() } } ) } func collapseToTopAction(commentTreeTracker: CommentTreeTracker? = nil) -> BasicAction { .init( id: "collapseToTop\(uid)", appearance: .collapseToTop(), callback: { @MainActor in withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { commentTreeTracker?.nodesKeyedByActorId[self.actorId]?.topParent.collapsed.toggle() } } ) } // MARK: - Action Groups // swiftlint:disable:next cyclomatic_complexity func action( appState: AppState, type: CommentBarConfiguration.ActionType, navigation: NavigationLayer?, commentTreeTracker: CommentTreeTracker? = nil, communityContext: Community? = nil, reportContext: Report? = nil ) -> (any Action)? { switch type { case .upvote: if let upvoteAction = upvoteAction(appState: appState, feedback: [.haptic]) { return upvoteAction } case .downvote: if let downvoteAction = downvoteAction(appState: appState, feedback: [.haptic]) { return downvoteAction } case .save: return saveAction(appState: appState, feedback: [.haptic]) case .reply: return replyAction(appState: appState, commentTreeTracker: commentTreeTracker) case .share: return shareAction(navigation: navigation) case .selectText: return selectTextAction() case .report: return reportAction(appState: appState, communityContext: communityContext) case .resolve: return reportContext?.resolveAction(appState: appState, feedback: [.haptic]) case .remove: return removeAction(appState: appState) case .ban: return reportContext?.contextualBanAction(appState: appState) case .collapse: return collapseAction(commentTreeTracker: commentTreeTracker) case .collapseParent: return collapseParentAction(commentTreeTracker: commentTreeTracker) case .collapseToTop: return collapseToTopAction(commentTreeTracker: commentTreeTracker) } return nil } func action( appState: AppState, type: ReplyBarConfiguration.ActionType, navigation: NavigationLayer?, notification: InboxNotification, commentTreeTracker: CommentTreeTracker? = nil, communityContext: Community? = nil, reportContext: Report? = nil ) -> (any Action)? { switch type { case .upvote: if let upvoteAction = upvoteAction(appState: appState, feedback: [.haptic]) { return upvoteAction } case .downvote: if let downvoteAction = downvoteAction(appState: appState, feedback: [.haptic]) { return downvoteAction } case .save: return saveAction(appState: appState, feedback: [.haptic]) case .reply: return replyAction(appState: appState, commentTreeTracker: commentTreeTracker) case .selectText: return selectTextAction() case .report: return reportAction(appState: appState, communityContext: communityContext) case .markRead: return markReadAction(appState: appState, notification: notification) } return nil } // MARK: - Action Groups @ActionBuilder func allMenuActions( appState: AppState, expanded: Bool = false, feedback: Set = [.haptic, .toast], showAllActions: Bool = true, navigation: NavigationLayer?, notification: InboxNotification? = nil, commentTreeTracker: CommentTreeTracker? = nil, report: Report? = nil ) -> [any Action] { basicMenuActions( appState: appState, feedback: feedback, navigation: navigation, notification: notification, commentTreeTracker: commentTreeTracker ) if canModerate { ActionGroup( appearance: .init(label: "Moderation...", color: .themedModeration, icon: Icons.moderation), displayMode: Settings.get(\.menus_modActionGrouping) == .divider || expanded ? .section : .disclosure ) { moderatorMenuActions(appState: appState, feedback: feedback, showAllActions: showAllActions, report: report) } } } @ActionBuilder func basicMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], navigation: NavigationLayer?, notification: InboxNotification? = nil, commentTreeTracker: CommentTreeTracker? = nil ) -> [any Action] { ActionGroup(displayMode: .compactSection) { if let upvoteAction = upvoteAction(appState: appState, feedback: feedback) { upvoteAction } if let downvoteAction = downvoteAction( appState: appState, feedback: feedback) { downvoteAction } if let saveAction = saveAction(appState: appState, feedback: feedback) { saveAction } replyAction(appState: appState, commentTreeTracker: commentTreeTracker) if let notification { markReadAction(appState: appState, notification: notification, feedback: feedback) } if !deleted { selectTextAction() } shareAction(navigation: navigation) if let navigation, notification == nil { createImageAction(navigation: navigation, commentTreeTracker: commentTreeTracker) } if isOwnComment { editAction(appState: appState) deleteAction(appState: appState, feedback: feedback) } else { if !canModerate, !deleted { reportAction(appState: appState) } if let blockCreatorAction = blockCreatorAction(appState: appState, feedback: feedback) { blockCreatorAction } } } } @ActionBuilder func moderatorMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], showAllActions: Bool = true, report: Report? = nil ) -> [any Action] { let viewVotesIsPossible = api.supports(.viewVotes, defaultValue: false) if viewVotesIsPossible, showAllActions || Settings.get(\.menus_allModActions) { viewVotesAction() } if !isOwnComment { removeAction(appState: appState).disabled(!canModerate) if let creator = creator.value, let community = community.value { creator.banActions(appState: appState, community: community, withUserLabel: true) } } if api.isAdmin, api.supports(.purgeContent, defaultValue: false) { purgeAction(appState: appState) if !isOwnComment, let purgeCreatorAction = purgeCreatorAction(appState: appState) { purgeCreatorAction } } if let report { ActionGroup { report.menuActions(appState: appState) } } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Comment/Comment+Extensions.swift ================================================ // // Comment+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-01-21. // import MlemMiddleware extension Comment { func shouldShowLoadingSymbol(for barConfiguration: CommentBarConfiguration? = nil) -> Bool { // TODO: NOW really? false } var shouldHideInFeed: Bool { (creator.value_?.shouldHideInFeed ?? false) || purged } var isOwnComment: Bool { creatorId == api.myPerson?.id } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Community/Community+Extensions.swift ================================================ // // Community+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-02-20. // import MlemMiddleware extension Community { var shouldHideInFeed: Bool { blocked_.realizedValue } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/CommunityOrPersonStub+Extensions.swift ================================================ // // CommunityOrPersonStub+Extensions.swift // Mlem // // Created by Sjmarf on 30/05/2024. // import MlemMiddleware import SwiftUI import Theming extension CommunityOrPerson { func copyFullNameWithPrefix(feedback: Set = [.toast]) { if feedback.contains(.toast) { ToastModel.main.add(.success("Copied")) } UIPasteboard.general.string = fullNameWithPrefix } func copyNameAction(feedback: Set = [.toast]) -> BasicAction { .init( id: "copyName\(actorId)", appearance: .init( label: "Copy Name", color: .themedNeutralAccent, icon: Icons.copy, swipeIcon2: Icons.copyFill ), callback: { self.copyFullNameWithPrefix(feedback: feedback) } ) } func attributedName( showInstance: Bool = true, font: Font = .body, palette: Theming.Palette, nameColor: ThemedColor = .themedSecondary, instanceColor: ThemedColor = .themedTertiary ) -> AttributedString? { var outputString = AttributedString(name) outputString.foregroundColor = nameColor.resolve(with: palette) outputString.font = font.bold() if showInstance { var instanceString = AttributedString("@\(host)") instanceString.foregroundColor = instanceColor.resolve(with: palette) instanceString.font = font outputString += instanceString } outputString.link = actorId.url return outputString } func nameTextView( showFlairs: Bool, showInstance: Bool = true, communityContext: Community? = nil, font: Font = .body, palette: Theming.Palette, nameColor: ThemedColor = .themedSecondary, instanceColor: ThemedColor = .themedTertiary ) -> Text { let attributedName = attributedName( showInstance: showInstance, font: font, palette: palette, nameColor: nameColor, instanceColor: instanceColor ) if showFlairs, let flairs = (self as? Person)?.flairs(communityContext: communityContext) { return flairs.textView.font(font) + Text(attributedName ?? "") } else { return Text(attributedName ?? "") } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/DeletableProviding+Extensions.swift ================================================ // // DeletableProviding+Extensions.swift // Mlem // // Created by Sjmarf on 22/07/2024. // import MlemMiddleware extension DeletableProviding { func toggleDeleted(feedback: Set) { if feedback.contains(.toast), !deleted { toggleDeleted { status in switch status { case .success: if self is any Message1Providing, !self.api.supports(.undeletePrivateMessages, defaultValue: true) { ToastModel.main.add( .basic( "Deleted", icon: .general.delete, color: .themedNegative ) ) } else { ToastModel.main.add( .undoable( "Deleted", icon: .general.delete, callback: { self.updateDeleted(false, callback: nil) }, color: .themedNegative ) ) } case .failure: ToastModel.main.add(.failure("Failed to delete post!")) } } } else { toggleDeleted() } } func deleteAction(appState: AppState, feedback: Set) -> BasicAction { .init( id: "delete\(uid)", appearance: .init( label: deleted ? "Restore" : "Delete", isOn: deleted, isDestructive: !deleted, color: deleted ? .themedPositive : .themedNegative, icon: deleted ? Icons.undelete : Icons.delete ), confirmationPrompt: deleted ? nil : "Really delete?", callback: api.canInteract(appState: appState) ? { @MainActor in self.toggleDeleted(feedback: feedback) } : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/InboxItemProviding+Extensions.swift ================================================ // // InboxItemProviding+Extensions.swift // Mlem // // Created by Sjmarf on 05/07/2024. // import Haptics import MlemMiddleware extension InboxItemProviding { func toggleRead(feedback: Set) { if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } toggleRead() } func markReadAction(appState: AppState, feedback: Set = []) -> BasicAction { .init( id: "markRead\(uid)", appearance: .markRead(isOn: read), callback: api.canInteract(appState: appState) ? { @MainActor in self.toggleRead(feedback: feedback) } : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/InboxItemType+Extensions.swift ================================================ // // InboxItemType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-01-17. // import Foundation import Icons import MlemMiddleware extension InboxItemType { var label: LocalizedStringResource { switch self { case .reply: "Replies" case .mention: "Mentions" case .message: "Messages" case .postReport: "Post Reports" case .commentReport: "Comment Reports" case .messageReport: "Message Reports" case .registrationApplication: "Registration Applications" } } fileprivate var labelOnly: LocalizedStringResource { switch self { case .reply: "Replies Only" case .mention: "Mentions Only" case .message: "Messages Only" case .postReport: "Post Reports Only" case .commentReport: "Comment Reports Only" case .messageReport: "Message Reports Only" case .registrationApplication: "Applications Only" } } fileprivate var labelExcept: LocalizedStringResource { switch self { case .reply: "Except Replies" case .mention: "Except Mentions" case .message: "Except Messages" case .postReport: "Except Post Reports" case .commentReport: "Except Comment Reports" case .messageReport: "Except Message Reports" case .registrationApplication: "Except Applications" } } var icon: Icon { switch self { case .reply: .lemmy.reply case .mention: .lemmy.mention case .message: .lemmy.message case .postReport: .lemmy.post case .commentReport: .lemmy.replies case .messageReport: .lemmy.report case .registrationApplication: .lemmy.registrationApplication } } var requiredAccountType: AccountType { switch self { case .reply: .user case .mention: .user case .message: .user case .postReport: .moderator case .commentReport: .moderator case .messageReport: .admin case .registrationApplication: .admin } } } extension Sequence { func label(accountType: AccountType) -> String { let items = Set(filter { accountType >= $0.requiredAccountType }) let allItems = Set.all.filter { accountType >= $0.requiredAccountType } if items.isEmpty { return .init(localized: "None") } if items == allItems { return .init(localized: "All") } if items == .personal { return .init(localized: "Personal Only") } if accountType >= .moderator, items == .reports.filter({ accountType >= $0.requiredAccountType }) { return .init(localized: "Reports Only") } if accountType == .admin, items == .moderatorAndAdmin { return .init(localized: "Mod Mail Only") } if items.count == 2 { return items.map { String(localized: $0.label) }.sorted().joined(separator: " & ") } if items.count == 1, let first = items.first { return .init(localized: first.labelOnly) } let disabledItems = allItems.subtracting(items) if disabledItems.count == 1, let first = disabledItems.first { return .init(localized: first.labelExcept) } return .init(localized: "Some") } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Instance+Extensions.swift ================================================ // // Instance3+Extensions.swift // Mlem // // Created by Sjmarf on 2025-01-02. // import Foundation import MlemMiddleware import MlemBackend private let uptimeSupportedInstances: Set = [ "aussie.zone", "beehaw.org", "discuss.online", "discuss.tchncs.de", "dubvee.org", "feddit.org", "feddit.dk", "hexbear.net", "infosec.pub", "jlai.lu", "lemdro.id", "lemm.ee", "lemmings.world", "lemmy.blahaj.zone", "lemmy.ca", "lemmy.dbzer0.com", "lemmy.eco.br", "lemmy.ml", "lemmy.myserv.one", "lemmy.nz", "lemmy.world", "lemmy.zip", "literature.cafe", "mander.xyz", "midwest.social", "programming.dev", "sh.itjust.works", "slrpnk.net", "sopuli.xyz", "startrek.website", "szmer.info", "toast.ooo" ] extension Instance { func slurRegex() -> Regex? { do { if let regex = slurFilterRegex.value as? String { return try .init(regex) } } catch { handleError(error, silent: true) } return nil } var instanceSummary: InstanceSummary? { if let userCount = userCount.value, let software = software.value { return .init( displayName: displayName, name: name, totalUsers: userCount, avatar: avatar, software: .init(from: software) ) } return nil } var canFetchUptime: Bool { uptimeSupportedInstances.contains(host) } var uptimeDataUrl: URL? { guard canFetchUptime else { return nil } let name = "_\(host.replacingOccurrences(of: ".", with: "-"))" return URL(string: "https://lemmy-status.org/api/v1/endpoints/\(name)/statuses?page=1") } var uptimeFrontendUrl: URL? { guard canFetchUptime else { return nil } let name = "_\(host.replacingOccurrences(of: ".", with: "-"))" return URL(string: "https://lemmy-status.org/endpoints/\(name)") } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Instance3+Extensions.swift ================================================ ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Interactable/InteractableProviding+Actions.swift ================================================ // // InteractableProviding+Actions.swift // Mlem // // Created by Eric Andrews on 2026-01-24. // import MlemMiddleware import Theming import os // Methods to support actions extension InteractableProviding { // MARK: Actions func upvoteAction(appState: AppState, feedback: Set = []) -> BasicAction? { guard let toggleUpvoted, let votes = votes.value else { return nil } return .init(id: "upvote\(uid)", appearance: .upvote(isOn: votes.myVote == .upvote), callback: api.canInteract(appState: appState) ? { @MainActor in toggleUpvoted(feedback) } : nil ) } func downvoteAction(appState: AppState, feedback: Set = []) -> BasicAction? { guard let toggleDownvoted, let votes = votes.value else { return nil } return .init( id: "downvote\(uid)", appearance: .downvote(isOn: votes.myVote == .downvote), callback: api.canInteract(appState: appState) && downvotesEnabled ? { @MainActor in toggleDownvoted(feedback) } : nil ) } func saveAction(appState: AppState, feedback: Set = []) -> BasicAction? { guard let toggleSaved, let saved = saved.value else { return nil } return .init( id: "save\(uid)", appearance: .save(isOn: saved), callback: api.canInteract(appState: appState) ? { @MainActor in toggleSaved(feedback) } : nil ) } func replyAction(appState: AppState, commentTreeTracker: CommentTreeTracker? = nil) -> BasicAction { return .init( id: "reply\(uid)", appearance: .reply(), callback: api.canInteract(appState: appState) ? { @MainActor in self.showReplySheet(commentTreeTracker: commentTreeTracker) } : nil ) } func blockCreatorAction(appState: AppState, feedback: Set = [], showConfirmation: Bool = true) -> BasicAction? { guard let creator = creator.value, let toggleBlocked = creator.toggleBlocked else { return nil } return .init( id: "blockCreator\(uid)", appearance: .blockCreator(), confirmationPrompt: showConfirmation ? "Really block this user?" : nil, callback: api.canInteract(appState: appState) ? { @MainActor in toggleBlocked(feedback, nil) } : nil ) } func purgeCreatorAction(appState: AppState) -> BasicAction? { guard let creator = creator.value else { return nil } return .init( id: "purgeCreator\(uid)", appearance: .purgePerson(), callback: api.canInteract(appState: appState) && api.isAdmin ? { @MainActor in creator.showPurgeSheet() } : nil ) } // MARK: Readouts var createdReadout: Readout { .init( id: "created\(uid)", label: (updated ?? created).getShortRelativeTime(), icon: updated == nil ? Icons.time : Icons.updated ) } func scoreReadout(showColor: Bool) -> Readout? { guard let votes = votes.value else { return nil } let icon: String let color: ThemedColor? switch votes.myVote { case .upvote: icon = Icons.upvoteSquareFill color = .themedUpvote case .downvote: icon = Icons.downvoteSquareFill color = .themedDownvote default: icon = Icons.upvoteSquare color = nil } return Readout( id: "score\(uid)", label: votes.total.description, icon: icon, color: showColor ? color : nil ) } func upvoteReadout(showColor: Bool) -> Readout? { guard let votes = votes.value else { return nil } let isOn = votes.myVote == .upvote return Readout( id: "upvote\(uid)", label: votes.upvotes.description, icon: isOn ? Icons.upvoteSquareFill : Icons.upvoteSquare, color: isOn && showColor ? .themedUpvote : nil ) } func downvoteReadout(showColor: Bool) -> Readout? { guard let votes = votes.value else { return nil } let isOn = votes.myVote == .downvote return Readout( id: "downvote\(uid)", label: votes.downvotes.description, icon: isOn ? Icons.downvoteSquareFill : Icons.downvoteSquare, color: isOn && showColor ? .themedDownvote : nil ) } var commentReadout: Readout? { guard let commentCount = commentCount.value else { return nil } let value: String? if let unreadCount = (self as? Post)?.unreadCommentCount.value, unreadCount > 0, unreadCount != commentCount { value = "+\(unreadCount)" } else { value = nil } return .init( id: "comment\(uid)", label: commentCount.description, icon: Icons.replies, value: value, valueColor: .themedPositive ) } func savedReadout(showColor: Bool) -> Readout? { guard let saved = saved.value else { return nil } let isOn = saved return .init( id: "saved\(uid)", label: nil, icon: isOn ? Icons.saveFill : Icons.save, color: isOn && showColor ? .themedSave : nil ) } // MARK: Counters func upvoteCounter(appState: AppState) -> Counter? { guard let votes = votes.value, let upvoteAction = upvoteAction(appState: appState, feedback: [.haptic]) else { return nil } return .init( value: votes.upvotes, leadingAction: upvoteAction, trailingAction: nil ) } func downvoteCounter(appState: AppState, downvotesEnabled: Bool) -> Counter? { guard let votes = votes.value, let downvoteAction = downvoteAction( appState: appState, feedback: [.haptic]) else { return nil } return .init( value: votes.downvotes, leadingAction: downvoteAction, trailingAction: nil ) } func scoreCounter( appState: AppState, downvotesEnabled: Bool ) -> Counter? { guard let votes = votes.value, let upvoteAction = upvoteAction(appState: appState, feedback: [.haptic]) else { return nil } return .init( value: votes.total, leadingAction: upvoteAction, trailingAction: downvoteAction( appState: appState, feedback: [.haptic] ) ) } func replyCounter(appState: AppState, commentTreeTracker: CommentTreeTracker? = nil) -> Counter? { guard let commentCount = self.commentCount.value else { return nil } return .init( value: commentCount, leadingAction: replyAction(appState: appState, commentTreeTracker: commentTreeTracker), trailingAction: nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Interactable/InteractableProviding+Extensions.swift ================================================ // // InteractableProviding+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-01-24. // import MlemMiddleware // Utility extensions for InteractableProviding extension InteractableProviding { @MainActor func showReplySheet(commentTreeTracker: CommentTreeTracker? = nil) { if let responseContext { NavigationModel.main.openSheet(.createComment(responseContext, commentTreeTracker: commentTreeTracker)) } else { handleError(MlemError.navigationError("Cannot open sheet"), silent: true) } } private var responseContext: CommentEditorView.Context? { if let self = self as? Post { return .post(self) } if let self = self as? Comment { return .comment(self) } return nil } func contextualFlairs() -> Set { var output: Set = [] if creatorIsAdmin.value ?? false { output.insert(.admin) } if creatorIsModerator.value ?? false { output.insert(.moderator) } if let comment = self as? Comment { if let post = comment.post.value_, comment.creatorId == post.creatorId { output.insert(.op) } } return output } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Interactable/InteractableProviding+Toggles.swift ================================================ // // InteractableProviding+Toggles.swift // Mlem // // Created by Eric Andrews on 2024-05-02. // import Haptics import MlemMiddleware import SwiftUI import Theming // Convenience methods for toggling statuses with feedback extension InteractableProviding { private var inboxItem: (any InboxItemProviding)? { self as? any InboxItemProviding } var toggleVote: ((ScoringOperation, Set) -> Void)? { if let updateVote, let votes = votes.value { return { operation, feedback in if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } updateVote(operation == votes.myVote ? .none : operation) self.inboxItem?.updateRead(true) } } return nil } var toggleUpvoted: ((Set) -> Void)? { if let updateVote, let votes = votes.value { return { feedback in if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } updateVote(votes.myVote == .upvote ? .none : .upvote) self.inboxItem?.updateRead(true) } } return nil } var toggleDownvoted: ((Set) -> Void)? { if let updateVote, let votes = votes.value { return { feedback in if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } updateVote(votes.myVote == .downvote ? .none : .downvote) self.inboxItem?.updateRead(true) } } return nil } var toggleSaved: ((Set) -> Void)? { if let saved = saved.value, let votes = votes.value, let updateVote { return { feedback in if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } @Setting(\.behavior_upvoteOnSave) var upvoteOnSave if upvoteOnSave, !saved, votes.myVote != .upvote { updateVote(.upvote) } self.updateSaved(!saved) } } return nil } func toggleRemoved(reason: String?, feedback: Set) { let initialValue = removed if feedback.contains(.haptic) { HapticManager.main.play(haptic: .success, tier: .low) } toggleRemoved(reason: reason) { status in if case .failure = status { ToastModel.main.add(.failure(initialValue ? "Failed to remove content" : "Failed to restore content")) } } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Message1Providing+Extensions.swift ================================================ // // Message1Providing+Extensions.swift // Mlem // // Created by Sjmarf on 05/07/2024. // import Haptics import MlemMiddleware import QuickSwipes extension Message1Providing { var self2: (any Message2Providing)? { self as? any Message2Providing } func swipeActions(notification: InboxNotification?, appState: AppState) -> SwipeConfiguration { .init( trailingActions: { if api.canInteract(appState: appState), !isOwnMessage, let notification { markReadAction(appState: appState, notification: notification, feedback: [.haptic]) } } ) } @ActionBuilder func allMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], isInMessageFeed: Bool = false, editCallback: (@MainActor () -> Void)?, navigation: NavigationLayer? = nil, notification: InboxNotification? = nil, report: Report? = nil ) -> [any Action] { basicMenuActions( appState: appState, feedback: feedback, isInMessageFeed: isInMessageFeed, editCallback: editCallback, navigation: navigation, notification: notification ) if api.isAdmin { ActionGroup( appearance: .init(label: "Moderation...", color: .themedModeration, icon: Icons.moderation), displayMode: Settings.get(\.menus_modActionGrouping) == .divider ? .section : .disclosure ) { moderatorMenuActions(appState: appState, feedback: feedback, report: report) } } } // swiftlint:disable:next cyclomatic_complexity @ActionBuilder func basicMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], isInMessageFeed: Bool = false, editCallback: (@MainActor () -> Void)?, navigation: NavigationLayer? = nil, notification: InboxNotification? = nil, report: Report? = nil ) -> [any Action] { if !isOwnMessage { if let navigation, !isInMessageFeed { replyAction(appState: appState, navigation: navigation) } if let notification { markReadAction(appState: appState, notification: notification, feedback: feedback) } } if !deleted { selectTextAction() } if isOwnMessage { if api.supports(.editAndDeletePrivateMessages, defaultValue: true) { if let editCallback { editAction(appState: appState, callback: editCallback) } if api.supports(.undeletePrivateMessages, defaultValue: true) || !deleted { deleteAction(appState: appState, feedback: feedback) } } } else { if api.supports(.reportPrivateMessages, defaultValue: true) { if report == nil { reportAction(appState: appState) } } if !isInMessageFeed { blockCreatorAction(appState: appState, feedback: feedback) } } } @ActionBuilder func moderatorMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], report: Report? = nil ) -> [any Action] { if let report { ActionGroup { report.menuActions(appState: appState) } } } func editAction(appState: AppState, callback: @escaping @MainActor () -> Void) -> BasicAction { .init( id: "edit\(uid)", appearance: .edit(), callback: api.canInteract(appState: appState) ? callback : nil ) } // These actions are also defined in Interactable1Providing... another protocol for these may be a good idea func replyAction(appState: AppState, navigation: NavigationLayer) -> BasicAction { var callback: (@MainActor () -> Void)? if let creator = creator_, api.canInteract(appState: appState) { callback = { @MainActor in navigation.push(.messageFeed(creator, focusTextField: true)) } } return .init( id: "reply\(uid)", appearance: .reply(), callback: callback ) } func blockCreatorAction(appState: AppState, feedback: Set = [], showConfirmation: Bool = true) -> BasicAction { .init( id: "blockCreator\(uid)", appearance: .blockCreator(), callback: api.canInteract(appState: appState) ? { @MainActor in self.self2?.creator.toggleBlocked?(feedback, nil) } : nil ) } func markReadAction(appState: AppState, notification: InboxNotification, feedback: Set = []) -> BasicAction { .init( id: "markRead\(uid)", appearance: .markRead(isOn: notification.read), callback: api.canInteract(appState: appState) ? { @MainActor in notification.toggleRead() HapticManager.main.play(haptic: .lightSuccess, tier: .low) } : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ModlogEntryContent+Extensions.swift ================================================ // // ModlogEntryContent+Extensions.swift // Mlem // // Created by Sjmarf on 2024-12-26. // import Icons import MlemMiddleware import SwiftUI import Theming extension ModlogEntryContent { var icon: Icon { switch self { case let .removePost(_, _, removed, _), let .removeComment(_, _, _, _, removed, _), let .removeCommunity(_, removed, _): removed ? .lemmy.remove : .lemmy.restore case let .lockPost(_, _, locked: locked): locked ? .lemmy.addLock : .lemmy.removeLock case let .pinPost(_, _, pinned, _): pinned ? .lemmy.addPin : .lemmy.removePin case .purgePost, .purgeComment, .purgeCommunity, .purgePerson: .lemmy.purge case let .hideCommunity(_, hidden, _): hidden ? .general.hide : .general.show case .transferCommunityOwnership: .lemmy.transferCommunity case let .updatePersonModeratorStatus(_, _, appointed): appointed ? .lemmy.addModerator : .lemmy.removeModerator case .updatePersonAdminStatus: .lemmy.administration case let .banPersonFromCommunity(_, _, banned, _, _): banned ? .lemmy.banFromCommunity : .lemmy.unbanFromCommunity case let .banPersonFromInstance(_, banned, _, _): banned ? .lemmy.banFromInstance : .lemmy.unbanFromInstance } } var color: ThemedColor { switch self { case let .removePost(_, _, removed, _), let .removeComment(_, _, _, _, removed, _), let .removeCommunity(_, removed, _): removed ? .themedNegative : .themedPositive case .lockPost: .themedLockAccent case let .pinPost(_, _, _, type): type == .community ? .themedModeration : .themedAdministration case .purgePost, .purgeComment, .purgeCommunity, .purgePerson: .themedNegative case .hideCommunity: .themedColorfulAccent(4) case .transferCommunityOwnership: .themedColorfulAccent(8) case let .updatePersonModeratorStatus(_, _, appointed): appointed ? .themedModeration : .themedNegative case let .updatePersonAdminStatus(_, appointed): appointed ? .themedAdministration : .themedNegative case let .banPersonFromCommunity(_, _, banned, _, _), let .banPersonFromInstance(_, banned, _, _): banned ? .themedNegative : .themedPositive } } // swiftlint:disable:next cyclomatic_complexity function_body_length func label(userText: Text?) -> LocalizedStringKey { switch self { case let .removePost(_, _, removed, _): if let userText { removed ? "\(userText) removed a post" : "\(userText) restored a post" } else { removed ? "Post was removed" : "Post was restored" } case let .removeComment(_, _, _, _, removed, _): if let userText { removed ? "\(userText) removed a comment" : "\(userText) restored a comment" } else { removed ? "Comment was removed" : "Comment was restored" } case let .removeCommunity(_, removed, _): if let userText { removed ? "\(userText) removed a community" : "\(userText) restored a community" } else { removed ? "Community was removed" : "Community was restored" } case let .lockPost(_, _, locked): if let userText { locked ? "\(userText) locked a post" : "\(userText) unlocked a post" } else { locked ? "Post was locked" : "Post was unlocked" } case let .pinPost(_, community, pinned, type): pinLabel(userText: userText, community: community, pinned: pinned, type: type) case .purgePost: if let userText { "\(userText) purged a post" } else { "Post was purged" } case .purgeComment: if let userText { "\(userText) purged a comment" } else { "Comment was purged" } case .purgeCommunity: if let userText { "\(userText) purged a community" } else { "Community was purged" } case .purgePerson: if let userText { "\(userText) purged a user" } else { "User was purged" } case let .hideCommunity(_, hidden, _): if let userText { hidden ? "\(userText) hid a community" : "\(userText) unhid a community" } else { hidden ? "Community was hidden" : "Community was unhidden" } case .transferCommunityOwnership: if let userText { "\(userText) transferred ownership of a community" } else { "Community ownership was transferred" } case let .updatePersonModeratorStatus(_, _, appointed): if let userText { appointed ? "\(userText) appointed a moderator" : "\(userText) removed a moderator" } else { appointed ? "Moderator was appointed" : "Moderator was removed" } case let .updatePersonAdminStatus(_, appointed): if let userText { appointed ? "\(userText) appointed an administrator" : "\(userText) removed an administrator" } else { appointed ? "Administrator was appointed" : "Administrator was removed" } case let .banPersonFromCommunity(_, _, banned, _, _), let .banPersonFromInstance(_, banned, _, _): if let userText { banned ? "\(userText) banned a user" : "\(userText) unbanned a user" } else { banned ? "User was banned" : "User was unbanned" } } } } private func pinLabel( userText: Text?, community: Community, pinned: Bool, type: PostFeatureType ) -> LocalizedStringKey { let target: String = (type == .community ? community.fullName : community.api.host) if let userText { return pinned ? "\(userText) pinned a post to \(target)" : "\(userText) unpinned a post from \(target)" } else { return pinned ? "Post was pinned to \(target)" : "Post was unpinned from \(target)" } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ModlogEntryType+Extensions.swift ================================================ // // ApiModlogActionType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-01-11. // import Foundation import Icons import MlemMiddleware extension ModlogEntryType { var label: LocalizedStringResource { switch self { case .removePost: "Remove Post" case .lockPost: "Lock Post" case .pinPost: "Pin Post" case .removeComment: "Remove Comment" case .removeCommunity: "Remove Community" case .banPersonFromCommunity: "Ban from Community" case .updatePersonModeratorStatus: "Appoint Moderator" case .transferCommunityOwnership: "Transfer Community" case .updatePersonAdminStatus: "Appoint Administrator" case .banPersonFromInstance: "Ban from Instance" case .hideCommunity: "Hide Community" case .purgePerson: "Purge Person" case .purgeCommunity: "Purge Community" case .purgePost: "Purge Post" case .purgeComment: "Purge Comment" } } var contextualLabel: LocalizedStringResource { switch self { case .removePost, .removeComment, .removeCommunity: "Remove" case .lockPost: "Lock" case .pinPost: "Pin" case .banPersonFromCommunity: "Ban from Community" case .updatePersonModeratorStatus: "Appoint Moderator" case .transferCommunityOwnership: "Transfer Ownership" case .updatePersonAdminStatus: "Appoint Administrator" case .banPersonFromInstance: "Ban from Instance" case .hideCommunity: "Hide" case .purgePerson, .purgeCommunity, .purgePost, .purgeComment: "Purge" } } var icon: Icon { switch self { case .removePost, .removeComment, .removeCommunity: .lemmy.remove case .lockPost: .lemmy.addLock case .pinPost: .lemmy.addPin case .banPersonFromCommunity: .lemmy.banFromCommunity case .updatePersonModeratorStatus: .lemmy.moderation case .transferCommunityOwnership: .lemmy.transferCommunity case .updatePersonAdminStatus: .lemmy.administration case .banPersonFromInstance: .lemmy.banFromInstance case .hideCommunity: .general.hide case .purgePerson, .purgeCommunity, .purgePost, .purgeComment: .lemmy.purge } } var appliesToCommunity: Bool { switch self { case .removePost, .lockPost, .pinPost, .removeComment, .banPersonFromCommunity, .updatePersonModeratorStatus, .transferCommunityOwnership, .hideCommunity: true case .removeCommunity, .updatePersonAdminStatus, .banPersonFromInstance, .purgePerson, .purgeCommunity, .purgePost, .purgeComment: false } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Person/Person+Actions.swift ================================================ // // Person+Actions.swift // Mlem // // Created by Eric Andrews on 2026-02-06. // import MlemMiddleware extension Person { @MainActor func showBanSheet(community: Community?, isBannedFromCommunity: Bool, shouldBan: Bool) { NavigationModel.main.openSheet( .ban(self, isBannedFromCommunity: isBannedFromCommunity, shouldBan: shouldBan, community: community) ) } func banActions(appState: AppState, community: Community?, withUserLabel: Bool = false) -> [any Action] { let canBanFromCommunity: Bool let showBoth: Bool let canBanFromInstance = api.isAdmin && api.supports(.banFromInstance, defaultValue: false) if let myPerson = api.myPerson, let community, let myPersonModerates = myPerson.moderates { let supportedByApi = api.supports(.banFromCommunity, defaultValue: false) && ( apiIsLocal || api.supports(.banFromNonLocalCommunity, defaultValue: false) ) canBanFromCommunity = myPersonModerates(.id(community.id)) && supportedByApi showBoth = canBanFromInstance && isBannedFromCommunity(community) != bannedFromInstance } else { canBanFromCommunity = false showBoth = false } var output: [any Action] = .init() // admins should see separate 'ban' and 'unban' actions if ban statuses conflict; otherwise actions are grouped under a single entry (community or instance, depending on moderation status) // moderators see community ban action by default, regardless of admin status if canBanFromCommunity { if showBoth { output.append(banFromInstanceAction(appState: appState)) } if let community { output.append(banFromCommunityAction(appState: appState, community: community, withUserLabel: withUserLabel)) } } // non-moderator admins see instance ban action by default else if canBanFromInstance { output.append(banFromInstanceAction(appState: appState)) if showBoth, let community { output.append(banFromCommunityAction(appState: appState, community: community, withUserLabel: withUserLabel)) } } return output } func banFromInstanceAction(appState: AppState, withUserLabel: Bool = false) -> BasicAction { .init( id: "banFromInstance\(uid)", appearance: .banFromInstance(isOn: bannedFromInstance, withUserLabel: withUserLabel), callback: api.canInteract(appState: appState) && api.isAdmin ? { @MainActor in self.showBanSheet( community: nil, isBannedFromCommunity: false, shouldBan: !self.bannedFromInstance ) } : nil ) } func banFromCommunityAction(appState: AppState, community: Community, withUserLabel: Bool = false) -> BasicAction { let isBanned = isBannedFromCommunity(community) let callback: (@MainActor () -> Void)? if let isBanned, api.canInteract(appState: appState), community.canModerate { callback = { self.showBanSheet( community: community, isBannedFromCommunity: isBanned, shouldBan: !isBanned ) } } else { callback = nil } return .init( id: "banFromCommunity\(uid)", appearance: .banFromCommunity(isOn: isBanned ?? false, withUserLabel: withUserLabel), callback: callback ) } /// Action to add/remove admin /// - Parameters: /// - instance: instance to add the admin to /// - isOn: true if the user is already an admin, false otherwise func addAdminAction(instance: Instance, isOn: Bool) -> BasicAction { let callback: (@MainActor () -> Void) = { instance.addAdmin(personId: self.id, added: !isOn) } return .init( id: "addAdmin\(uid)", appearance: .addAdmin(isOn: isOn), confirmationPrompt: isOn ? "Really remove administrator \(displayName) from \(instance.displayName)?" : "Really appoint \(displayName) as an administrator of \(instance.displayName)?", callback: callback ) } func addModAction(community: Community, isOn: Bool) -> BasicAction { let callback: (@MainActor () -> Void) = { Task { do { try await community.addModerator(self, added: !isOn) } catch { handleError(error) } } } return .init( id: "addMod\(uid)", appearance: .addMod(isOn: isOn), confirmationPrompt: isOn ? "Really remove moderator \(displayName) from \(community.displayName)?" : "Really appoint \(displayName) as a moderator of \(community.displayName)?", callback: callback ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Person/Person+Extensions.swift ================================================ // // Person+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-02-06. // import MlemBackend import MlemMiddleware extension Person { var shouldHideInFeed: Bool { blocked_.realizedValue || purged } var isMlemDeveloper: Bool { BackendClient.main.flairs.developers.contains(actorId.description) } func flairs( interactableContext interactable: (any InteractableProviding)? = nil, communityContext community: Community? = nil ) -> [PersonFlair] { @Setting(\.person_ageVisibility) var alwaysShowAccountAge let community = community ?? interactable?.community.value var output: Set = [] if isMlemDeveloper { output.insert(.developer) } if isBot { output.insert(.bot) } if bannedFromInstance { output.insert(.bannedFromInstance) } if let community, isBannedFromCommunity(community) ?? false { output.insert(.bannedFromCommunity) } if (alwaysShowAccountAge == .newAccountsOnly && createdRecently) || alwaysShowAccountAge == .always { output.insert(.accountAge(created)) } else if isCakeDay { output.insert(.cakeDay) } if let interactable { if let creator = interactable.creator.value { assert(creator.actorId == actorId) } else { assertionFailure("No creator!") } output.formUnion(interactable.contextualFlairs()) } else { if api.myInstance?.administrators.value?.contains(where: { $0.id == id }) ?? false { output.insert(.admin) } } if let community, community.moderators.value?.contains(where: { $0.id == id }) ?? false { output.insert(.moderator) } return output.sorted { $0.sortVal < $1.sortVal } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/PersonContent+Extensions.swift ================================================ // // PersonContent+Extensions.swift // Mlem // // Created by Sjmarf on 2024-10-31. // import MlemMiddleware extension PersonContent { var shouldHideInFeed: Bool { switch wrappedValue { case let .post(post): post.shouldHideInFeed case let .comment(comment): comment.shouldHideInFeed } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Post/Post+Actions.swift ================================================ // // Post+Actions.swift // Mlem // // Created by Eric Andrews on 2026-01-03. // import MlemMiddleware import Foundation import os // swiftlint:disable file_length // Functions to support the old Action system extension Post { func hideAction(appState: AppState, feedback: Set) -> BasicAction? { guard let hidden = hidden.value, let toggleHidden = toggleHidden else { return nil } return .init( id: "hide\(uid)", appearance: .hide(isOn: hidden), callback: api.supports(.hidePosts, defaultValue: true) && api.canInteract(appState: appState) ? { @MainActor in toggleHidden(feedback) } : nil ) } func blockAction(appState: AppState, feedback: Set) -> ActionGroup { .init( appearance: .init( label: "Block...", isDestructive: true, color: .themedNegative, icon: Icons.block ), prompt: "Block community or user?", disabled: !api.canInteract(appState: appState), displayMode: .popup ) { if let blockCreatorAction = blockCreatorAction(appState: appState, feedback: feedback, showConfirmation: false) { blockCreatorAction } if let blockCommunityAction = blockCommunityAction(appState: appState, feedback: feedback, showConfirmation: false) { blockCommunityAction } } } func blockCommunityAction(appState: AppState, feedback: Set = [], showConfirmation: Bool = true) -> BasicAction? { guard let community = community.value, let toggleBlocked = community.toggleBlocked else { return nil } return .init( id: "blockCommunity\(actorId.description)", appearance: .init( label: "Block Community", isOn: false, isDestructive: true, color: .themedNegative, icon: Icons.block ), confirmationPrompt: showConfirmation ? "Really block this community?" : nil, callback: api.canInteract(appState: appState) ? { @MainActor in toggleBlocked(feedback, nil) } : nil ) } func crossPostAction() -> BasicAction { .init( id: "crosspost\(uid)", appearance: .crossPost(), callback: { var crossPostContent: String let crossPostedLabel = String(localized: "Crossposted from \(self.actorId.description)") if let content = self.content, !content.isEmpty { crossPostContent = "\(crossPostedLabel)\n-----\n\(content)" } else { crossPostContent = crossPostedLabel } NavigationModel.main.openSheet(.createPost( community: nil, title: self.title, content: crossPostContent, type: self.type, nsfw: self.nsfw, feedLoader: .init(wrappedValue: nil) )) } ) } func lockAction(appState: AppState, feedback: Set = []) -> BasicAction? { guard api.canInteract(appState: appState) && canModerate else { return nil } return .init( id: "lock\(uid)", appearance: .lock(isOn: locked, isInProgress: lockedPending), confirmationPrompt: locked ? "Really unlock this post?" : "Really lock this post?", callback: { self.toggleLocked(feedback) } ) } func pinAction(appState: AppState, feedback: Set = []) -> ActionGroup { .init( appearance: .pin(isOn: false, isInProgress: pinnedCommunityPending || pinnedInstancePending), prompt: "Pin to Community or Instance?", displayMode: .popup ) { pinToCommunityAction(appState: appState, feedback: feedback, showConfirmation: false) pinToInstanceAction(appState: appState, feedback: feedback, showConfirmation: false) } } func pinToCommunityAction( appState: AppState, feedback: Set = [], verboseTitle: Bool = true, showConfirmation: Bool = true ) -> BasicAction { let isOn = pinnedCommunity let prompt: LocalizedStringResource? if showConfirmation { if let communityName = community.value?.name { if isOn { prompt = "Really unpin this post from \(communityName)?" } else { prompt = "Really pin this post to \(communityName)?" } } else { if isOn { prompt = "Really unpin this post from the community?" } else { prompt = "Really pin this post to the community?" } } } else { prompt = nil } return .init( id: "pinToCommunity\(uid)", appearance: verboseTitle ? .pinToCommunity( isOn: isOn, isInProgress: pinnedCommunityPending ) : .pin( isOn: isOn, isInProgress: pinnedCommunityPending ), confirmationPrompt: prompt, callback: api.canInteract(appState: appState) && canModerate ? { @MainActor in self.togglePinnedCommunity(feedback: feedback) } : nil ) } func pinToInstanceAction(appState: AppState, feedback: Set = [], showConfirmation: Bool = true) -> BasicAction { let isOn = pinnedInstance let prompt: LocalizedStringResource? if showConfirmation { if isOn { prompt = "Really unpin this post from \(host)?" } else { prompt = "Really pin this post to \(host)?" } } else { prompt = nil } return .init( id: "pinToInstance\(uid)", appearance: .pinToInstance(isOn: isOn, isInProgress: pinnedInstancePending), confirmationPrompt: prompt, callback: api.canInteract(appState: appState) && api.isAdmin ? { @MainActor in self.togglePinnedInstance(feedback: feedback) } : nil ) } func createImageAction(navigation: NavigationLayer) -> BasicAction { .init( id: "exportAsImage\(uid)", appearance: .createImage()) { navigation.openSheet(.exportPostImage(self)) } } func editAction(appState: AppState, navigation: NavigationLayer) -> BasicAction? { guard api.canInteract(appState: appState) else { return nil } return .init( id: "edit\(uid)", appearance: .edit(), callback: { navigation.openSheet(.editPost(self)) } ) } func setNsfwAction(appState: AppState) -> BasicAction? { guard setNsfwIsAvailable(appState: appState) else { return nil } return .init( id: "setNsfw\(uid)", appearance: .toggleNsfw(isOn: nsfw), callback: { @MainActor in self.toggleNsfw { status in Task { await self.handleModerationActionCompletion( message: "Failed to set NSFW status", result: status, feedback: [.haptic] ) } } } ) } func setNsfwIsAvailable(appState: AppState) -> Bool { guard let community = community.value else { return false } guard community.apiIsLocal else { return false } guard canModerate else { return false } guard api.canInteract(appState: appState) else { return false } guard api.supports(.moderatorSetNsfw, defaultValue: false) else { return false } return true } func viewVotesAction(navigation: NavigationLayer) -> BasicAction? { guard canModerate && api.supports(.viewVotes, defaultValue: true) else { return nil } return .init( id: "viewVotes\(uid)", appearance: .viewVotes(), callback: { @MainActor in navigation.push(.votesList(.post(self))) } ) } // swiftlint:disable:next cyclomatic_complexity func action( appState: AppState, navigation: NavigationLayer, type: PostBarConfiguration.ActionType, feedback: Set = [.haptic, .toast], commentTreeTracker: CommentTreeTracker? = nil, communityContext: Community? = nil, reportContext: Report? = nil ) -> (any Action)? { switch type { case .upvote: return upvoteAction(appState: appState, feedback: feedback) case .downvote: return downvoteAction(appState: appState, feedback: feedback) case .save: return saveAction(appState: appState, feedback: feedback) case .reply: return replyAction(appState: appState, commentTreeTracker: commentTreeTracker) case .share: return shareAction(navigation: navigation) case .selectText: return selectTextAction() case .hide: return hideAction(appState: appState, feedback: feedback) case .block: return blockAction(appState: appState, feedback: feedback) case .report: return reportAction(appState: appState, communityContext: communityContext) case .crossPost: return crossPostAction() case .lock: return lockAction(appState: appState, feedback: feedback) case .pin: return api.isAdmin ? pinAction( appState: appState, feedback: feedback ) : pinToCommunityAction( appState: appState, feedback: feedback ) case .resolve: return reportContext?.resolveAction(appState: appState, feedback: feedback) case .remove: return removeAction(appState: appState, feedback: feedback) case .ban: return reportContext?.contextualBanAction(appState: appState) } } // MARK: - Readouts func upvoteReadout(showColor: Bool) -> Readout? { if let votes = votes.value { let isOn = votes.myVote == .upvote return Readout( id: "upvote\(actorId)", label: votes.upvotes.description, icon: isOn ? Icons.upvoteSquareFill : Icons.upvoteSquare, color: isOn && showColor ? .themedUpvote : nil ) } return nil } func downvoteReadout(showColor: Bool) -> Readout? { if let votes = votes.value { let isOn = votes.myVote == .downvote return Readout( id: "downvote\(actorId)", label: votes.downvotes.description, icon: isOn ? Icons.downvoteSquareFill : Icons.downvoteSquare, color: isOn && showColor ? .themedDownvote : nil ) } return nil } func readout(type: PostBarConfiguration.ReadoutType, showColor: Bool) -> Readout? { switch type { case .created: createdReadout // swiftlint:disable:next void_function_in_ternary case .score: downvotesEnabled ? scoreReadout(showColor: showColor) : upvoteReadout(showColor: showColor) case .upvote: upvoteReadout(showColor: showColor) case .downvote: downvotesEnabled ? downvoteReadout(showColor: showColor) : nil case .comment: commentReadout case .saved: savedReadout(showColor: showColor) } } // MARK: - Counters func counter( appState: AppState, type: PostBarConfiguration.CounterType, commentTreeTracker: CommentTreeTracker? = nil ) -> Counter? { switch type { case .score: scoreCounter(appState: appState, downvotesEnabled: downvotesEnabled) case .upvote: upvoteCounter(appState: appState) case .downvote: downvotesEnabled ? downvoteCounter(appState: appState, downvotesEnabled: downvotesEnabled) : nil case .reply: replyCounter(appState: appState, commentTreeTracker: commentTreeTracker) } } // MARK: - Action Groups @ActionBuilder func allMenuActions( appState: AppState, expanded: Bool = false, feedback: Set = [.haptic, .toast], showAllActions: Bool = true, navigation: NavigationLayer?, report: Report? = nil, commentTreeTracker: CommentTreeTracker? = nil ) -> [any Action] { basicMenuActions( appState: appState, expanded: expanded, feedback: feedback, navigation: navigation, commentTreeTracker: commentTreeTracker ) if canModerate { ActionGroup( appearance: .init(label: "Moderation...", color: .themedModeration, icon: Icons.moderation), displayMode: Settings.get(\.menus_modActionGrouping) == .divider || expanded ? .section : .disclosure ) { moderatorMenuActions( appState: appState, feedback: feedback, showAllActions: showAllActions, navigation: navigation, report: report ) } } } @ActionBuilder func basicMenuActions( appState: AppState, expanded: Bool, feedback: Set = [.haptic, .toast], navigation: NavigationLayer?, commentTreeTracker: CommentTreeTracker? = nil ) -> [any Action] { ActionGroup(displayMode: .compactSection) { if let upvoteAction = upvoteAction(appState: appState, feedback: feedback) { upvoteAction } if let downvoteAction = downvoteAction(appState: appState, feedback: feedback) { downvoteAction } if let saveAction = saveAction(appState: appState, feedback: feedback) { saveAction } replyAction(appState: appState, commentTreeTracker: commentTreeTracker) if !deleted { selectTextAction() } shareAction(navigation: navigation) if expanded, let navigation { createImageAction(navigation: navigation) } if isOwnPost, let navigation, let editAction = editAction(appState: appState, navigation: navigation) { editAction deleteAction(appState: appState, feedback: feedback) } else { if api.supports(.hidePosts, defaultValue: true), let hideAction = hideAction(appState: appState, feedback: feedback) { hideAction } if !canModerate, !deleted { reportAction(appState: appState) } blockAction(appState: appState, feedback: feedback) } } } @ActionBuilder // swiftlint:disable:next cyclomatic_complexity func moderatorMenuActions( appState: AppState, feedback: Set = [.haptic, .toast], showAllActions: Bool = true, navigation: NavigationLayer?, report: Report? = nil ) -> [any Action] { if showAllActions || Settings.get(\.menus_allModActions) { pinToCommunityAction(appState: appState, feedback: feedback, verboseTitle: api.isAdmin) if api.isAdmin { pinToInstanceAction(appState: appState, feedback: feedback) } if let lockAction = lockAction(appState: appState, feedback: feedback) { lockAction } if setNsfwIsAvailable(appState: appState), let setNsfwAction = setNsfwAction(appState: appState) { setNsfwAction } if let navigation, api.supports(.viewVotes, defaultValue: false), let viewVotesAction = viewVotesAction(navigation: navigation) { viewVotesAction } } if !isOwnPost { if canModerate { removeAction(appState: appState) } if let creator = creator.value, let community = community.value { creator.banActions(appState: appState, community: community, withUserLabel: true) } } if api.isAdmin, api.supports(.purgeContent, defaultValue: false) { purgeAction(appState: appState) if !isOwnPost, let purgeCreatorAction = purgeCreatorAction(appState: appState) { purgeCreatorAction } } if let report { ActionGroup { report.menuActions(appState: appState) } } } } // swiftlint:enable file_length ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Post/Post+Extensions.swift ================================================ // // Post+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-01-07. // import MlemMiddleware import SwiftUI extension Post { func shouldShowLoadingSymbol(for barConfiguration: PostBarConfiguration? = nil) -> Bool { if lockedPending, !(barConfiguration?.all.contains(.action(.lock)) ?? false) { return true } if pinnedCommunityPending, !(barConfiguration?.all.contains(.action(.pin)) ?? false) { return true } if pinnedInstancePending, !(barConfiguration?.all.contains(.action(.pin)) ?? false) { return true } if nsfwPending { return true } return false } var shouldHideInFeed: Bool { (creator.value_?.shouldHideInFeed ?? false) || (community.value_?.shouldHideInFeed ?? false) || (hidden.value_ ?? false) || purged } func taggedTitle(communityContext: Community?) -> Text { let hasTags: Bool = removed || deleted || pinnedInstance || (communityContext != nil && pinnedCommunity) || locked return postTag(active: removed, icon: .lemmy.removed, color: .themedNegative) + postTag(active: deleted, icon: .general.delete, color: .themedNegative) + postTag(active: pinnedInstance, icon: .lemmy.pinned, color: .themedAdministration) + postTag(active: pinnedCommunity && communityContext != nil, icon: .lemmy.pinned, color: .themedModeration) + postTag(active: locked, icon: .lemmy.locked, color: .themedLockAccent) + Text(verbatim: "\(hasTags ? " " : "")\(title)") } var imageFallback: MediaView.Fallback { switch type { case .text: .text case let .media(url), let .embedded(url, _): url.proxyAwarePathExtension?.isMovieExtension ?? false ? .movie : .image case .link: .link case .poll: .poll case .titleOnly: .titleOnly } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Post/Post+Toggles.swift ================================================ // // Post+Toggles.swift // Mlem // // Created by Eric Andrews on 2026-01-04. // import MlemMiddleware import Haptics import os import Foundation // Convenience methods for toggling statuses with feedback extension Post { var toggleHidden: ((Set) -> Void)? { guard let hidden = hidden.value else { return nil } return { feedback in if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } if feedback.contains(.toast) { if hidden { ToastModel.main.add(.success("Shown")) } else { ToastModel.main.add( .undoable( "Hidden", icon: .general.hide, callback: { self.updateHidden(false) } ) ) } } self.updateHidden(!hidden) } } func togglePinnedCommunity(feedback: Set) { let shouldPin = !pinnedCommunity togglePinnedCommunity { status in Task { await self.handleModerationActionCompletion( message: shouldPin ? "Failed to pin post" : "Failed to unpin post", result: status, feedback: feedback ) } } } func toggleLocked(_ feedback: Set, callback: ((UpdateStatus) -> Void)? = nil) { if feedback.contains(.haptic) { HapticManager.main.play(haptic: .lightSuccess, tier: .low) } updateLocked(!locked, callback: callback) } /// Toggles the community pinned status of this post /// - Parameter callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func togglePinnedCommunity(callback: ((UpdateStatus) -> Void)? = nil) { updatePinnedCommunity(!pinnedCommunity, callback: callback) } func togglePinnedInstance(feedback: Set) { let shouldPin = !pinnedInstance togglePinnedInstance { status in Task { await self.handleModerationActionCompletion( message: shouldPin ? "Failed to pin post" : "Failed to unpin post", result: status, feedback: feedback ) } } } /// Toggles the instance pinned status of this post /// - Parameter callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func togglePinnedInstance(callback: ((UpdateStatus) -> Void)? = nil) { updatePinnedInstance(!pinnedInstance, callback: callback) } func toggleNsfw(callback: ((UpdateStatus) -> Void)?) { updateNsfw(!nsfw, callback: callback) } // MARK: - Helpers // TODO: UpdateQueue remove this shim code internal func handleModerationActionCompletion( message: LocalizedStringResource, result: UpdateStatus, feedback: Set ) async { var stateUpdateResult: StateUpdateResult switch result { case .success: stateUpdateResult = .succeeded case .failure: stateUpdateResult = .failed } await handleModerationActionCompletion(message: message, result: stateUpdateResult, feedback: feedback) } internal func handleModerationActionCompletion( message: LocalizedStringResource, result: StateUpdateResult, feedback: Set ) async { if feedback.contains(.haptic) { HapticManager.main.play(haptic: .success, tier: .low) } switch result { case .failed: ToastModel.main.add(.failure(message)) default: break } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ProfileProviding+Extensions.swift ================================================ // // ProfileProviding+Extensions.swift // Mlem // // Created by Sjmarf on 2024-12-13. // import Foundation import MlemMiddleware extension ProfileProviding { var isCakeDay: Bool { profileCreated?.isAnniversaryToday ?? false } var createdRecently: Bool { guard let created = profileCreated else { return false } let intervalSinceCreation = Date.now.timeIntervalSince(created) return intervalSinceCreation < 30 * 24 * 60 * 60 } static var avatarFallback: MediaView.Fallback { if self is Community.Type { return .communityAvatar } else if self is Instance.Type { return .instanceAvatar } else if self is Person.Type || self is any Account.Type { return .personAvatar } else { assertionFailure() return .personAvatar } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/PurgableProviding+Extensions.swift ================================================ // // PurgableProviding+Extensions.swift // Mlem // // Created by Sjmarf on 2024-10-27. // import Foundation import MlemMiddleware extension PurgableProviding { @MainActor func showPurgeSheet() { NavigationModel.main.openSheet(.purge(self)) } func purgeAction(appState: AppState) -> BasicAction { .init( id: "purge\(uid)", appearance: .purge(), callback: (api.canInteract(appState: appState) && api.isAdmin) ? showPurgeSheet : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/RegistrationApplication+Extensions.swift ================================================ // // RegistrationApplication+Extensions.swift // Mlem // // Created by Sjmarf on 2025-01-14. // import Foundation import MlemMiddleware extension RegistrationApplication { @MainActor func showDenialSheet() { NavigationModel.main.openSheet(.denyApplication(self)) } @ActionBuilder func menuActions() -> [any Action] { if resolution != .approved { approveAction() } if !resolution.isDenied { denyAction() } } func approveAction() -> BasicAction { .init( id: "approveApplication\(id)", appearance: .init( label: "Approve", color: .themedPositive, icon: Icons.successCircle ), callback: { self.approve() } ) } func denyAction() -> BasicAction { .init( id: "denyApplication\(id)", appearance: .init( label: "Deny", color: .themedNegative, icon: Icons.failureCircle ), callback: showDenialSheet ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/RemovableProviding+Extensions.swift ================================================ // // RemovableProviding+Extensions.swift // Mlem // // Created by Sjmarf on 2024-12-15. // import MlemMiddleware extension RemovableProviding { @MainActor func showRemoveSheet() { NavigationModel.main.openSheet(.remove(self)) } func removeAction(appState: AppState, feedback: Set = []) -> BasicAction { .init( id: "remove\(uid)", appearance: .remove(isOn: removed, isInProgress: removedPending), callback: api.canInteract(appState: appState) ? showRemoveSheet : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Reply1Providing+Extensions.swift ================================================ ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Report+Extensions.swift ================================================ // // Report+Extensions.swift // Mlem // // Created by Sjmarf on 2024-12-16. // import Haptics import MlemMiddleware extension Report { func toggleResolved(feedback: Set) { if feedback.contains(.haptic) { HapticManager.main.play(haptic: .success, tier: .low) } toggleResolved() } @ActionBuilder func menuActions( appState: AppState, feedback: Set = [.haptic] ) -> [any Action] { resolveAction(appState: appState, feedback: feedback) } func resolveAction(appState: AppState, feedback: Set = []) -> BasicAction { .init( id: "resolve\(cacheId)", appearance: .resolve(isOn: resolved), callback: api.canInteract(appState: appState) ? { @MainActor in self.toggleResolved(feedback: feedback) } : nil ) } func contextualBanAction(appState: AppState) -> BasicAction? { guard let myPerson = api.myPerson, let myPersonModerates = myPerson.moderates else { return nil } if let community = target.community, let creator = target.creator.value, myPersonModerates(.id(community.id)) { return creator.banFromCommunityAction(appState: appState, community: community) } return target.creator.value?.banFromInstanceAction(appState: appState) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ReportableProviding+Extensions.swift ================================================ // // ReportableProviding+Extensions.swift // Mlem // // Created by Sjmarf on 12/08/2024. // import MlemMiddleware extension ReportableProviding { @MainActor func showReportSheet(communityContext: Community? = nil) { NavigationModel.main.openSheet(.report(self, community: communityContext)) } func reportAction(appState: AppState, communityContext: Community? = nil) -> BasicAction { .init( id: "report\(uid)", appearance: .report(), callback: api.canInteract(appState: appState) ? { @MainActor in self.showReportSheet(communityContext: communityContext) } : nil ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/ScoringOperation+Extensions.swift ================================================ // // ScoringOperation+Extensions.swift // Mlem // // Created by Sjmarf on 2024-12-18. // import Icons import MlemMiddleware import SwiftUI import Theming extension ScoringOperation { var systemImage: String { switch self { case .none: Icons.resetVoteSquare case .upvote: Icons.upvoteSquare case .downvote: Icons.downvoteSquare } } var icon: Icon { switch self { case .none: .lemmy.removeUpvote case .upvote: .lemmy.addUpvote case .downvote: .lemmy.addDownvote } } var color: ThemedColor { switch self { case .none: .themedSecondary case .upvote: .themedUpvote case .downvote: .themedDownvote } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/SelectableContentProviding+Extensions.swift ================================================ // // SelectableContentProviding+Extensions.swift // Mlem // // Created by Sjmarf on 02/07/2024. // import MlemMiddleware extension SelectableContentProviding { @MainActor func showTextSelectionSheet() { NavigationModel.main.openSheet(.selectText(selectableContent ?? "")) } func selectTextAction() -> BasicAction { .init( id: "selectText\(actorId.description)", appearance: .selectText(), callback: selectableContent == nil ? nil : showTextSelectionSheet ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/Sharable+Extensions.swift ================================================ // // Sharable+Extensions.swift // Mlem // // Created by Sjmarf on 2025-03-09. // import Foundation import MlemMiddleware import SwiftUI extension Sharable { var lemmyverseUrl: URL? { (URL(string: "https://lemmyverse.link/")? .appendingPathComponent(actorId.host) .appendingPathComponent(actorId.url.path())) } func shareAction(navigation: NavigationLayer?) -> BasicAction { .init(id: "share\(actorId)", appearance: .share(), callback: { let url: URL? = switch Settings.get(\.links_shareMode) { case .myInstance: self.url() case .originalInstance: self.actorId.url case .lemmyverse: self.lemmyverseUrl case .askEveryTime: nil } if let url, let navigation { navigation.model?.shareInfo = .init(url: url, actions: self.shareSheetActions()) } else { navigation?.openSheet(.shareInstancePicker(self)) } }) } func shareSheetActions() -> [BasicAction] { var shareActions: [BasicAction] = [sendLinkInPrivateMessageAction()] if let post = self as? Post { shareActions.prepend(post.crossPostAction()) } return shareActions } func sendLinkInPrivateMessageAction() -> BasicAction { .init( id: "sendLinkInPrivateMessage\(actorId)", appearance: .init( label: "Send to Lemmy User", color: .themedAccent, icon: Icons.personCircle ), callback: { NavigationModel.main.openSheet(.personPicker(callback: { person, navigation in navigation.push( .messageFeed(person, messageContent: String(describing: self.actorId), focusTextField: true) ) })) } ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/SiteSoftware+Extensions.swift ================================================ // // SiteSoftware+Extensions.swift // Mlem // // Created by Sjmarf on 2025-06-14. // import Foundation import MlemBackend import MlemMiddleware extension SiteSoftware { init(from software: InstanceSummarySoftware) { let type: SiteSoftwareType = switch software.type { case .lemmy: .lemmy case .pieFed: .pieFed } let version: SiteVersion = .init(software.version) self.init(type: type, version: version) } var label: String { "\(String(localized: type.label)) \(version)" } } extension SiteSoftwareType { var label: LocalizedStringResource { switch self { case .lemmy: "Lemmy" case .pieFed: "PieFed" } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/SiteSoftwareType+Extensions.swift ================================================ // // SiteSoftwareType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-07-15. // import Foundation import MlemMiddleware extension SiteSoftwareType { var minimumSupportedVersion: SiteVersion { switch self { case .lemmy: .init("0.19.0") case .pieFed: .init("1.0.0") } } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/UnreadCount+Extensions.swift ================================================ // // UnreadCount+Extensions.swift // Mlem // // Created by Sjmarf on 05/07/2024. // import MlemMiddleware extension UnreadCount { var badgeLabel: Int? { let total = Settings.get(\.tab_inbox_badgeIncludedTypes).reduce(0) { $0 + self[$1] } return total <= 0 ? nil : total } } ================================================ FILE: Mlem/App/Utility/Extensions/Content Models/VotesModel+Extensions.swift ================================================ // // VotesModel+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-06-17. // import Foundation import MlemMiddleware import SwiftUI import Theming extension VotesModel { var iconName: String { switch myVote { case .upvote: Icons.upvoteSquareFill case .downvote: Icons.downvoteSquareFill case .none: Icons.upvoteSquare } } var iconColor: ThemedColor { switch myVote { case .upvote: .themedUpvote case .downvote: .themedDownvote case .none: .themedSecondary } } } ================================================ FILE: Mlem/App/Utility/Extensions/Data+Extensions.swift ================================================ // // Data+Extensions.swift // Mlem // // Created by Eric Andrews on 2025-10-10. // import Foundation extension Data { func writeToTempFile(fileName: String) throws -> URL { let fileUrl = FileManager.default.temporaryDirectory.appending(path: fileName) if FileManager.default.fileExists(atPath: fileUrl.absoluteString) { try FileManager.default.removeItem(at: fileUrl) } try write(to: fileUrl) return fileUrl } } ================================================ FILE: Mlem/App/Utility/Extensions/Date+Extensions.swift ================================================ // // Date+Extensions.swift // Mlem // // Created by Jake Shirley on 6/22/23. // import SwiftUI extension Date { /// Forges a localized `String` with inside the computed elapsed time between `self` and another `Date`. /// Uses a `RelativeDateTimeFormatter` and a given units style. /// /// For example, if the `self` is date 04/11/2023 and `date` is 15/05/2025, will return "1 year ago" (localized). /// /// - Parameters: /// - date: The date to compare with `self`, by default `Date.now` /// - unitsStyle: The style of the string to forge, by default `RelativeDateTimeFormatter.UnitsStyle.full` /// - Returns String: The localized string based. public func getRelativeTime(date: Date = .now, unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) -> String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = unitsStyle return formatter.localizedString(for: self, relativeTo: date) } /// Returns the current `Date` as a shorter version in `String`. /// For example if the date in the 5th of October 2023, returns "5/10/2023". /// Uses the current locale to let the `DateFormatter` apply the suitable date format depending to the user needs. public var dateString: String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .none dateFormatter.locale = Locale.current return dateFormatter.string(from: self) } func getShortRelativeTime(date: Date = .now, unitsStyle: DateComponentsFormatter.UnitsStyle = .abbreviated) -> String { let formatter = DateComponentsFormatter() formatter.unitsStyle = unitsStyle formatter.maximumUnitCount = 1 let interval = date.timeIntervalSince(self) if interval < 1 { return String(localized: "Now") } let components = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: self, to: date ).roundingDownToMostSignificantComponent() let value = formatter.string(from: components) return value ?? String(localized: "Unknown") } var isAnniversaryToday: Bool { let calendar = Calendar.current let date = calendar.dateComponents([.month, .day, .year], from: self) let current = calendar.dateComponents([.month, .day, .year], from: .now) return date.month == current.month && date.day == current.day && date.year != current.year } // https://stackoverflow.com/a/48652058/17629371 func messagesRelativeDate() -> String { let dateFormatter = DateFormatter() let calendar = Calendar(identifier: .gregorian) dateFormatter.doesRelativeDateFormatting = true if calendar.isDateInToday(self) { dateFormatter.timeStyle = .short dateFormatter.dateStyle = .medium } else if calendar.isDateInYesterday(self) { dateFormatter.timeStyle = .short dateFormatter.dateStyle = .medium } else if calendar.compare(Date(), to: self, toGranularity: .weekOfYear) == .orderedSame { let weekday = calendar.dateComponents([.weekday], from: self).weekday ?? 0 return dateFormatter.weekdaySymbols[weekday - 1] } else { dateFormatter.timeStyle = .none dateFormatter.dateStyle = .short } return dateFormatter.string(from: self) } } ================================================ FILE: Mlem/App/Utility/Extensions/DateComponents+Extensions.swift ================================================ // // DateComponents+Extensions.swift // Mlem // // Created by Sjmarf on 2025-04-22. // import Foundation extension DateComponents { // This is used to fix #1988 func roundingDownToMostSignificantComponent() -> DateComponents { if let year, year >= 1 { return .init(year: year) } if let month, month >= 1 { return .init(month: month) } if let day, day >= 1 { return .init(day: day) } if let hour, hour >= 1 { return .init(hour: hour) } if let minute, minute >= 1 { return .init(minute: minute) } return self } } ================================================ FILE: Mlem/App/Utility/Extensions/EnvironmentValues+Extensions.swift ================================================ // // EnvironmentValues+Extensions.swift // Mlem // // Created by Sjmarf on 19/09/2024. // import Haptics import MlemMiddleware import SwiftUI extension EnvironmentValues { @Entry var postContext: Post? @Entry var commentContext: Comment? @Entry var communityContext: Community? @Entry var reportContext: Report? @Entry var feedContext: FeedContext? @Entry var feedLoader: (any FeedLoading)? @Entry var parentFrameWidth: CGFloat = .zero @Entry var isRootView: Bool = false @Entry var scrollProxy: ScrollViewProxy? @Entry var exposeRemovedContent: Bool = false @Entry var isContextMenu: Bool = false var appState: AppState { if let appState = self[AppState.self] { return appState } else { assertionFailure() return .main } } var hapticManager: HapticManager { if let hapticManager = self[HapticManager.self] { return hapticManager } else { assertionFailure() return .main } } var popupModel: PopupAnchorModel? { self[PopupAnchorModel.self] } var toastModel: ToastModel? { self[ToastModel.self] } var navigation: NavigationLayer? { self[NavigationLayer.self] } var commentTreeTracker: CommentTreeTracker? { self[CommentTreeTracker.self] } } struct RootLayer { let layer: NavigationLayer } ================================================ FILE: Mlem/App/Utility/Extensions/FederationMode+Extensions.swift ================================================ // // FederationMode+Extensions.swift // Mlem // // Created by Sjmarf on 2025-11-30. // import Foundation import Theming import MlemMiddleware extension FederationMode { var label: LocalizedStringResource { switch self { case .all: "Yes" case .local: "Local Only" case .disable: "No" } } var color: ThemedColor { switch self { case .all: .themedPositive case .local: .themedWarning case .disable: .themedNegative } } } ================================================ FILE: Mlem/App/Utility/Extensions/GetContentFilter+Extensions.swift ================================================ // // GetContentFilter+Extensions.swift // Mlem // // Created by Sjmarf on 2025-10-29. // import Foundation import MlemMiddleware extension GetContentFilter { var label: LocalizedStringResource { switch self { case .saved: "Saved" case .upvoted: "Upvoted" case .downvoted: "Downvoted" } } } ================================================ FILE: Mlem/App/Utility/Extensions/HapticLevel+Extensions.swift ================================================ // // HapticTier+Extensions.swift // Mlem // // Created by Sjmarf on 2025-05-29. // import Foundation import Haptics extension HapticTier { var label: LocalizedStringResource { switch self { case .low: "Low" case .high: "High" } } } ================================================ FILE: Mlem/App/Utility/Extensions/InstanceSummarySoftware+Extensions.swift ================================================ // // InstanceSummarySoftware+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-03-27. // import MlemMiddleware import MlemBackend extension InstanceSummarySoftware { init(from software: SiteSoftware) { let type: InstanceSummarySoftwareType = switch software.type { case .lemmy: .lemmy case .pieFed: .pieFed } self.init( type: type, version: software.version.description ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Int+Extensions.swift ================================================ // // Int+Extensions.swift // Mlem // // Created by Sjmarf on 13/04/2024. // import Foundation extension Int { var abbreviated: String { if self >= 10_000_000 { return "\(Int(floor(Double(self) / 1_000_000)))M" } if self >= 1_000_000 { return "\(Double(floor(Double(self) / 100_000) / 10))M" } if self >= 10000 { return "\(Int(floor(Double(self) / 1000)))K" } if self >= 1000 { return "\(Double(floor(Double(self) / 100) / 10))K" } return String(self) } } ================================================ FILE: Mlem/App/Utility/Extensions/ListingType+Extensions.swift ================================================ // // ApiListingType+Extensions.swift // Mlem // // Created by Sjmarf on 29/07/2024. // import Foundation import MlemMiddleware extension ListingType { var label: LocalizedStringResource { switch self { case .all: "All" case .local: "Local" case .subscribed: "Subscribed" case .moderated: "Moderated" case .popular: "Popular" case .suggested: "Suggested" } } static var guestCases: [ListingType] { [.all, .local, .popular] } static var userCases: [ListingType] { [.all, .local, .popular, .suggested, .subscribed] } static var moderatorCases: [ListingType] { allCases } static func cases(for accountType: AccountType, api: ApiClient) -> [Self] { let cases = switch accountType { case .guest: guestCases case .user: userCases case .moderator, .admin: moderatorCases } return cases.filter { api.supports(.listingType($0), defaultValue: false) } } var description: FeedDescription { switch self { case .all: .all case .local: .local case .subscribed: .subscribed case .moderated: .moderated case .popular: .popular case .suggested: .suggested } } var feedContext: FeedContext { switch self { case .all: .all case .local: .local case .subscribed: .subscribed case .moderated: .moderated case .popular: .popular case .suggested: .suggested } } } ================================================ FILE: Mlem/App/Utility/Extensions/MarkdownConfiguration+Extensions.swift ================================================ // // MarkdownConfiguration+Extensions.swift // Mlem // // Created by Sjmarf on 30/05/2024. // import LemmyMarkdownUI import MlemMiddleware import Nuke import Rest import SwiftUI import Theming enum MarkdownConfigurationType { case `default`, defaultBlurred, dimmed, caption, inverted, removedContent } extension MarkdownConfiguration { init(type: MarkdownConfigurationType, palette: Palette) { self = switch type { case .default: .default(palette: palette) case .defaultBlurred: .defaultBlurred(palette: palette) case .dimmed: .dimmed(palette: palette) case .caption: .caption(palette: palette) case .inverted: .inverted(palette: palette) case .removedContent: .removedContent(palette: palette) } } static func `default`(palette: Palette) -> MarkdownConfiguration { let currentPaletteOption = Settings.get(\.appearance_palette) let enableSyntaxHighlighting = ![.solarized, .monochrome].contains(currentPaletteOption) return .init( inlineImageLoader: loadInlineImage, imageBlockView: { imageView($0, shouldBlur: false) }, wrapCodeBlockLines: Settings.get(\.markdown_wrapCodeBlockLines), spoilerLabel: .init(localized: "Spoiler"), tableLabel: .init(localized: "Table"), censorLabel: .init(localized: "Censored"), primaryColor: palette.label.primary, secondaryColor: palette.label.secondary, codeBackgroundColor: palette.groupedBackground.tertiary, censorColor: palette.warning, codeFontScaleFactor: 0.9, enableSyntaxHighlighting: enableSyntaxHighlighting ) } static func defaultBlurred(palette: Palette) -> MarkdownConfiguration { var config = Self.default(palette: palette) config.imageBlockView = { imageView($0, shouldBlur: true) } return config } static func dimmed(palette: Palette) -> MarkdownConfiguration { var config = Self.default(palette: palette) // Don't load any images; they will remain as placeholders config.imagePresentationMode = .inline config.inlineImageLoader = { _ in } config.primaryColor = palette.label.secondary config.secondaryColor = palette.label.tertiary return config } static func caption(palette: Palette) -> MarkdownConfiguration { var config = Self.default(palette: palette) config.font = .preferredFont(forTextStyle: .caption1) return config } static func inverted(palette: Palette) -> MarkdownConfiguration { var config = Self.default(palette: palette) config.primaryColor = palette.contrastingLabel config.secondaryColor = palette.contrastingLabel.opacity(0.8) config.spoilerHeaderBackgroundColor = palette.contrastingLabel.opacity(0.1) config.spoilerOutlineColor = palette.contrastingLabel.opacity(0.5) config.codeBackgroundColor = palette.contrastingLabel.opacity(0.1) return config } static func removedContent(palette: Palette) -> MarkdownConfiguration { var config = Self.default(palette: palette) config.primaryColor = palette.negative config.secondaryColor = palette.negative.opacity(0.8) config.spoilerHeaderBackgroundColor = palette.negative.opacity(0.1) config.spoilerOutlineColor = palette.negative.opacity(0.5) config.codeBackgroundColor = palette.negative.opacity(0.1) return config } } private func imageView(_ image: MarkdownImage, shouldBlur: Bool) -> AnyView { if image.url.absoluteString == "https://ko-fi.com/img/githubbutton_sm.svg" { return AnyView(ShieldsBadgeView(label: "KoFi", message: nil, link: image.parentLink)) } switch image.url.host() { case "img.shields.io": return AnyView(ShieldsBadgeView(shieldsUrl: image.url, link: image.parentLink)) case "fediseer.com": return AnyView(ShieldsBadgeView(label: "Fediseer", message: nil, link: image.parentLink)) case "lemmy-status.org": return AnyView(ShieldsBadgeView(label: .init(localized: "Uptime"), message: nil, link: image.parentLink)) default: return AnyView( MediaView.largeImage(url: image.url, shouldBlur: shouldBlur) ) } } private func loadInlineImage(inlineImage: MarkdownImage) async { guard inlineImage.image == nil else { return } let urlRequest = mlemUrlRequest(url: inlineImage.url) let imageTask = ImagePipeline.shared.imageTask(with: .init(urlRequest: urlRequest)) guard let image: UIImage = try? await imageTask.image else { return } let height = inlineImage.fontSize let width = image.size.width * (height / image.size.height) Task { @MainActor in let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) let newImage = renderer.image { _ in image.draw(in: CGRect(x: 0, y: 0, width: width, height: height)) } inlineImage.image = Image(uiImage: newImage) } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/ActorIdentifier+Mock.swift ================================================ // // ActorIdentifier+Mock.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation import MlemMiddleware private let hosts = [ "lemm.ee", "lemmy.world", "sh.itjust.works", "sopuli.xyz", "programming.dev", "lemmy.zip" ] extension ActorIdentifier { static func mockPerson(name: String) -> ActorIdentifier { // Poor man's hash - using `hashValue` directly gives a different value each time the program is executed let hashValue = name.unicodeScalars.reduce(0) { $0 + Int($1.value) } var generator = SeededRandomNumberGenerator(seed: hashValue) let value = Int.random(in: 0 ..< hosts.count, using: &generator) let host = hosts[value] return .init(url: URL(string: "https://\(host)/u/\(name)")!)! } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/Comment+Mock.swift ================================================ // // Comment+Mock.swift // Mlem // // Created by Sjmarf on 2025-03-15. // // TODO: updated mocks // import Foundation // import MlemMiddleware // // extension Comment1 { // static func mock( // _ type: CommentMockType, // api: MockApiClient = .mock // ) -> Comment1 { // .mock( // api: api, // id: type.id, // content: type.content, // removed: false, // created: type.created, // updated: nil, // deleted: false, // creatorId: type.creator.id, // postId: type.post.id, // parentCommentIds: type.parentComments.map(\.id), // distinguished: false, // languageId: 0 // ) // } // } // // extension Comment2 { // static func mock( // _ type: CommentMockType, // api: MockApiClient = .mock // ) -> Comment2 { // .mock( // api: api, // comment1: .mock(type, api: api), // creator: .mock(type.creator, api: api), // post: .mock(type.post, api: api), // community: .mock(type.post.community, api: api), // votes: type.votes, // saved: false, // creatorIsModerator: false, // creatorIsAdmin: false, // bannedFromCommunity: false, // commentCount: type.commentCount // ) // } // } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/CommentMockType.swift ================================================ // // CommentMockType.swift // Mlem // // Created by Sjmarf on 2025-03-17. // import Foundation import MlemMiddleware enum CommentMockType { case generic var id: Int { switch self { case .generic: 0 } } var content: String { switch self { // swiftlint:disable:next line_length case .generic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." } } var created: Date { var generator = SeededRandomNumberGenerator(seed: id) let lowerBound = 60 * 60 * 1 // 1h let upperBound = 60 * 60 * 24 // 24h let timeInterval = TimeInterval(Int.random(in: lowerBound ... upperBound, using: &generator)) return .now.addingTimeInterval(-timeInterval) } var votes: VotesModel { var generator = SeededRandomNumberGenerator(seed: id) let score = Int.random(in: 100 ... 1000, using: &generator) return .init(upvotes: Int(Double(score) * 0.8), downvotes: Int(Double(score) * 0.2), myVote: .none) } var post: PostMockType { switch self { case .generic: .generic } } var creator: PersonMockType { switch self { case .generic: .generic } } var parentComments: [CommentMockType] { switch self { case .generic: [] } } var commentCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 0 ... 50, using: &generator) } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/Community+Mock.swift ================================================ // // Community+Mock.swift // Mlem // // Created by Sjmarf on 2025-02-03. // import Foundation import MlemMiddleware // TODO: updated mocks // extension Community1 { // static func mock( // _ type: CommunityMockType, // api: MockApiClient = .mock // ) -> Community1 { // .mock( // api: api, // actorId: type.actorId, // id: type.id, // name: type.name, // created: type.created, // instanceId: 0, // updated: nil, // displayName: type.displayName, // description: type.description, // removed: false, // deleted: false, // nsfw: false, // avatar: type.avatar, // banner: type.banner, // hidden: false, // onlyModeratorsCanPost: false, // blocked: false // ) // } // } // // extension Community2 { // static func mock( // _ type: CommunityMockType, // api: MockApiClient = .mock // ) -> Community2 { // .mock( // community1: .mock(type, api: api), // subscriberCount: type.subscriberCount, // localSubscriberCount: type.localSubscriberCount, // subscribed: false, // subscriptionPending: false, // postCount: type.postCount, // commentCount: type.commentCount, // activeUserCount: .init(sixMonths: 0, month: 0, week: 0, day: 0), // bannedFromCommunity: nil // ) // } // } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/CommunityMockType+Realistic.swift ================================================ // // CommunityMockType+Realistic.swift // Mlem // // Created by Sjmarf on 2025-02-04. // import Foundation extension CommunityMockType { // These values are localized for use in marketing material (e.g. preview images on the App Store) enum Realistic: CaseIterable, Identifiable { case news case pics case meIrl case technology case nature case showerThoughts var id: Int { switch self { case .news: 0 case .pics: 1 case .meIrl: 2 case .technology: 3 case .nature: 4 case .showerThoughts: 5 } } var name: String { switch self { case .news: .init( localized: "community.1.name", defaultValue: "news", table: "PreviewLocalizable" ) case .pics: .init( localized: "community.2.name", defaultValue: "pics", table: "PreviewLocalizable" ) case .meIrl: .init( localized: "community.3.name", defaultValue: "me_irl", table: "PreviewLocalizable" ) case .technology: .init( localized: "community.4.name", defaultValue: "technology", table: "PreviewLocalizable" ) case .nature: .init( localized: "community.5.name", defaultValue: "nature", table: "PreviewLocalizable" ) case .showerThoughts: .init( localized: "community.6.name", defaultValue: "showerthoughts", table: "PreviewLocalizable" ) } } var displayName: String { switch self { case .news: .init( localized: "community.1.displayName", defaultValue: "World News", table: "PreviewLocalizable" ) case .pics: .init( localized: "community.2.displayName", defaultValue: "Pics", table: "PreviewLocalizable" ) case .meIrl: .init( localized: "community.3.displayName", defaultValue: "me_irl", table: "PreviewLocalizable" ) case .technology: .init( localized: "community.4.displayName", defaultValue: "Technology", table: "PreviewLocalizable" ) case .nature: .init( localized: "community.5.displayName", defaultValue: "Nature", table: "PreviewLocalizable" ) case .showerThoughts: .init( localized: "community.6.displayName", defaultValue: "Nature", table: "PreviewLocalizable" ) } } var description: String? { switch self { case .news: nil case .pics: nil case .meIrl: nil case .technology: nil case .nature: nil case .showerThoughts: nil } } var avatar: URL? { switch self { case .news: .init(string: "mlempreview://image/pfp.news") case .pics: .init(string: "mlempreview://image/pfp.balloon") case .meIrl: .init(string: "mlempreview://image/pfp.person") case .technology: .init(string: "mlempreview://image/pfp.circuit") case .nature: .init(string: "mlempreview://image/pfp.lakeside") case .showerThoughts: .init(string: "mlempreview://image/pfp.shower") } } var banner: URL? { switch self { case .news: nil case .pics: nil case .meIrl: nil case .technology: nil case .nature: nil case .showerThoughts: .init(string: "mlempreview://image.droplets") } } } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/CommunityMockType.swift ================================================ // // CommunityMockType.swift // Mlem // // Created by Sjmarf on 2025-02-03. // import Foundation import MlemMiddleware enum CommunityMockType { case realistic(Realistic) case generic var id: Int { switch self { case let .realistic(value): 100 + value.id case .generic: 0 } } var actorId: ActorIdentifier { switch self { case let .realistic(value): .mockPerson(name: value.name) case .generic: .init(url: URL(string: "https://example.com/c/\(name)")!)! } } var name: String { switch self { case let .realistic(value): value.name case .generic: "community" } } var displayName: String { switch self { case let .realistic(value): value.displayName case .generic: "Community" } } var description: String? { switch self { case let .realistic(value): value.description case .generic: "ABC" } } var avatar: URL? { switch self { case let .realistic(value): value.avatar case .generic: nil } } var banner: URL? { switch self { case let .realistic(value): value.banner case .generic: nil } } var created: Date { var generator = SeededRandomNumberGenerator(seed: id) let lowerBound = 60 * 60 * 24 * 30 * 3 // 3mo let upperBound = 60 * 60 * 24 * 365 * 2 // 2y let timeInterval = TimeInterval(Int.random(in: lowerBound ... upperBound, using: &generator)) return .now.addingTimeInterval(-timeInterval) } var subscriberCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 500 ... 20000, using: &generator) } var localSubscriberCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 100 ... 1000, using: &generator) } var postCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 2000 ... 10000, using: &generator) } var commentCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 5000 ... 25000, using: &generator) } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/MockApiClient+Realistic.swift ================================================ // // MockApiClient+Realistic.swift // Mlem // // Created by Sjmarf on 2025-02-23. // import Foundation import MlemMiddleware // TODO: updated mocks // extension MockApiClient { // static let realistic: MockApiClient = { // let client = MockApiClient() // client.setPosts(PostMockType.Realistic.allCases.map { Post2.mock(.realistic($0), api: client) }) // client.setCommunities(CommunityMockType.Realistic.allCases.map { Community2.mock(.realistic($0), api: client) }) // client.setPeople(PersonMockType.Realistic.allCases.map { Person2.mock(.realistic($0), api: client) }) // return client // }() // } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/Person+Mock.swift ================================================ // // Person1+Mock.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation import MlemMiddleware // TODO: updated mocks // extension Person1 { // static func mock( // _ type: PersonMockType, // api: MockApiClient = .mock // ) -> Person1 { // .mock( // api: api, // actorId: type.actorId, // id: type.id, // name: type.name, // created: type.created, // instanceId: 0, // updated: nil, // displayName: type.displayName, // description: type.description, // matrixUserId: type.matrixUserId, // avatar: type.avatar, // banner: type.banner, // deleted: false, // isBot: type.isBot, // instanceBan: .notBanned, // blocked: false // ) // } // } // // extension Person2 { // static func mock( // _ type: PersonMockType, // api: MockApiClient = .mock, // isAdmin: Bool = false // ) -> Person2 { // .mock( // person1: .mock(type, api: api), // postCount: type.postCount, // commentCount: type.commentCount, // isAdmin: isAdmin // ) // } // } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/PersonMockType+Realistic.swift ================================================ // // PersonMockType+Realistic.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation extension PersonMockType { // These values are localized for use in marketing material (e.g. preview images on the App Store) enum Realistic: CaseIterable, Identifiable { case flowerTail case commanderGoose case billyDaFish case grt38 case anteSocial45 var id: Int { switch self { case .flowerTail: 0 case .commanderGoose: 1 case .billyDaFish: 2 case .grt38: 3 case .anteSocial45: 4 } } var name: String { switch self { case .flowerTail: .init( localized: "person.1.name", defaultValue: "flowertail", table: "PreviewLocalizable" ) case .commanderGoose: .init( localized: "person.2.name", defaultValue: "CommanderGoose", table: "PreviewLocalizable" ) case .billyDaFish: .init( localized: "person.3.name", defaultValue: "BillyDAFISH", table: "PreviewLocalizable" ) case .grt38: .init( localized: "person.4.name", defaultValue: "Grt38", table: "PreviewLocalizable" ) case .anteSocial45: .init( localized: "person.5.name", defaultValue: "ante_social_58", table: "PreviewLocalizable" ) } } var displayName: String { switch self { case .flowerTail: .init( localized: "person.1.displayName", defaultValue: "Flowertail", table: "PreviewLocalizable" ) case .commanderGoose: .init( localized: "person.2.displayName", defaultValue: "Commander Goose", table: "PreviewLocalizable" ) case .billyDaFish: .init( localized: "person.3.displayName", defaultValue: "BillyDAFISH", table: "PreviewLocalizable" ) case .grt38: .init( localized: "person.4.displayName", defaultValue: "Grt38", table: "PreviewLocalizable" ) case .anteSocial45: .init( localized: "person.5.displayName", defaultValue: "AnteSocial", table: "PreviewLocalizable" ) } } var description: String? { switch self { case .flowerTail: nil case .commanderGoose: .init( localized: "person.2.description", defaultValue: "HONK", table: "PreviewLocalizable" ) case .billyDaFish: nil case .grt38: nil case .anteSocial45: nil } } var avatar: URL? { switch self { case .flowerTail: .init(string: "mlempreview://image/pfp.flowers") case .commanderGoose: .init(string: "mlempreview://image/pfp.goose") case .billyDaFish: .init(string: "mlempreview://image/pfp.fish") case .grt38: .init(string: "mlempreview://image/pfp.firework") case .anteSocial45: nil } } var banner: URL? { switch self { case .flowerTail: nil case .commanderGoose: nil case .billyDaFish: nil case .grt38: nil case .anteSocial45: nil } } var matrixUserId: String? { switch self { case .flowerTail: nil case .commanderGoose: nil case .billyDaFish: nil case .grt38: nil case .anteSocial45: nil } } var isBot: Bool { switch self { default: false } } } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/PersonMockType.swift ================================================ // // PersonMockType.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation import MlemMiddleware enum PersonMockType: Identifiable { case realistic(Realistic) case generic var id: Int { switch self { case let .realistic(value): 100 + value.id case .generic: 0 } } var actorId: ActorIdentifier { switch self { case let .realistic(value): .mockPerson(name: value.name) case .generic: .init(url: URL(string: "https://example.com/u/\(name)")!)! } } var name: String { switch self { case let .realistic(value): value.name case .generic: "user" } } var displayName: String { switch self { case let .realistic(value): value.displayName case .generic: "User" } } var description: String? { switch self { case let .realistic(value): value.description case .generic: "ABC" } } var avatar: URL? { switch self { case let .realistic(value): value.avatar case .generic: nil } } var banner: URL? { switch self { case let .realistic(value): value.banner case .generic: nil } } var created: Date { var generator = SeededRandomNumberGenerator(seed: id) let lowerBound = 60 * 5 // 5h let upperBound = 60 * 60 * 24 * 365 * 2 // 2y let timeInterval = TimeInterval(Int.random(in: lowerBound ... upperBound, using: &generator)) return .now.addingTimeInterval(-timeInterval) } var matrixUserId: String? { switch self { case let .realistic(value): value.matrixUserId case .generic: nil } } var isBot: Bool { switch self { case let .realistic(value): value.isBot case .generic: false } } var postCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 0 ... 100, using: &generator) } var commentCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 0 ... 700, using: &generator) } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/Post+Mock.swift ================================================ // // Post1+Mock.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Foundation import MlemMiddleware // TODO: updated mocks // extension Post1 { // static func mock( // _ type: PostMockType, // api: MockApiClient = .mock, // deleted: Bool = false, // pinnedCommunity: Bool = false, // pinnedInstance: Bool = false, // locked: Bool = false, // nsfw: Bool = false, // removed: Bool = false // ) -> Post1 { // .mock( // api: api, // id: type.id, // creatorId: 0, // communityId: 0, // created: type.created, // title: type.title, // content: type.content, // linkUrl: type.linkUrl, // deleted: deleted, // embed: nil, // pinnedCommunity: pinnedCommunity, // pinnedInstance: pinnedInstance, // locked: locked, // nsfw: nsfw, // removed: removed, // thumbnailUrl: nil, // updated: nil, // languageId: 0, // altText: nil // ) // } // } // // extension Post2 { // static func mock( // _ type: PostMockType, // api: MockApiClient = .mock // ) -> Post2 { // .mock( // api: api, // post1: .mock(type, api: api), // creator: .mock(type.creator, api: api), // community: .mock(type.community, api: api), // votes: type.votes, // creatorIsModerator: false, // creatorIsAdmin: false, // creatorBannedFromCommunity: false, // commentCount: type.commentCount, // unreadCommentCount: 0, // saved: false, // read: false, // hidden: false // ) // } // } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/PostMockType+Realistic.swift ================================================ // // PostMockType+Realistic.swift // Mlem // // Created by Sjmarf on 2025-02-23. // import Foundation extension PostMockType { enum Realistic: CaseIterable, Identifiable { case yorkshireDales case meguroRiver case showerThoughtPizza var id: Int { switch self { case .yorkshireDales: 0 case .meguroRiver: 1 case .showerThoughtPizza: 2 } } var title: String { switch self { case .yorkshireDales: .init( localized: "post.1.title", defaultValue: "The Yorkshire Dales, England", table: "PreviewLocalizable" ) case .meguroRiver: .init( localized: "post.2.title", defaultValue: "Meguro River, Matsuno, Japan", table: "PreviewLocalizable" ) case .showerThoughtPizza: .init( localized: "post.3.title", // swiftlint:disable:next line_length defaultValue: "During a nuclear explosion, there is a certain distance of the radius where all the frozen supermarket pizzas are cooked to perfection.", table: "PreviewLocalizable" ) } } var content: String? { switch self { case .yorkshireDales: nil case .meguroRiver: nil case .showerThoughtPizza: nil } } var linkUrl: URL? { switch self { case .yorkshireDales: .init(string: "mlempreview://image/image.yorkshire_dales") case .meguroRiver: .init(string: "mlempreview://image/image.meguro_river") case .showerThoughtPizza: nil } } var creator: PersonMockType.Realistic { switch self { case .yorkshireDales: .commanderGoose case .meguroRiver: .anteSocial45 case .showerThoughtPizza: .billyDaFish } } var community: CommunityMockType.Realistic { switch self { case .yorkshireDales: .nature case .meguroRiver: .pics case .showerThoughtPizza: .showerThoughts } } } } ================================================ FILE: Mlem/App/Utility/Extensions/MlemMiddleware Mock/PostMockType.swift ================================================ // // PostMockType.swift // Mlem // // Created by Sjmarf on 2025-02-04. // import Foundation import MlemMiddleware // swiftlint:disable line_length enum PostMockType { case generic case realistic(Realistic) var id: Int { switch self { case .generic: 0 case let .realistic(value): 100 + value.id } } var title: String { switch self { case .generic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." case let .realistic(value): value.title } } var content: String? { switch self { case .generic: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." case let .realistic(value): value.content } } var created: Date { var generator = SeededRandomNumberGenerator(seed: id) let lowerBound = 60 * 60 * 1 // 1h let upperBound = 60 * 60 * 24 // 24h let timeInterval = TimeInterval(Int.random(in: lowerBound ... upperBound, using: &generator)) return .now.addingTimeInterval(-timeInterval) } var votes: VotesModel { var generator = SeededRandomNumberGenerator(seed: id) let score = Int.random(in: 100 ... 1000, using: &generator) return .init(upvotes: Int(Double(score) * 0.8), downvotes: Int(Double(score) * 0.2), myVote: .none) } var linkUrl: URL? { switch self { case .generic: nil case let .realistic(value): value.linkUrl } } var creator: PersonMockType { switch self { case .generic: .generic case let .realistic(value): .realistic(value.creator) } } var community: CommunityMockType { switch self { case .generic: .generic case let .realistic(value): .realistic(value.community) } } var commentCount: Int { var generator = SeededRandomNumberGenerator(seed: id) return Int.random(in: 0 ... 50, using: &generator) } } // swiftlint:enable line_length ================================================ FILE: Mlem/App/Utility/Extensions/PersonContentFeedLoader+Extensions.swift ================================================ // // SingleSourceMixedFeedLoader+Extensions.swift // Mlem // // Created by Sjmarf on 2025-10-29. // import MlemMiddleware extension SingleSourceMixedFeedLoader { func itemsForType(_ type: PersonContentType) -> [PersonContent] { switch type { case .all: items case .posts: posts case .comments: comments } } func loadingStateForType(_ type: PersonContentType) -> FeedLoadingState { switch type { case .all: loadingState case .posts: postLoadingState case .comments: commentLoadingState } } } ================================================ FILE: Mlem/App/Utility/Extensions/PostSortType+Extensions.swift ================================================ // // PostSortType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-02-28. // import Foundation import Icons import MlemMiddleware extension PostSortType { func label(timeRangeFormat: SortTimeRange.FormatStyle = .timescaleFull) -> String { switch self { case .active: .init(localized: "Active") case .hot: .init(localized: "Hot") case .new: .init(localized: "New") case .old: .init(localized: "Old") case let .top(timeRange): timeRange.label(name: "Top", prefix: "Top:", format: timeRangeFormat) case .mostComments: .init(localized: "Most Comments") case .newComments: .init(localized: "New Comments") case .controversial: .init(localized: "Controversial") case .scaled: .init(localized: "Scaled") } } var icon: Icon { switch self { case .active: .lemmy.activeSort case .hot: .lemmy.hotSort case .new: .lemmy.newSort case .old: .lemmy.oldSort case .mostComments: .lemmy.mostCommentsSort case .newComments: .lemmy.newCommentsSort case .controversial: .lemmy.controversialSort case .scaled: .lemmy.scaledSort case .top: .lemmy.topSort } } var explanation: LocalizedStringResource? { switch self { case .hot: "Ranks posts based on the post score and creation time." case .scaled: "Similar to Hot, but ranks posts from smaller communities higher." case .active: "Ranks posts based on the post score and the time since the last comment was created." default: nil } } } ================================================ FILE: Mlem/App/Utility/Extensions/PostType+Extensions.swift ================================================ // // PostType+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-06-03. // import MlemMiddleware extension PostType { var lineLimit: Int { switch self { case .text, .titleOnly: 4 case .media, .embedded, .link, .poll: 2 } } } ================================================ FILE: Mlem/App/Utility/Extensions/PrefetchingConfiguration+Extensions.swift ================================================ // // PrefetchingConfiguration+Extensions.swift // Mlem // // Created by Sjmarf on 10/08/2024. // import MlemMiddleware import Nuke extension PrefetchingConfiguration { static func forPostSize(_ postSize: PostSize) -> Self { .init( prefetcher: .init(pipeline: .shared, destination: .memoryCache, maxConcurrentRequestCount: 40), imageSize: .unlimited, fetchFavicons: Settings.get(\.privacy_showFavicons), embedLoops: Settings.get(\.links_embedLoops), avatarSize: postSize.avatarSize ) } } ================================================ FILE: Mlem/App/Utility/Extensions/QuickSwipeAction+Actions.swift ================================================ // // QuickSwipeAction+Actions.swift // Mlem // // Created by Sjmarf on 2025-11-18. // import Actions import Icons import QuickSwipes import SwiftUI private extension QuickSwipeAction { init?(label: ActionLabel, callback: @escaping () -> Void) { if label.visibility == .hidden { return nil } self.init( icon: label.icon, color: label.color, enabled: label.visibility == .enabled, confirmationPrompt: nil, callback: callback ) } } private struct QuickSwipesActionsViewModifier: ViewModifier { @Environment(\.self) var environment let leadingActions: [any Actions.Action] let trailingActions: [any Actions.Action] func body(content: Content) -> some View { content .quickSwipes(config) } var config: SwipeConfiguration { .init( leadingActions: leadingActions.compactMap(self.createAction), trailingActions: trailingActions.compactMap(self.createAction) ) } func createAction(_ action: any Actions.Action) -> QuickSwipeAction? { .init( label: action.createLabel(environment: environment), callback: { action.execute(environment: environment) } ) } } extension View { @ViewBuilder func quickSwipes(leading: [any Actions.Action], trailing: [any Actions.Action]) -> some View { modifier(QuickSwipesActionsViewModifier( leadingActions: leading, trailingActions: trailing )) } } ================================================ FILE: Mlem/App/Utility/Extensions/QuickSwipeAction+Extensions.swift ================================================ // // QuickSwipeAction+Extensions.swift // Mlem // // Created by Sjmarf on 2025-08-22. // import Icons import QuickSwipes extension QuickSwipeAction { init?(from action: any Action) { switch action { case let action as BasicAction: self.init(action: action) case let group as ActionGroup: self.init(group: group) default: assertionFailure() return nil } } private init(action: BasicAction) { self.init( icon: .init(from: action.appearance), color: action.appearance.color, enabled: action.callback != nil, confirmationPrompt: action.confirmationPrompt, callback: action.callback ?? {} ) } private init(group: ActionGroup) { self.init( icon: .init(from: group.appearance), color: group.appearance.color, enabled: true, alertTitle: group.prompt ?? "", choices: group.children.compactMap(QuickSwipeChoice.init) ) } } extension QuickSwipeChoice { init?(from action: any Action) { switch action { case let action as BasicAction: self.init( label: action.appearance.label, destructive: action.appearance.isDestructive, callback: action.callback ?? {} ) default: assertionFailure() return nil } } } // Temporary shim. Eventually the action system will use Icon rather than String and this can be removed private extension Icon { init(from appearance: ActionAppearance) { self = .custom { variant in switch variant { case .active: return appearance.swipeIcon2 case .inactive: return appearance.swipeIcon1 default: assertionFailure() return appearance.swipeIcon1 } } } } ================================================ FILE: Mlem/App/Utility/Extensions/RegistrationMode+Extensions.swift ================================================ // // ApiRegistrationMode+Extensions.swift // Mlem // // Created by Sjmarf on 08/08/2024. // import MlemMiddleware import SwiftUI import Theming extension RegistrationMode { var label: LocalizedStringResource { switch self { case .closed: "Closed" case .requiresApplication: "Requires Application" case .open: "Open" } } var color: ThemedColor { switch self { case .closed: .themedNegative case .requiresApplication: .themedCaution case .open: .themedPositive } } } ================================================ FILE: Mlem/App/Utility/Extensions/SearchSortType+Extensions.swift ================================================ // // SearchSortType+Extensions.swift // Mlem // // Created by Sjmarf on 2025-03-01. // import Foundation import Icons import MlemMiddleware extension SearchSortType { func label(timeRangeFormat: SortTimeRange.FormatStyle = .timescaleFull) -> String { switch self { case .new: .init(localized: "New") case .old: .init(localized: "Old") case let .top(timeRange): timeRange.label(name: "Top", prefix: "Top:", format: timeRangeFormat) } } var icon: Icon { switch self { case .new: .lemmy.newSort case .old: .lemmy.oldSort case .top: .lemmy.topSort } } } ================================================ FILE: Mlem/App/Utility/Extensions/Set+Extensions.swift ================================================ // // Set+Extensions.swift // Mlem // // Created by Sjmarf on 2025-01-17. // import Foundation // https://stackoverflow.com/a/65598711/17629371 extension Set: @retroactive RawRepresentable where Element: Codable { public init?(rawValue: String) { guard let data = rawValue.data(using: .utf8), let result = try? JSONDecoder().decode(Set.self, from: data) else { return nil } self = result } public var rawValue: String { guard let data = try? JSONEncoder().encode(self), let result = String(data: data, encoding: .utf8) else { return "[]" } return result } } ================================================ FILE: Mlem/App/Utility/Extensions/SortTimeRange+Extensions.swift ================================================ // // SortTimeRange+Extensions.swift // Mlem // // Created by Sjmarf on 2025-03-01. // import Foundation import MlemMiddleware extension SortTimeRange { enum FormatStyle { case topOnly case timescaleAbbreviated case timescaleFull case topAndTimescale } func label(name: LocalizedStringResource, prefix: LocalizedStringResource, format: FormatStyle) -> String { switch format { case .topOnly: String(localized: name) case .topAndTimescale: "\(String(localized: prefix)) \(label(abbreviateUnits: false))" case .timescaleAbbreviated: label(abbreviateUnits: true) case .timescaleFull: label(abbreviateUnits: false) } } private func label(abbreviateUnits: Bool) -> String { switch self { case let .limited(timeInterval): var seconds = Int(timeInterval) let dateComponents: DateComponents // Check if time range is exact number of weeks if seconds % (3600 * 24 * 7) == 0 { dateComponents = .init(weekOfMonth: seconds / (3600 * 24 * 7)) } else { // Convert a year to exactly 365 days let years = seconds / (3600 * 24 * 365) seconds %= (3600 * 24 * 365) // Convert a month to exactly 30 days let months = seconds / (3600 * 24 * 30) seconds %= (3600 * 24 * 30) dateComponents = .init(year: years, month: months, second: seconds) } if abbreviateUnits { return formatter(unitsStyle: .abbreviated).string(for: dateComponents) ?? "" } else { return formatter(unitsStyle: .full) .string(for: dateComponents)? .capitalized ?? "" } case .allTime: return abbreviateUnits ? .init(localized: "All") : .init(localized: "All Time") } } private func formatter(unitsStyle: DateComponentsFormatter.UnitsStyle) -> DateComponentsFormatter { let formatter = DateComponentsFormatter() formatter.unitsStyle = unitsStyle formatter.allowedUnits = [.year, .month, .weekOfMonth, .day, .hour, .minute, .second] return formatter } } ================================================ FILE: Mlem/App/Utility/Extensions/String+Extensions.swift ================================================ // // String+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-09-27. // extension String { var isMovieExtension: Bool { ["mp4", "m4v", "mov"].contains(self) } } ================================================ FILE: Mlem/App/Utility/Extensions/String+extension.swift ================================================ // // Software Name: Mlem // SPDX-FileCopyrightText: Copyright (c) Mlem Group // SPDX-License-Identifier: GPL-3.0 // // This software is distributed under the GNU General Public License v3.0 license, // the text of which is available at https://www.gnu.org/licenses/gpl-3.0-standalone.html // or see the "LICENSE" file for more details. // import Foundation extension String { /// Returns the localized result string using `self` as key. /// - Returns String: The conversion of `self` as `NSLocalizedString` func localized() -> String { let prefferedLocalization = Bundle.preferredLocalization guard let path = Bundle.main.path(forResource: prefferedLocalization, ofType: "lproj") else { return NSLocalizedString(self, bundle: Bundle.main, comment: "") } guard let languageBundle = Bundle(path: path) else { return NSLocalizedString(self, bundle: Bundle.main, comment: "") } return NSLocalizedString(self, tableName: nil, bundle: languageBundle, value: "", comment: "") } } ================================================ FILE: Mlem/App/Utility/Extensions/SwipeConfiguration+Extensions.swift ================================================ // // SwipeConfiguration+Extensions.swift // Mlem // // Created by Sjmarf on 2025-08-22. // import Foundation import QuickSwipes extension SwipeConfiguration { // Prevents ambiguous init declaration init() { self.init(leadingActions: [QuickSwipeAction](), trailingActions: [QuickSwipeAction]()) } init( leadingActions: [any Action] = [], trailingActions: [any Action] = [] ) { self.init( leadingActions: leadingActions.compactMap(QuickSwipeAction.init), trailingActions: trailingActions.compactMap(QuickSwipeAction.init) ) } init( @ActionBuilder leadingActions: () -> [any Action] = { [] }, @ActionBuilder trailingActions: () -> [any Action] = { [] } ) { self.init( leadingActions: leadingActions().compactMap(QuickSwipeAction.init), trailingActions: trailingActions().compactMap(QuickSwipeAction.init) ) } } ================================================ FILE: Mlem/App/Utility/Extensions/UIApplication+Extensions.swift ================================================ // // UIApplication+FirstKeyWindow.swift // Mlem // // Created by David Bureš on 18.05.2023. // import Foundation import UIKit extension UIApplication { var firstKeyWindow: UIWindow? { connectedScenes .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } .first? .keyWindow } } extension UIApplication { var topMostViewController: UIViewController? { UIApplication.shared.firstKeyWindow?.rootViewController?.topMostViewController() } } ================================================ FILE: Mlem/App/Utility/Extensions/UIDevice+Extensions.swift ================================================ // // UIDevice+Extensions.swift // Mlem // // Created by Sjmarf on 13/06/2024. // import UIKit extension UIDevice { static var isPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } static var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } static var isIos26: Bool { if #available(iOS 26, *) { true } else { false } } static var frameType: DeviceFrameType { if let simulatorModelIdentifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { let nameSimulator = simulatorModelIdentifier return .init(deviceName: nameSimulator) } var sysinfo = utsname() uname(&sysinfo) // ignore return value let name = String( bytes: Data( bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN) ), encoding: .ascii )!.trimmingCharacters(in: .controlCharacters) return .init(deviceName: name) } } enum DeviceFrameType { case noNotch, wideNotch, narrowNotch, dynamicIsland init(deviceName: String) { // The number in the device name is 1 higher than the commerical number. switch deviceName { case _ where deviceName.starts(with: /iPhone[1-9][6-9]/): // iPhone 15 and above self = .dynamicIsland case _ where deviceName.starts(with: "iPhone15"): // iPhone 14 if deviceName == "iPhone15,2" || deviceName == "iPhone15,3" { self = .dynamicIsland } else { self = .narrowNotch } case _ where deviceName.starts(with: "iPhone14"): // iPhone 13 self = .narrowNotch case _ where deviceName.starts(with: /iPhone1[1-3]/): // iPhone X - 12 self = .wideNotch default: self = .noNotch } } } ================================================ FILE: Mlem/App/Utility/Extensions/UIImage+Extensions.swift ================================================ // // UIImage+Extensions.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import CoreGraphics import UIKit import Media extension UIImage { var isPortrait: Bool { size.height > size.width } var isLandscape: Bool { size.width > size.height } var breadth: CGFloat { min(size.width, size.height) } var breadthSize: CGSize { .init(width: breadth, height: breadth) } var breadthRect: CGRect { .init(origin: .zero, size: breadthSize) } static let blank: UIImage = .init() func validSize() -> CGSize? { size == .zero ? nil : size } func boundedAspectRatio(bounds: CoreMediaView.AspectRatioBounds) -> CGSize { // sanity check: bounds do not conflict assert(bounds.boundsAreSane, "bounds are not sane") guard size != .zero else { return bounds.defaultSize } switch bounds { case let .bounded(vertical, horizontal): let aspectRatio = size.aspectRatio if let vertical, aspectRatio > vertical.aspectRatio { // if vertically bounded and taller than vertical bounds, clip to vertical bounds return vertical } if let horizontal, aspectRatio < horizontal.aspectRatio { // if horizontally bounded and wider than horizontal bounds, clip to horizontal bounds return horizontal } return size case let .absolute(size): // absolute: just return size return size } } var circleMasked: UIImage { let diameter = min(size.width, size.height) let isLandscape = size.width > size.height let xOffset = isLandscape ? (size.width - diameter) / 2 : 0 let yOffset = isLandscape ? 0 : (size.height - diameter) / 2 let imageSize = CGSize(width: diameter, height: diameter) return UIGraphicsImageRenderer(size: imageSize).image { _ in let ovalPath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: imageSize)) ovalPath.addClip() draw(at: CGPoint(x: -xOffset, y: -yOffset)) } } func circleBorder(color: UIColor, width: CGFloat) -> UIImage { let diameter = min(size.width, size.height) let isLandscape = size.width > size.height let xOffset = isLandscape ? (size.width - diameter) / 2 : 0 let yOffset = isLandscape ? 0 : (size.height - diameter) / 2 let imageSize = CGSize(width: diameter, height: diameter) return UIGraphicsImageRenderer(size: imageSize).image { _ in let ovalPath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: imageSize)) ovalPath.addClip() draw(at: CGPoint(x: -xOffset, y: -yOffset)) color.setStroke() ovalPath.lineWidth = width ovalPath.stroke() } } func resized(to newSize: CGSize) -> UIImage { let image = UIGraphicsImageRenderer(size: newSize).image { _ in draw(in: CGRect(origin: .zero, size: newSize)) } return image.withRenderingMode(renderingMode) } } ================================================ FILE: Mlem/App/Utility/Extensions/UITextView+Extensions.swift ================================================ // // UITextView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import UIKit extension UITextView { func wrapSelectionWithDelimiters(_ delimiter: String) { wrapSelectionWithDelimiters(leading: delimiter, trailing: delimiter) } func wrapSelectionWithDelimiters(leading leadingDelimiter: String, trailing trailingDelimiter: String) { if let range = selectedTextRange, let text = text(in: range) { if range.start == range.end { let checkStart = position(from: range.start, offset: -leadingDelimiter.count) let checkEnd = position(from: range.end, offset: trailingDelimiter.count) // Checking for delimiters on both sides of the cursor, in which case the delimiters are removed if let checkStart, let checkEnd, let checkRange = textRange(from: checkStart, to: checkEnd) { if self.text(in: checkRange) == leadingDelimiter + trailingDelimiter { // Removing delimiters around the cursor replace(checkRange, withText: "") return } } // Checking for delimiters on the trailing side, in which case the cursor is moved to after the delimiters if let checkEnd, let checkRange = textRange(from: range.end, to: checkEnd) { if self.text(in: checkRange) == trailingDelimiter { selectedTextRange = textRange(from: checkEnd, to: checkEnd) return } } // If no surrounding delimiters are detected, add some replace(range, withText: leadingDelimiter + text + trailingDelimiter) if let newPosition = position(from: range.start, offset: leadingDelimiter.count) { selectedTextRange = textRange(from: newPosition, to: newPosition) } } else { if text.hasPrefix(leadingDelimiter), text.hasSuffix(trailingDelimiter) { // If delimiters are detected in selection, remove them replace(range, withText: String(text.dropLast(trailingDelimiter.count).dropFirst(leadingDelimiter.count))) if let newEnd = position(from: range.end, offset: -(leadingDelimiter.count + trailingDelimiter.count)) { selectedTextRange = textRange(from: range.start, to: newEnd) } } else { // Otherwise, wrap the selection in delimiters replace(range, withText: leadingDelimiter + text + trailingDelimiter) if let newEnd = position(from: range.end, offset: leadingDelimiter.count + trailingDelimiter.count) { selectedTextRange = textRange(from: range.start, to: newEnd) } } } } } func wrapSelectionWithSpoiler() { insertBlock(prefix: "::: spoiler \(String(localized: "Spoiler"))", suffix: ":::") } func wrapSelectionWithCodeBlock() { insertBlock(prefix: "```", suffix: "```") } private func insertBlock(prefix: String, suffix: String) { if let range = selectedTextRange, let text = text(in: range) { // let atStart = range.start == beginningOfDocument let atEnd = range.end == endOfDocument let newText = "\(prefix)\n\(text)\n\(suffix)\(atEnd ? "" : "\n")" replace(range, withText: newText) if let newPosition = position(from: range.start, offset: prefix.count + 1 + text.count) { selectedTextRange = textRange(from: newPosition, to: newPosition) } } } func wrapSelectionWithLink() { let url: URL? if let pastedUrl = UIPasteboard.general.url { url = pastedUrl } else if let pastedString = UIPasteboard.general.string, pastedString.starts(with: "http") { url = URL(string: pastedString, encodingInvalidCharacters: false) } else { url = nil } wrapSelectionWithDelimiters(leading: "[", trailing: "](\(url?.absoluteString ?? ""))") } func toggleQuoteAtCursor() { toggleLinePrefix(prefix: "> ") } func toggleHeadingAtCursor(level: Int) { guard 1 ... 6 ~= level else { assertionFailure() return } toggleLinePrefix(prefix: String(repeating: "#", count: level) + " ") } // swiftlint:disable:next function_body_length private func toggleLinePrefix(prefix: String) { if let selectedTextRange, let selectedText = text(in: selectedTextRange) { if let firstTargetedNewLineIndex = findLastNewlineIndex(), let lookBehindRange = textRange(from: beginningOfDocument, to: selectedTextRange.start) { // Remove "> " if exists var allText = text ?? "" if let endIndex = allText.index(firstTargetedNewLineIndex, offsetBy: prefix.count, limitedBy: allText.endIndex) { if allText[firstTargetedNewLineIndex ..< endIndex] == prefix { let selectedEndIndex = allText.index( allText.endIndex, offsetBy: offset(from: selectedTextRange.end, to: selectedTextRange.end) ) allText = allText.replacingOccurrences( of: "\n\(prefix)", with: "\n", range: firstTargetedNewLineIndex ..< selectedEndIndex ) allText.removeSubrange(firstTargetedNewLineIndex ..< endIndex) var startDistance = 0 if let startIndex = stringIndex(from: selectedTextRange.start) { // Avoid fatalError from `distance()` if startIndex > allText.endIndex { startDistance = prefix.count } else { startDistance = allText.distance(from: firstTargetedNewLineIndex, to: startIndex) } } let newStart = position( from: selectedTextRange.start, offset: -min(startDistance, prefix.count) ) ?? beginningOfDocument let newEnd = position( from: selectedTextRange.end, offset: allText.count - text.count ) ?? endOfDocument text = allText self.selectedTextRange = textRange(from: newStart, to: newEnd) return } } // Insert "> " if it doesn't exist guard var lookBehindText = text(in: lookBehindRange) else { assertionFailure() return } lookBehindText.insert(contentsOf: prefix, at: firstTargetedNewLineIndex) let newSelectedText = selectedText.replacingOccurrences(of: "\n", with: "\n\(prefix)") let finalText = lookBehindText + newSelectedText if let finalRange = textRange(from: beginningOfDocument, to: selectedTextRange.end) { replace(finalRange, withText: finalText) let newStart = position( from: selectedTextRange.start, offset: prefix.count ) ?? beginningOfDocument let newEnd = position( from: selectedTextRange.end, offset: (newSelectedText.count - selectedText.count) + prefix.count ) ?? endOfDocument self.selectedTextRange = textRange(from: newStart, to: newEnd) } } } } // MARK: Helper functions private func findLastNewlineIndex() -> String.Index? { if let start = selectedTextRange?.start, let lookBehindRange = textRange(from: beginningOfDocument, to: start), let lookBehindText = text(in: lookBehindRange) { if let newlineIndex = lookBehindText.lastIndex(of: "\n") { return lookBehindText.index(newlineIndex, offsetBy: 1, limitedBy: lookBehindText.endIndex) } else { return lookBehindText.startIndex } } return nil } private func stringIndex(from textPosition: UITextPosition) -> String.Index? { guard let text else { return nil } let offset = offset(from: beginningOfDocument, to: textPosition) guard offset >= 0, offset <= text.utf16.count else { return nil } return String.Index(utf16Offset: offset, in: text) } } ================================================ FILE: Mlem/App/Utility/Extensions/UIUserInterfaceStyle+Extensions.swift ================================================ // // UIUserInterfaceStyle+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-08-31. // import Foundation import Icons import SwiftUI import UIKit extension UIUserInterfaceStyle: @retroactive Codable {} extension UIUserInterfaceStyle { var label: String { switch self { case .unspecified: "System" case .light: "Light" case .dark: "Dark" default: "Unknown" } } var icon: Icon { switch self { case .unspecified: .settings.systemMode case .light: .settings.lightMode case .dark: .settings.darkMode default: .settings.systemMode } } var colorScheme: ColorScheme? { switch self { case .light: .light case .dark: .dark default: nil } } static var optionCases: [UIUserInterfaceStyle] { [.unspecified, .light, .dark] } } ================================================ FILE: Mlem/App/Utility/Extensions/UIViewController+Extensions.swift ================================================ // // UIViewController+TopMostViewController.swift // Mlem // // Created by mormaer on 19/07/2023. // // import UIKit extension UIViewController { func topMostViewController() -> UIViewController { if let presented = presentedViewController { return presented.topMostViewController() } if let navigation = self as? UINavigationController { return navigation.visibleViewController?.topMostViewController() ?? navigation } if let tab = self as? UITabBarController { return tab.selectedViewController?.topMostViewController() ?? tab } return self } } ================================================ FILE: Mlem/App/Utility/Extensions/UsernameValidity+Extensions.swift ================================================ // // UsernameValidity+Extensions.swift // Mlem // // Created by Sjmarf on 2025-05-24. // import Foundation import MlemMiddleware extension UsernameValidity { var label: LocalizedStringResource { switch self { case .available: "Available" case .taken: "Username is taken." case let .invalid(reason): reason.label } } } extension UsernameValidity.InvalidityReason { var label: LocalizedStringResource { switch self { case let .tooShort(minLength: minLength): return "Username must be at least \(minLength) characters long." case let .tooLong(maxLength: maxLength): return "Username cannot be longer than \(maxLength) characters." case let .containsInvalidCharacters(characters): let characterList = characters.map { "\"\($0)\"" }.formatted(.list(type: .or)) return "Username cannot contain \(characterList)." case .other: return "Username is invalid." } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/Label+Profile1.swift ================================================ // // Label+Profile1.swift // Mlem // // Created by Sjmarf on 2024-11-11. // import MlemMiddleware import SwiftUI extension Label { init(_ model: ProfileProviding) where Title == Text, Icon == SimpleAvatarView { self.init { Text(model.name) } icon: { SimpleAvatarView(url: model.avatar, type: type(of: model).avatarFallback) } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/NavigationLink+NavigationPage.swift ================================================ // // NavigationLink+NavigationPage.swift // Mlem // // Created by Sjmarf on 07/05/2024. // import Icons import SwiftUI extension NavigationLink where Destination == Never { init(_ value: NavigationPage, @ViewBuilder label: () -> Label) { self.init(value: value, label: label) } init(_ titleKey: LocalizedStringResource, destination: NavigationPage) where Label == Text { self.init(value: destination) { Text(titleKey) } } init( _ titleKey: LocalizedStringResource, value: String, fallbackValue: String, icon: Icon? = nil, destination: NavigationPage ) where Label == NavigationLinkPickerLabelView { self.init(destination) { NavigationLinkPickerLabelView( title: .init(localized: titleKey), value: value, fallbackValue: fallbackValue, icon: icon ) } } @_disfavoredOverload init(_ title: String, destination: NavigationPage) where Label == Text { self.init(value: destination) { Text(title) } } init( _ titleKey: LocalizedStringResource, icon: Icon, destination: NavigationPage ) where Label == SwiftUI.Label { self.init(value: destination) { Label(String(localized: titleKey), icon: icon) } } } struct NavigationLinkPickerLabelView: View { let title: String let value: String let fallbackValue: String let icon: Icon? var body: some View { HStack(spacing: 10) { Group { if let icon { Label(title, icon: icon) } else { Text(title) } } .frame(maxWidth: .infinity, alignment: .leading) ViewThatFits { Text(value) Text(fallbackValue) } .foregroundStyle(.themedSecondary) .lineLimit(1) } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/PreviewModifier+SampleEnvironment.swift ================================================ // // PreviewModifier+SampleEnvironment.swift // Mlem // // Created by Sjmarf on 2025-02-02. // import Haptics import MlemMiddleware import SwiftUI // TODO: updated mocks // #if DEBUG // private struct SampleEnvironmentPreviewModifier: PreviewModifier { // // Kinda unfortunate typealias naming considering we have our own AppState... // typealias AppState = Void // // var api: MockApiClient = .mock // // static func makeSharedContext() async throws -> AppState { // // no-op // } // // func body(content: Content, context: AppState) -> some View { // content // .environment(NavigationLayer(root: .blockList, model: .main)) // .environment(Mlem.AppState.mock(api: api)) // .environment(FiltersTracker.main) // .environment(TabReselectTracker.main) // .environment(BackendClient.main) // .environment(HapticManager.main) // } // } // // extension PreviewTrait where T == Preview.ViewTraits { // static var sampleEnvironment: PreviewTrait { // if #available(iOS 18.0, *) { // return .modifier(SampleEnvironmentPreviewModifier()) // } else { // return .defaultLayout // } // } // // static func sampleEnvironment(api: MockApiClient) -> PreviewTrait { // if #available(iOS 18.0, *) { // return .modifier(SampleEnvironmentPreviewModifier(api: api)) // } else { // return .defaultLayout // } // } // } // #endif ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/PopupAnchorModel.swift ================================================ // // PopupAnchorModel.swift // Mlem // // Created by Sjmarf on 2025-10-14. // import Foundation @Observable class PopupAnchorModel { struct PopupData { var message: String var actions: [Action]? } enum Outcome { case cancelled, confirmed } struct Action { let title: String let isDestructive: Bool let callback: @MainActor () -> Void init(title: LocalizedStringResource, isDestructive: Bool = false, callback: @escaping () -> Void) { self.title = .init(localized: title) self.isDestructive = isDestructive self.callback = callback } @_disfavoredOverload init(title: some StringProtocol, isDestructive: Bool = false, callback: @escaping () -> Void) { self.title = String(title) self.isDestructive = isDestructive self.callback = callback } } private(set) var data: PopupData? var outcome: Outcome? func showPopup(message: LocalizedStringResource, _ actions: [Action]?) { showPopup(message: .init(localized: message), actions) } @_disfavoredOverload func showPopup(message: String, _ actions: [Action]?) { let newData = PopupData(message: message, actions: actions) if data == nil { data = newData } else { data = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.data = newData } } } func dismissPopup() { data = nil } } extension PopupAnchorModel { func showPopup(_ actionGroup: ActionGroup) { let children: [PopupAnchorModel.Action] = actionGroup.children.map { child in .init(title: child.appearance.label, isDestructive: child.appearance.isDestructive) { @MainActor in if let child = child as? BasicAction { child.callbackWithConfirmation(popupModel: self) } else { assertionFailure("Not implemented") } } } showPopup( message: actionGroup.prompt ?? actionGroup.appearance.label, children ) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+AccountSwitcherGesture.swift ================================================ // // View+AccountSwitcherGesture.swift // Mlem // // Created by Eric Andrews on 2025-09-03. // import SwiftUI struct AccountSwitcherGesture: ViewModifier { let tabReselectTracker: TabReselectTracker let navigationModel: NavigationModel @GestureState private var dragGestureActive: Bool = false @State var switcherOpened: Bool = false @State var dragCompleted: Bool = false func body(content: Content) -> some View { if #available(iOS 26, *), !UIDevice.isPad { content .simultaneousGesture(DragGesture() .updating($dragGestureActive) { _, state, _ in state = true } .onChanged { value in if (UIScreen.main.bounds.height - value.startLocation.y) < 80, value.translation.height < -100, !switcherOpened { switcherOpened = true tabReselectTracker.blockTabSwitch = true navigationModel.openSheet(.quickSwitcher) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { tabReselectTracker.blockTabSwitch = false } } }) .onChange(of: dragGestureActive) { if !dragGestureActive { switcherOpened = false } } } else { content } } } extension View { func withAccountSwitcherGesture(tabReselectTracker: TabReselectTracker, navigationModel: NavigationModel) -> some View { modifier(AccountSwitcherGesture(tabReselectTracker: tabReselectTracker, navigationModel: navigationModel)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+Background.swift ================================================ // // View+Background.swift // Mlem // // Created by Sjmarf on 2025-04-01. // import SwiftUI import Theming extension View { func themedGroupedBackground() -> some View { Group { if #available(iOS 18.0, *) { containerBackground(.themedGroupedBackground, for: .navigation) } else { background(ThemedColor.themedGroupedBackground, in: Rectangle()) } } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+ConditionalNavigationTitle.swift ================================================ // // View+onditionalNavigationTitle.swift // Mlem // // Created by Sjmarf on 2025-03-30. // import SwiftUI private struct ConditionalNavigationTitle: ViewModifier { let title: String @State private var isAtTop: Bool = true func body(content: Content) -> some View { content .isAtTopSubscriber(isAtTop: $isAtTop) // Unfortunately `.toolbar(removing: )` doesn't work with a condition :( .navigationTitle(isAtTop ? "" : title) } } extension View { func conditionalNavigationTitle(_ title: LocalizedStringResource) -> some View { modifier(ConditionalNavigationTitle(title: .init(localized: title))) } @_disfavoredOverload func conditionalNavigationTitle(_ title: String) -> some View { modifier(ConditionalNavigationTitle(title: title)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+ContentMenu.swift ================================================ // // View+ContentMenu.swift // Mlem // // Created by Sjmarf on 23/06/2024. // import SwiftUI extension View { func contextMenu(actions: [any Action]) -> some View { contextMenu { ForEach(actions, id: \.id) { action in MenuButton(action: action) } } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+ContextMenu.swift ================================================ // // View+ContextMenu.swift // Mlem // // Created by Sjmarf on 23/06/2024. // import SwiftUI // This setup avoids actually generating the array of actions until the context menu itself // is opened. This can have performance benefits in certain situations. extension View { @ViewBuilder func contextMenu(@ActionBuilder actions: @escaping () -> [any Action]) -> some View { contextMenu { // Having a proper view here is necessary - if `ForEach` is used directly, `actions()` gets called early. MenuButtons(actions: actions) } .popupAnchor() } } struct MenuButtons: View { @ActionBuilder let actions: () -> [any Action] var body: some View { ForEach(actions(), id: \.id) { action in MenuButton(action: action) } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+DynamicBlur.swift ================================================ // // View+DynamicBlur.swift // Mlem // // Created by Eric Andrews on 2024-08-22. // import Foundation import SwiftUI private struct DynamicBlur: ViewModifier { @State var blurValue: CGFloat = 100 let blurred: Bool func body(content: Content) -> some View { content .blur(radius: blurred ? blurValue : 0, opaque: true) .background { GeometryReader { geo in Color.clear.contentShape(.rect) .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: geo.size, initial: true) { blurValue = max(geo.size.width, geo.size.height) / 12 } } } } } extension View { /// Blurs an image relative to its size func dynamicBlur(blurred: Bool) -> some View { modifier(DynamicBlur(blurred: blurred)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+ExternalApiWarning.swift ================================================ // // View+ExternalApiWarning.swift // Mlem // // Created by Sjmarf on 02/06/2024. // import MlemMiddleware import SwiftUI private struct ExternalApiWarningModifier: ViewModifier { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation let entity: any ContentModel & ActorIdentifiable let isLoading: Bool func body(content: Content) -> some View { content .safeAreaInset(edge: .top) { if !isLoading, !entity.api.isActive(appState: appState) { label .padding(8) .frame(maxWidth: .infinity) // Using colors directly here causes the background to extend // into the navbar, so use filled rectangles instead .background { Rectangle() .fill(.themedAccent.opacity(0.2)) } .background { Rectangle() .fill(.thickMaterial) } .padding(.bottom, 3) } } } var label: some View { HStack { Text(title) .foregroundStyle(.themedPrimary.opacity(0.5)) Spacer() Button("More Info", systemImage: "questionmark.circle") { navigation.openSheet(.externalApiInfo( api: entity.api, actorId: entity.actorId )) } .labelStyle(.iconOnly) } } var title: AttributedString { let host = entity.api.host var attributedString = AttributedString(localized: "Viewing \(host) as guest") if let range = attributedString.range(of: host) { attributedString[range].font = .body.weight(.semibold) } return attributedString } } extension View { func externalApiWarning(entity: any ContentModel & ActorIdentifiable, isLoading: Bool) -> some View { modifier(ExternalApiWarningModifier(entity: entity, isLoading: isLoading)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+HiddenNavigationTitle.swift ================================================ // // View+HiddenNavigationTitle.swift // Mlem // // Created by Sjmarf on 2025-03-18. // import SwiftUI extension View { @ViewBuilder func hiddenNavigationTitle(_ title: LocalizedStringResource) -> some View { hiddenNavigationTitle(String(localized: title)) } @_disfavoredOverload @ViewBuilder func hiddenNavigationTitle(_ title: String) -> some View { if #available(iOS 18.0, *) { self .navigationTitle(title) .toolbar(removing: .title) } else { self } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+IsAtTopSubscriber.swift ================================================ // // View+IsAtTopSubscriber.swift // Mlem // // Created by Eric Andrews on 2024-08-14. // import Foundation import SwiftUI private struct IsAtTopSubscriber: ViewModifier { @Binding var isAtTop: Bool func body(content: Content) -> some View { content .onPreferenceChange(IsAtTopPreferenceKey.self, perform: { value in if value != isAtTop { isAtTop = value } }) } } extension View { /// Updates a given bool according to the IsAtTopPreferenceKey func isAtTopSubscriber(isAtTop: Binding) -> some View { modifier(IsAtTopSubscriber(isAtTop: isAtTop)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+LoadFeed.swift ================================================ // // View+LoadFeed.swift // Mlem // // Created by Eric Andrews on 2024-07-05. // import Foundation import MlemMiddleware import SwiftUI private struct LoadFeed: ViewModifier { @Setting(\.post_size) var postSize let feedLoader: (any FeedLoading)? let shouldLoad: Bool let errorDetails: Binding? func body(content: Content) -> some View { content .onChange(of: onChangeHash, initial: true) { if let feedLoader, shouldLoad, feedLoader.loadingState == .initial { // wrapping this in a Task instead of using .task prevents cancellation errors from the parent view de-rendering Task { do { try await feedLoader.loadMoreItems() errorDetails?.wrappedValue = nil } catch { handleLoadFailure(error) } } } } .onChange(of: postSize) { (feedLoader as? CorePostFeedLoader)?.setPrefetchingConfiguration(.forPostSize(postSize)) } } func handleLoadFailure(_ error: any Error) { if let errorDetailsBinding = self.errorDetails { if var details = handleErrorWithDetails(error) { details.refresh = { do { try await feedLoader?.loadMoreItems() return true } catch { return false } } errorDetailsBinding.wrappedValue = details } } else { handleError(error) } } var onChangeHash: Int { var hasher = Hasher() hasher.combine(feedLoader == nil) hasher.combine(shouldLoad) return hasher.finalize() } } extension View { /// Convenience modifier. Attach to a view to load items from the given FeedLoading on appear if the given FeedLoading has no items func loadFeed( _ feedLoader: (any FeedLoading)?, shouldLoad: Bool = true, errorDetails: Binding? = nil ) -> some View { modifier(LoadFeed( feedLoader: feedLoader, shouldLoad: shouldLoad, errorDetails: errorDetails )) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+MarkReadOnScroll.swift ================================================ // // View+MarkReadOnScroll.swift // Mlem // // Created by Eric Andrews on 2024-08-28. // import Foundation import MlemMiddleware import SwiftUI private struct MarkReadOnScroll: ViewModifier { @Setting(\.feed_markReadOnScroll) var markReadOnScroll @Setting(\.post_size) var postSize var index: Int var post: Post var postFeedLoader: CorePostFeedLoader @Binding var bottomAppearedItemIndex: Int func body(content: Content) -> some View { if #available(iOS 18.0, *) { ios18Body(content: content) } else { legacyBody(content: content) } } @available(iOS 18.0, *) func ios18Body(content: Content) -> some View { content .onGeometryChange(for: Bool.self) { geometry in geometry.frame(in: .global).maxY < 90 } action: { wasAboveTop, isAboveTop in if markReadOnScroll, !wasAboveTop, isAboveTop { post.updateRead(true, shouldQueue: true) } } } func legacyBody(content: Content) -> some View { content .task { if markReadOnScroll { bottomAppearedItemIndex = max(index, bottomAppearedItemIndex) } } .onDisappear { if markReadOnScroll, // mark read on scroll enabled index <= (bottomAppearedItemIndex - postSize.markReadOffset) || index >= (postFeedLoader.items.count - postSize.markReadOffset) { // edge case: end of feed post.updateRead(true, shouldQueue: true) } } } } extension View { /// Handles mark read on scroll behavior: /// - On appear, stages previous posts to be marked read /// - On disappear, if this post is staged, marks it as read func markReadOnScroll( index: Int, post: Post, postFeedLoader: CorePostFeedLoader, bottomAppearedItemIndex: Binding ) -> some View { modifier(MarkReadOnScroll( index: index, post: post, postFeedLoader: postFeedLoader, bottomAppearedItemIndex: bottomAppearedItemIndex )) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+NavigationTransition.swift ================================================ // // View+NavigationTransition.swift // Mlem // // Created by Sjmarf on 29/08/2024. // import SwiftUI extension View { func navigationTransition_(sourceID: any Hashable, in namespace: Namespace.ID?) -> some View { // The code below requires Xcode 16, and is intentionally left commented for now // until we upgrade to Xcode 16. self // Group { // if #available(iOS 18.0, *), let namespace { // self.navigationTransition(.zoom(sourceID: sourceID, in: namespace)) // } else { // self // } // } } func matchedTransitionSource_(id: some Hashable, in namespace: Namespace.ID) -> some View { // The code below requires Xcode 16, and is intentionally left commented for now // until we upgrade to Xcode 16. self // Group { // if #available(iOS 18.0, *) { // self.matchedTransitionSource(id: id, in: namespace, configuration: { config in // config.clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) // }) // } else { // self // } // } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+NavigtionStackPreview.swift ================================================ // // View+NavigtionStackPreview.swift // Mlem // // Created by Sjmarf on 2025-03-13. // import SwiftUI #if DEBUG // This can be used in previews to show a back button in the corner private struct NavigationStackPreviewModifier: ViewModifier { let backButtonLabel: String func body(content: Content) -> some View { NavigationStack(path: .constant([1])) { Color.clear // If EmptyView() is used here, the back button isn't labelled correctly .navigationTitle(backButtonLabel) .navigationDestination(for: Int.self) { _ in content } } } } extension View { func previewNavigationStack(backButtonLabel: LocalizedStringResource = "Back") -> some View { modifier(NavigationStackPreviewModifier(backButtonLabel: .init(localized: backButtonLabel))) } } #endif ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+OutdatedFeedPopup.swift ================================================ // // View+OutdatedFeedPopup.swift // Mlem // // Created by Sjmarf on 03/08/2024. // import MlemMiddleware import SwiftUI private struct OutdatedFeedPopupModifier: ViewModifier { @Environment(AppState.self) var appState @Environment(FiltersTracker.self) var filtersTracker let feedLoader: (any FeedLoading)? let canShowPopup: Bool let onManualRefresh: (() -> Void)? init(feedLoader: (any FeedLoading)?, showPopup canShowPopup: Bool, onManualRefresh: (() -> Void)? = nil) { self.feedLoader = feedLoader self.canShowPopup = canShowPopup self.onManualRefresh = onManualRefresh } @State var showRefreshPopup: Bool = false func body(content: Content) -> some View { content .refreshable(isEnabled: feedLoader != nil) { if let feedLoader { await refresh(feedLoader, clearBeforeRefresh: false) onManualRefresh?() } } .onChange(of: apiChangeHash) { if let feedLoader { if let newApi = feedLoader.items.first?.api { showRefreshPopup = canShowPopup && ( newApi !== appState.firstApi && ![.loading, .initial].contains(feedLoader.loadingState) ) } else { showRefreshPopup = false } } } .onChange(of: filtersTracker.changeHash) { if let feedLoader { if feedLoader.items.count > 0 { showRefreshPopup = true } } } .overlay(alignment: .bottom) { RefreshPopupView("Feed is outdated", isPresented: $showRefreshPopup) { Task { if let feedLoader { await refresh(feedLoader, clearBeforeRefresh: true) } } } } } var apiChangeHash: Int { var hasher = Hasher() hasher.combine(canShowPopup) hasher.combine(appState.firstApi) hasher.combine(feedLoader?.loadingState) hasher.combine(feedLoader?.items.first?.api) return hasher.finalize() } func refresh(_ feedLoader: any FeedLoading, clearBeforeRefresh: Bool) async { do { showRefreshPopup = false await feedLoader.changeApi(to: appState.firstApi, context: filtersTracker.filterContext) // This duplication isn't ideal, but it works for now if let feedLoader = feedLoader as? AggregatePostFeedLoader { if try await !appState.firstApi.supports(.postSortType(feedLoader.sortType)) { try await feedLoader.changeSortType(to: appState.initialFeedSortType, forceRefresh: true) return } } if let feedLoader = feedLoader as? CommunityPostFeedLoader { if try await !appState.firstApi.supports(.postSortType(feedLoader.sortType)) { try await feedLoader.changeSortType(to: appState.initialFeedSortType, forceRefresh: true) return } } try await feedLoader.refresh(clearBeforeRefresh: clearBeforeRefresh) } catch { handleError(error) } } } extension View { func outdatedFeedPopup(feedLoader: (any FeedLoading)?, showPopup: Bool = true, onManualRefresh: (() -> Void)? = nil) -> some View { modifier(OutdatedFeedPopupModifier(feedLoader: feedLoader, showPopup: showPopup, onManualRefresh: onManualRefresh)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+PaletteBorder.swift ================================================ // // View+PaletteBorder.swift // Mlem // // Created by Eric Andrews on 2024-10-10. // import Foundation import SwiftUI private struct PaletteBorder: ViewModifier { @Environment(\.palette) var palette var cornerRadius: CGFloat func body(content: Content) -> some View { content .overlay { if palette.bordered { RoundedRectangle(cornerRadius: cornerRadius) .stroke(.themedDivider, lineWidth: 0.5) } } } } extension View { /// Applies a rounded rect border to the view if the current palette `.bordered` is `true` func paletteBorder(cornerRadius: CGFloat) -> some View { modifier(PaletteBorder(cornerRadius: cornerRadius)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+PopupAnchor.swift ================================================ // // View+PopupAnchor.swift // Mlem // // Created by Sjmarf on 18/09/2024. // import Icons import SwiftUI struct PopupAnchor: ViewModifier { @State var model: PopupAnchorModel var actions: [PopupAnchorModel.Action] { model.data?.actions ?? [] } var isPresented: Binding { Binding( get: { model.data != nil }, set: { if !$0 { model.dismissPopup() } } ) } func body(content: Content) -> some View { if #available(iOS 26, *) { content .alert( model.data?.message ?? "", isPresented: isPresented ) { buttonsView } .environment(model) } else { content .confirmationDialog( model.data?.message ?? "", isPresented: isPresented ) { buttonsView } message: { Text(model.data?.message ?? "") } .environment(model) } } @ViewBuilder var buttonsView: some View { ForEach(Array(actions.enumerated()), id: \.offset) { _, action in Button( action.title, role: action.isDestructive ? .destructive : nil ) { action.callback() model.outcome = .confirmed } } Button("Cancel", role: .cancel) { model.outcome = .cancelled } } } extension View { @ViewBuilder func popupAnchor(model: PopupAnchorModel = .init()) -> some View { modifier(PopupAnchor(model: model)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+QuickSwipes.swift ================================================ // // View+QuickSwipes.swift // Mlem // // Created by Sjmarf on 2025-08-23. // import MlemMiddleware import QuickSwipes import SwiftUI private struct QuickSwipeEnvironmentReaderViewModifier: ViewModifier { @Environment(\.self) var environment var buildConfiguration: (EnvironmentValues) -> SwipeConfiguration init(_ buildConfiguration: @escaping (EnvironmentValues) -> SwipeConfiguration) { self.buildConfiguration = buildConfiguration } func body(content: Content) -> some View { content.quickSwipes(buildConfiguration(environment)) } } extension View { @ViewBuilder func quickSwipes( leading: [any Action] = [], trailing: [any Action] = [] ) -> some View { quickSwipes(.init(leadingActions: leading, trailingActions: trailing)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+Refreshable.swift ================================================ // // View+Refreshable.swift // Mlem // // Created by Sjmarf on 2025-02-18. // import Foundation import SwiftUI struct RefreshableWrapperView: ViewModifier { let isEnabled: Bool let refreshAction: @Sendable () async -> Void func body(content: Content) -> some View { RefreshToggling(isEnabled: isEnabled, content: content) .refreshable(action: refreshAction) } } private struct RefreshToggling: View { @Environment(\.refresh) private var refresh let isEnabled: Bool let content: Content var body: some View { content .environment(EnvironmentValues.safeWritableRefreshKeyPath, isEnabled ? refresh : nil) } } private struct RefreshCastFailsafeKey: EnvironmentKey { static let defaultValue: RefreshAction? = nil } private extension EnvironmentValues { static let safeWritableRefreshKeyPath: WritableKeyPath = { guard let keyPath = \EnvironmentValues.refresh as? WritableKeyPath else { handleError(MlemError.modelError("Using refreshFailsafe - .refreshable isn't working!"), silent: true) return \.refreshFailsafe } return keyPath }() var refreshFailsafe: RefreshAction? { get { self[RefreshCastFailsafeKey.self] } set { self[RefreshCastFailsafeKey.self] = newValue } } } public extension View { func refreshable(isEnabled: Bool, _ operation: @escaping @Sendable () async -> Void) -> some View { modifier(RefreshableWrapperView(isEnabled: isEnabled, refreshAction: operation)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+ReloadOnAccountSwitch.swift ================================================ // // View+ReloadOnAccountSwitch.swift // Mlem // // Created by Eric Andrews on 2026-02-10. // import MlemMiddleware import SwiftUI private struct ReloadOnAccountSwitchModifier: ViewModifier { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Binding var entity: T @Binding var isLoading: Bool var callback: ((T) -> Void)? func body(content: Content) -> some View { content .onChange(of: appState.firstApi) { isLoading = true Task { do { let newEntity = try await entity.resolve(with: appState.firstApi) callback?(newEntity) Task { @MainActor in entity = newEntity isLoading = false } } catch { handleError(error) Task { @MainActor in isLoading = false } } } } } } extension View { func reloadOnAccountSwitch( entity: Binding, isLoading: Binding, callback: ((T) -> Void)? = nil) -> some View { modifier(ReloadOnAccountSwitchModifier(entity: entity, isLoading: isLoading, callback: callback)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+SafeAreaBar.swift ================================================ // // View+SafeAreaBar.swift // Mlem // // Created by Sjmarf on 2025-10-04. // import SwiftUI extension View { @ViewBuilder func safeAreaBar_(edge: VerticalEdge, @ViewBuilder content: () -> some View) -> some View { if #available(iOS 26.0, *) { safeAreaBar(edge: edge, content: content) } else { safeAreaInset(edge: edge, content: content) } } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+TabBarPreview.swift ================================================ // // View+TabBarPreview.swift // Mlem // // Created by Sjmarf on 2025-02-23. // import Foundation import SwiftUI #if DEBUG struct TabBarPreviewModifier: ViewModifier { @Environment(AppState.self) var appState var selected: ContentView.Tab func body(content: Content) -> some View { TabView(selection: .constant(selected)) { ForEach(ContentView.Tab.allCases, id: \.self) { type in content .tag(type) .tabItem { Label( type.label(appState: appState, profileLabelType: .anonymous), icon: type.icon.representingState(active: selected == type) ) } } } } } extension View { func previewTabBar(selected: ContentView.Tab) -> some View { modifier(TabBarPreviewModifier(selected: selected)) } } #endif ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+TabReselectConsumer.swift ================================================ // // View+TabReselectionConsumer.swift // Mlem // // Created by Eric Andrews on 2024-05-11. // import Foundation import SwiftUI struct TabReselectionConsumer: ViewModifier { @Environment(TabReselectTracker.self) var tabReselectTracker /// Reselect actions should only trigger when the view is shown, so we track it with this @State var displayed: Bool = false var action: () -> Void func body(content: Content) -> some View { content .onChange(of: tabReselectTracker.flag) { // Only execute the action if: // - This view is currently displayed (this prevents it from triggering while in a different tab) // - Flag is true--combined with the reset() call below, this ensures that only one consumer will consume this action, preventing the behavior where a "dismiss" action also scrolls the previous page if displayed, tabReselectTracker.flag { tabReselectTracker.reset() action() } } .onAppear { if !displayed { displayed = true tabReselectTracker.consumers += 1 } } .onDisappear { if displayed { displayed = false tabReselectTracker.consumers -= 1 } } } } extension View { func onReselectTab(action: @escaping () -> Void) -> some View { modifier(TabReselectionConsumer(action: action)) } } ================================================ FILE: Mlem/App/Utility/Extensions/Views/View Modifiers/View+WidthReader.swift ================================================ // // View+WidthReader.swift // Mlem // // Created by Eric Andrews on 2024-07-28. // import Foundation import SwiftUI private struct WidthReader: ViewModifier { @Binding var width: CGFloat func body(content: Content) -> some View { content .background { GeometryReader { geo in Color.clear .onChange(of: geo.size.width, initial: true) { width = geo.size.width } } } } } extension View { /// Convenience modifier. Attach to a view to load items from the given FeedLoading on appear if the given FeedLoading has no items func widthReader(width: Binding) -> some View { modifier(WidthReader(width: width)) } } ================================================ FILE: Mlem/App/Utility/Extensions/[BlockNode]+Extensions.swift ================================================ // // [BlockNode]+Extensions.swift // Mlem // // Created by Sjmarf on 11/08/2024. // import LemmyMarkdownUI extension [BlockNode] { // swiftlint:disable:next cyclomatic_complexity function_body_length func rules(isProbableRuleList: Bool = false) -> [[BlockNode]] { var output: [[BlockNode]] = [] /// Is `true` when the previous block was a "Rules" title, or if there is only one block in the array. var isProbableRuleList: Bool = isProbableRuleList || count == 1 /// Stores the parts of a rule currently being parsed. /// This happens when a rule consists of a heading followed by a paragraph. var currentRuleParts: [BlockNode]? // Matches "1. ", "2) ", "Rule 1" etc let numberedListRegex = /\d+[-\.:\)]\s+|Rule \d+[-\.:\)]?\s+/ loop: for block in self { if let parts = currentRuleParts { switch block { case .heading: output.append(parts) currentRuleParts = nil case .thematicBreak: if parts.count > 1 { output.append(parts) currentRuleParts = nil } else { fallthrough } default: currentRuleParts?.append(block) continue loop } } switch block { case let .paragraph(inlines: inlines), .heading(level: _, inlines: let inlines): // Test if the heading is a rule title e.g. "1. No spam" if inlines.stringLiteral.starts(with: numberedListRegex) { // This doesn't preserve the markdown in the title, but it's a rare case for there to be any let text = String(inlines.stringLiteral.trimmingPrefix(numberedListRegex)) let blocks: [BlockNode] = [.paragraph(inlines: [.strong(children: [.text(text)])])] if case .paragraph = block { output.append(blocks) } else { currentRuleParts = blocks } break } // AskLemmy uses "criteria" rather than "rules" // TODO: localize this? if stringIsRulesTitle(inlines.stringLiteral) { isProbableRuleList = true continue loop } case .bulletedList(isTight: _, items: let items), .numberedList(isTight: _, start: _, items: let items): if isProbableRuleList { output.append(contentsOf: items.map(\.blocks)) } isProbableRuleList = false case let .spoiler(title: title, blocks: blocks): // This handles the situation where a spoiler is used to enclose the rules list. if let title, stringIsRulesTitle(title) { return blocks.rules(isProbableRuleList: true) } // This handles situations where each item of the rules list contains a spoiler // block that can be expanded for more info on that rule. if let title, title.starts(with: numberedListRegex) { let text = String(title.trimmingPrefix(numberedListRegex)) let titleBlock: BlockNode = .paragraph( inlines: [ .strong(children: [.text(text)]) ] ) output.append([titleBlock] + blocks.filter { $0 != .thematicBreak }) } default: break } } if let currentRuleParts { output.append(currentRuleParts) } return output } } private func stringIsRulesTitle(_ string: String) -> Bool { ["Rules", "Criteria"].contains(where: { string.localizedCaseInsensitiveContains($0) }) } ================================================ FILE: Mlem/App/Views/Pages/Community/AdvancedSortView+SortButton.swift ================================================ // // AdvancedSortView+SortButton.swift // Mlem // // Created by Sjmarf on 2024-12-12. // import Haptics import MlemMiddleware import SwiftUI extension AdvancedSortView { struct SortButton: View { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let type: PostSortType var timeRangeFormat: SortTimeRange.FormatStyle = .timescaleFull @Binding var selectedSort: PostSortType @State var showingExplanation: Bool = false var body: some View { HStack(spacing: Constants.main.standardSpacing) { Button { selectedSort = type dismiss() } label: { HStack(spacing: Constants.main.standardSpacing) { Image(icon: type.icon) .symbolVariant(type == selectedSort ? .fill : .none) .frame(width: 30, alignment: .center) .foregroundStyle(type == selectedSort ? .primary : .secondary) // No palette! titleView .padding(.vertical, Constants.main.halfSpacing) Spacer() Button("Pin", icon: .lemmy.pinned) { hapticManager.play(haptic: .gentleInfo, tier: .low) if PinnedSortTracker.main.pinnedSortTypes.contains(type) { PinnedSortTracker.main.pinnedSortTypes.remove(type) } else { PinnedSortTracker.main.pinnedSortTypes.insert(type) } } .symbolVariant(PinnedSortTracker.main.pinnedSortTypes.contains(type) ? .fill : .none) .labelStyle(.iconOnly) .foregroundStyle(type == selectedSort ? .themedContrastingLabel : .themedAccent) } .frame(minHeight: 45) .buttonStyle(.plain) .padding(.horizontal, Constants.main.standardSpacing) .foregroundStyle(type == selectedSort ? .themedContrastingLabel : .themedPrimary) .background( type == selectedSort ? .themedAccent : .themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing) ) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } .disabled(!appState.firstApi.supports(.postSortType(type), defaultValue: true)) } @ViewBuilder var titleView: some View { HStack(spacing: Constants.main.standardSpacing) { Text(type.label(timeRangeFormat: timeRangeFormat)) if let explanation = type.explanation { Button { showingExplanation.toggle() } label: { Image(systemName: "questionmark.circle") .foregroundStyle(.secondary) // No palette! } .popover(isPresented: $showingExplanation) { PopoverContainer { Text(explanation) .frame(maxWidth: 200) .fixedSize(horizontal: false, vertical: true) .font(.footnote) .padding(10) .foregroundStyle(.themedPrimary) } .presentationCompactAdaptation(.none) } .environment(\.isEnabled, true) // Janky fix to override the higher-level `.disabled` modifier. } } } } } // https://stackoverflow.com/a/77556014/17629371 private struct PopoverContainer: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { guard subviews.count == 1 else { fatalError() } let newProposal = ProposedViewSize( width: proposal.width ?? UIScreen.main.bounds.width, height: proposal.height ?? UIScreen.main.bounds.height ) return subviews[0].sizeThatFits(newProposal) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { // entrusts default } } ================================================ FILE: Mlem/App/Views/Pages/Community/AdvancedSortView.swift ================================================ // // AdvancedSortView.swift // Mlem // // Created by Sjmarf on 2024-12-08. // import ComponentViews import MlemMiddleware import SwiftUI struct AdvancedSortView: View { enum Tab: CaseIterable { case sort, filter var label: LocalizedStringResource { switch self { case .sort: "Sort" case .filter: "Filter" } } } @Environment(AppState.self) var appState @State var selectedTab: Tab = .sort @Binding var selectedSort: PostSortType var body: some View { VStack { switch selectedTab { case .sort: sortTab case .filter: filterTab } } .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .navigationBarTitleDisplayMode(.inline) .toolbar { CloseButtonToolbarItem() // Intentionally left commented out! // // ToolbarItem(placement: .principal) { // Picker("Tab", selection: $selectedTab) { // ForEach(Tab.allCases, id: \.self) { // Text($0.label) // } // } // .frame(maxWidth: .infinity) // .pickerStyle(.segmented) // } } } @ViewBuilder var sortTab: some View { ScrollView { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { ForEach(nonTopCases, id: \.self) { type in SortButton(type: type, selectedSort: $selectedSort) } subtitle("Top of...") ForEach(topCases, id: \.self) { type in SortButton(type: type, selectedSort: $selectedSort) } let unavailableCases = unavailableCases if !unavailableCases.isEmpty { subtitle("Unavailable") ForEach(unavailableCases, id: \.self) { type in SortButton(type: type, timeRangeFormat: .topAndTimescale, selectedSort: $selectedSort) } } } .padding(.horizontal, 15) } } @ViewBuilder func subtitle(_ title: LocalizedStringResource) -> some View { Text(title) .foregroundStyle(.secondary) .fontWeight(.semibold) .padding(.leading, Constants.main.standardSpacing) .padding(.top, Constants.main.standardSpacing) } @ViewBuilder var filterTab: some View { Text("Filter") } var nonTopCases: [PostSortType] { PostSortType.nonTopCases.filter { appState.firstApi.supports(.postSortType($0), defaultValue: true) } } var topCases: [PostSortType] { PostSortType.legacyTopCases.filter { appState.firstApi.supports(.postSortType($0), defaultValue: true) } } var unavailableCases: [PostSortType] { PostSortType.legacyCases.filter { !appState.firstApi.supports(.postSortType($0), defaultValue: true) } } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunityAboutView.swift ================================================ // // CommunityView.swift // Mlem // // Created by Sjmarf on 30/07/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct CommunityAboutView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette let community: Community var body: some View { VStack(spacing: Constants.main.standardSpacing) { if let banner = community.banner { MediaView.largeImage(url: banner, shouldBlur: false) } if let description = community.description { descriptionView(description) } else if canEditDescription { noDescriptionView } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } @ViewBuilder func descriptionView(_ description: String) -> some View { VStack(alignment: .trailing) { if canEditDescription { HStack { Text("Description") .font(.callout) Spacer() Button("Edit") { edit() } .font(.footnote) .buttonStyle(.bordered) } .foregroundStyle(.secondary) .fontWeight(.semibold) .padding(.horizontal, Constants.main.standardSpacing) Divider() } Markdown(description, configuration: .default(palette: palette)) .padding(.horizontal, Constants.main.standardSpacing) } .padding(.vertical, Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } @ViewBuilder var noDescriptionView: some View { VStack(spacing: 20) { Image(systemName: "note.text") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 50) .foregroundStyle(.tertiary) Button("Add description") { edit() } .buttonStyle(.borderedProminent) } .padding(.vertical, 20) } var canEditDescription: Bool { guard appState.firstApi.supports(.editCommunityDescription, defaultValue: false) else { return false } guard let firstPerson = appState.firstPerson else { return false } return (firstPerson.isAdmin.value ?? false) || (firstPerson.moderates?(.community(community)) ?? false) } func edit() { navigation.openSheet(.editCommunity(community)) } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunityDetailsView.swift ================================================ // // CommunityDetailsView.swift // Mlem // // Created by Sjmarf on 08/08/2024. // import MlemMiddleware import SwiftUI import Theming struct CommunityDetailsView: View { let community: Community var body: some View { VStack(spacing: 16) { FormSection { ProfileDateView(profilable: community) .padding(.vertical, Constants.main.standardSpacing) } FormSection { VStack(spacing: Constants.main.halfSpacing) { Text("Subscribers") .foregroundStyle(.themedSecondary) Text(community.subscription.value?.total ?? 0, format: .number) .font(.title) .fontWeight(.semibold) .contentTransition(.numericText(value: Double(community.subscription.value?.total ?? 0))) .animation(.default, value: Double(community.subscription.value?.total ?? 0)) if let localSubscriberCount = community.subscription.value?.local { Text(localSubscriberCountText) .contentTransition(.numericText(value: Double(localSubscriberCount))) .animation(.default, value: Double(localSubscriberCount)) .foregroundStyle(.themedSecondary) .font(.footnote) } } .monospacedDigit() .padding(.vertical, Constants.main.standardSpacing) } HStack(spacing: 16) { FormReadout("Posts", value: community.postCount.value ?? 0) .tint(.themedPostAccent) FormReadout("Comments", value: community.commentCount.value ?? 0) .tint(.themedCommentAccent) } .frame(maxWidth: .infinity) if let activeUserCount = community.activeUserCount.value, community.api.supports(.viewCommunityActiveUsers, defaultValue: true) { ActiveUserCountView(activeUserCount: activeUserCount) } } .padding([.horizontal, .bottom], 16) } var localSubscriberCountText: String { guard let count = community.subscription.value?.local else { return "" } return .init( localized: .init( "local.subscriber.count.text", defaultValue: "\(count) on \(community.api.host)", // swiftlint:disable:next line_length comment: "Used in the \"Details\" tab of a community page to indicate how many local subscribers use the instance. E.g. \"56 on lemmy.world\"." ) ) } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunitySearchSortPicker.swift ================================================ // // CommunitySearchSortPicker.swift // Mlem // // Created by Sjmarf on 2024-12-12. // import MlemMiddleware import SwiftUI struct CommunitySearchSortPicker: View { @Environment(AppState.self) var appState @Binding var sort: SearchSortType @State var topSortPopupPresented: Bool = false var sortTypes: [SearchSortType] { SearchSortType.nonTopCases .filter { appState.firstApi.supports(.searchSortType($0), defaultValue: true) } } var body: some View { Menu(sort.label(timeRangeFormat: .topAndTimescale), icon: sort.icon) { ForEach(sortTypes, id: \.self) { type in Toggle( type.label(), icon: type.icon, isOn: .init(get: { sort == type }, set: { _ in sort = type }) ) } Toggle( "Top...", icon: .lemmy.topSort, isOn: .init(get: { sort.isTop }, set: { _ in topSortPopupPresented = true }) ) } .popover(isPresented: $topSortPopupPresented) { TopSortPicker(action: { sort = .top($0) }) .presentationBackground(.clear) .presentationCornerRadius(18) .presentationCompactAdaptation(.popover) } } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunityStubResolutionPage.swift ================================================ // // CommunityStubResolutionPage.swift // Mlem // // Created by Eric Andrews on 2026-02-20. // import MlemMiddleware import SwiftUI import Theming struct CommunityStubResolutionPage: View { @Environment(NavigationLayer.self) var navigation let stub: CommunityStub @State var upgradeError: Error? var body: some View { content .themedGroupedBackground() } @ViewBuilder var content: some View { if let upgradeError { ErrorView(.init( error: upgradeError, refresh: fetchCommunity )) } else { ProgressView() .task { await fetchCommunity() } } } @discardableResult func fetchCommunity() async -> Bool { do { let community = try await stub.getCommunity() navigation.replace(.community(community, visitContext: .other)) return true } catch { upgradeError = error return false } } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunityView+Logic.swift ================================================ // // CommunityView+Logic.swift // Mlem // // Created by Eric Andrews on 2025-01-12. // import Foundation import MlemMiddleware import QuickSwipes extension CommunityView { func canEditModeratorList(_ community: Community) -> Bool { guard let firstPerson = appState.firstPerson else { return false } if !firstPerson.api.supports(.editModeratorList, defaultValue: true) { return false } return (firstPerson.isAdmin.value ?? false) || (firstPerson.moderates?(.community(community)) ?? false) } func openAddModSheet() { navigation.openSheet(.personPicker { person in newMod = person DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { showingConfirmation = true } }) } func addNewMod() { guard let newMod else { assertionFailure("newMod cannot be nil") return } Task { do { try await community.addModerator(newMod, added: true) } catch { handleError(error) } } } func moderatorQuickSwipes(community: Community, person: Person) -> SwipeConfiguration { guard let communityModerators = community.moderators.value, canEditModeratorList(community), let myPerson = appState.firstPerson, myPerson.canModerate(person, communityModerators: communityModerators) else { return .init() } return .init(trailingActions: [person.addModAction(community: community, isOn: true)]) } func setupFeedLoader(community: Community) { if postFeedLoader == nil { Task { @MainActor in @Setting(\.behavior_internetSpeed) var internetSpeed @Setting(\.feed_showRead) var showReadInFeed postFeedLoader = try await .init( pageSize: internetSpeed.pageSize, sortType: appState.initialFeedSortType, showReadPosts: showReadInFeed, filterContext: filtersTracker.filterContext, prefetchingConfiguration: .forPostSize(postSize), urlCache: Constants.main.urlCache, community: community ) } } else if postFeedLoader?.community.api != community.api { postFeedLoader?.community = community } } func logVisit(_ community: Community) { if let session = (appState.firstSession as? UserSession), let visitHistory = session.visitHistory { guard session.api === community.api else { return } visitHistory.addCommunity(community, context: visitContext) Task(priority: .background) { try await session.saveVisitHistory() } } } } ================================================ FILE: Mlem/App/Views/Pages/Community/CommunityView.swift ================================================ // // CommunityView.swift // Mlem // // Created by Sjmarf on 30/07/2024. // import Actions import Dependencies import Haptics import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct CommunityView: View { enum Tab: String, CaseIterable, Identifiable { case posts, comments, about, moderation, details var id: Self { self } var label: LocalizedStringResource { switch self { case .posts: "Posts" case .comments: "Comments" case .about: "About" case .moderation: "Moderation" case .details: "Details" } } } @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(FiltersTracker.self) var filtersTracker @Environment(HapticManager.self) var hapticManager @Environment(\.palette) var palette @Environment(\.dismiss) var dismiss @Setting(\.post_size) var postSize @Setting(\.feed_showRead) var showRead @Setting(\.safety_enableNsfwCommunityWarning) var showNsfwCommunityWarning @Setting(\.safety_blurNsfw) var blurNsfw @ObservationIgnored @Dependency(\.persistenceRepository) private var persistenceRepository let visitContext: VisitHistory.VisitContext @State var community: Community @State private var selectedTab: Tab = .posts @State var postFeedLoader: CommunityPostFeedLoader? @State var warningPresented: Bool @State var isLoading: Bool = false @State var showingConfirmation: Bool = false @State var newMod: Person? @State var showHiddenReadBanner: Bool = false @State var lastRefreshDate: Date? init( community: Community, visitContext: VisitHistory.VisitContext ) { @Setting(\.safety_enableNsfwCommunityWarning) var showNsfwCommunityWarning self.community = community self.visitContext = visitContext self._warningPresented = .init(wrappedValue: showNsfwCommunityWarning && community.nsfw) } var body: some View { content .reloadOnAccountSwitch(entity: $community, isLoading: $isLoading) .externalApiWarning(entity: community, isLoading: isLoading) .task { setupFeedLoader(community: community) } .onAppear { logVisit(community) } .navigationBarTitleDisplayMode(.inline) .frame(maxWidth: .infinity, maxHeight: .infinity) .themedGroupedBackground() .environment(\.communityContext, community) .environment(\.feedContext, .community) } @ViewBuilder var content: some View { FancyScrollView { HStack { FeedHeaderView( title: Text(community.displayName), subtitle: Text(community.fullNameWithPrefix), dropdownStyle: .disabled, image: { CircleCroppedImageView( community, frame: Constants.main.feedHeaderSize, blurred: community.nsfw && (blurNsfw == .always) ) } ) subscribeButton .padding(.top, Constants.main.halfSpacing) } BubblePicker( tabs, selected: $selectedTab, label: \.label ) VStack { switch selectedTab { case .posts: VStack { if let postFeedLoader { postsTab(postFeedLoader: postFeedLoader) .padding(.bottom, -4) } } .toolbar { ToolbarItem(placement: .topBarTrailing) { FeedSortPicker(feedLoader: postFeedLoader, showTopTimescaleInIcon: true) } } case .about: CommunityAboutView(community: community) case .moderation: moderationTab case .details: CommunityDetailsView(community: community) default: EmptyView() } } } .animation(.snappy, value: showHiddenReadBanner && !showRead) .conditionalNavigationTitle(community.name) .toolbar { ToolbarItemGroup(placement: .secondaryAction) { ActionButtons(community: community) .environment(postFeedLoader) } } // don't show the refresh popup if community api isn't the active api, since that indicates an unresolvable community .popupAnchor() .outdatedFeedPopup( feedLoader: postFeedLoader, showPopup: selectedTab == .posts && community.api === appState.firstApi, onManualRefresh: { guard !showRead else { return } let now = Date() if let lastRefresh = lastRefreshDate, now.timeIntervalSince(lastRefresh) < 5 { showHiddenReadBanner = true } lastRefreshDate = now } ) .onChange(of: showRead) { if showRead { showHiddenReadBanner = false } lastRefreshDate = nil } .fullScreenCover(isPresented: $warningPresented) { WarningOverlayView( text: "This community likely contains graphic or explicit content.", isPresented: $warningPresented, showWarningAgain: $showNsfwCommunityWarning ) } } @ViewBuilder func postsTab(postFeedLoader: CommunityPostFeedLoader) -> some View { if community.removed { VStack(spacing: Constants.main.standardSpacing) { Image(icon: .lemmy.remove) .font(.title) Text("This community has been removed.") .fontWeight(.semibold) } .foregroundStyle(.themedWarning) .padding(.top, Constants.main.doubleSpacing) } else { if showHiddenReadBanner, !showRead { HiddenReadBannerView { showHiddenReadBanner = false } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } PostGridView(postFeedLoader: postFeedLoader) } } @ViewBuilder var moderationTab: some View { VStack(spacing: Constants.main.standardSpacing) { if community.api.supports(.modlog, defaultValue: true) { ModlogButtonView(community: community) } VStack(spacing: Constants.main.halfSpacing) { // ExpectedView causes rendering issues here ForEach(community.moderators.value ?? []) { person in PersonListRow(person) .quickSwipes(moderatorQuickSwipes(community: community, person: person)) } } if canEditModeratorList(community) { Button("Add Moderator", icon: .general.add, action: openAddModSheet) .buttonStyle(.capsule) .confirmationDialog("Add Moderator", isPresented: $showingConfirmation) { Button("Yes", action: addNewMod) } message: { if let displayName = newMod?.displayName { Text("Really appoint \(displayName) as a moderator of \(community.displayName)?") } else { Text("Really appoint this user as a moderator of \(community.displayName)?") } } } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } @ViewBuilder var subscribeButton: some View { if let subscription = community.subscription.value, let updateSubscribed = community.updateSubscribed { Button { if community.api.willSendToken { hapticManager.play(haptic: .gentleInfo, tier: .low) updateSubscribed(!subscription.subscribed) } } label: { HStack { Text(subscription.total.abbreviated) Image(icon: subscription.subscribed ? .general.success : .lemmy.personAvatar) .symbolVariant(.circle) .symbolVariant(subscription.subscribed ? .fill : .none) .symbolRenderingMode(.hierarchical) } .fontWeight(.semibold) .padding(.vertical, 3) .padding(.trailing, 6) .padding(.leading, 8) .background(subscription.subscribed ? .themedAccent : .themedSecondary.opacity(0.2), in: .capsule) .foregroundStyle(subscription.subscribed ? .themedContrastingLabel : .themedSecondary) } .padding(.trailing, Constants.main.standardSpacing) .padding(.bottom, Constants.main.halfSpacing) } } var tabs: [Tab] { var output: [Tab] = [.posts, .moderation, .details] let canModerate: Bool if !appState.firstApi.supports(.editCommunityDescription, defaultValue: false) { canModerate = false } else if let firstPerson = appState.firstPerson { canModerate = (firstPerson.moderates?(.community(community)) ?? false) || (firstPerson.isAdmin.value ?? false) } else { canModerate = false } if community.description != nil || community.banner != nil || canModerate { output.insert(.about, at: 1) } return output } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment(api: .realistic)) { // CommunityView( // community: .init(Community2.mock(.realistic(.pics), api: .realistic)), // visitContext: .other // ) // .previewNavigationStack() // .previewTabBar(selected: .feeds) // } // #endif ================================================ FILE: Mlem/App/Views/Pages/Community/FeedSortPicker.swift ================================================ // // FeedSortPicker.swift // Mlem // // Created by Sjmarf on 10/08/2024. // import Flow import Icons import MlemMiddleware import SwiftUI import Theming struct FeedSortPicker: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation enum Value: Hashable { case known(PostSortType) case unknown var sortType: PostSortType? { switch self { case let .known(postSortType): postSortType case .unknown: nil } } } let showTopTimescaleInIcon: Bool @Binding var value: Value init(sort: Binding, showTopTimescaleInIcon: Bool = false) { self._value = .init(get: { .known(sort.wrappedValue) }, set: { if let sortType = $0.sortType { sort.wrappedValue = sortType } else { assertionFailure() } }) self.showTopTimescaleInIcon = showTopTimescaleInIcon } init(feedLoader: CommunityPostFeedLoader?, showTopTimescaleInIcon: Bool = false) { self._value = .init(get: { if let feedLoader { .known(feedLoader.sortType) } else { .unknown } }, set: { value in if let sort = value.sortType { Task { @MainActor in do { try await feedLoader?.changeSortType(to: sort, forceRefresh: false) } catch { handleError(error) } } } }) self.showTopTimescaleInIcon = showTopTimescaleInIcon } init(feedLoader: AggregatePostFeedLoader?, showTopTimescaleInIcon: Bool = false) { self._value = .init(get: { if let feedLoader { .known(feedLoader.sortType) } else { .unknown } }, set: { value in if let sort = value.sortType { Task { @MainActor in do { try await feedLoader?.changeSortType(to: sort, forceRefresh: false) } catch { handleError(error) } } } }) self.showTopTimescaleInIcon = showTopTimescaleInIcon } var nonTopSortTypes: [PostSortType] { PostSortType.nonTopCases .filter { PinnedSortTracker.main.pinnedSortTypes.contains($0) } .filter { appState.firstApi.supports(.postSortType($0), defaultValue: true) } } var topSortTypes: [PostSortType] { PostSortType.legacyTopCases .filter { PinnedSortTracker.main.pinnedSortTypes.contains($0) } .filter { appState.firstApi.supports(.postSortType($0), defaultValue: true) } } var body: some View { Menu { Section { ForEach(nonTopSortTypes, id: \.self) { type in Toggle( type.label(), icon: type.icon, isOn: .init(get: { value.sortType == type }, set: { _ in value = .known(type) }) ) } if collapseTopSorts { Menu("Top...", icon: .lemmy.topSort) { topResultsView } } } if !collapseTopSorts { Section("Top...") { topResultsView } } Section { Button("More...", icon: .general.toolbarMenu) { navigation.openSheet(.advancedSorting(.init(get: { value.sortType ?? .hot }, set: { value = .known($0) }))) } } } label: { labelView } .disabled(!appState.firstApi.contextIsFetched) } var collapseTopSorts: Bool { topSortTypes.count > 3 && !nonTopSortTypes.isEmpty } @ViewBuilder var topResultsView: some View { ForEach(topSortTypes, id: \.self) { type in Toggle( type.label(timeRangeFormat: .timescaleFull), icon: type.icon, isOn: .init(get: { value.sortType == type }, set: { _ in value = .known(type) }) ) } } @ViewBuilder var labelView: some View { VStack { if showTopTimescaleInIcon, let sort = value.sortType, sort.isTop { HStack { Image(icon: .lemmy.topSort) .imageScale(.small) Text(sort.label(timeRangeFormat: .timescaleAbbreviated)) .font(UIDevice.isIos26 ? .body : .footnote) .fontDesign(.rounded) } .padding(.horizontal, 10) .padding(.vertical, 5) .background { if !UIDevice.isIos26 { Capsule() // 1.51 is intentional - iOS doesn't render it quite right at 1.5 (iPhone 12) .strokeBorder(.themedAccent, lineWidth: 1.51) } } .accessibilityLabel(sort.label(timeRangeFormat: .topAndTimescale)) } else if let sortType = value.sortType { Label(sortType.label(timeRangeFormat: topSortTypes.count == 1 ? .topOnly : .topAndTimescale), icon: sortType.icon) } } .animation(.easeOut(duration: 0.4), value: value == .unknown) } var formatter: DateComponentsFormatter { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 1 return formatter } } ================================================ FILE: Mlem/App/Views/Pages/Community/TopSortPicker.swift ================================================ // // FeedSortPicker+TopSortPicker.swift // Mlem // // Created by Sjmarf on 2024-12-12. // import Flow import MlemMiddleware import SwiftUI struct TopSortPicker: View { @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme var action: (SortTimeRange) -> Void var filter: (SortTimeRange) -> Bool = { _ in true } var timeRanges: [SortTimeRange] { SortTimeRange.legacyCases .filter(filter) .filter { appState.firstApi.supports(.sortTimeRange($0), defaultValue: true) } } var body: some View { HFlow(spacing: 10) { ForEach(timeRanges, id: \.self) { type in button(type) .frame(minWidth: 60) } } .padding(10) .frame(width: 222) } @ViewBuilder func button(_ type: SortTimeRange) -> some View { Button { action(type) dismiss() } label: { Group { if type == .allTime { if timeRanges.count % 3 == 0 { Text("All") } else { Text("All Time") } } else { Text(type.label(name: "Top", prefix: "Top:", format: .timescaleAbbreviated)) } } .frame(maxWidth: .infinity) .contentShape(.rect) } .frame(maxWidth: .infinity) .frame(height: 40) .background { if colorScheme == .dark { RoundedRectangle(cornerRadius: 8) .fill(.themedPrimary.opacity(0.2)) } else { RoundedRectangle(cornerRadius: 8) .fill(.themedBackground) .shadow(color: .black.opacity(0.05), radius: 3) } } .foregroundStyle(.themedPrimary) } } ================================================ FILE: Mlem/App/Views/Pages/DeleteAccountView.swift ================================================ // // DeleteAccountView.swift // Mlem // // Created by Eric Andrews on 2024-08-19. // import ComponentViews import Dependencies import Foundation import MlemMiddleware import SwiftUI struct DeleteAccountView: View { @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss let account: UserAccount @State private var password = "" @State var confirmed: Bool = false @State var deleteContent: Bool = true var body: some View { content .task { do { try await account.api.ensureContextPresence() } catch { handleError(error) } } } var content: some View { VStack(alignment: .center, spacing: Constants.main.doubleSpacing) { Text("Really delete \(account.name)?") .font(.title) .fontWeight(.bold) WarningView( icon: .general.warning, text: "This will permanently remove it from \(account.host), not just Mlem!", inList: false ) deleteConfirmation CloseButtonView(ios18Label: .cancel) } .multilineTextAlignment(.center) .padding(Constants.main.doubleSpacing) } @ViewBuilder var deleteConfirmation: some View { if confirmed { if account.api.contextIsFetched { passwordPrompt() } else { VStack(spacing: Constants.main.standardSpacing) { ProgressView() Text("Loading instance details") .foregroundStyle(.themedSecondary) } } } else { Button("Permanently delete \(account.name)") { withAnimation { confirmed = true } } .buttonStyle(.borderedProminent) .tint(.red) } } @ViewBuilder func passwordPrompt() -> some View { Text("To confirm, please enter your password:") Group { SecureField(String(""), text: $password) .padding(4) .background(.themedSecondaryBackground) .cornerRadius(Constants.main.smallItemCornerRadius) .textContentType(.password) .submitLabel(.go) .onSubmit { deleteAccount() } .labelsHidden() Toggle(isOn: $deleteContent) { Text("Delete posts and comments") } Button("Permanently delete \(account.name)") { deleteAccount() } .buttonStyle(.borderedProminent) .tint(.red) } .padding(.horizontal, 30) } func deleteAccount() { Task { do { try await account.api.deleteAccount(password: password, deleteContent: deleteContent) Task { @MainActor in AccountsTracker.main.removeAccount(account: account) dismiss() } } catch { handleError(error) } } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/CommentEditorView+Context.swift ================================================ // // CommentEditorView+Context.swift // Mlem // // Created by Sjmarf on 20/08/2024. // import MlemMiddleware extension CommentEditorView { enum Context: Hashable { case post(Post) case comment(Comment) static func == (lhs: Context, rhs: Context) -> Bool { lhs.hashValue == rhs.hashValue } func hash(into hasher: inout Hasher) { switch self { case let .post(post): hasher.combine("post") hasher.combine(post.hashValue) case let .comment(comment): hasher.combine("comment") hasher.combine(comment.hashValue) } } var item: any SelectableContentProviding { switch self { case let .post(post): post case let .comment(comment): comment } } var api: ApiClient { switch self { case let .post(post): post.api case let .comment(comment): comment.api } } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/CommentEditorView+Logic.swift ================================================ // // CommentEditorView+Logic.swift // Mlem // // Created by Sjmarf on 20/08/2024. // import MlemMiddleware import SwiftUI extension CommentEditorView { func resolveContext() async { guard let originalContext else { return } do { if originalContext.api === account.api { resolutionState = .success resolvedContext = originalContext } else { Task { @MainActor in resolutionState = .resolving } switch originalContext { case let .post(post): let post = try await account.api.getPost(url: post.actorId.url) Task { @MainActor in resolutionState = .success resolvedContext = .post(post) } case let .comment(comment): let comment = try await account.api.getComment(url: comment.actorId.url) Task { @MainActor in resolutionState = .success resolvedContext = .comment(comment) } } } } catch ApiClientError.noEntityFound { handleError(ApiClientError.noEntityFound, silent: true) Task { @MainActor in resolutionState = .notFound } } catch { Task { @MainActor in resolutionState = .error(.init(error: error)) } } } func inferContextFromCommentToEdit() async { guard originalContext == nil else { return } do { if let commentToEdit { if let parent = try await commentToEdit.getParent(cachedValueAcceptable: true) { originalContext = .comment(parent) } else if let post = commentToEdit.post.value_ { originalContext = .post(post) } } } catch { handleError(error) } } func send() async { uploadHistory.deleteWhereNotPresent(in: textView.text) do { if let commentToEdit { try await commentToEdit.edit(content: textView.text, languageId: nil) } else if let resolvedContext { let result: Comment let parent: Comment? switch resolvedContext { case let .post(post): result = try await post.reply(content: textView.text, languageId: nil) parent = nil case let .comment(comment): result = try await comment.reply(content: textView.text) parent = comment } commentTreeTracker?.insertCreatedComment(result, parent: parent) } else { assertionFailure() return } Task { @MainActor in textView.resignFirstResponder() textView.isEditable = false hapticManager.play(haptic: .success, tier: .low) dismiss() } } catch { Task { @MainActor in sending = false textView.isEditable = true handleError(error) } } } func checkSlurFilter(text: String) { do { if let output = try slurRegex?.firstMatch(in: text.lowercased()) { slurMatch = String(text[output.range]) } else { slurMatch = nil } } catch { handleError(error, silent: true) } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/CommentEditorView.swift ================================================ // // CommentEditorView.swift // Mlem // // Created by Sjmarf on 14/07/2024. // import ComponentViews import Haptics import LemmyMarkdownUI import MlemMiddleware import os import SwiftUI import Theming struct CommentEditorView: View { private let log: Logger = .mlemLogger() @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss @Setting(\.person_showAvatar) private var showPersonAvatar @Setting(\.community_showAvatar) private var showCommunityAvatar enum ResolutionState: Equatable { case success, notFound, error(ErrorDetails), resolving } @State var textView: UITextView = .init() let commentTreeTracker: CommentTreeTracker? @State var commentToEdit: Comment? @State var originalContext: Context? @State var resolvedContext: Context? @State var resolutionState: ResolutionState = .success @State var sending: Bool = false @State var account: UserAccount @State var presentationSelection: PresentationDetent = .large @State var textIsEmpty: Bool = true @State var markdownToolbarEditorModel: MarkdownEditorToolbarModel = .init() @State var uploadHistory: ImageUploadHistoryManager = .init() @State var slurMatch: String? @State var slurRegex: Regex? init?( commentToEdit: Comment? = nil, context: Context? = nil, commentTreeTracker: CommentTreeTracker? = nil ) { self.commentToEdit = commentToEdit self._originalContext = .init(wrappedValue: context) self._resolvedContext = .init(wrappedValue: context) self.commentTreeTracker = commentTreeTracker if let userAccount = (AppState.main.firstAccount as? UserAccount) { self._account = .init(wrappedValue: userAccount) } else { return nil } self._slurRegex = .init(wrappedValue: AppState.main.firstApi.myInstance?.slurRegex()) textView.text = commentToEdit?.content ?? "" } var minTextEditorHeight: CGFloat { UIFont.preferredFont(forTextStyle: .body).lineHeight * 4 + 15 } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: textIsEmpty) { NavigationStack { content .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel, requiresConfirmation: !textIsEmpty) } ToolbarItem(placement: .principal) { if AccountsTracker.main.userAccounts.count > 1, commentToEdit == nil { AccountPickerMenu(account: $account) { HStack(spacing: 3) { FullyQualifiedLabelView(account, labelStyle: .medium, showAvatar: false) Image(icon: .general.dropDown) .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) .tint(.themedSecondary) .imageScale(.small) .fontWeight(.bold) } } } } ToolbarItem(placement: .topBarTrailing) { if sending { ProgressView() } else { sendButton } } } .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) } .task(id: account) { await resolveContext() } } .onDisappear { // If we didn't have the `isAlive` check here, the images would // get deleted when you click on a link in the reply context if !navigation.isAlive, !sending, !uploadHistory.uploads.isEmpty { log.debug("Deleting uploaded images...") uploadHistory.deleteAll() } } .onChange(of: presentationSelection) { if presentationSelection == .large { textView.becomeFirstResponder() } } .onChange(of: navigation.isTopSheet) { if navigation.isTopSheet, navigation.model != nil { textView.becomeFirstResponder() } } .onChange(of: account) { if let instance = account.api.myInstance { slurRegex = instance.slurRegex() checkSlurFilter(text: textView.text) } else { Task { do { let instance = try await account.api.getMyInstance() slurRegex = instance.slurRegex() checkSlurFilter(text: textView.text) } catch { handleError(error) } } } } } @ViewBuilder var content: some View { ScrollView { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { if resolutionState == .notFound { resolutionWarning .padding(.horizontal, 10) } VStack(spacing: Constants.main.standardSpacing) { MarkdownTextEditor( onChange: { if $0.isEmpty != textIsEmpty { textIsEmpty = $0.isEmpty } checkSlurFilter(text: $0) }, prompt: "Start writing...", textView: textView, content: { MarkdownEditorToolbarView( textView: textView, uploadHistory: uploadHistory, model: markdownToolbarEditorModel ) } ) .onChange(of: account.api, initial: true) { markdownToolbarEditorModel.imageUploadApi = account.api } if let slurMatch { FilterViolationWarning(failures: [account.host: slurMatch]) .padding(.horizontal, Constants.main.standardSpacing) } } .frame( maxWidth: .infinity, minHeight: minTextEditorHeight, maxHeight: .infinity, alignment: .topLeading ) .padding(.vertical, Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .padding(.horizontal, Constants.main.standardSpacing) contextView .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .padding(.horizontal, Constants.main.standardSpacing) } .animation(.easeOut(duration: 0.2), value: resolutionState == .notFound) .padding(.bottom, Constants.main.standardSpacing) } .scrollBounceBehavior(.basedOnSize) .task { await inferContextFromCommentToEdit() } } @ViewBuilder var contextView: some View { switch originalContext { case let .post(post): VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { ExpectedView(post.community) { community in FullyQualifiedLinkView( community, labelStyle: .medium, blurred: post.nsfw ) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } Spacer() selectTextButton } LargePostBodyView(post: post, isPostPage: true, shouldBlur: false) ExpectedView(post.creator) { creator in FullyQualifiedLinkView( creator, labelStyle: .medium, blurred: post.nsfw) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } case let .comment(comment): VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { ExpectedView(comment.creator) { creator in FullyQualifiedLinkView( creator, labelStyle: .small ) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } Spacer() selectTextButton } CommentBodyView(comment: comment) } case nil: ProgressView() } } @ViewBuilder var selectTextButton: some View { Button("Select Text", icon: .general.select) { Task { @MainActor in textView.resignFirstResponder() } originalContext?.item.showTextSelectionSheet() } .labelStyle(.iconOnly) } @ViewBuilder var resolutionWarning: some View { Text("Failed to resolve post. Try another account.") .padding(.vertical, 3) .frame(maxWidth: .infinity) .background(.opacity(0.2), in: .capsule) .foregroundStyle(.themedCaution) } @ViewBuilder var sendButton: some View { Button("Send", icon: commentToEdit != nil ? .general.success : .lemmy.send) { sending = true Task(priority: .userInitiated) { await send() } } .disabled(resolutionState != .success || textIsEmpty || slurMatch != nil) .glassProminentButtonStyle() } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/LinkEditorView.swift ================================================ // // LinkEditorView.swift // Mlem // // Created by Sjmarf on 2025-11-04. // import MlemMiddleware import SwiftUI import Theming struct LinkEditorView: View { @Environment(\.palette) var palette let api: ApiClient let close: (PostLink) -> Void let originalUrl: URL init(url: URL, api: ApiClient, close: @escaping (PostLink) -> Void) { self.api = api self.close = close self.originalUrl = url } @State var urlString: String = "" @State var textView: UITextView? @State var isSubmitting: Bool = false @FocusState var focused: Bool var attributedStringBinding: Binding { .init { var string = AttributedString(urlString) string.foregroundColor = ThemedColor.themedSecondary.resolve(with: palette) if let url = URL(string: urlString), let host = url.host() { if let range = string.range(of: host) { string[range].foregroundColor = ThemedColor.themedPrimary.resolve(with: palette) } } return string } set: { urlString = String($0.characters) } } var body: some View { VStack(spacing: 5) { HStack { Spacer() Button { if let url = URL(string: self.urlString) { focused = false Task { self.isSubmitting = true let link: PostLink do { link = try await api.getPostLinkOrUseOpenGraph(url: url) } catch { link = .init(content: url, thumbnail: nil, label: url.absoluteString) handleError(error, silent: true) } self.isSubmitting = false close(link) } } } label: { Label("Done", icon: .general.success) .font(.title) .fontWeight(.semibold) .imageScale(.large) .labelStyle(.iconOnly) .symbolVariant(.circle.fill) .symbolRenderingMode(.palette) .foregroundStyle(.secondary, .themedTertiaryGroupedBackground) .opacity(isSubmitting ? 0 : 1) .overlay { if isSubmitting { ProgressView() } } } .buttonStyle(.plain) } textEditor .padding(.horizontal, 5) } .padding(Constants.main.standardSpacing) } @ViewBuilder var textEditor: some View { Group { if #available(iOS 26.0, *) { TextEditor(text: attributedStringBinding) } else { TextEditor(text: $urlString) } } .focused($focused) .onAppear { focused = true } .fixedSize(horizontal: false, vertical: true) .layoutPriority(6) .frame(maxHeight: .infinity) .scrollContentBackground(.hidden) .introspect(.textEditor, on: .iOS(.v26)) { if textView == nil { textView = $0 // The text has to be set here; otherwise the textview has a height of 0 for some reason textView?.text = originalUrl.absoluteString } } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorTargetView.swift ================================================ // // PostEditorTargetView.swift // Mlem // // Created by Sjmarf on 14/08/2024. // import MlemMiddleware import SwiftUI struct PostEditorTargetView: View { @Environment(NavigationLayer.self) private var navigation @Bindable var target: PostEditorTarget let isMoreThanOneTarget: Bool var body: some View { HStack { HStack(spacing: Constants.main.standardSpacing) { if AccountsTracker.main.userAccounts.count > 1 { communityPicker .frame(maxWidth: .infinity, alignment: .leading) accountPicker .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } else { communityPicker } } if isMoreThanOneTarget { Group { switch target.sendState { case .unsent: EmptyView() case .sent: Image(icon: .general.success) .foregroundStyle(.themedPositive) case .failed: Image(icon: .general.error) .foregroundStyle(.themedNegative) } } .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) } } } @ViewBuilder var communityPicker: some View { Button { navigation.openSheet(.communityPicker( api: target.account.api, callback: { target.community = .init($0) } )) } label: { let singleAccount = AccountsTracker.main.userAccounts.count == 1 HStack(spacing: 0) { if let community = target.community { FullyQualifiedLabelView(community, labelStyle: singleAccount ? .medium : .large) } else { HStack(spacing: 7) { Image(icon: .lemmy.community) .resizable() .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) .aspectRatio(1, contentMode: .fit) .frame( width: singleAccount ? Constants.main.mediumAvatarSize : Constants.main.largeAvatarSize ) Text("Choose a community...") .font(.footnote) .multilineTextAlignment(.leading) .environment(\._lineHeightMultiple, 0.8) .offset(y: 1) } } if !singleAccount || isMoreThanOneTarget { Spacer() } } .padding(.vertical, 6) .padding(.horizontal, 8) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } } @ViewBuilder var accountPicker: some View { HStack { AccountPickerMenu(account: $target.account) { FullyQualifiedLabelView(target.account, labelStyle: .large) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) .padding(.horizontal, 8) } .onChange(of: target.account) { Task { await resolveCommunity() } } switch target.resolutionState { case .notFound, .error: Image(icon: .general.warning) .imageScale(.large) .symbolVariant(.fill) .symbolRenderingMode(.hierarchical) .foregroundStyle(.themedCaution) .fontWeight(.semibold) default: EmptyView() } } } @MainActor func resolveCommunity() async { guard target.community?.api !== target.account.api else { return } guard let community = target.community else { return } target.resolutionState = .resolving do { let newCommunity: Community = try await target.account.api.getCommunity(url: community.allResolvableUrls[0]) target.community = newCommunity target.resolutionState = .success } catch ApiClientError.noEntityFound { target.resolutionState = .notFound } catch { target.resolutionState = .error(.init(error: error)) } } } @Observable class PostEditorTarget: Identifiable { enum ResolutionState: Equatable { case success, notFound, error(ErrorDetails), resolving } enum SendState: Equatable { case unsent, sent, failed } var community: Community? var account: UserAccount { didSet { slurRegex_ = nil onAccountChange() } } let id = UUID() var resolutionState: ResolutionState = .success var sendState: SendState = .unsent var onAccountChange: () -> Void private var slurRegex_: Regex? var slurRegex: Regex? { get async throws { if let slurRegex_ { return slurRegex_ } slurRegex_ = try await account.api.getMyInstance().slurRegex() return slurRegex_ } } init( community: Community? = nil, account: UserAccount, onAccountChange: @escaping () -> Void = {} ) { self.community = community self.account = account self.slurRegex_ = account.api.myInstance?.slurRegex() self.onAccountChange = onAccountChange } /// If this target matches the given feedLoader, prepends the given post func prepend(post: Post, to feedLoader: (any FeedLoading)?) { guard let feedLoader else { return } if let community, let communityFeedLoader = feedLoader as? CommunityPostFeedLoader, communityFeedLoader.community.actorId == community.actorId { Task { @MainActor in withAnimation { communityFeedLoader.prependItem(post) } } return } if let personContentFeedLoader = feedLoader as? SingleSourceMixedFeedLoader, personContentFeedLoader.userId == account.id, personContentFeedLoader.api == account.api { Task { @MainActor in withAnimation { personContentFeedLoader.prependItem(.init(wrappedValue: .post(post))) } } return } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView+ImageView.swift ================================================ // // PostEditorView+ImageView.swift // Mlem // // Created by Sjmarf on 29/08/2024. // import MlemMiddleware import PhotosUI import SwiftUI extension PostEditorView { var imageView: some View { PostEditorImageUploadWidgetView(primaryApi: primaryApi, imageUrl: $imageUrl, imageManager: $imageManager) } } struct PostEditorImageUploadWidgetView: View { @Environment(NavigationLayer.self) var navigation @ScaledMetric(relativeTo: .subheadline) var buttonHeight: CGFloat = 40 let primaryApi: ApiClient @Binding var imageUrl: URL? @Binding var imageManager: ImageUploadManager? @ViewBuilder var body: some View { VStack(spacing: 0) { switch imageManager?.state { case let .done(image): uploadedImageView(url: image.url) { Task { do { try await image.delete() } catch { handleError(error, silent: true) } } } .transition(.opacity) case let .uploading(progress: progress): uploadingProgressView(progress: progress) .transition(.opacity) default: if let imageUrl, imageManager?.state != .idle { uploadedImageView(url: imageUrl) } else { imageWaitingView } } } .background(.themedAccent.opacity(imageUrl != nil || imageManager?.image != nil ? 0 : 0.2)) // This second background is to prevent the view from being partially see-through, which makes the animations cleaner .background(.themedGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .onTapGesture { imageManager = imageManager ?? .init() } } @ViewBuilder private func uploadedImageView(url: URL, onRemove: @escaping () -> Void = {}) -> some View { MediaView( url: url, aspectRatioBounds: .imageDefault, cornerRadius: Constants.main.mediumItemCornerRadius ) .overlay(alignment: .topTrailing) { Button("Remove", systemImage: Icons.closeCircleFill) { onRemove() imageManager = nil imageUrl = nil } .symbolRenderingMode(.palette) .foregroundStyle(.secondary, .thinMaterial) .font(.title) .labelStyle(.iconOnly) .padding() } } @ViewBuilder private var imageWaitingView: some View { VStack(spacing: 8) { HStack { HStack { if imageManager?.state == nil { Image(icon: .markdown.uploadImage) } Text(imageManager?.state == nil ? "Add Image" : "Add an image...") } .geometryGroup() .padding(.leading, 4) .frame(maxWidth: .infinity, alignment: imageManager?.state == nil ? .center : .leading) if imageManager?.state != nil { Button("Remove", systemImage: Icons.closeCircleFill) { imageManager = nil } .font(.title2) .labelStyle(.iconOnly) .symbolRenderingMode(.hierarchical) .foregroundStyle(.themedAccent) } } .foregroundStyle(.themedAccent) if imageManager?.state != nil { VStack { uploadOptionsView(height: buttonHeight + 14) .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .move(edge: .bottom).combined(with: .opacity) )) } } } .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding(8) } @ViewBuilder func uploadingProgressView(progress: Double) -> some View { VStack { Text("Uploading...") .foregroundStyle(.themedAccent) if progress == 1.0 { ProgressView() } else { ProgressView(value: progress) .progressViewStyle(.linear) .frame(maxWidth: .infinity) .padding([.bottom, .horizontal], 4) } } .frame(maxWidth: .infinity) .padding(8) } @ViewBuilder func uploadOptionsView(height: CGFloat) -> some View { HStack { Button("Photos", icon: .general.photoLibary) { guard let imageManager else { return } navigation.showPhotosPicker(for: imageManager, api: primaryApi) } Button("Files", icon: .general.chooseFile) { guard let imageManager else { return } navigation.showFilePicker(for: imageManager, api: primaryApi) } Button("Paste", icon: .general.paste) { guard let imageManager else { return } navigation.uploadImageFromClipboard(for: imageManager, api: primaryApi) } } .font(.subheadline) .buttonStyle(ImageSourceButtonStyle(height: height)) } } private struct ImageSourceButtonStyle: ButtonStyle { let height: CGFloat func makeBody(configuration: Self.Configuration) -> some View { configuration.label .labelStyle(ImageSourceButtonLabelStyle()) .foregroundStyle(.themedContrastingLabel) .frame(maxWidth: .infinity) .frame(height: height) .background(.themedAccent, in: .rect(cornerRadius: 8)) } } private struct ImageSourceButtonLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { VStack(spacing: 4) { configuration.icon configuration.title } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView+LinkView.swift ================================================ // // PostEditorView+LinkView.swift // Mlem // // Created by Sjmarf on 29/08/2024. // import MlemMiddleware import OpenGraph import SwiftUI extension PostEditorView { @ViewBuilder func addLinkButton() -> some View { Label("Add Link", icon: .general.link) .lineLimit(1) .fontWeight(.semibold) .foregroundStyle(.themedAccent) .padding(.leading, 8) .frame( maxWidth: .infinity, alignment: link == .none ? .center : .leading ) .padding(8) .background(.themedAccent.opacity(0.2)) // This second background is to prevent the view from being partially see-through, which makes the animations cleaner .background(.themedGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .onTapGesture { pasteLink() } } private func pasteLink() { let url: URL? if let pastedUrl = UIPasteboard.general.url { url = pastedUrl } else if let pastedString = UIPasteboard.general.string, pastedString.starts(with: "http") { url = URL(string: pastedString, encodingInvalidCharacters: false) } else { ToastModel.main.add(.urlCopyError) return } if let url { Task { do { if let api = targets.first?.account.api { link = try await .value(api.getPostLinkOrUseOpenGraph(url: url)) } } catch { link = .value(.init(content: url, thumbnail: nil, label: url.absoluteString)) handleError(error, silent: true) } } } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView+Logic.swift ================================================ // // PostEditorView+Logic.swift // Mlem // // Created by Sjmarf on 20/08/2024. // import MlemMiddleware import SwiftUI extension PostEditorView { var minTextEditorHeight: CGFloat { UIFont.preferredFont(forTextStyle: .body).lineHeight * 4 + 15 } var minTitleEditorHeight: CGFloat { UIFont.preferredFont(forTextStyle: .title2).lineHeight + 15 } var attachmentTransition: AnyTransition { .asymmetric(insertion: .scale.combined(with: .opacity), removal: .opacity) } var canDismiss: Bool { titleIsEmpty && contentIsEmpty && targets.count == 1 && link == .none && imageManager == nil } var canSubmit: Bool { if !(imageManager?.state.isDone ?? true) || link == .waiting || !titleSlurMatches.isEmpty || !bodySlurMatches.isEmpty { return false } if postToEdit != nil { return true } return !titleIsEmpty && targets.allSatisfy { $0.community != nil && $0.resolutionState == .success } } // ApiClient for uploading images etc var primaryApi: ApiClient { targets.first?.account.api ?? appState.firstApi } @MainActor func submit() async { uploadHistory.deleteWhereNotPresent(in: contentTextView.text) if postToEdit != nil { editPost() } else { await send() } } private func editPost() { guard let post = postToEdit else { return } do { try post.edit( title: titleTextView.text, content: contentTextView.text, linkUrl: imageManager?.image?.url ?? link.url ?? imageUrl, altText: post.altText, thumbnail: thumbnailManager.image?.url, nsfw: hasNsfwTag, languageId: nil ) hapticManager.play(haptic: .success, tier: .low) dismiss() } catch { handleError(error) sending = false } } private func send() async { let validTargets = targets.filter { $0.sendState != .sent } let posts = await withTaskGroup( of: (target: PostEditorTarget, post: Post?).self, returning: [Post].self ) { taskGroup in for target in validTargets { if let community = target.community as? Community { taskGroup.addTask { @MainActor in let post: Post? do { guard community.api === target.account.api else { assertionFailure() throw PostEditorViewError.mismatchingTargetApi } post = try await community.api.createPost( communityId: community.id, title: titleTextView.text, content: contentTextView.text, linkUrl: imageManager?.image?.url ?? link.url ?? imageUrl, thumbnail: thumbnailManager.image?.url, nsfw: hasNsfwTag ) } catch { handleError(error, silent: true) post = nil } return (target, post) } } } var posts = [Post]() while let result = await taskGroup.next() { if let post = result.post { posts.append(post) result.target.prepend(post: post, to: feedLoader) if self.targets.count == 1 { result.target.sendState = .sent } } else { result.target.sendState = .failed } } return posts } if posts.count == validTargets.count { hapticManager.play(haptic: .success, tier: .low) dismiss() } else { sending = false } } var animationHashValue: Int { var hasher = Hasher() hasher.combine(link) hasher.combine(imageManager) hasher.combine(hasNsfwTag) return hasher.finalize() } func restoreFocusState() { switch lastFocusedField { case .title: titleTextView.becomeFirstResponder() case .content: contentTextView.becomeFirstResponder() case nil: break } } func saveFocusState() { if contentTextView.isFirstResponder { lastFocusedField = .content } else if titleTextView.isFirstResponder { lastFocusedField = .title } else { lastFocusedField = nil } } func checkSlurFilter(text: String, slurMatches: Binding<[String: String]>) { Task { let matches = await findSlurFilterMatches(text: text) Task { @MainActor in slurMatches.wrappedValue = matches } } } func checkSlurFilters() { checkSlurFilter(text: contentTextView.text, slurMatches: $bodySlurMatches) checkSlurFilter(text: titleTextView.text, slurMatches: $titleSlurMatches) } /// Checks if the given text fails `slurRegex` and updates the given `String?` binding to the current /// validation state func findSlurFilterMatches(text: String) async -> [String: String] { var newSlurMatches: [String: String] = .init() for target in targets { let host = target.account.host guard newSlurMatches[host] == nil else { continue } do { if let output = try await target.slurRegex?.firstMatch(in: text.lowercased()) { newSlurMatches[host] = String(text[output.range]) } } catch { handleError(error, silent: true) } } return newSlurMatches } } private enum PostEditorViewError: Error { case mismatchingTargetApi } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView+Toolbar.swift ================================================ // // PostEditorView+Toolbar.swift // Mlem // // Created by Sjmarf on 02/09/2024. // import ComponentViews import SwiftUI extension PostEditorView { @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel, requiresConfirmation: !canDismiss) { dismiss() } } ToolbarItemGroup(placement: .topBarTrailing) { Menu("Add", icon: .general.add) { Toggle("NSFW Tag", icon: .lemmy.tag, isOn: $hasNsfwTag) if postToEdit == nil { Button("Crosspost", systemImage: "shuffle") { if let account = targets.last?.account { let newTarget: PostEditorTarget = .init(account: account, onAccountChange: checkSlurFilters) targets.append(newTarget) navigation.openSheet(.communityPicker(api: account.api, callback: { community in newTarget.community = community })) } } } } if self.sending { ProgressView() } else { sendButton } } } @ViewBuilder var sendButton: some View { Button("Send", icon: postToEdit != nil ? .general.success : .lemmy.send) { self.sending = true Task { await submit() } } .disabled(!canSubmit) .glassProminentButtonStyle() } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView+Views.swift ================================================ // // PostEditorView+Views.swift // Mlem // // Created by Sjmarf on 30/09/2024. // import SwiftUI extension PostEditorView { @ViewBuilder var attachmentPickerView: some View { switch link { case let .value(link): PostEditorWebsitePreviewView( link: .init( get: { link }, set: { self.link = .value($0) } ), imageManager: $thumbnailManager, primaryApi: primaryApi, removeCallback: { self.link = .none }, shouldBlur: false ) .transition(.scale.combined(with: .opacity)) default: HStack(spacing: 10) { if imageManager == nil, imageUrl == nil { addLinkButton() .transition(.move(edge: .leading).combined(with: .opacity)) } if link == .none { imageView .transition(.move(edge: .trailing).combined(with: .opacity)) } } .transition(.scale.combined(with: .opacity)) } } @ViewBuilder var targetSelectionView: some View { let showWarning = !targets.allSatisfy { $0.sendState != .failed } VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { if let postToEdit { ExpectedView(postToEdit.community) { community in FullyQualifiedLinkView(community, labelStyle: .medium) .padding(.horizontal, Constants.main.standardSpacing) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } else { ForEach(Array(targets.enumerated()), id: \.element.id) { index, target in HStack(spacing: Constants.main.standardSpacing) { PostEditorTargetView(target: target, isMoreThanOneTarget: targets.count > 1) .frame(maxWidth: .infinity, alignment: .leading) if targets.count > 1 { Button("Remove", icon: .general.close) { targets.remove(at: index) checkSlurFilters() } .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) .imageScale(.large) .labelStyle(.iconOnly) } } .frame(maxWidth: .infinity, alignment: .leading) } } if showWarning { Text(targets.count == 1 ? "Post failed to send." : "One of more of your posts failed to send.") .multilineTextAlignment(.center) .padding(.vertical, 3) .frame(maxWidth: .infinity) .background(.opacity(0.2), in: .capsule) .foregroundStyle(.themedNegative) .padding(.horizontal) } } .animation(.easeOut(duration: 0.2), value: showWarning) } @ViewBuilder var nsfwTagView: some View { Button { hasNsfwTag = false } label: { HStack { Text("NSFW") .font(.footnote) .fontWeight(.black) .foregroundStyle(.themedContrastingLabel) Image(icon: .general.close) .foregroundStyle(.opacity(0.8)) } .foregroundStyle(.white) .padding(.vertical, 2) .padding(.horizontal, 8) .background(.themedWarning, in: .capsule) } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorView.swift ================================================ // // PostEditorView.swift // Mlem // // Created by Sjmarf on 12/08/2024. // import ComponentViews import Haptics import MlemMiddleware import PhotosUI import SwiftUI struct PostEditorView: View { enum Field { case title, content } enum LinkState: Hashable { case none, waiting, value(PostLink) var url: URL? { switch self { case let .value(link): link.content default: nil } } } @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss @State var titleTextView: UITextView @State var contentTextView: UITextView @State var postToEdit: Post? @State var presentationSelection: PresentationDetent = .large @State var titleIsEmpty: Bool = true @State var contentIsEmpty: Bool = true @State var lastFocusedField: Field? = .title @State var hasNsfwTag: Bool = false @State var link: LinkState = .none @State var imageUrl: URL? @State var imageManager: ImageUploadManager? @State var thumbnailManager: ImageUploadManager = .init() @State var uploadHistory: ImageUploadHistoryManager = .init() @State var markdownToolbarEditorModel: MarkdownEditorToolbarModel = .init() @State var sending: Bool = false @State var targets: [PostEditorTarget] @State var titleSlurMatches: [String: String] = .init() @State var bodySlurMatches: [String: String] = .init() var feedLoader: (any FeedLoading)? /// Initializer for editing a post init?( postToEdit: Post, community: Community? ) { self.init( community: community, title: postToEdit.title, content: postToEdit.content, type: postToEdit.type, nsfw: postToEdit.nsfw, feedLoader: nil ) self.postToEdit = postToEdit } /// Initializer for creating a post init?( community: Community?, title: String = "", content: String? = nil, type: PostType? = nil, nsfw: Bool = false, feedLoader: (any FeedLoading)? ) { if let account = (AppState.main.firstAccount as? UserAccount) { self._targets = .init(wrappedValue: [.init(community: community, account: account)]) } else { return nil } self.feedLoader = feedLoader self.titleTextView = .init() self.contentTextView = .init() titleTextView.tag = 0 contentTextView.tag = 1 titleTextView.text = title contentTextView.text = content ?? "" self._titleIsEmpty = .init(wrappedValue: title.isEmpty) self._hasNsfwTag = .init(wrappedValue: nsfw) switch type { case let .media(url): self._imageUrl = .init(wrappedValue: url) case let .embedded(_, url): self._link = .init(wrappedValue: .value(.init(content: url, thumbnail: nil, label: ""))) case let .link(url): self._link = .init(wrappedValue: .value(url)) case .titleOnly, .text, .poll, nil: break } } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: canDismiss) { NavigationStack { contentView .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .background(.themedGroupedBackground) } .presentationBackground(.themedGroupedBackground) .onAppear { contentTextView.resignFirstResponder() titleTextView.becomeFirstResponder() } } .onAppear { targets.first?.onAccountChange = checkSlurFilters } .onChange(of: imageManager?.image) { imageUrl = imageManager?.image?.url } .onChange(of: presentationSelection) { if presentationSelection == .large { restoreFocusState() } else { saveFocusState() } } .onChange(of: navigation.isTopSheet) { if navigation.isTopSheet { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: restoreFocusState) } else { saveFocusState() } } .onChange(of: sending) { if sending { titleTextView.resignFirstResponder() titleTextView.isEditable = false contentTextView.resignFirstResponder() contentTextView.isEditable = false } else { titleTextView.isEditable = true contentTextView.isEditable = true } } .onDisappear { if !navigation.isAlive, !sending { Task { do { try await imageManager?.image?.delete() try await thumbnailManager.image?.delete() } catch { handleError(error, silent: true) } } uploadHistory.deleteAll() } } } @ViewBuilder var contentView: some View { ScrollView { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { targetSelectionView if postToEdit == nil { Line() .stroke(style: StrokeStyle(lineWidth: 2, dash: [5])) .frame(height: 2) .foregroundStyle(.themedPrimary.opacity(0.2)) // The line isn't centered properly due to the way that SwiftUI shapes work; this fixes it .padding(.bottom, -1) .padding(.top, 1) } VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { VStack(spacing: Constants.main.standardSpacing) { MarkdownTextEditor( onChange: { // Avoid unnecessary view update if titleIsEmpty != $0.isEmpty { titleIsEmpty = $0.isEmpty } checkSlurFilter(text: $0, slurMatches: $titleSlurMatches) }, prompt: "Title", textView: titleTextView, font: .preferredFont(forTextStyle: .title2), content: { MarkdownEditorToolbarView( showing: .inlineOnly, textView: titleTextView, model: .init() ) } ) .frame( maxWidth: .infinity, minHeight: minTitleEditorHeight, maxHeight: .infinity, alignment: .topLeading ) if !titleSlurMatches.isEmpty { FilterViolationWarning(failures: titleSlurMatches) .padding(.horizontal, Constants.main.standardSpacing) .padding(.bottom, Constants.main.standardSpacing) } } .padding(.top, Constants.main.halfSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) if hasNsfwTag { nsfwTagView .padding(.leading, 10) .transition(attachmentTransition) } attachmentPickerView VStack { MarkdownTextEditor( onChange: { // Avoid unnecessary view update if contentIsEmpty != $0.isEmpty { contentIsEmpty = $0.isEmpty } checkSlurFilter(text: $0, slurMatches: $bodySlurMatches) }, prompt: "Optional Description", textView: contentTextView, content: { MarkdownEditorToolbarView( textView: contentTextView, uploadHistory: uploadHistory, model: markdownToolbarEditorModel ) } ) .onChange(of: primaryApi, initial: true) { markdownToolbarEditorModel.imageUploadApi = primaryApi } .frame( maxWidth: .infinity, minHeight: minTextEditorHeight, maxHeight: .infinity, alignment: .topLeading ) if !bodySlurMatches.isEmpty { FilterViolationWarning(failures: bodySlurMatches) .padding(.horizontal, Constants.main.standardSpacing) .padding(.bottom, Constants.main.standardSpacing) } } .padding([.vertical, .bottom], Constants.main.standardSpacing) .background( .themedSecondaryGroupedBackground, in: UnevenRoundedRectangle(cornerRadii: .init( topLeading: Constants.main.standardSpacing, bottomLeading: Constants.main.standardSpacing, bottomTrailing: Constants.main.standardSpacing, topTrailing: Constants.main.standardSpacing )) ) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) .animation(.snappy(duration: 0.2, extraBounce: 0.05), value: animationHashValue) } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommentEditor/PostEditor/PostEditorWebsitePreviewView.swift ================================================ // // PostEditorWebsitePreviewView.swift // Mlem // // Created by Sjmarf on 2025-10-07. // import MlemMiddleware import SwiftUI import Theming struct PostEditorWebsitePreviewView: View { @Environment(\.palette) var palette @Setting(\.post_webPreview_showIcon) var showFavicons @Setting(\.behavior_muteVideos) var muteVideos @Binding var link: PostLink @Binding var imageManager: ImageUploadManager @State var isEditing: Bool = false let primaryApi: ApiClient let removeCallback: () -> Void let shouldBlur: Bool var body: some View { content .frame(maxWidth: .infinity, alignment: .leading) .background(.themedSecondaryGroupedBackground) .clipShape(RoundedRectangle(cornerRadius: Constants.main.mediumItemCornerRadius)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.mediumItemCornerRadius)) .paletteBorder(cornerRadius: Constants.main.mediumItemCornerRadius) .contentShape(.rect) } @ViewBuilder var content: some View { VStack(alignment: .leading, spacing: 0) { if isEditing { LinkEditorView(url: link.content, api: primaryApi) { link in self.link = link withAnimation(.easeOut(duration: 0.2)) { isEditing = false } } } else if let thumbnailUrl = imageManager.image?.url ?? link.effectiveThumbnail { imageView(thumbnailUrl) footerView(withLinkHost: false, withRemoveButton: false) } else if primaryApi.supports(.customPostThumbnail, defaultValue: false) { imagePlaceholderView footerView(withLinkHost: true, withRemoveButton: false) } else { footerView(withLinkHost: true, withRemoveButton: true) } } } @ViewBuilder func footerView(withLinkHost showLinkHost: Bool, withRemoveButton showRemoveButton: Bool) -> some View { HStack { VStack(alignment: .leading, spacing: 5) { if showLinkHost { LinkHostView(link: link, withCapsule: false) .padding([.horizontal, .top], Constants.main.standardSpacing) } titleView } Spacer() HStack { editButton .padding(.vertical, 10) if showRemoveButton { removeButton } } .foregroundStyle(.secondary, .themedTertiaryGroupedBackground) .padding(.trailing, 10) } } @ViewBuilder var titleView: some View { Text(link.label) .font(.subheadline) .fontWeight(.semibold) .padding(Constants.main.standardSpacing) .foregroundStyle(.themedPrimary) } @ViewBuilder func imageView(_ thumbnailUrl: URL) -> some View { MediaView( url: thumbnailUrl, controlState: .constant(.init( blurred: shouldBlur, animating: false, muted: muteVideos )), aspectRatioBounds: .bounded(vertical: .init(width: 1, height: 1), horizontal: nil), contentMode: .fill, overlays: shouldBlur ? [.controls, .nsfw, .error] : [.controls, .error] ) .overlay(alignment: .bottomLeading) { LinkHostView(link: link, withCapsule: true) .padding(Constants.main.halfSpacing) } .overlay(alignment: .topLeading) { if primaryApi.supports(.customPostThumbnail, defaultValue: false) { thumbnailUploadButton .padding(10) } } .overlay(alignment: .topTrailing) { removeButton .padding(10) .foregroundStyle(.secondary, .thinMaterial) } } @ViewBuilder var imagePlaceholderView: some View { ZStack { ThemedColor.themedAccent.resolve(with: palette).opacity(0.2) .frame(maxWidth: .infinity) .aspectRatio(5 / 3, contentMode: .fit) VStack { Image(icon: .general.photoLibary) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.themedAccent.opacity(0.2)) .frame(width: 80) Text("No image") .foregroundStyle(.themedAccent.opacity(0.5)) .fontWeight(.semibold) ImageUploadMenu(imageManager: imageManager, imageUploadApi: primaryApi) { Text("Upload") } .font(.footnote) .buttonStyle(.borderedProminent) .padding(.top, 10) } } .overlay(alignment: .topTrailing) { removeButton .padding(10) .foregroundStyle(.themedAccent, .themedAccent.opacity(0.2)) } } @ViewBuilder var removeButton: some View { Button( "Remove", icon: .general.close ) { Task { do { try await imageManager.image?.delete() removeCallback() } catch { handleError(error) } } } .buttonStyle(OverlayButtonStyle()) } @ViewBuilder var editButton: some View { Button("Edit link", icon: .general.link) { withAnimation(.easeOut(duration: 0.2)) { isEditing = true } } .buttonStyle(OverlayButtonStyle()) } @ViewBuilder var thumbnailUploadButton: some View { if imageManager.image != nil { Button("Custom Thumbnail", icon: .general.close) { Task { do { try await imageManager.delete() } catch { handleError(error) } } } .fontWeight(.semibold) .padding(.vertical, 2) .padding(.horizontal, 8) .background(.thinMaterial, in: .capsule) .foregroundStyle(.secondary) .padding(5) } else { ImageUploadMenu(imageManager: imageManager, imageUploadApi: primaryApi) { Label("Change Thumbnail", icon: .general.chooseImage) } .buttonStyle(OverlayButtonStyle()) .foregroundStyle(.secondary, .thinMaterial) } } } private struct OverlayButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.title) .fontWeight(.semibold) .imageScale(.large) .labelStyle(.iconOnly) .symbolVariant(.circle.fill) .symbolRenderingMode(.palette) } } ================================================ FILE: Mlem/App/Views/Pages/Editors/CommunityDescriptionEditorView.swift ================================================ // // CommunityDescriptionEditorView.swift // Mlem // // Created by Sjmarf on 2025-10-30. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct CommunityDescriptionEditorView: View { @Environment(NavigationLayer.self) var navigation @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let community: Community @State var textView: UITextView = .init() @State var textHasChanged: Bool = false @State var sending: Bool = false @State var markdownToolbarEditorModel: MarkdownEditorToolbarModel = .init() @State var uploadHistory: ImageUploadHistoryManager = .init() @State var presentationSelection: PresentationDetent = .large init(community: Community) { self.community = community textView.text = community.description ?? "" } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: !textHasChanged) { NavigationStack { content .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { if sending { ProgressView() } else { sendButton } } } } } .onChange(of: presentationSelection) { if presentationSelection == .large { textView.becomeFirstResponder() } } .onChange(of: navigation.isTopSheet) { if navigation.isTopSheet, navigation.model != nil { textView.becomeFirstResponder() } } } var content: some View { ScrollView { textEditorView .frame( maxWidth: .infinity, minHeight: minTextEditorHeight, maxHeight: .infinity, alignment: .topLeading ) .padding(.vertical, Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .padding(.horizontal, Constants.main.standardSpacing) } .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .scrollBounceBehavior(.basedOnSize) } var textEditorView: some View { MarkdownTextEditor( onChange: { newValue in textHasChanged = newValue != (community.description ?? "") }, prompt: "Start writing...", textView: textView, content: { MarkdownEditorToolbarView( textView: textView, uploadHistory: uploadHistory, model: markdownToolbarEditorModel ) } ) } @ViewBuilder var sendButton: some View { Button("Send", icon: community.description == nil ? .lemmy.send : .general.success) { sending = true Task(priority: .userInitiated) { await send() } } .disabled(!textHasChanged) .glassProminentButtonStyle() } func send() async { uploadHistory.deleteWhereNotPresent(in: textView.text) community.updateDescription(textView.text) { status in switch status { case .success: textView.resignFirstResponder() textView.isEditable = false hapticManager.play(haptic: .success, tier: .low) dismiss() case let .failure(error): sending = false handleError(error) } } } var minTextEditorHeight: CGFloat { UIFont.preferredFont(forTextStyle: .body).lineHeight * 4 + 15 } } ================================================ FILE: Mlem/App/Views/Pages/Editors/ContentPurgeEditorView.swift ================================================ // // ContentPurgeEditorView.swift // Mlem // // Created by Sjmarf on 2024-10-26. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct ContentPurgeEditorView: View { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let target: any PurgableProviding @State var community: ExpectedValue<(Community)>? @State var reason: String = "" @FocusState var reasonFocused: Bool @State var presentationSelection: PresentationDetent = .large init(target: any PurgableProviding) { self.target = target self._community = .init(wrappedValue: (target as? any InteractableProviding)?.community) } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: reason.isEmpty) { NavigationStack { Form { Section { WarningView( icon: .lemmy.purge, text: "Purged content is erased from the database and cannot be restored.", inList: true ) } Section { TextField("Reason (Optional)", text: $reason, axis: .vertical) .focused($reasonFocused) } Section { ReasonShortcutView(reason: $reason) } if let community { ExpectedView(community) { community in RulesListView(model: community, reason: $reason) } } if let instance = appState.firstSession.instance { RulesListView(model: instance, reason: $reason) } } .scrollDismissesKeyboard(.interactively) .navigationBarTitleDisplayMode(.inline) .navigationTitle("Purge") .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Send", icon: .lemmy.send) { Task { await send() } } .glassProminentButtonStyle() } } } .onAppear { reasonFocused = true } } } func send() async { do { try await target.purge(reason: reason.isEmpty ? nil : reason) hapticManager.play(haptic: .success, tier: .low) dismiss() } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/ContentRemovalEditorView.swift ================================================ // // ContentRemovalEditorView.swift // Mlem // // Created by Sjmarf on 09/10/2024. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct ContentRemovalEditorView: View { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss enum Mode { case remove, restore } let target: any RemovableProviding @State var mode: Mode @State var community: ExpectedValue<(Community)>? @State var reason: String = "" @FocusState var reasonFocused: Bool @State var presentationSelection: PresentationDetent = .large init(target: any RemovableProviding) { self.target = target self._mode = .init(wrappedValue: target.removed ? .restore : .remove) self._community = .init(wrappedValue: (target as? any InteractableProviding)?.community) } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: reason.isEmpty) { NavigationStack { Form { TextField("Reason (Optional)", text: $reason, axis: .vertical) .focused($reasonFocused) if mode == .remove { Section { ReasonShortcutView(reason: $reason) } // ExpectedView causes rendering issues here if let community = community?.value { RulesListView(model: community, reason: $reason) } if let instance = appState.firstSession.instance { RulesListView(model: instance, reason: $reason) } } } .scrollDismissesKeyboard(.interactively) .navigationBarTitleDisplayMode(.inline) .navigationTitle(mode == .restore ? "Restore" : "Remove") .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Send", icon: .lemmy.send) { send() } .glassProminentButtonStyle() } } } .onAppear { reasonFocused = true } } } func send() { target.toggleRemoved(reason: reason) { status in Task { @MainActor in switch status { case .success: hapticManager.play(haptic: .success, tier: .low) dismiss() case let .failure(error): handleError(error) } } } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/FilterViolationWarning.swift ================================================ // // FilterViolationWarning.swift // Mlem // // Created by Eric Andrews on 2025-02-03. // import SwiftUI struct FilterViolationWarning: View { let failures: [String: String] var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { Label("Filter violation", icon: .general.warning) .font(.footnote) .foregroundStyle(.themedWarning) .padding(.vertical, 5) .padding(.horizontal, 7) .background { Capsule() .fill(.themedWarning.opacity(0.2)) .stroke(.themedWarning) } ForEach(failures.keys.sorted(), id: \.self) { instance in let failingText = Text(failures[instance] ?? "").fontWeight(.semibold) Text("\(instance) disallows \(failingText)") } } .frame(maxWidth: .infinity, alignment: .leading) } } ================================================ FILE: Mlem/App/Views/Pages/Editors/NoteEditorView.swift ================================================ // // NoteEditorView.swift // Mlem // // Created by Sjmarf on 2025-12-14. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct NoteEditorView: View { @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let person: Person @State var note: String @FocusState var textFieldFocused: Bool @State var presentationSelection: PresentationDetent = .large init(person: Person) { self.person = person self.note = person.note ?? "" } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: note.isEmpty) { NavigationStack { Form { TextField("Note", text: $note, axis: .vertical) .focused($textFieldFocused) } .scrollDismissesKeyboard(.interactively) .navigationBarTitleDisplayMode(.inline) .navigationTitle("Edit Note") .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Save", icon: .general.success) { Task { await send() } } .glassProminentButtonStyle() } } } .onAppear { textFieldFocused = true } } } func send() async { person.updateNote(content: note.isEmpty ? nil : note) hapticManager.play(haptic: .success, tier: .low) dismiss() } } ================================================ FILE: Mlem/App/Views/Pages/Editors/PersonBanEditorView+Logic.swift ================================================ // // PersonBanEditorView+Logic.swift // Mlem // // Created by Sjmarf on 2024-11-15. // import Foundation extension PersonBanEditorView { var dateFormatter: DateComponentsFormatter { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 1 return formatter } func send() async { do { if shouldBan { try await banUser() } else { try await unbanUser() } dismiss() } catch { handleError(error) } } private func banUser() async throws { if targetInstance { try await person.banFromInstance( removeContent: removeContent, reason: reason, expires: isPermanent ? nil : expiryDate ) } else if let community { try await person.ban( from: community, removeContent: removeContent, reason: reason, expires: isPermanent ? nil : expiryDate ) } } private func unbanUser() async throws { if targetInstance { try await person.unbanFromInstance(reason: reason) } else if let community { try await person.unban(from: community, reason: reason) } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/PersonBanEditorView.swift ================================================ // // PersonBanEditorView.swift // Mlem // // Created by Sjmarf on 2024-11-11. // import ComponentViews import Haptics import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct PersonBanEditorView: View { enum FocusedField: Hashable { case reason, days } @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss let person: Person let community: Community? var isBannedFromCommunity: Bool var shouldBan: Bool = true @State var targetInstance: Bool @State var isPermanent: Bool = true @State var expiryDate: Date = Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now @State var reason: String = "" @State var removeContent: Bool = false @FocusState var focusedField: FocusedField? @State var presentationSelection: PresentationDetent = .large var selectedTarget: (any ProfileProviding)? { if targetInstance { appState.firstSession.instance } else { community } } init( person: Person, community: Community?, isBannedFromCommunity: Bool, shouldBan: Bool ) { self.person = person self.community = community self.isBannedFromCommunity = isBannedFromCommunity self.shouldBan = shouldBan let isCommunityModerator: Bool if let community { isCommunityModerator = (AppState.main.firstSession as? UserSession)?.person?.moderates?(.community(community)) ?? false } else { isCommunityModerator = false } self._targetInstance = .init( wrappedValue: !(isCommunityModerator || person.bannedFromInstance == shouldBan) || isBannedFromCommunity == shouldBan ) } var days: Int { get { Calendar.current.dateComponents( [.day], from: .now, // This prevents the number of days ticking down if you leave the sheet open for more than a minute to: expiryDate.addingTimeInterval(60 * 60) ).day ?? 0 } nonmutating set { expiryDate = Calendar.current.date(byAdding: .day, value: newValue, to: .now) ?? .now } } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: reason.isEmpty) { NavigationStack { Form { scopeSection if appState.firstApi.supports(.unbanWithReason, defaultValue: true) || shouldBan { reasonSection } if shouldBan { durationSection removeContentSection } } .navigationTitle(shouldBan ? "Ban \(person.name)" : "Unban \(person.name)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Send", icon: .lemmy.send) { Task { await send() } } .glassProminentButtonStyle() } } } } } var scopeSectionTitle: LocalizedStringResource { if community != nil, appState.firstApi.isAdmin { shouldBan ? "Ban from..." : "Unban from..." } else { shouldBan ? "Banning from..." : "Unbanning from..." } } @ViewBuilder var scopeSection: some View { Section { if let instance = appState.firstSession.instance { if let community, appState.firstApi.isAdmin, isBannedFromCommunity == person.bannedFromInstance { Menu { Picker("Ban Target", selection: $targetInstance) { Label(instance).tag(true) Label(community).tag(false) } } label: { HStack { targetLabel Spacer() Image(icon: .general.dropDown) .fontWeight(.semibold) .foregroundStyle(.themedSecondary) } } .buttonStyle(.empty) } else { targetLabel } } } header: { Text(scopeSectionTitle) .textCase(nil) } } @ViewBuilder var targetLabel: some View { if let selectedTarget { HStack { CircleCroppedImageView(selectedTarget, frame: 24) Text(selectedTarget.name) } } } @ViewBuilder var reasonSection: some View { if let selectedTarget { Group { Section { TextField("Reason", text: $reason, axis: .vertical) .focused($focusedField, equals: .reason) } Section { ReasonShortcutView(reason: $reason, rulesTarget: selectedTarget) } .listSectionSpacing(10) } } } @ViewBuilder var durationSection: some View { Section { Toggle("Permanent", isOn: $isPermanent) .tint(.themedWarning) } .listSectionSpacing(60) Section("Ban Duration") { HStack { Text("Days:") .onTapGesture { focusedField = .days } TextField(String(""), value: Binding( get: { days }, set: { days = $0 } ), format: .number) .keyboardType(.numberPad) .focused($focusedField, equals: .days) } DatePicker( "Expires:", selection: $expiryDate, in: Date.now..., displayedComponents: [.date, .hourAndMinute] ) HStack { daysPresetButton(.init(day: 1), value: 1) daysPresetButton(.init(day: 3), value: 3) daysPresetButton(.init(day: 7), value: 7) daysPresetButton(.init(day: 30), value: 30) daysPresetButton(.init(day: 60), value: 60) daysPresetButton(.init(day: 90), value: 90) daysPresetButton(.init(year: 1), value: 365) } .padding(.horizontal, -8) } .opacity(isPermanent ? 0.5 : 1) .disabled(isPermanent) } @ViewBuilder func daysPresetButton(_ date: DateComponents, value: Int) -> some View { Button(dateFormatter.string(for: date) ?? "") { days = value hapticManager.play(haptic: .gentleInfo, tier: .low) } .buttonStyle(BanFormButtonStyle(selected: days == value && !isPermanent)) } @ViewBuilder var removeContentSection: some View { Section { Toggle("Remove Content", isOn: $removeContent) .tint(.themedWarning) } } } private struct BanFormButtonStyle: ButtonStyle { let selected: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .font(.callout) .foregroundStyle(selected ? .themedContrastingLabel : .themedPrimary) .padding(.vertical, 4) .frame(maxWidth: 150) .background(selected ? .themedAccent : .themedGroupedBackground, in: .rect(cornerRadius: 6)) } } ================================================ FILE: Mlem/App/Views/Pages/Editors/RegistrationApplicationDenialEditorView.swift ================================================ // // RegistrationApplicationDenialEditorView.swift // Mlem // // Created by Sjmarf on 2025-01-14. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct RegistrationApplicationDenialEditorView: View { @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let application: RegistrationApplication @State var reason: String = "" @FocusState var reasonFocused: Bool @State var presentationSelection: PresentationDetent = .large var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: reason.isEmpty) { NavigationStack { Form { TextField("Reason (Optional)", text: $reason, axis: .vertical) .focused($reasonFocused) } .scrollDismissesKeyboard(.interactively) .navigationBarTitleDisplayMode(.inline) .navigationTitle("Deny Application") .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Send", icon: .lemmy.send) { Task { await send() } } .glassProminentButtonStyle() } } } .onAppear { reasonFocused = true } } } func send() async { let result = await application.deny(reason: reason.isEmpty ? nil : reason).result.get() switch result { case .succeeded: hapticManager.play(haptic: .success, tier: .low) dismiss() case .failed: ToastModel.main.add(.failure()) default: break } } } ================================================ FILE: Mlem/App/Views/Pages/Editors/ReportEditorView.swift ================================================ // // ReportEditorView.swift // Mlem // // Created by Sjmarf on 11/08/2024. // import ComponentViews import Haptics import MlemMiddleware import SwiftUI struct ReportEditorView: View { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss let target: any ReportableProviding @State var community: (any ValueProviding<(Community)>)? @State var reason: String = "" @FocusState var reasonFocused: Bool @State var presentationSelection: PresentationDetent = .large init(target: any ReportableProviding, community: Community?) { self.target = target if let community { self._community = .init(wrappedValue: RealizedValue(community)) } else if let community = (target as? any InteractableProviding)?.community { self._community = .init(wrappedValue: community) } else { self._community = .init(wrappedValue: nil) } } var body: some View { CollapsibleSheetView(presentationSelection: $presentationSelection, canDismiss: reason.isEmpty) { NavigationStack { Form { TextField("Reason", text: $reason, axis: .vertical) .focused($reasonFocused) Section { ReasonShortcutView(reason: $reason) } // ExpectedView causes rendering issues here if let community = community?.value { RulesListView(model: community, reason: $reason) } if let instance = appState.firstSession.instance { RulesListView(model: instance, reason: $reason) } } .scrollDismissesKeyboard(.interactively) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Button("Send", icon: .lemmy.send) { Task { await send() } } .glassProminentButtonStyle() .disabled(reason.isEmpty) } } } .onAppear { reasonFocused = true } } } func send() async { do { try await target.report(reason: reason) hapticManager.play(haptic: .success, tier: .low) dismiss() } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Views/Pages/ExternalApiInfoView.swift ================================================ // // ExternalApiInfoView.swift // Mlem // // Created by Sjmarf on 08/06/2024. // import ComponentViews import MlemMiddleware import SwiftUI struct ExternalApiInfoView: View { @Environment(AppState.self) private var appState @State private var isLoading: Bool = true @State private var internalFederationStatus: FederationStatus? @State private var externalFederationStatus: FederationStatus? @State private var externalInstance: Instance? /// The ``ApiClient`` of the model being inspected. let fallbackApi: ApiClient /// The local ``ApiClient`` of the model, created using `entityHost`. let entityLocalApi: ApiClient init(api: ApiClient, actorId: ActorIdentifier) { self.fallbackApi = api self.entityLocalApi = .getApiClient(url: actorId.hostUrl, username: nil) } var body: some View { VStack { if isLoading { Text("Diagnosing...") .foregroundStyle(.secondary) } else { ScrollView { content } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .animation(.easeOut(duration: 0.2), value: isLoading) .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .task { await loadData() } .presentationBackgroundInteraction(.enabled) } @ViewBuilder var content: some View { VStack(spacing: 16) { box(spacing: 16) { if internalFederationStatus?.isAllowed ?? false, externalFederationStatus?.isAllowed ?? false { Text( // swiftlint:disable:next line_length "Your instance and **\(entityLocalApi.host)** federate, but the content could not be loaded. It may not have federated yet, or your instance may have purged it." ) .padding(.horizontal, Constants.main.standardSpacing) } else { avatars .padding(.horizontal, 16) Text(text) .multilineTextAlignment(.center) .padding(.horizontal, 16) } } box(alignment: .leading) { Text("This content will be loaded from **\(fallbackApi.host)** instead.") .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Constants.main.standardSpacing) } box(alignment: .leading, spacing: 6) { Text("What is Federation?") .font(.title2) .fontWeight(.bold) .padding(.horizontal, Constants.main.standardSpacing) Text( String( localized: "federation.explanation", // swiftlint:disable:next line_length defaultValue: "Lemmy instances talk to each other so that content can be shared across sites. This is called \"federation\". Instance administrators can choose which other instances they would like their instance to federate with. Some instances federate with all but a curated \"block-list\" of other instances; other instances might only federate with instances on an \"allow-list\"." ) ) .padding(.horizontal, Constants.main.standardSpacing) } } .frame(maxWidth: .infinity) .padding(16) } @ViewBuilder func box( alignment: HorizontalAlignment = .center, spacing: CGFloat = 16, @ViewBuilder content: () -> some View ) -> some View { VStack(alignment: alignment, spacing: spacing) { content() } .padding(.vertical, 16) .frame(maxWidth: .infinity) .background( .themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.mediumItemCornerRadius) ) } @ViewBuilder var avatars: some View { Line() .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [5])) .frame(height: 2) .foregroundStyle(.themedTertiary) .frame(width: 150, height: 48) .padding(.horizontal) .overlay { HStack { CircleCroppedImageView(appState.firstSession.instance, frame: 48) Image(icon: .general.failure) .bold() .foregroundStyle(.red) .imageScale(.large) .frame(maxWidth: .infinity) CircleCroppedImageView(externalInstance, frame: 48) } } } var text: LocalizedStringKey { let externalHost = entityLocalApi.host let internalHost = appState.firstApi.host switch (externalFederationStatus?.isAllowed ?? false, internalFederationStatus?.isAllowed ?? false) { case (false, false): return "**\(internalHost)** and **\(externalHost)** chose to defederate from one another." case (false, true): if externalFederationStatus?.isExplicit ?? false { return "**\(externalHost)** chose to defederate from your instance, **\(internalHost)**." } else { return "**\(externalHost)** hasn't chosen to federate with your instance, **\(internalHost)**." } case (true, false): if internalFederationStatus?.isExplicit ?? false { return "Your instance, **\(internalHost)**, chose to defederate from **\(externalHost)**." } else { return "Your instance, **\(internalHost)**, hasn't chosen to federate with **\(externalHost)**." } case (true, true): return "Unknown" } } @MainActor func loadData() async { let externalApi = entityLocalApi let internalApi = appState.firstApi do { async let externalFederationStatus = await externalApi.federatedWith(with: internalApi.baseUrl) async let internalFederationStatus = await internalApi.federatedWith(with: externalApi.baseUrl) async let externalInstance = await externalApi.getMyInstance() self.externalFederationStatus = try await externalFederationStatus self.internalFederationStatus = try await internalFederationStatus self.externalInstance = try await externalInstance } catch { handleError(error, silent: true) } isLoading = false } } ================================================ FILE: Mlem/App/Views/Pages/ImageViewer+Views.swift ================================================ // // ImageViewer+Views.swift // Mlem // // Created by Eric Andrews on 2025-01-18. // import SwiftUI import Icons // swiftlint:disable file_length extension ImageViewer { struct ControlTranslationEffect: GeometryEffect { var offset: CGFloat var isDismissing: Bool var animatableData: CGFloat { get { isDismissing ? 0 : offset } set { offset = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { return ProjectionTransform(.init(translationX: 0, y: offset)) } } @ViewBuilder var controlOverlay: some View { VStack { topControlBar .modifier(ControlTranslationEffect(offset: -controlOffset, isDismissing: isDismissing)) Spacer() bottomControlBar .modifier(ControlTranslationEffect(offset: controlOffset, isDismissing: isDismissing)) } .font(.title2) .fontWeight(.light) .foregroundStyle(.white) .labelStyle(.iconOnly) .opacity(controlOpacity) } // MARK: Top control bar @ViewBuilder var topControlBar: some View { HStack(alignment: .top) { Spacer() if developerMode { devTools } if showCloseButton { closeButton .padding(.trailing, Constants.main.standardSpacing) } } } @ViewBuilder var closeButton: some View { Button { fadeDismiss() } label: { if #available(iOS 26, *) { closeButtonContent .glassEffect(.regular.interactive()) } else { closeButtonContent .background(.ultraThinMaterial, in: .circle) } } .contentShape(.rect) .environment(\.colorScheme, .dark) } @ViewBuilder var devTools: some View { Group { if !devToolsShown { Button { withAnimation { devToolsShown = true } } label: { if #available(iOS 26, *) { devToolsButtonContent .glassEffect(.regular.interactive()) } else { devToolsButtonContent .background(.ultraThinMaterial, in: .circle) } } .contentShape(.rect) .environment(\.colorScheme, .dark) } else { Group { if #available(iOS 26, *) { devToolsContent .glassEffect(.regular.interactive(), in: .rect(cornerRadius: Constants.main.standardSpacing)) } else { devToolsContent .background(.ultraThinMaterial, in: .rect(cornerRadius: Constants.main.standardSpacing)) } } .onTapGesture { withAnimation { devToolsShown = false } } } } .environment(\.colorScheme, .dark) } // MARK: Bottom control bar @ViewBuilder var bottomControlBar: some View { VStack(spacing: 0) { if controlState.animationAvailable { playbackBar } ZStack(alignment: .bottom) { if controlState.animationAvailable { playButton .frame(maxWidth: .infinity, alignment: .leading) } Group { if #available(iOS 26, *) { bottomControlBarContent .glassEffect(.regular.interactive()) } else { bottomControlBarContent .background { Capsule().fill(.ultraThinMaterial) } } } .frame(maxWidth: .infinity, alignment: .center) if controlState.audioAvailable { muteButton .frame(maxWidth: .infinity, alignment: .trailing) } } } .environment(\.colorScheme, .dark) } @ViewBuilder var playbackBar: some View { VStack(spacing: Constants.main.halfSpacing) { if let readouts = controlState.playbackReadouts { HStack { Text(readouts.position) Spacer() Text(readouts.duration) } .font(.footnote) .fontWeight(.semibold) .shadow(radius: 2) } playbackBarBaseCapsule .frame(maxWidth: .infinity) .frame(height: 10) .overlay { GeometryReader { geo in let width = geo.size.width - 10 // prevent circle going past end of capsule Circle() .fill(.white) .frame(width: 6, height: 6) .padding(2) .offset(x: (controlState.scrubTarget ?? controlState.playbackPosition) * width) .onAppear { // set playbackBarHitbox to be a bit thicker than the real hitbox let realHitbox = geo.frame(in: .global) playbackBarHitbox = .init( x: realHitbox.minX, y: realHitbox.maxY - 80, width: realHitbox.width, height: 100 ) } } } .environment(\.colorScheme, .dark) } .padding(.horizontal, Constants.main.standardSpacing) .allowsHitTesting(false) } @ViewBuilder var playButton: some View { Button { controlState.animating.toggle() } label: { Group { if #available(iOS 26, *) { playButtonContent .glassEffect(.regular.interactive()) } else { playButtonContent .background(.ultraThinMaterial, in: .circle) } } .padding(.leading, Constants.main.standardSpacing) .padding([.top, .trailing], Constants.main.doubleSpacing) .contentShape(.rect) } } @ViewBuilder var saveButton: some View { Button { Task { await saveMedia(url: url) } } label: { Label("Save", icon: .general.import) .padding(Constants.main.standardSpacing) .contentShape(.rect) } .offset(y: -2) } @ViewBuilder var shareButton: some View { Button { Task { await shareImage(url: url, navigation: navigation) } } label: { Label("Share", icon: .general.share) .padding(Constants.main.standardSpacing) .contentShape(.rect) } .offset(y: -2) } @ViewBuilder var quickLookButton: some View { Button { Task { await showQuickLook(url: url) } } label: { Label("Quick Look", icon: .general.menu) .symbolVariant(.circle) .padding(Constants.main.standardSpacing) .contentShape(.rect) } } @ViewBuilder var muteButton: some View { Button { controlState.muted.toggle() } label: { Group { if #available(iOS 26, *) { muteButtonContent .glassEffect(.regular.interactive()) } else { muteButtonContent .background(.ultraThinMaterial, in: .circle) } } .padding(.trailing, Constants.main.standardSpacing) .padding([.top, .leading], Constants.main.doubleSpacing) .contentShape(.rect) } } // MARK: Zoom and Scale @ViewBuilder var scaleDisplay: some View { Group { if #available(iOS 26, *) { scaleDisplayContent .glassEffect() } else { scaleDisplayContent .background { Capsule().fill(.ultraThinMaterial) } } } .environment(\.colorScheme, .dark) .padding(.leading, Constants.main.standardSpacing) .opacity(scaleDisplayShown ? 1 : 0) } @ViewBuilder func buttonLabel(text: LocalizedStringResource, icon: Icon, frameSize: CGFloat, padding: CGFloat) -> some View { Label { Text(text) } icon: { Image(icon: icon) .resizable() .scaledToFit() .frame(width: frameSize, height: frameSize) .padding(padding) } .labelStyle(.iconOnly) } @ViewBuilder func videoStateButtonLabel( isOn: Bool, text: (on: LocalizedStringResource, off: LocalizedStringResource), icons: (on: Icon, off: Icon)) -> some View { Label { Text(isOn ? text.on : text.off) } icon: { Image(icon: isOn ? icons.on : icons.off) .symbolVariant(.fill) .scaledToFit() .frame(width: 22, height: 22) .contentTransition(.symbolEffect(.replace, options: .speed(2))) .padding(Constants.main.standardSpacing + 4) // +4 to match .title2 implicit padding plus offset } .labelStyle(.iconOnly) } // MARK: Platform Compatibility // TODO: iOS 18 deprecation remove @ViewBuilder var closeButtonContent: some View { buttonLabel(text: "Close", icon: .general.close, frameSize: 18, padding: Constants.main.standardSpacing + 6) } @ViewBuilder var devToolsButtonContent: some View { buttonLabel( text: "Toggle Developer Tools", icon: .settings.developerMode, frameSize: 22, padding: Constants.main.standardSpacing + 4 ) } @ViewBuilder var devToolsContent: some View { VStack(alignment: .leading, spacing: Constants.main.halfSpacing) { let imageType: String = url.proxyAwarePathExtension?.lowercased() ?? "Unknown" Text(verbatim: "Media Type: \(imageType) ") if let duration = controlState.duration { Text(verbatim: "Duration: \(String(format: "%.4fs", duration))") .monospacedDigit() } else { Text(verbatim: "Duration: None") } Text(verbatim: "Playback Position: \(String(format: "%.4f", controlState.playbackPosition))") .monospacedDigit() if let target = controlState.scrubTarget { Text(verbatim: "Scrub Target: \(String(format: "%.4f", target))") .monospacedDigit() } else { Text(verbatim: "Scrub Target: None") } Text(verbatim: "Scrub Rate: \(String(format: "%.4f", scrubRate))") .monospacedDigit() } .padding(Constants.main.standardSpacing) .contentShape(.rect) .foregroundStyle(.white) .font(.footnote) } @ViewBuilder var playbackBarBaseCapsule: some View { if #available(iOS 26, *) { Color.clear.contentShape(.rect) .glassEffect() } else { Capsule() .fill(.ultraThinMaterial) } } @ViewBuilder var bottomControlBarContent: some View { HStack { saveButton shareButton quickLookButton } .padding(.horizontal, Constants.main.halfSpacing) } @ViewBuilder var playButtonContent: some View { videoStateButtonLabel( isOn: controlState.animating, text: (on: "Pause", off: "Play"), icons: (on: .general.pause, off: .general.play)) } @ViewBuilder var muteButtonContent: some View { videoStateButtonLabel( isOn: controlState.muted, text: (on: "Mute", off: "Unmute"), icons: (on: .general.mute, off: .general.unmute)) } @ViewBuilder var scaleDisplayContent: some View { Text(String(format: "%.1fx", scaleDisplayValue)) .foregroundStyle(.white) .padding(Constants.main.standardSpacing) .padding(.horizontal, Constants.main.halfSpacing) } } // swiftlint:enable file_length ================================================ FILE: Mlem/App/Views/Pages/ImageViewer.swift ================================================ // // ImageViewer.swift // Mlem // // Created by Sjmarf on 13/06/2024. // import SwiftUI import Media struct ImageViewer: View { @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @Setting(\.dev_developerMode) var developerMode @Setting(\.a11y_zoomSliderLocation) var zoomSliderLocation let url: URL let duration: CGFloat = 0.25 let maxControlOffset: CGFloat = 50 let screenHeight: CGFloat = UIScreen.main.bounds.height @State var controlState: MediaControlState = .init( blurred: false, animating: true, muted: Settings.get(\.behavior_muteVideos), scrubbingAvailable: true ) @Setting(\.imageViewer_showControls) var showControls @Setting(\.imageViewer_showCloseButton) var showCloseButton @Setting(\.imageViewer_showZoomIndicator) var showZoomIndicator @Setting(\.imageViewer_dismissThreshold) var dismissThreshold /// Current scale of the zoomable image @State var zoomScale: CGFloat = 1.0 /// Offset of the zoomable image @State var zoomOffset: CGSize = .zero /// True when the scale indicator should be visible, false otherwise @State var scaleDisplayShown: Bool = false /// True when the current drag gesture is a scrub, false when dismiss, nil when no gesture @State var dragIsScrub: Bool? /// controlState.playbackPosition when current scrub segment began @State var scrubStartedPlaybackPosition: CGFloat? /// controlState.playbackPosition when current scrub segment began @State var scrubSegmentOffset: CGFloat = 0 /// Current scrubbing rate @State var scrubRate: CGFloat = 1 /// Hitbox of the playback bar @State var playbackBarHitbox: CGRect? /// True when the image is zoomed in, false otherwise @State var isZoomed: Bool = false /// True when dimissal is in progress, false otherwise @State var isDismissing: Bool = false /// Vertical offset of the viewer @State var offset: CGFloat = 0 /// Opacity of the viewer @State var opacity: CGFloat = 0 /// Vertical offset for the control overlay @State var controlOffset: CGFloat = 0 /// Opacity for the control overlay @State var controlOpacity: CGFloat = 1 /// When true, enables tapping to show/hide controls @State var enableControlTap: Bool = true @State var quickLookUrl: URL? /// Value to show in the top leading scale display (either scrub rate or zoom depending) @State var scaleDisplayValue: CGFloat = 1 @State var devToolsShown: Bool = false /// Whether the controls are currently visible var controlsShown: Bool { controlOpacity > 0 } init(url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } self.controlOpacity = Settings.get(\.imageViewer_showControls) == .immediately ? 1 : 0 self.url = components.url! } var body: some View { ZoomableImageView( url: url, controlState: $controlState, scale: $zoomScale, offset: $zoomOffset, customDragMoved: dragMoved, customDragEnded: dragEnded ) { if enableControlTap, showControls != .never { if controlsShown { hideControls() } else { showControls() } } } .offset(y: offset) .background(.black) .overlay(controlOverlay) .overlay(alignment: .topLeading) { if showZoomIndicator { scaleDisplay } } .opacity(opacity) .onChange(of: isZoomed) { if isZoomed { hideControls(withSlide: true) } else if showControls == .immediately { showControls(withSlide: true) } } .onAppear { animateOpacityUpdate(1.0) } .onChange(of: scrubRate) { if dragIsScrub ?? false { // don't update value if not currently scrubbing scaleDisplayValue = scrubRate } } .onChange(of: zoomScale) { scaleDisplayValue = zoomScale isZoomed = zoomScale != 1.0 } .onChange(of: scaleDisplayValue) { if !scaleDisplayShown { withAnimation(.easeIn(duration: 0.1)) { scaleDisplayShown = true } } let oldScale: CGFloat = scaleDisplayValue DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if scaleDisplayValue == oldScale { withAnimation { scaleDisplayShown = false } } } } .quickLookPreview($quickLookUrl) .background(ClearBackgroundView()) .statusBarHidden(!isDismissing) } func dragMoved(value: BridgeDragValue) { guard !isZoomed else { return } let dragIsScrub = dragIsScrub ?? (abs(value.velocity.height) < abs(value.velocity.width)) self.dragIsScrub = dragIsScrub if dragIsScrub { if controlState.animationAvailable, controlState.enableAnimation { handleScrubUpdate(value) } } else if !isDismissing { handleOffsetUpdate(value.translation.height) } } func dragEnded() { guard let scrubbing = dragIsScrub else { assertionFailure("dragGesture ended but dragIsScrub not defined") return } dragIsScrub = nil if scrubbing { // scrub ended: reset scrubbing and re-enable control tap scrubRate = 1 scrubStartedPlaybackPosition = nil scrubSegmentOffset = 0 controlState.scrubTarget = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { enableControlTap = true } } else { // dismiss swipe ended: choose whether to dismiss or reset if abs(offset) > CGFloat(dismissThreshold) * 10 { swipeDismiss(finalOffset: offset > 0 ? screenHeight : -screenHeight) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { enableControlTap = true } animateOffsetUpdate(0) } } } func fadeDismiss() { isDismissing = true animateOpacityUpdate(0) { withoutAnimation { dismiss() } } } private func swipeDismiss(finalOffset: CGFloat = UIScreen.main.bounds.height) { isDismissing = true animateOffsetUpdate(finalOffset) { withoutAnimation { dismiss() } } } func hideControls(withSlide: Bool = false) { withAnimation(.easeOut(duration: duration)) { if withSlide { controlOffset = maxControlOffset } controlOpacity = 0 } } /// Returns controls to a visible state func showControls(withSlide: Bool = false) { guard !controlsShown else { return } controlOffset = withSlide ? maxControlOffset : 0 withAnimation(.easeIn(duration: duration)) { controlOpacity = 1 if withSlide { controlOffset = 0 } } } private func animateOpacityUpdate(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { opacity = newOpacity } if let callback { DispatchQueue.main.asyncAfter(deadline: .now() + duration) { callback() } } } /// Sets the offsets to the given value with animation. If a callback is given, calls it when the animation completes. /// - Parameters: /// - newOffset: value to update offsets to /// - callback: function to call when animation completes private func animateOffsetUpdate(_ newOffset: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { handleOffsetUpdate(newOffset) } if let callback { DispatchQueue.main.asyncAfter(deadline: .now() + duration) { callback() } } } /// Updates offset, controlOffset, and opacity to match the given raw offset˜ /// - Parameter newOffset: raw offset to update for private func handleOffsetUpdate(_ newOffset: CGFloat) { let absOffset = abs(newOffset) offset = newOffset if controlsShown { controlOffset = absOffset / 3 controlOpacity = 1.0 - (controlOffset / maxControlOffset) } opacity = 1.0 - (absOffset / screenHeight) } /// Responds to scrub updates /// - Parameter value: latest scrub gesture value private func handleScrubUpdate(_ value: BridgeDragValue) { showControls() let onPlaybackBar: Bool = playbackBarHitbox?.contains(value.startLocation) ?? false // Track playback position when current scrub segment started to offset from. // If the scrub started on playback bar, we want to snap to the start location, so we set scrubStartedPlaybackPosition // to the value corresponding to the scrub start position if scrubStartedPlaybackPosition == nil { scrubStartedPlaybackPosition = onPlaybackBar ? value.startLocation.x / UIScreen.main.bounds.width : controlState.playbackPosition } // disable variable scrub rate if scrubbing playback bar if !onPlaybackBar { // scrub rate is controlled by the height of the scrub gesture. // Every 50px increases/decreases scrub rate by 2x to a max of 8x; update in increments of 10px let heightStep: CGFloat = value.translation.height.stepped(by: 10) / 50 let newScrubRate: CGFloat = (1 / pow(2, heightStep)).bounded(lower: 0.125, upper: 8) if newScrubRate != scrubRate { // when the scrub rate changes, compute future scrub targets as if the translation started at the current point and scrubTarget scrubStartedPlaybackPosition = controlState.scrubTarget ?? controlState.playbackPosition scrubSegmentOffset = value.translation.width scrubRate = newScrubRate } } guard let scrubStartedPlaybackPosition else { assertionFailure("drag is scrub but scrubStartedPlaybackPosition is nil") return } // compute x translation since scrub segment began and adjust by scrub rate let scrubSegmentTranslation = (value.translation.width - scrubSegmentOffset) * scrubRate // convert translation to a percentage of scrub area let scrubTargetDelta = scrubSegmentTranslation / UIScreen.main.bounds.width let newScrubTarget = (scrubStartedPlaybackPosition + scrubTargetDelta).bounded(lower: 0, upper: 1) controlState.scrubTarget = newScrubTarget } func showQuickLook(url: URL) async { if let fileUrl = await downloadImageToFileSystem(url: url) { quickLookUrl = fileUrl } } } // https://stackoverflow.com/a/75037657 // .presentationBackground doesn't behave properly on iOS 17, but this does // TODO: iOS 17 deprecation: remove this and replace usage with .presentationBackground private struct ClearBackgroundView: UIViewRepresentable { func makeUIView(context: Context) -> UIView { InnerView() } func updateUIView(_ uiView: UIView, context: Context) {} private class InnerView: UIView { override func didMoveToWindow() { super.didMoveToWindow() superview?.superview?.backgroundColor = .clear } } } ================================================ FILE: Mlem/App/Views/Pages/Instance/Fediseer.swift ================================================ // // Fediseer.swift // Mlem // // Created by Sjmarf on 03/02/2024. // import Icons import MlemMiddleware import SwiftUI import Theming // https://fediseer.com/api/v1/whitelist/lemmy.world struct FediseerData: Hashable, Equatable { var instance: FediseerInstance var endorsements: [FediseerEndorsement]? var hesitations: [FediseerHesitation]? var censures: [FediseerCensure]? var topEndorsements: [FediseerEndorsement] { if var endorsements { endorsements = endorsements.sorted { $0.reason != nil && $1.reason == nil } return endorsements } return [] } func opinions(ofType type: FediseerOpinionType) -> [any FediseerOpinion] { switch type { case .endorsement: endorsements ?? [] case .hesitation: hesitations ?? [] case .censure: censures ?? [] } } func hash(into hasher: inout Hasher) { hasher.combine(instance.domain) } } struct FediseerInstance: Codable, Equatable { let id: Int let domain: String // let software: String let claimed: Int let approvals: Int // This is the number of endorsements given let endorsements: Int // This is the number of endorsements received let guarantor: String? // Fediseer lets instances admins self-report these values let sysadmins: Int? let moderators: Int? } struct FediseerEndorsements: Codable { var instances: [FediseerEndorsement] = .init() } struct FediseerHesitations: Codable { var instances: [FediseerHesitation] = .init() } struct FediseerCensures: Codable { var instances: [FediseerCensure] = .init() } enum FediseerOpinionType: CaseIterable, Identifiable { case endorsement, hesitation, censure var id: FediseerOpinionType { self } var label: String { switch self { case .endorsement: .init(localized: "Endorsements") case .hesitation: .init(localized: "Hesitations") case .censure: .init(localized: "Censures") } } } protocol FediseerOpinion { var domain: String { get } var reason: String? { get } var evidence: String? { get } static var icon: Icon { get } static var color: ThemedColor { get } } extension FediseerOpinion { var instanceStub: InstanceStub? { guard let url = URL(string: "https://\(domain)") else { return nil } return .init(api: .getApiClient(url: url, username: nil), actorId: .instance(host: domain)) } var formattedReason: String? { if let reason { return "- \(reason.split(separator: ",").joined(separator: "\n- "))" } return nil } } struct FediseerEndorsement: Codable { let domain: String let endorsementReasons: [String]? } extension FediseerEndorsement: FediseerOpinion, Equatable { static var icon: Icon = .fediseer.endorsement static var color: ThemedColor { .themedFediseerEndorsement } var reason: String? { endorsementReasons?.first } var evidence: String? { nil } } struct FediseerHesitation: Codable { let domain: String let hesitationReasons: [String]? let hesitationEvidence: [String]? } extension FediseerHesitation: FediseerOpinion, Equatable { static var icon: Icon = .fediseer.hesitation static var color: ThemedColor { .themedFediseerHesitation } var reason: String? { hesitationReasons?.first } var evidence: String? { hesitationEvidence?.first } } struct FediseerCensure: Codable { let domain: String let censureReasons: [String]? let censureEvidence: [String]? } extension FediseerCensure: FediseerOpinion, Equatable { static var icon: Icon = .fediseer.censure static var color: ThemedColor { .themedFediseerCensure } var reason: String? { censureReasons?.first } var evidence: String? { censureEvidence?.first } } ================================================ FILE: Mlem/App/Views/Pages/Instance/FediseerInfoView.swift ================================================ // // FediseerInfoView.swift // Mlem // // Created by Sjmarf on 04/02/2024. // import ComponentViews import Icons import SwiftUI // swiftlint:disable line_length struct FediseerInfoView: View { var body: some View { FancyScrollView { VStack(alignment: .leading) { subHeading("The Fediseer", icon: .fediseer.fediseer, color: .indigo) Text("The Fediseer is a service that instance administrators use to identify spam instances and express their approval or disapproval of other instances.") .padding(.horizontal, Constants.main.standardSpacing) subHeading("Guarantees", icon: .fediseer.guarantee, color: .green) Text("If an instance is \"guaranteed\", it is known as definitely not spam. Unguaranteed instances are not necessarily spam; rather, it is unknown whether a non-guaranteed instance is spam or not.\n\nAn instance can be guaranteed by any other guaranteed instance. This forms a chain of guaranteed instances known as the \"Chain of Trust\". The Chain of Trust starts at the Fediseer itself, which guarantees several of the largest instances.\n\nA guarantee can be revoked by the guarantor at any time. If an instance's guarantee is revoked, it returns to a \"not guaranteed\" state along with any instances it guarantees.\n\nOnce an instance has been guaranteed, it is able to express its approval or disapproval of other instances using endorsements, hesitations and censures.") .padding(.horizontal, Constants.main.standardSpacing) subHeading("Endorsements", icon: .fediseer.endorsement, color: .teal) Text("An endorsement signifies that an instance approves of another instance. It is completely subjective, and a reason does not have to be given.") .padding(.horizontal, Constants.main.standardSpacing) subHeading("Censures", icon: .fediseer.censure, color: .red) Text("A censure signifies that an instance disapproves of another instance. Like an endorsement, it is completely subjective and a reason does not have to be given.") .padding(.horizontal, Constants.main.standardSpacing) subHeading("Hesitations", icon: .fediseer.hesitation, color: .yellow) Text("A hesitation signifies that an instance mistrusts another instance. It is a milder version of a censure.") .padding(.horizontal, Constants.main.standardSpacing) Divider() .padding(.top, 20) linkButton( "Fediseer FAQ", systemImage: "questionmark.circle.fill", destination: URL(string: "https://fediseer.com/faq/eng")! ) .padding(.bottom, 50) } .frame(maxWidth: .infinity) } .toolbar { CloseButtonToolbarItem() } } @ViewBuilder func subHeading(_ title: LocalizedStringResource, icon: Icon, color: Color) -> some View { VStack(alignment: .leading, spacing: 5) { HStack { Image(icon: icon) .foregroundStyle(color) .symbolVariant(.fill) Text(title) .fontWeight(.semibold) } .font(.title2) .padding(.horizontal, Constants.main.standardSpacing) Divider() } .padding(.top, 20) } @ViewBuilder func linkButton(_ title: String, systemImage: String, destination: URL) -> some View { Link(destination: destination) { Label(title, systemImage: systemImage) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .fill(Color(uiColor: .secondarySystemFill)) ) } .buttonStyle(.plain) .padding(.horizontal, Constants.main.standardSpacing) } } // swiftlint:enable line_length ================================================ FILE: Mlem/App/Views/Pages/Instance/FediseerOpinionListView.swift ================================================ // // FediseerOpinionListView.swift // Mlem // // Created by Sjmarf on 04/02/2024. // import MlemMiddleware import SwiftUI import Theming struct FediseerOpinionListView: View { let instance: Instance let opinionType: FediseerOpinionType let fediseerData: FediseerData var body: some View { FancyScrollView { VStack(spacing: 16) { let items = fediseerData.opinions(ofType: opinionType).sorted { $0.reason != nil && $1.reason == nil } ForEach(items, id: \.domain) { opinion in FediseerOpinionView(opinion: opinion) } } .padding(16) } .themedGroupedBackground() .navigationTitle(opinionType.label) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/FediseerOpinionView.swift ================================================ // // EndorsementView.swift // Mlem // // Created by Sjmarf on 03/02/2024. // import ComponentViews import LemmyMarkdownUI import SwiftUI struct FediseerOpinionView: View { @Environment(\.palette) var palette let opinion: any FediseerOpinion var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { if let stub = opinion.instanceStub { NavigationLink(.instanceStub(stub)) { title } .buttonStyle(.plain) } else { title } Spacer() } .foregroundStyle(type(of: opinion).color) .padding(.horizontal) divider if let reason = opinion.formattedReason { Markdown(reason, configuration: .default(palette: palette)) .padding(.horizontal) } else { Text("No reason given") .foregroundStyle(.themedSecondary) .italic() .padding(.leading) } if let evidence = opinion.evidence { divider Markdown(evidence, configuration: .default(palette: palette)) .padding(.horizontal) } } .frame(maxWidth: .infinity) .padding(.vertical, 10) .font(.callout) .background(.themedSecondaryGroupedBackground) .cornerRadius(Constants.main.standardSpacing) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } @ViewBuilder var title: some View { Image(icon: type(of: opinion).icon) .fontWeight(.semibold) .symbolVariant(.fill) .foregroundStyle(.secondary) // Don't use palette here Text(opinion.domain) .fontWeight(.semibold) } @ViewBuilder var divider: some View { Line() .stroke(style: StrokeStyle(lineWidth: 2, dash: [5])) .frame(height: 2) .foregroundStyle(.themedGroupedBackground) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceCommunityListView.swift ================================================ // // InstanceCommunityListView.swift // Mlem // // Created by Sjmarf on 2025-11-09. // import MlemMiddleware import SwiftUI import os struct InstanceCommunityListView: View { let logger: Logger = .mlemLogger() let communityLoader: CommunityFeedLoader @Binding var errorDetails: ErrorDetails? var body: some View { LazyVStack(spacing: 0) { if let errorDetails { ErrorView(errorDetails) .padding(.top, 30) } else { SearchResultsView(results: communityLoader.items) { community in CommunityListRow( community, readout: .subscribers, visitContext: .other ) .onAppear { do { try communityLoader.loadIfThreshold(community) } catch { handleError(error) } } } EndOfFeedView(feedLoader: communityLoader, viewType: .hobbit) } } .animation(.easeOut(duration: 0.1), value: communityLoader.items.isEmpty) .task { logger.error("\(String(describing: errorDetails?.errorText()))") if errorDetails == nil { await refresh() } } } func refresh() async { do { if communityLoader.loadingState == .initial { try await communityLoader.refresh(listing: .local) } self.errorDetails = nil } catch { var errorDetails = handleErrorWithDetails(error) errorDetails?.refresh = { await refresh() return true } if case let ApiClientError.response(response, _) = error { if response.instanceIsPrivate { errorDetails?.title = String(localized: "Instance is private") errorDetails?.body = String(localized: "You cannot view the communities of a private instance.") errorDetails?.icon = .lemmy.private errorDetails?.refresh = nil } } self.errorDetails = errorDetails } } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceDetailsView.swift ================================================ // // InstanceStatsView.swift // Mlem // // Created by Sjmarf on 20/01/2024. // import Icons import MlemMiddleware import SwiftUI import Theming // swiftlint:disable:next type_body_length struct InstanceDetailsView: View { @State private var showingSlurRegex: Bool = false @State var uptimeData: UptimeDataStatus? let instance: Instance var body: some View { content .task { let fetchedData = await loadUptimeData(instance: instance) withAnimation(.easeOut(duration: 0.2)) { uptimeData = fetchedData } } } var content: some View { VStack(spacing: 16) { if instance.api.supports(.viewInstanceCreationDate, defaultValue: true) { FormSection { ProfileDateView(profilable: instance) .padding(.vertical, Constants.main.standardSpacing) } } statsView FormSection { if case let .success(uptimeData) = uptimeData { NavigationLink(.instanceUptime(instance, uptimeData: uptimeData)) { uptimeSummary } .buttonStyle(.plain) } else { uptimeSummary } } if instance.api.supports(.viewInstanceSettings, defaultValue: true) { settingsListView } } .padding([.horizontal, .bottom], 16) } @ViewBuilder var statsView: some View { HStack(spacing: 16) { ExpectedView(instance.userCount) { userCount in FormReadout("Users", value: userCount) .tint(.themedPersonAccent) } ExpectedView(instance.communityCount) { communityCount in FormReadout("Communities", value: communityCount) .tint(.themedCommunityAccent) } } .frame(maxWidth: .infinity) HStack(spacing: 16) { ExpectedView(instance.postCount) { postCount in FormReadout("Posts", value: postCount) .tint(.themedPostAccent) } ExpectedView(instance.commentCount) { commentCount in FormReadout("Comments", value: commentCount) .tint(.themedCommentAccent) } } .frame(maxWidth: .infinity) ExpectedView(instance.activeUserCount) { activeUserCount in ActiveUserCountView(activeUserCount: activeUserCount) } } @ViewBuilder var settingsListView: some View { FormSection { VStack(alignment: .leading, spacing: 0) { ExpectedView(instance.isPrivate) { isPrivate in settingRow( "Private", icon: .lemmy.private, value: isPrivate ) } Divider() ExpectedView(instance.federationEnabled) { federationEnabled in settingRow( "Federates", icon: .lemmy.federation, value: federationEnabled ) } } } FormSection { ExpectedView(instance.registrationMode) { registrationMode in VStack(alignment: .leading, spacing: 0) { settingRow( "Registration", icon: .lemmy.person, value: registrationMode.label, color: registrationMode.color ) if registrationMode != .closed { Divider() ExpectedView(instance.emailVerificationRequired) { emailVerificationRequired in settingRow( "Email Verification", icon: .general.email, value: emailVerificationRequired ) } Divider() ExpectedView(instance.captchaDifficulty) { captchaDifficulty in settingRow( "Captcha", icon: .lemmy.captcha, value: captchaLabel(for: captchaDifficulty), color: captchaDifficulty == nil ? .themedNegative : .themedPositive ) } } } } } FormSection { ExpectedView(instance.voteFederationMode) { voteMode in VStack(alignment: .leading, spacing: 0) { voteFederationRow( "Post Upvotes", type: .upvote, value: voteMode.postUpvote ) Divider() voteFederationRow( "Post Downvotes", type: .downvote, value: voteMode.postDownvote ) Divider() voteFederationRow( "Comment Upvotes", type: .upvote, value: voteMode.commentUpvote ) Divider() voteFederationRow( "Comment Downvotes", type: .downvote, value: voteMode.commentDownvote ) } } } FormSection { VStack(alignment: .leading, spacing: 0) { ExpectedView(instance.nsfwContentEnabled) { nsfwContentEnabled in settingRow( "NSFW Content", icon: .settings.blurNsfw, value: nsfwContentEnabled ) } Divider() ExpectedView(instance.communityCreationRestrictedToAdmins) { communityCreationRestrictedToAdmins in settingRow( "Community Creation", icon: .lemmy.community, value: !communityCreationRestrictedToAdmins ) } Divider() // ExpectedView causes rendering issues here if let slurFilterRegex = instance.slurFilterRegex.value { Group { settingRow( "Slur Filter", icon: .general.filter, value: slurFilterRegex != nil ) if let slurFilterRegex { Divider() VStack(alignment: .leading, spacing: 2) { if showingSlurRegex { Text(slurFilterRegex) .foregroundStyle(.themedSecondary) .textSelection(.enabled) } else { Text("Tap to show slur filter regex.") Label( "This probably contains foul language.", icon: .general.warning ) .foregroundStyle(.themedCaution) } } .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .contentShape(.rect) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { showingSlurRegex.toggle() } } } } } Divider() ExpectedView(instance.defaultFeed) { defaultFeed in settingRow( "Default Feed Type (Desktop)", icon: .lemmy.feed, value: defaultFeed.label ) } } } FormSection { VStack(alignment: .leading, spacing: 0) { ExpectedView(instance.hideModlogNames) { hideModlogNames in settingRow( "Show Mod Names in Modlog", icon: .lemmy.moderation, value: !hideModlogNames ) } Divider() ExpectedView(instance.emailApplicationsToAdmins) { emailApplicationsToAdmins in settingRow( "Applications Email Admins", icon: .lemmy.registrationApplication, value: emailApplicationsToAdmins ) } Divider() ExpectedView(instance.emailReportsToAdmins) { emailReportsToAdmins in settingRow( "Reports Email Admins", icon: .lemmy.report, value: emailReportsToAdmins ) } } } } @ViewBuilder func settingRow( _ label: LocalizedStringResource, icon: Icon, value: LocalizedStringResource, color: ThemedColor? = nil ) -> some View { HStack { Image(icon: icon) .foregroundStyle(.themedSecondary) .frame(width: 30) Text(label) Spacer() Text(value) .foregroundStyle(color ?? .themedPrimary) } .padding(12) } func captchaLabel(for diff: CaptchaDifficulty?) -> LocalizedStringResource { if let diff { return .init( "Captcha Difficulty Yes", defaultValue: "Yes (\(diff.label))", comment: "Used to indicate Captcha difficulty. E.g. \"Yes (Hard)\"." ) } return "No" } @ViewBuilder func settingRow(_ label: LocalizedStringResource, icon: Icon, value: Bool) -> some View { settingRow( label, icon: icon, value: value ? "Yes" : "No", color: value ? .themedPositive : .themedNegative ) } @ViewBuilder func voteFederationRow( _ label: LocalizedStringResource, type: ScoringOperation, value: FederationMode ) -> some View { settingRow( label, icon: type.icon, value: value.label, color: value.color ) } @ViewBuilder var uptimeSummary: some View { VStack(spacing: Constants.main.standardSpacing) { HStack { Text("Uptime") Spacer() if case .success = uptimeData { (Text("Details") + Text(verbatim: " ") + Text(Image(icon: .general.forward))) .font(.footnote) .foregroundStyle(.themedAccent) } } switch uptimeData { case let .success(uptimeData): RecentUptimeChecks(results: uptimeData.results) case .unavailable: Text("Data not available") .italic() .foregroundStyle(.themedWarning) .frame(maxWidth: .infinity, alignment: .leading) case let .failure(error): ErrorView(.init(error: error)) default: ProgressView() .padding(Constants.main.halfSpacing) } } .padding(Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceSafetyView.swift ================================================ // // InstanceSafetyView.swift // Mlem // // Created by Sjmarf on 03/02/2024. // import MlemMiddleware import SwiftUI struct InstanceSafetyView: View { @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette let instance: Instance let fediseerData: FediseerData var body: some View { VStack(alignment: .leading, spacing: 0) { section { guarantorView } HStack { Button("Learn more...") { navigation.openSheet(.fediseerInfo) } .buttonStyle(.plain) Spacer() if let url = URL(string: "https://gui.fediseer.com/instances/detail/\(instance.name)") { Link(destination: url) { Text("Fediseer GUI") Image(systemName: "arrow.up.forward") } } } .font(.footnote) .foregroundStyle(.tint) .padding(.horizontal, 6) .padding(.top, 7) .padding(.bottom, 30) opinionsView } .frame(maxWidth: .infinity) .padding(.horizontal, 16) } @ViewBuilder var guarantorView: some View { VStack(alignment: .leading, spacing: 6) { HStack { if fediseerData.instance.guarantor != nil { Label("Guaranteed", icon: .fediseer.guarantee) .foregroundStyle(.themedPositive) } else if fediseerData.censures?.isEmpty ?? true { Label("Not Guaranteed", icon: .fediseer.unguarantee) .foregroundStyle(.themedSecondary) } else { Label("Censured", icon: .fediseer.censure) .foregroundStyle(.themedNegative) } Spacer() } .symbolVariant(.fill) .fontWeight(.semibold) .font(.title2) Text(summaryCaption) .foregroundStyle(.themedSecondary) .font(.footnote) } .frame(maxWidth: .infinity) .padding(.vertical, Constants.main.standardSpacing) .padding(.horizontal) } var summaryCaption: String { if let guarantor = fediseerData.instance.guarantor { .init(localized: "\(instance.name) is guaranteed by \(guarantor).") } else if fediseerData.censures?.isEmpty ?? true { .init(localized: "This instance is not part of the Fediseer Chain of Trust.") } else { .init(localized: "This instance is viewed very negatively by one or more trusted instances.") } } @ViewBuilder var opinionsView: some View { VStack(spacing: 22) { let opinionTypes = FediseerOpinionType.allCases.sorted { fediseerData.opinions(ofType: $0).count > fediseerData.opinions(ofType: $1).count } ForEach(opinionTypes, id: \.self) { opinionType in let items = fediseerData.opinions(ofType: opinionType).sorted { $0.reason != nil && $1.reason == nil } if !items.isEmpty { VStack(alignment: .leading, spacing: 7) { let destination: NavigationPage = .instanceOpinionList( instance: instance, opinionType: opinionType, data: fediseerData ) opinionSubheading( title: opinionType.label, count: items.count, destination: items.count > 5 ? destination : nil ) ForEach(items.prefix(5), id: \.domain) { item in FediseerOpinionView(opinion: item) .padding(.bottom, 9) } } } } } } @ViewBuilder func section(spacing: CGFloat = 5, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: spacing) { content() } .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground) .cornerRadius(Constants.main.standardSpacing) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } @ViewBuilder func opinionSubheading(title: String, count: Int, destination: NavigationPage? = nil) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 0) { (Text(title) + Text(verbatim: " (\(count))").foregroundColor(palette.label.secondary)) .font(.title2) .fontWeight(.semibold) Spacer() if let destination { NavigationLink("See All", destination: destination) .foregroundStyle(.tint) .buttonStyle(.plain) } } } .padding(.horizontal, 6) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceStubResolutionPage.swift ================================================ // // InstanceStubResolutionPage.swift // Mlem // // Created by Eric Andrews on 2026-03-22. // import MlemMiddleware import SwiftUI import Theming struct InstanceStubResolutionPage: View { @Environment(NavigationLayer.self) var navigation let stub: InstanceStub let targetPage: (Instance) -> NavigationPage @State var upgradeError: Error? var body: some View { content .themedGroupedBackground() } @ViewBuilder var content: some View { if let upgradeError { ErrorView(.init( error: upgradeError, refresh: fetchInstance )) } else { ProgressView() .task { await fetchInstance() } } } @discardableResult func fetchInstance() async -> Bool { do { let instance = try await stub.getLocalInstance() navigation.replace(targetPage(instance)) return true } catch { upgradeError = error return false } } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceUptimeView+Logic.swift ================================================ // // InstanceUptimeView+Logic..swift // Mlem // // Created by Eric Andrews on 2025-05-08. // import Foundation import MlemMiddleware enum UptimeDataStatus { case success(UptimeData) case unavailable case failure(Error) } func loadUptimeData(instance: Instance) async -> UptimeDataStatus { if let url = instance.uptimeDataUrl { do { let data = try await URLSession.shared.data(from: url).0 let uptimeData = try JSONDecoder.defaultDecoder.decode(UptimeData.self, from: data) return .success(uptimeData) } catch { handleError(error) return .failure(error) } } else { return .unavailable } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceUptimeView+Views.swift ================================================ // // InstanceUptimeView+Views.swift // Mlem // // Created by Eric Andrews on 2025-05-09. // import SwiftUI struct RecentUptimeChecks: View { @Environment(\.accessibilityDifferentiateWithoutColor) var diffWithoutColor: Bool let results: [UptimeResponseTime] var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 3) { ForEach(results) { result in Group { if diffWithoutColor { Image(icon: result.success ? .uptime.online : .uptime.offline) .resizable() .aspectRatio(contentMode: .fit) .symbolVariant(.circle.fill) } else { Circle() } } .foregroundStyle(result.success ? .themedPositive : .themedNegative) .frame(maxWidth: 20) .frame(maxWidth: 25) } } HStack { Text(timeOnlyFormatter.string(from: results.first?.timestamp ?? .now)) Spacer() Text(timeOnlyFormatter.string(from: results.last?.timestamp ?? .now)) } .font(.footnote) .foregroundStyle(.themedSecondary) .frame(maxWidth: CGFloat(results.count * 25 + (results.count - 1) * 3)) .padding(.top, 4) } } var timeOnlyFormatter: DateFormatter { let dateFormatter = DateFormatter() dateFormatter.timeStyle = .short dateFormatter.dateStyle = .none return dateFormatter } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceUptimeView.swift ================================================ // // InstanceUptimeView.swift // Mlem // // Created by Sjmarf on 28/01/2024. // import Charts import MlemMiddleware import SwiftUI struct InstanceUptimeView: View { @Environment(\.accessibilityDifferentiateWithoutColor) var diffWithoutColor: Bool @Environment(\.palette) var palette @State var showingExactTime: Bool = false @State var showingAllDowntimes: Bool = false let instance: Instance @State var uptimeData: UptimeData var uptimeRefreshTimer = Timer.publish(every: 30, tolerance: 0.5, on: .main, in: .common) .autoconnect() var body: some View { ScrollView { content .padding(.top, 16) } .background(.themedGroupedBackground) .onReceive(uptimeRefreshTimer) { _ in Task { let uptimeStatus = await loadUptimeData(instance: instance) switch uptimeStatus { case let .success(uptimeData): withAnimation(.easeOut(duration: 0.2)) { self.uptimeData = uptimeData } case .unavailable: assertionFailure("Uptime data unavailable.") case let .failure(error): handleError(error) } } } .navigationTitle("Uptime") } @ViewBuilder var content: some View { VStack(alignment: .leading, spacing: 0) { section { summary } .padding(.bottom, 10) section("Recent Checks") { RecentUptimeChecks(results: uptimeData.results) .padding(.horizontal) .padding(.vertical, 15) } .padding(.top, 20) // String interpolation used here to avoid localizing the number footnote("Automatically refreshes every \(30) seconds.") .padding(.top, 8) .padding(.leading, 6) .padding(.bottom, 30) section("Response Time") { VStack(alignment: .leading, spacing: 4) { responseTimeChart .padding(.horizontal, 20) let milliseconds = uptimeData.results.map(\.durationMs).reduce(0, +) / uptimeData.results.count footnote("Average: \(formatMilliseconds(milliseconds))") .padding(.leading, 20) } .padding(.top, 17) .padding(.bottom, 8) } subHeading("Incidents") .padding(.top, 30) .padding(.bottom, 3) let todayDowntimes = uptimeData.downtimes.filter { abs($0.endTime.timeIntervalSinceNow) < 60 * 60 * 24 } Text( todayDowntimes.count == 0 ? "There were no recorded incidents today." : "There were \(todayDowntimes.count) recorded incidents today." ) .font(.footnote) .foregroundStyle(.themedSecondary) .padding(.leading, 6) .padding(.bottom, 7) let displayedIncidents = showingAllDowntimes ? uptimeData.downtimes : todayDowntimes if !displayedIncidents.isEmpty { section(spacing: 0) { ForEach(displayedIncidents) { event in if event.id != uptimeData.downtimes.first?.id { Divider() } IncidentRow(event: event, showingExactTime: showingExactTime) .padding(.vertical, 10) .padding(.leading) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { showingExactTime.toggle() } } } .padding(.bottom, 30) } Button { withAnimation { showingAllDowntimes.toggle() } } label: { Text(showingAllDowntimes ? "Hide Older Incidents" : "Show Older Incidents") .foregroundStyle(.themedAccent) .padding(.leading, 12) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } .buttonStyle(.empty) if let url = instance.uptimeFrontendUrl { // Extra string interpolation used here to avoid unnecessary localization Text( (try? AttributedString( markdown: .init(localized: "Uptime data fetched from \("[lemmy-status.org](\(url))")")) ) ?? .init() ) .font(.footnote) .foregroundStyle(.themedSecondary) .padding(.vertical, 8) .padding(.leading, 6) } } .padding([.horizontal, .bottom], 16) } @ViewBuilder var summary: some View { VStack(alignment: .leading) { if let mostRecentOutage = uptimeData.downtimes.first { if uptimeData.results.filter(\.success).count < 15 { summaryHeader(isHealthy: false) footnote("\(instance.name) has been unresponsive recently.") } else { summaryHeader(isHealthy: true) let relTime = mostRecentOutage.relativeTimeCaption let length = mostRecentOutage.differenceTitle(unitsStyle: .full) footnote("The most recent outage was \(relTime), and lasted for \(length).") } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) .padding(.horizontal) } @ViewBuilder func summaryHeader(isHealthy: Bool) -> some View { HStack(spacing: 5) { summaryHeaderText(isHealthy: isHealthy) .font(.title2) Image(icon: isHealthy ? .uptime.online : .uptime.outage) .symbolVariant(.circle.fill) .foregroundStyle(isHealthy ? .themedPositive : .themedNegative) } .fontWeight(.semibold) } func summaryHeaderText(isHealthy: Bool) -> some View { let resource: LocalizedStringResource let color: Color if isHealthy { resource = .init( "\(instance.name) is {{online}}", comment: "The word(s) within the curly brackets will be colored green." ) color = palette.positive } else { resource = .init( "\(instance.name) is {{unhealthy}}", comment: "The word(s) within the curly brackets will be colored red." ) color = palette.negative } let string = String(localized: resource) let parts = string.split(separator: /\{\{|\}\}/, omittingEmptySubsequences: false) guard parts.count == 3 else { assertionFailure() return Text(string) } return Text(parts[0]) + Text(parts[1]).foregroundColor(color) + Text(parts[2]) } @ViewBuilder var responseTimeChart: some View { Chart { ForEach(uptimeData.results) { node in let time = Int(node.durationMs) LineMark( x: .value("Time", node.timestamp), y: .value("Response Time", time) ) } } .frame(height: 200) .chartXAxis { let marks = [uptimeData.results.first?.timestamp ?? .distantPast, uptimeData.results.last?.timestamp ?? .distantFuture] AxisMarks(format: .dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute(.twoDigits), values: marks) } .chartYScale(domain: [0, max(1000, (uptimeData.results.map(\.durationMs).max() ?? 0) + 100)]) .chartYAxis { AxisMarks(values: .automatic) { value in AxisGridLine() AxisValueLabel { if let intValue = value.as(Int.self) { Text(formatMilliseconds(intValue)) } } } } } @ViewBuilder func section( _ title: LocalizedStringResource? = nil, spacing: CGFloat = 5, @ViewBuilder content: () -> some View ) -> some View { VStack(alignment: .leading, spacing: 7) { if let title { subHeading(title) } VStack(alignment: .leading, spacing: spacing) { content() } .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground) .cornerRadius(Constants.main.standardSpacing) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } @ViewBuilder func subHeading(_ title: LocalizedStringResource) -> some View { Text(title) .font(.title2) .fontWeight(.semibold) .padding(.leading, 6) } @ViewBuilder func footnote(_ title: LocalizedStringResource) -> some View { Text(title) .font(.footnote) .foregroundStyle(.themedSecondary) } @_disfavoredOverload @ViewBuilder func footnote(_ title: String) -> some View { Text(title) .font(.footnote) .foregroundStyle(.themedSecondary) } func formatMilliseconds(_ milliseconds: Int) -> String { let measurement = Measurement(value: Double(milliseconds), unit: UnitDuration.milliseconds) let formatter = MeasurementFormatter() formatter.unitOptions = .providedUnit formatter.unitStyle = .short return formatter.string(from: measurement) } } private struct IncidentRow: View { let event: DowntimePeriod let showingExactTime: Bool var body: some View { VStack(alignment: .leading) { HStack { Image(icon: .uptime.outage) .symbolVariant(.fill) .foregroundStyle(event.severityColor) .foregroundStyle(.themedSecondary) Text("Unhealthy for \(event.differenceTitle())") } Text(showingExactTime ? event.differenceCaption : event.relativeTimeCaption) .font(.footnote) .foregroundStyle(.themedSecondary) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceView+About.swift ================================================ // // InstanceView+About.swift // Mlem // // Created by Sjmarf on 2025-08-11. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI extension InstanceView { @ViewBuilder var aboutTab: some View { VStack(spacing: Constants.main.standardSpacing) { if let shortDescription = instance.shortDescription { markdownBox(shortDescription) } if let description = instance.description { markdownBox(description) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } private func markdownBox(_ text: String) -> some View { Markdown(text, configuration: .default(palette: palette)) .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceView+Logic.swift ================================================ // // InstanceView+Logic.swift // Mlem // // Created by Sjmarf on 23/09/2024. // import MlemMiddleware import QuickSwipes import SwiftUI extension InstanceView { var availableTabs: [Tab] { var result: [Tab] = [] result.append(.about) if instance.api.supports(.searchLocalCommunities, defaultValue: true) { result.append(.communities) } result += [.administration, .details, .safety] return result } func logVisit(_ instance: Instance) { guard let visitContext else { return } if let session = (appState.firstSession as? UserSession), let visitHistory = session.visitHistory, let summary = instance.instanceSummary { visitHistory.addInstance(summary, context: visitContext) Task(priority: .background) { try await session.saveVisitHistory() } } } func openAddAdminSheet() { navigation.openSheet(.personPicker(filter: .local) { person in newAdmin = person DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { showingConfirmation = true } }) } func addNewAdmin() { guard let newAdmin else { assertionFailure("newAdmin cannot be nil") return } guard newAdmin.apiIsLocal else { ToastModel.main.add(.error(.init(title: "Cannot appoint non-local user as administrator"))) return } guard instance.local || instance.host == "localhost" else { assertionFailure("Instance is not local") return } instance.addAdmin(personId: newAdmin.id, added: true) } func administratorQuickSwipes(person: Person) -> SwipeConfiguration { guard let myPerson = appState.firstPerson, myPerson.api.isHigherAdmin(than: person), let myInstance = appState.firstApi.myInstance, let isAdmin = person.isAdmin.value else { return .init() } return .init(trailingActions: [person.addAdminAction(instance: myInstance, isOn: isAdmin)]) } func attemptToLoadFediseerData() { if fediseerData == nil { let host = instance.host Task { do { guard let instanceURL = URL(string: "https://fediseer.com/api/v1/whitelist/\(host)") else { return } async let instanceData = try await URLSession.shared.data(from: instanceURL).0 async let endorsementsData = try await URLSession.shared.data( from: URL(string: "https://fediseer.com/api/v1/endorsements/\(host)")! ).0 async let hesitationsData = try await URLSession.shared.data( from: URL(string: "https://fediseer.com/api/v1/hesitations/\(host)")! ).0 async let censuresData = try await URLSession.shared.data( from: URL(string: "https://fediseer.com/api/v1/censures/\(host)")! ).0 let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let fediseerData = try await FediseerData( instance: decoder.decode( FediseerInstance.self, from: instanceData ), endorsements: decoder.decode( FediseerEndorsements.self, from: endorsementsData ).instances, hesitations: decoder.decode( FediseerHesitations.self, from: hesitationsData ).instances, censures: decoder.decode( FediseerCensures.self, from: censuresData ).instances ) Task { @MainActor in withAnimation(.easeOut(duration: 0.2)) { self.fediseerData = fediseerData } } } catch { handleError(error) } } } } func refresh() async { guard upgradeState == .idle else { return } upgradeState = .loading do { if !instance.apiIsLocal { instance = try await instance.getLocal() } upgradeState = .done errorDetails = nil } catch { upgradeState = .idle var errorDetails = handleErrorWithDetails(error) errorDetails?.refresh = { await refresh() return true } if case let ApiClientError.decoding(data, _) = error { let string = String(data: data, encoding: .utf8) if string?.contains("Just a moment...") ?? false { errorDetails?.title = .init(localized: "Blocked by Cloudflare") errorDetails?.icon = .general.cloudflare errorDetails?.body = .init(localized: "This page can't be displayed because Cloudflare blocked the request.") errorDetails?.refresh = nil } } self.errorDetails = errorDetails } } } ================================================ FILE: Mlem/App/Views/Pages/Instance/InstanceView.swift ================================================ // // InstanceView.swift // Mlem // // Created by Sjmarf on 08/07/2024. // import ComponentViews import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct InstanceView: View { enum Tab: String, CaseIterable, Identifiable { case about, communities, administration, details, safety var label: LocalizedStringResource { switch self { case .about: "About" case .communities: "Communities" case .administration: "Administration" case .details: "Details" case .safety: "Trust & Safety" } } var id: Self { self } } @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.colorScheme) var colorScheme let visitContext: VisitHistory.VisitContext? // This is fetched from the instance itself, not from the logged-in account. @State var instance: Instance @State var fediseerData: FediseerData? @State var upgradeState: LoadingState = .idle @State var communityLoader: CommunityFeedLoader @State var selectedTab: Tab = .about @State var showingConfirmation: Bool = false @State var newAdmin: Person? @State var errorDetails: ErrorDetails? @State var communityListErrorDetails: ErrorDetails? init(instance: Instance, visitContext: VisitHistory.VisitContext?) { self._instance = .init(wrappedValue: instance) self._communityLoader = .init(wrappedValue: .init( api: .getApiClient(url: instance.actorId.hostUrl, username: nil), hostApi: instance.api )) self.visitContext = visitContext } var body: some View { content .animation(.easeOut(duration: 0.2), value: instance.apiIsLocal) .task { await refresh() } .onAppear { logVisit(instance) } .navigationBarTitleDisplayMode(.inline) .themedGroupedBackground() } @ViewBuilder var content: some View { FancyScrollView { ProfileHeaderView( instance, fallback: .instanceAvatar, blockedOverride: (appState.firstSession as? UserSession)?.blocks?.contains(instance) ) .padding([.horizontal, .bottom], Constants.main.standardSpacing) if instance.apiIsLocal { BubblePicker( availableTabs, selected: $selectedTab, label: { $0.label } ) switch selectedTab { case .about: aboutTab case .communities: InstanceCommunityListView( communityLoader: communityLoader, errorDetails: $communityListErrorDetails ) case .details: InstanceDetailsView(instance: instance) case .administration: administrationTab case .safety: safetyTab .onAppear(perform: attemptToLoadFediseerData) } } else { ProgressView() .tint(.themedSecondary) .padding(.top) } } .toolbar { ToolbarEllipsisMenu(instance: instance) } } @ViewBuilder var administrationTab: some View { VStack(spacing: Constants.main.standardSpacing) { if instance.api.supports(.modlog, defaultValue: true) { ModlogButtonView(instance: instance) } ExpectedView(instance.administrators) { administrators in VStack(spacing: Constants.main.halfSpacing) { ForEach(administrators) { person in PersonListRow(person) .quickSwipes(administratorQuickSwipes(person: person)) } } } if appState.firstApi.isAdmin { Button("Add Administrator", icon: .general.add, action: openAddAdminSheet) .buttonStyle(.capsule) .padding(.bottom, Constants.main.halfSpacing) .confirmationDialog("Add Administrator", isPresented: $showingConfirmation) { Button("Yes", action: addNewAdmin) } message: { if let displayName = newAdmin?.displayName { Text("Really appoint \(displayName) as an administrator of \(instance.displayName)?") } else { Text("Really appoint this user as an administrator of \(instance.displayName)?") } } } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } @ViewBuilder var safetyTab: some View { if let fediseerData { InstanceSafetyView(instance: instance, fediseerData: fediseerData) } else { ProgressView() .padding(.top, 30) } } } ================================================ FILE: Mlem/App/Views/Pages/Instance/UptimeData.swift ================================================ // // UptimeData.swift // Mlem // // Created by Sjmarf on 28/01/2024. // import Foundation import SwiftUI struct UptimeData: Codable, Hashable { let results: [UptimeResponseTime] let events: [UptimeEvent] var downtimes: [DowntimePeriod] { var ret: [DowntimePeriod] = [] var previous: UptimeEvent? for event in events { if event.type == .healthy { if let previous { ret.append(.init(startTime: previous.timestamp, endTime: event.timestamp)) } } previous = event } return ret.reversed() } } struct UptimeResponseTime: Codable, Identifiable, Hashable { let success: Bool let duration: Int let timestamp: Date var durationMs: Int { duration / 1_000_000 } var id: Int { Int(timestamp.timeIntervalSince1970) } } struct UptimeEvent: Codable, Identifiable, Hashable { enum EventType: String, Codable { case start = "START" case healthy = "HEALTHY" case unhealthy = "UNHEALTHY" } let type: EventType let timestamp: Date var id: Int { Int(timestamp.timeIntervalSince1970) } } struct DowntimePeriod: Codable, Identifiable { let startTime: Date let endTime: Date var id: Int { Int(startTime.timeIntervalSince1970) } var duration: TimeInterval { endTime.timeIntervalSince(startTime) } var severityColor: Color { if duration < 60 * 5 { .secondary } else if duration < 60 * 30 { .orange } else { .red } } func differenceTitle(unitsStyle: DateComponentsFormatter.UnitsStyle = .short) -> String { let formatter = DateComponentsFormatter() formatter.unitsStyle = unitsStyle formatter.maximumUnitCount = 2 return formatter.string(from: duration) ?? "Unknown" } var relativeTimeCaption: String { endTime.getRelativeTime() } private var timeOnlyFormatter: DateFormatter { let formatter = DateFormatter() formatter.timeStyle = .short formatter.dateStyle = .none return formatter } private var dateAndTimeFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short return formatter } var differenceCaption: String { if duration < 60 { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short return formatter.string(from: startTime) } let onSameDay = Calendar.current.isDate(startTime, equalTo: endTime, toGranularity: .day) if onSameDay { return "\(dateAndTimeFormatter.string(from: startTime)) to \(timeOnlyFormatter.string(from: endTime))" } return "\(dateAndTimeFormatter.string(from: startTime)) to \(dateAndTimeFormatter.string(from: endTime))" } } ================================================ FILE: Mlem/App/Views/Pages/MessageFeedView/MessageBubbleView.swift ================================================ // // MessageBubbleView.swift // Mlem // // Created by Sjmarf on 2024-12-22. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct MessageBubbleView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette let message: any Message var body: some View { Group { let blocks: [BlockNode] = .init(message.content) if blocks.isSimpleParagraphs { MarkdownText( blocks, configuration: message.isOwnMessage ? .inverted(palette: palette) : .default(palette: palette) ) } else { Markdown( blocks, configuration: message.isOwnMessage ? .inverted(palette: palette) : .default(palette: palette) ) } } .tint(message.isOwnMessage ? palette.contrastingLabel.opacity(0.6) : palette.accent) .padding(Constants.main.standardSpacing) .padding(message.isOwnMessage ? .trailing : .leading, 7) .padding(message.isOwnMessage ? .leading : .trailing, 2) .background( message.isOwnMessage ? .themedAccent : .themedSecondaryGroupedBackground, in: BubbleShape(myMessage: message.isOwnMessage) ) .contentShape(.contextMenuPreview, BubbleShape(myMessage: message.isOwnMessage)) .contextMenu(message: message) } } private struct BubbleShape: Shape { var myMessage: Bool // swiftlint:disable:next function_body_length func path(in rect: CGRect) -> Path { let width = rect.width let height = rect.height let bezierPath = UIBezierPath() if !myMessage { bezierPath.move(to: CGPoint(x: 20, y: height)) bezierPath.addLine(to: CGPoint(x: width - 15, y: height)) bezierPath.addCurve( to: CGPoint(x: width, y: height - 15), controlPoint1: CGPoint(x: width - 8, y: height), controlPoint2: CGPoint(x: width, y: height - 8) ) bezierPath.addLine(to: CGPoint(x: width, y: 15)) bezierPath.addCurve( to: CGPoint(x: width - 15, y: 0), controlPoint1: CGPoint(x: width, y: 8), controlPoint2: CGPoint(x: width - 8, y: 0) ) bezierPath.addLine(to: CGPoint(x: 20, y: 0)) bezierPath.addCurve( to: CGPoint(x: 5, y: 15), controlPoint1: CGPoint(x: 12, y: 0), controlPoint2: CGPoint(x: 5, y: 8) ) bezierPath.addLine(to: CGPoint(x: 5, y: height - 10)) bezierPath.addCurve( to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 5, y: height - 1), controlPoint2: CGPoint(x: 0, y: height) ) bezierPath.addLine(to: CGPoint(x: -1, y: height)) bezierPath.addCurve( to: CGPoint(x: 12, y: height - 4), controlPoint1: CGPoint(x: 4, y: height + 1), controlPoint2: CGPoint(x: 8, y: height - 1) ) bezierPath.addCurve( to: CGPoint(x: 20, y: height), controlPoint1: CGPoint(x: 15, y: height), controlPoint2: CGPoint(x: 20, y: height) ) } else { bezierPath.move(to: CGPoint(x: width - 20, y: height)) bezierPath.addLine(to: CGPoint(x: 15, y: height)) bezierPath.addCurve( to: CGPoint(x: 0, y: height - 15), controlPoint1: CGPoint(x: 8, y: height), controlPoint2: CGPoint(x: 0, y: height - 8) ) bezierPath.addLine(to: CGPoint(x: 0, y: 15)) bezierPath.addCurve( to: CGPoint(x: 15, y: 0), controlPoint1: CGPoint(x: 0, y: 8), controlPoint2: CGPoint(x: 8, y: 0) ) bezierPath.addLine(to: CGPoint(x: width - 20, y: 0)) bezierPath.addCurve( to: CGPoint(x: width - 5, y: 15), controlPoint1: CGPoint(x: width - 12, y: 0), controlPoint2: CGPoint(x: width - 5, y: 8) ) bezierPath.addLine(to: CGPoint(x: width - 5, y: height - 12)) bezierPath.addCurve( to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 5, y: height - 1), controlPoint2: CGPoint(x: width, y: height) ) bezierPath.addLine(to: CGPoint(x: width + 1, y: height)) bezierPath.addCurve( to: CGPoint(x: width - 12, y: height - 4), controlPoint1: CGPoint(x: width - 4, y: height + 1), controlPoint2: CGPoint(x: width - 8, y: height - 1) ) bezierPath.addCurve( to: CGPoint(x: width - 20, y: height), controlPoint1: CGPoint(x: width - 15, y: height), controlPoint2: CGPoint(x: width - 20, y: height) ) } return Path(bezierPath.cgPath) } } ================================================ FILE: Mlem/App/Views/Pages/MessageFeedView/MessageFeedView+Logic.swift ================================================ // // MessageFeedView+Logic.swift // Mlem // // Created by Sjmarf on 2024-12-23. // import Foundation import MlemMiddleware import SwiftUI extension MessageFeedView { var shouldDelayBecomeFirstResponder: Bool { // Only delay the keyboard opening if being pushed onto the navigation stack rather than opening in sheet !navigation.isAtRoot } func sendMessage(_ scrollProxy: ScrollViewProxy) async { self.isSending = true defer { self.isSending = false } do { guard !textView.text.isEmpty else { return } let message = try await appState.firstApi.createMessage(personId: person.id, content: textView.text) withAnimation { feedLoader?.prependItem(message) scrollProxy.scrollTo(message.id, anchor: .bottom) } textView.text = "" } catch { handleError(error) } } func editMessage(_ message: any Message) async { do { try await message.edit(content: textView.text) editing = nil textView.text = "" textView.resignFirstResponder() } catch { handleError(error) } } func messageIsFirstOfDay(_ message: Message2) -> Bool { guard let feedLoader else { return false } guard let index = feedLoader.items.firstIndex(of: message) else { assertionFailure() return false } guard index < feedLoader.items.count - 1 else { return true } let previousMessage = feedLoader.items[index + 1] return !Calendar.current.isDate(previousMessage.created, inSameDayAs: message.created) } var minTextEditorHeight: CGFloat { Constants.main.standardSpacing * 2 + UIFont.preferredFont(forTextStyle: .body).lineHeight } func messageFooterText(for message: Message2) -> String? { var parts: [String] = .init() if message == feedLoader?.items.first, Calendar.current.isDateInToday(message.created) { parts.append(message.created.formatted(date: .omitted, time: .shortened)) } if message.updated != nil { parts.append(.init(localized: "Edited")) } return parts.joined(separator: " • ") } } ================================================ FILE: Mlem/App/Views/Pages/MessageFeedView/MessageFeedView.swift ================================================ // // MessageFeedView.swift // Mlem // // Created by Sjmarf on 2024-12-22. // import Actions import ComponentViews import Icons import MlemMiddleware import SwiftUI import Theming // swiftlint:disable:next type_body_length struct MessageFeedView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss let person: Person let focusTextField: Bool @State var editing: (any Message)? /// Tracks whether the text view was firstResponder when a sheet was opened. Nil when no sheet is open. @State var textViewWasFirstResponder: Bool? let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() @State var markdownToolbarEditorModel: MarkdownEditorToolbarModel = .init() @ScaledMetric(relativeTo: .body) var sendButtonHeight = 28 init( person: Person, messageContent: String = "", focusTextField: Bool, editing: (any Message)? ) { self.person = person self.focusTextField = focusTextField self._editing = .init(wrappedValue: editing) let textView = UITextView() textView.text = editing?.content ?? messageContent _textView = .init(wrappedValue: textView) } @State var feedLoader: MessageFeedLoader? @State var textView: UITextView = .init() @State var uploadHistory: ImageUploadHistoryManager = .init() @State var isSending: Bool = false var body: some View { content(person: person) .navigationTitle(person.displayName) .navigationBarTitleDisplayMode(.inline) .toolbar { if navigation.isInsideSheet { ToolbarItem(placement: .topBarTrailing) { CloseButtonView() } } else { ToolbarItem(placement: .principal) { navigationTitleView(person: person) } ToolbarItemGroup(placement: .secondaryAction) { SwiftUI.Section { ActionButtons(person: person) } } } } .popupAnchor() } // swiftlint:disable:next function_body_length @ViewBuilder func content(person: Person) -> some View { ScrollViewReader { scrollProxy in ScrollView { if let feedLoader { LazyVStack(spacing: 0) { ForEach(feedLoader.items.reversed()) { message in if !message.deleted { bubbleView(message: message, feedLoader: feedLoader) .id(message.id) } } } .scrollTargetLayout() .padding(.top, 50) .onReceive(timer) { _ in Task { @MainActor in do { try await feedLoader.refresh(clearBeforeRefresh: false) } catch { handleError(error) } } } } } .safeAreaBar_(edge: .bottom) { if #available(iOS 26.0, *) { textInput(scrollProxy) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) .padding(.horizontal, textView.isFirstResponder ? Constants.main.standardSpacing : Constants.main.doubleSpacing) .padding(.bottom, 25) .padding(.top, Constants.main.standardSpacing) } else { textInput(scrollProxy) .background( RoundedRectangle(cornerRadius: Constants.main.doubleSpacing) .strokeBorder(.themedTertiary.opacity(0.5), lineWidth: 1) ) .padding(Constants.main.standardSpacing) .background(.bar) } } .defaultScrollAnchor(.bottom) .scrollDismissesKeyboard(.interactively) .themedGroupedBackground() .onAppear { if feedLoader == nil { feedLoader = .init(person: person, pageSize: 50) Task { @MainActor in do { try await feedLoader?.loadMoreItems() } catch { handleError(error) } } } } .onChange(of: navigation.model?.layers.count) { if let numLayers = navigation.model?.layers.count { if numLayers > 0, textViewWasFirstResponder == nil { textViewWasFirstResponder = textView.isFirstResponder textView.resignFirstResponder() } else if textViewWasFirstResponder ?? false { textViewWasFirstResponder = nil textView.becomeFirstResponder() } } } .environment(\.isInMessageFeed, true) .environment(\.editMessage) { message in editing = message textView.text = message.content textView.becomeFirstResponder() } } } @ViewBuilder func bubbleView(message: Message2, feedLoader: MessageFeedLoader) -> some View { if messageIsFirstOfDay(message) { Text(message.created.messagesRelativeDate()) .font(.footnote) .foregroundStyle(.themedSecondary) .padding(.bottom, Constants.main.halfSpacing) } VStack(alignment: message.isOwnMessage ? .trailing : .leading, spacing: Constants.main.halfSpacing) { MessageBubbleView(message: message) .padding(message.isOwnMessage ? .leading : .trailing, 50) .frame(maxWidth: 400, alignment: message.isOwnMessage ? .trailing : .leading) .onAppear { do { try feedLoader.loadIfThreshold(message) } catch { handleError(error) } } if let footerText = messageFooterText(for: message) { Text(footerText) .font(.footnote) .foregroundStyle(.themedSecondary) .padding(.horizontal, Constants.main.halfSpacing) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } @ViewBuilder func textInput(_ scrollProxy: ScrollViewProxy) -> some View { OptimalHeightLayout { HStack(alignment: .bottom) { ScrollView { textInputView(scrollProxy) } .scrollBounceBehavior(.basedOnSize, axes: .vertical) .scrollIndicators(.hidden) HStack(spacing: 6) { if editing != nil { cancelEditButton() } sendButton(scrollProxy) } .frame(height: minTextEditorHeight - 12) .padding(6) .fontWeight(.semibold) } .frame(minHeight: minTextEditorHeight, maxHeight: 200) .padding(UIDevice.isIos26 ? 2 : 0) } } @ViewBuilder func cancelEditButton() -> some View { Button { editing = nil textView.text = "" textView.resignFirstResponder() } label: { textInputButtonLabel(icon: .general.close) } .tint(.themedTertiary) } @ViewBuilder func sendButton(_ scrollProxy: ScrollViewProxy) -> some View { Button { Task { @MainActor in if let editing { await editMessage(editing) } else { await sendMessage(scrollProxy) } } } label: { textInputButtonLabel(icon: editing == nil ? .lemmy.sendMessage : .general.success) } .tint(.themedAccent) .compositingGroup() .opacity(isSending ? 0.4 : 1) .disabled(isSending) } @ViewBuilder func textInputButtonLabel(icon: Icon) -> some View { if UIDevice.isIos26 { Image(icon: icon) .foregroundStyle(.themedContrastingLabel) .frame(height: sendButtonHeight) .padding(.horizontal, 12) .background(.tint, in: .capsule) .frame(height: minTextEditorHeight - 12) .padding(.bottom, 1) } else { Image(icon: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: .infinity) .foregroundStyle(.themedContrastingLabel, .tint) .symbolVariant(.circle.fill) } } @ViewBuilder func textInputView(_ scrollProxy: ScrollViewProxy) -> some View { MarkdownTextEditor( onBeginEditing: { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { withAnimation { scrollProxy.scrollTo(feedLoader?.items.first?.id) } } }, prompt: "Send a Message...", textView: textView, insets: .init( top: Constants.main.standardSpacing, left: Constants.main.standardSpacing, bottom: Constants.main.standardSpacing, right: Constants.main.standardSpacing ), firstResponder: focusTextField && !shouldDelayBecomeFirstResponder, sizingOffset: 5, content: { MarkdownEditorToolbarView( textView: textView, uploadHistory: uploadHistory, model: markdownToolbarEditorModel ) } ) .onChange(of: appState.firstApi) { markdownToolbarEditorModel.imageUploadApi = appState.firstApi } .frame( maxWidth: .infinity, minHeight: minTextEditorHeight ) .onAppear { if focusTextField, shouldDelayBecomeFirstResponder { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { textView.becomeFirstResponder() } } } } @ViewBuilder func navigationTitleView(person: Person) -> some View { NavigationLink(.person(person)) { HStack(spacing: Constants.main.halfSpacing) { CircleCroppedImageView(person, frame: 24) Text(person.displayName) .foregroundStyle(.themedPrimary) .font(.headline) Image(icon: .general.forward) .imageScale(.small) .fontWeight(.semibold) .foregroundStyle(.themedTertiary) } } } } extension EnvironmentValues { @Entry var editMessage: ((Message2) -> Void)? @Entry var isInMessageFeed: Bool = false } ================================================ FILE: Mlem/App/Views/Pages/Modlog/ModlogEntryView.swift ================================================ // // ModlogEntryView.swift // Mlem // // Created by Sjmarf on 2024-12-25. // import MlemMiddleware import SwiftUI struct ModlogEntryView: View { @Environment(\.palette) var palette let entry: ModlogEntry var targetCommunity: Community? @State private var id = UUID() var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { headerView contentView HStack(spacing: 5) { Image(icon: .general.time) Text(entry.created.formatted(date: .abbreviated, time: .shortened)) } .font(.footnote) .foregroundStyle(.themedSecondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .environment(\.communityContext, entry.type.community) } @ViewBuilder var headerView: some View { HStack(spacing: Constants.main.standardSpacing) { Circle() .fill(entry.type.color.opacity(0.3)) .frame(width: 24, height: 24) .overlay { Image(icon: entry.type.icon) .foregroundStyle(entry.type.color) } Text(headerText) .font(.footnote) .foregroundStyle(.secondary) } .imageScale(.small) .symbolVariant(.fill) } var headerText: LocalizedStringKey { if let moderator = entry.moderator { let userText = moderator.nameTextView( showFlairs: true, showInstance: true, communityContext: targetCommunity ?? entry.type.community, font: .footnote, palette: palette ) return entry.type.label(userText: userText) } return entry.type.label(userText: nil) } @ViewBuilder var contentView: some View { switch entry.type { case let .removePost(post, community: community, removed: _, reason: reason): reasonView(reason) postLink(post: post, community: community) case let .lockPost(post, community: community, locked: _): postLink(post: post, community: community) case let .pinPost(post, community: community, pinned: _, type: _): postLink(post: post, community: community) case let .purgePost(reason: reason): reasonView(reason) case let .removeComment(comment, creator: _, post: _, community: _, removed: _, reason: reason): reasonView(reason) commentLink(comment: comment) case let .purgeComment(reason: reason): reasonView(reason) case let .removeCommunity(community, removed: _, reason: reason): reasonView(reason) FullyQualifiedLinkView(community, labelStyle: .medium) case let .purgeCommunity(reason: reason): reasonView(reason) case let .hideCommunity(community, hidden: _, reason: reason): reasonView(reason) FullyQualifiedLinkView(community, labelStyle: .medium) case let .transferCommunityOwnership(person: person, community: community): transferCommunityView(person: person, community: community) case let .updatePersonModeratorStatus(person: person, community: community, appointed: appointed): updatePersonModeratorStatusView(person: person, community: community, appointed: appointed) case let .updatePersonAdminStatus(person: person, appointed: appointed): updatePersonModeratorStatusView(person: person, community: nil, appointed: appointed) case let .banPersonFromCommunity(person: person, community: community, banned: banned, reason: reason, expires: expires): reasonView(reason) banPersonView(person: person, community: community, banned: banned, expires: expires) case let .banPersonFromInstance(person: person, banned: banned, reason: reason, expires: expires): reasonView(reason) banPersonView(person: person, community: nil, banned: banned, expires: expires) case let .purgePerson(reason: reason): reasonView(reason) } } @ViewBuilder func banPersonView(person: Person, community: Community?, banned: Bool, expires: Date?) -> some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { let userText = person.nameTextView( showFlairs: true, showInstance: true, communityContext: targetCommunity ?? community, font: .subheadline, palette: palette ) let targetText: Text if let community { targetText = community.nameTextView( showFlairs: true, showInstance: true, font: .subheadline, palette: palette ) } else { targetText = Text("Instance") } if banned { let expiresText = expires?.formatted(date: .abbreviated, time: .omitted) ?? "Never" return Text("Banned: \(userText)\nFrom: \(targetText)\nExpires: \(expiresText)") } else { return Text("Unbanned: \(userText)\nFrom: \(targetText)") } } .imageScale(.small) .symbolVariant(.fill) .foregroundStyle(.themedSecondary) .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.main.standardSpacing) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } @ViewBuilder func transferCommunityView( person: Person, community: Community ) -> some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { let userText = person.nameTextView( showFlairs: true, showInstance: true, communityContext: targetCommunity ?? community, font: .subheadline, palette: palette ) let communityText = community.nameTextView( showFlairs: true, showInstance: true, font: .subheadline, palette: palette ) Text("Community: \(communityText)\nNew Owner: \(userText)") .imageScale(.small) } .foregroundStyle(.themedSecondary) .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.main.standardSpacing) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } @ViewBuilder func updatePersonModeratorStatusView( person: Person, community: Community?, appointed: Bool ) -> some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { let userText = person.nameTextView( showFlairs: true, showInstance: true, communityContext: targetCommunity ?? community, font: .subheadline, palette: palette ) if let community { let communityText = community.nameTextView( showFlairs: true, showInstance: true, font: .subheadline, palette: palette ) Text( appointed ? "Appointed: \(userText)\nTo: \(communityText)" : "Removed: \(userText)\nFrom: \(communityText)" ) } else { Text(appointed ? "Appointed: \(userText)" : "Removed: \(userText)") } } .foregroundStyle(.themedSecondary) .imageScale(.small) .symbolVariant(.fill) .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.main.standardSpacing) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } @ViewBuilder func reasonView(_ reason: String?) -> some View { if let reason { Text("Reason:").foregroundStyle(.secondary) + Text(verbatim: " \(reason)") } else { Text("No reason given") .foregroundStyle(.secondary) .italic() } } @ViewBuilder func postLink(post: Post, community: Community) -> some View { NavigationLink(.post(post)) { FooterLinkView(title: post.title, subtitle: community.fullNameWithPrefix) } .id("\(id)_modlog_footer") } @ViewBuilder func commentLink(comment: Comment) -> some View { NavigationLink(.comment(comment, exposeRemovedContent: true)) { VStack { Text(comment.content) .frame(maxWidth: .infinity, alignment: .leading) .font(.subheadline) .fontWeight(.semibold) .multilineTextAlignment(.leading) .lineLimit(5) } .foregroundStyle(.themedSecondary) .padding(Constants.main.standardSpacing) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } .id("\(id)_modlog_footer") } } ================================================ FILE: Mlem/App/Views/Pages/Modlog/ModlogView+Filters.swift ================================================ // // ModlogView+Filters.swift // Mlem // // Created by Sjmarf on 2025-11-22. // import Icons import MlemMiddleware import SwiftUI extension ModlogView { @ViewBuilder func filtersView(communityFilter: CommunityFilter) -> some View { ScrollView(.horizontal) { HStack { typeFilterView() .buttonStyle(.feedFilter(isOn: actionTypeFilter != nil)) communityFilterView(communityFilter: communityFilter) personFilterView(filter: $targetPersonFilter, icon: .lemmy.targetedPerson) personFilterView(filter: $moderatorPersonFilter, icon: .lemmy.moderation) } .padding(.horizontal, Constants.main.standardSpacing) } .scrollIndicators(.hidden) } @ViewBuilder func communityFilterView(communityFilter: CommunityFilter) -> some View { Button { if communityFilter == .any { navigation.openSheet(.communityPicker(api: api) { community in self.communityFilter = .community(community) }) } else { self.communityFilter = .any } } label: { Label(communityFilter.label, icon: .lemmy.community) } .buttonStyle( .feedFilter( isOn: communityFilter != .any, icon: communityFilter == .any ? .general.dropDown : .general.close ) ) } @ViewBuilder func personFilterView(filter: Binding, icon: Icon) -> some View { Button { if filter.wrappedValue == .any { navigation.openSheet(.personPicker(api: api) { person in filter.wrappedValue = .person(person) }) } else { filter.wrappedValue = .any } } label: { Label(filter.wrappedValue.label, icon: icon) } .buttonStyle( .feedFilter( isOn: filter.wrappedValue != .any, icon: filter.wrappedValue == .any ? .general.dropDown : .general.close ) ) } @ViewBuilder func typeFilterView() -> some View { Menu( String(localized: actionTypeFilter?.label ?? "Action Type"), icon: actionTypeFilter?.icon ?? .general.action ) { Section { Toggle( "Any", icon: .general.action, isOn: .init(get: { actionTypeFilter == nil }, set: { _ in actionTypeFilter = nil }) ) } Section { Picker("Post", icon: .lemmy.post, selection: $actionTypeFilter) { typeFilterLabel(.removePost) typeFilterLabel(.lockPost) typeFilterLabel(.pinPost) typeFilterLabel(.purgePost) } Picker("Comment", icon: .lemmy.comment, selection: $actionTypeFilter) { typeFilterLabel(.removeComment) typeFilterLabel(.purgeComment) } Picker("Community", icon: .lemmy.community, selection: $actionTypeFilter) { typeFilterLabel(.removeCommunity) typeFilterLabel(.hideCommunity) typeFilterLabel(.updatePersonModeratorStatus) typeFilterLabel(.transferCommunityOwnership) typeFilterLabel(.purgeCommunity) } Picker("User", icon: .lemmy.person, selection: $actionTypeFilter) { typeFilterLabel(.banPersonFromInstance) typeFilterLabel(.banPersonFromCommunity) typeFilterLabel(.updatePersonModeratorStatus) typeFilterLabel(.updatePersonAdminStatus) typeFilterLabel(.purgePerson) } } } .pickerStyle(.menu) } @ViewBuilder func typeFilterLabel(_ type: ModlogEntryType) -> some View { if type.appliesToCommunity || communityFilter == .any { Label(type.contextualLabel.key, icon: type.icon) .tag(type) } } } ================================================ FILE: Mlem/App/Views/Pages/Modlog/ModlogView+Logic.swift ================================================ // // ModlogView+Logic.swift // Mlem // // Created by Sjmarf on 2025-01-11. // import MlemMiddleware import SwiftUI extension ModlogView { enum InitialTarget: Hashable { case community(Community) case instance(Instance) case currentInstance } enum CommunityFilter: Hashable { case any case community(Community) var label: String { switch self { case .any: .init(localized: "Any Community") case let .community(community): community.name } } var communityValue: Community? { switch self { case let .community(community): community default: nil } } static func == (lhs: CommunityFilter, rhs: CommunityFilter) -> Bool { switch (lhs, rhs) { case let (.community(lhs), .community(rhs)): lhs === rhs case (.any, .any): true default: false } } func hash(into hasher: inout Hasher) { hasher.combine(communityValue?.api.cacheId) hasher.combine(communityValue?.id) } } enum PersonFilter: Hashable { case any case person(Person) var label: String { switch self { case .any: .init(localized: "Any User") case let .person(person): person.name } } var personValue: (Person)? { switch self { case let .person(person): person default: nil } } static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { case let (.person(lhs), .person(rhs)): lhs === rhs case (.any, .any): true default: false } } func hash(into hasher: inout Hasher) { hasher.combine(personValue?.api.cacheId) hasher.combine(personValue?.id) } } func refresh() async throws { try await feedLoader.refresh( api: api, communityId: communityFilter?.communityValue?.id, targetPersonId: targetPersonFilter.personValue?.id, moderatorPersonId: moderatorPersonFilter.personValue?.id, clearBeforeRefresh: true ) } var activeFeedLoader: any FeedLoading { if let actionTypeFilter { feedLoader.childLoader(ofType: actionTypeFilter) } else { feedLoader } } var refreshHashValue: Int { var hasher = Hasher() hasher.combine(communityFilter) hasher.combine(targetPersonFilter) hasher.combine(moderatorPersonFilter) return hasher.finalize() } } ================================================ FILE: Mlem/App/Views/Pages/Modlog/ModlogView.swift ================================================ // // ModlogView.swift // Mlem // // Created by Sjmarf on 2024-12-25. // import ComponentViews import MlemMiddleware import SwiftUI import Theming struct ModlogView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Setting(\.safety_enableModlogWarning) var showModlogWarning let api: ApiClient let initialTarget: InitialTarget @State var feedLoader: ModlogFeedLoader @State var warningPresented: Bool = Settings.get(\.safety_enableModlogWarning) @State var communityFilter: CommunityFilter? @State var targetPersonFilter: PersonFilter = .any @State var moderatorPersonFilter: PersonFilter = .any @State var actionTypeFilter: ModlogEntryType? init( initialTarget: InitialTarget, targetPerson: Person?, moderatorPerson: Person? ) { self._feedLoader = .init( wrappedValue: .init( api: AppState.main.firstApi, pageSize: Settings.get(\.behavior_internetSpeed).pageSize, communityId: nil, targetPersonId: nil, moderatorPersonId: nil, sortType: .new ) ) self.initialTarget = initialTarget switch initialTarget { case let .community(community): self._communityFilter = .init(wrappedValue: .community(community)) self.api = community.api case let .instance(instance): self._communityFilter = .init(wrappedValue: .any) self.api = instance.api case .currentInstance: self._communityFilter = .init(wrappedValue: .any) self.api = AppState.main.firstApi } if let person = targetPerson { self._targetPersonFilter = .init(wrappedValue: .person(person)) } if let person = moderatorPerson { self._moderatorPersonFilter = .init(wrappedValue: .person(person)) } } var body: some View { Group { switch initialTarget { case let .community(initialCommunity): Group { if let communityFilter { content(communityFilter: communityFilter) } else { ProgressView() .onAppear { if communityFilter == nil { communityFilter = .community(initialCommunity) } } } } case .instance, .currentInstance: if let communityFilter { content(communityFilter: communityFilter) } else { ProgressView() } } } .navigationTitle("Modlog") .navigationBarTitleDisplayMode(.inline) .fullScreenCover(isPresented: $warningPresented) { WarningOverlayView( text: "The modlog may contain disturbing or adult material.", isPresented: $warningPresented, showWarningAgain: $showModlogWarning ) } .toolbar { if navigation.isInsideSheet { CloseButtonToolbarItem(ios18Label: .xmark) } } .onChange(of: refreshHashValue, initial: true) { oldValue, newValue in // This prevents the feed from refreshing when changing tabs guard oldValue != newValue || feedLoader.loadingState == .initial else { return } if communityFilter != nil { Task { do { try await refresh() } catch { handleError(error) } } } } } @ViewBuilder func content(communityFilter: CommunityFilter) -> some View { ScrollView { filtersView(communityFilter: communityFilter) LazyVStack(spacing: Constants.main.standardSpacing) { ForEach( Array(feedLoader.items(ofType: actionTypeFilter).enumerated()), id: \.offset ) { _, entry in entryView(entry) } EndOfFeedView(feedLoader: activeFeedLoader, viewType: .hobbit) } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } .themedGroupedBackground() } @ViewBuilder func entryView(_ entry: ModlogEntry) -> some View { ModlogEntryView(entry: entry, targetCommunity: communityFilter?.communityValue) .onAppear { do { try activeFeedLoader.loadIfThreshold(entry) } catch { handleError(error) } } } } ================================================ FILE: Mlem/App/Views/Pages/Person/PersonStubResolutionPage.swift ================================================ // // PersonStubResolutionView.swift // Mlem // // Created by Eric Andrews on 2026-02-08. // import MlemMiddleware import SwiftUI struct PersonStubResolutionPage: View { @Environment(NavigationLayer.self) var navigation let stub: PersonStub let visitContext: VisitHistory.VisitContext @State var upgradeError: Error? var body: some View { content .themedGroupedBackground() } @ViewBuilder var content: some View { if let upgradeError { ErrorView(.init( error: upgradeError, refresh: fetchPost )) } else { ProgressView() .task { await fetchPost() } } } @discardableResult func fetchPost() async -> Bool { do { let person = try await stub.getPerson() navigation.replace(.person(person, visitContext: visitContext)) return true } catch { upgradeError = error return false } } } ================================================ FILE: Mlem/App/Views/Pages/Person/PersonView+Logic.swift ================================================ // // PersonView+Logic.swift // Mlem // // Created by Eric Andrews on 2024-08-19. // import MlemMiddleware extension PersonView { func preheatFeedLoader() { Task { guard let feedLoader else { return } do { if feedLoader.loadingState == .initial { try await feedLoader.loadMoreItems() } } catch { // This is OK to silence because the feed loader will fail when // it appears if this fails, and will show an ErrorView. handleError(error, silent: true) } } } func tabs(person: Person) -> [Tab] { var output: [Tab] = [.overview, .posts, .comments] if !(person.moderatedCommunities.value?.isEmpty ?? true) { output.append(.communities) } return output } func logVisit(_ person: Person) { guard let visitContext else { return } if let session = (appState.firstSession as? UserSession), let visitHistory = session.visitHistory { guard session.api === person.api else { return } visitHistory.addPerson(person, context: visitContext) Task(priority: .background) { try await session.saveVisitHistory() } } } } ================================================ FILE: Mlem/App/Views/Pages/Person/PersonView.swift ================================================ // // PersonView.swift // Mlem // // Created by Sjmarf on 30/05/2024. // import Actions import ComponentViews import Flow import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct PersonView: View { enum Tab: String, CaseIterable, Identifiable { case overview, comments, posts, communities var id: Self { self } var label: LocalizedStringResource { switch self { case .overview: "Overview" case .comments: "Comments" case .posts: "Posts" case .communities: "Communities" } } } @Setting(\.post_size) var postSize @Setting(\.behavior_internetSpeed) var internetSpeed @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(FiltersTracker.self) var filtersTracker @Environment(\.palette) var palette let visitContext: VisitHistory.VisitContext? @State var person: Person @State private var selectedTab: Tab = .overview @State private var selectedContentType: PersonContentType = .all @State var feedLoader: SingleSourceMixedFeedLoader? @State var isLoading: Bool = false let isProfileTab: Bool init( appState: AppState = .main, person: Person, isProfileTab: Bool = false, visitContext: VisitHistory.VisitContext? ) { self.visitContext = visitContext self._person = .init(wrappedValue: person) self.isProfileTab = isProfileTab if person.api === appState.firstApi { self._feedLoader = .init(wrappedValue: .init( api: appState.firstApi, pageSize: internetSpeed.pageSize, userId: person.id, sortType: .new, savedOnly: false, prefetchingConfiguration: .forPostSize(postSize) )) } } var body: some View { content .id(person.uid) .reloadOnAccountSwitch(entity: $person, isLoading: $isLoading) { newPerson in feedLoader = .init( api: appState.firstApi, pageSize: internetSpeed.pageSize, userId: newPerson.id, sortType: .new, savedOnly: false, prefetchingConfiguration: .forPostSize(postSize) ) } .onAppear { preheatFeedLoader() } .onChange(of: selectedTab) { switch selectedTab { case .comments: selectedContentType = .comments case .posts: selectedContentType = .posts default: selectedContentType = .all } } .environment(\.feedContext, .person) } var content: some View { content(person: person) .externalApiWarning(entity: person, isLoading: isLoading) .onAppear { logVisit(person) } .toolbar { ToolbarItemGroup(placement: .secondaryAction) { SwiftUI.Section { ActionButtons(person: person) } } } .popupAnchor() .conditionalNavigationTitle(person.displayName) .navigationBarTitleDisplayMode(.inline) .frame(maxWidth: .infinity, maxHeight: .infinity) .themedGroupedBackground() } @ViewBuilder func content(person: Person) -> some View { FancyScrollView { VStack(spacing: 0) { VStack(spacing: Constants.main.standardSpacing) { ProfileHeaderView(person, fallback: .personAvatar) flairsView(person: person) bio(person: person) } .padding([.horizontal], Constants.main.standardSpacing) VStack(spacing: 0) { personContent(person: person) } } } .outdatedFeedPopup(feedLoader: feedLoader, showPopup: selectedTab != .communities) } @ViewBuilder func bio(person: Person) -> some View { if let bio = person.description { VStack(spacing: Constants.main.standardSpacing) { let blocks: [BlockNode] = .init(bio) if blocks.isSimpleParagraphs, bio.count < 300 { MarkdownText(blocks, configuration: .default(palette: palette)) .multilineTextAlignment(.center) dateLabel(person: person) .frame(maxWidth: .infinity, alignment: .center) } else { Markdown(blocks, configuration: .default(palette: palette)) dateLabel(person: person) .frame(maxWidth: .infinity, alignment: .leading) } } .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } else { dateLabel(person: person) .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, Constants.main.halfSpacing) } } @ViewBuilder func flairsView(person: Person) -> some View { if person.isBot || person.isMlemDeveloper || (person.isAdmin.value ?? false) || person.note != nil { HFlow(spacing: Constants.main.halfSpacing) { if person.isMlemDeveloper { Label("Mlem Developer", systemImage: Icons.developerFlair) .tint(.themedColorfulAccent(4)) } if person.isAdmin.value ?? false { Label("\(person.host) Administrator", systemImage: Icons.administrationFill) .tint(.themedAdministration) } if person.isBot { Label("Bot Account", icon: .lemmy.botFlair) .tint(.themedColorfulAccent(5)) } if let note = person.note { Label(note, icon: .lemmy.note) .tint(.themedNeutralAccent) .onTapGesture { navigation.openSheet(.editNote(person)) } } } .labelStyle(FlairLabelStyle()) } if person.bannedFromInstance { banFlairView(person: person) } } @ViewBuilder func banFlairView(person: Person) -> some View { HStack { Image(icon: .lemmy.bannedFromInstance) .imageScale(.large) .symbolVariant(.fill) switch person.instanceBan { case let .temporarilyBanned(expires: expires): Text("\(person.name) is banned from \(person.api.host) until \(expires.formatted(date: .numeric, time: .omitted)).") default: Text("\(person.name) is permanently banned from \(person.api.host).") } } .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(.themedNegative) .padding(Constants.main.standardSpacing) .background(.themedNegative.opacity(0.2), in: .rect(cornerRadius: Constants.main.standardSpacing)) } @ViewBuilder func dateLabel(person: Person) -> some View { ProfileDateView(profilable: person) .padding(.horizontal, Constants.main.standardSpacing) } @ViewBuilder func personContent(person: Person) -> some View { Section { switch selectedTab { case .communities: communitiesTab(person: person) default: if let feedLoader { if isProfileTab, selectedTab == .overview || selectedTab == .posts { Button("New Post", icon: .general.add) { navigation.openSheet(.createPost(community: nil, type: nil, feedLoader: feedLoader)) } .buttonStyle(.capsule) .padding([.horizontal, .bottom], Constants.main.standardSpacing) } PersonContentGridView(feedLoader: .singleSourceMixed(feedLoader, contentType: selectedContentType)) } else { ProgressView() } } } header: { BubblePicker( tabs(person: person), selected: $selectedTab, withDividers: [], label: \.label, value: { tab in switch tab { case .posts: person.postCount.value ?? 0 case .comments: person.commentCount.value ?? 0 case .communities: person.moderatedCommunities.value?.count ?? 0 default: nil } } ) } } @ViewBuilder func communitiesTab(person: Person) -> some View { VStack(spacing: Constants.main.halfSpacing) { ForEach(person.moderatedCommunities.value ?? [], id: \.actorId) { community in CommunityListRow(community) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } } private struct FlairLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(spacing: 5) { configuration.icon .imageScale(.small) configuration.title } .font(.footnote) .padding(.vertical, 2) .padding(.horizontal, 8) .foregroundStyle(.tint) .background(.tint.opacity(0.2), in: .capsule) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment(api: .realistic)) { // @Previewable @Environment(AppState.self) var appState // NavigationStack { // PersonView( // appState: appState, // person: .init(Person2.mock(.realistic(.anteSocial45), api: .realistic)), // isProfileTab: true, // visitContext: .other // ) // } // .previewTabBar(selected: .profile) // } // #endif ================================================ FILE: Mlem/App/Views/Pages/UploadConfirmationView.swift ================================================ // // UploadConfirmationView.swift // Mlem // // Created by Sjmarf on 30/09/2023. // import Haptics import MlemMiddleware import PhotosUI import SwiftUI struct UploadConfirmationView: View { @Environment(HapticManager.self) var hapticManager @Environment(\.palette) var palette @Environment(\.dismiss) var dismiss @Setting(\.behavior_confirmImageUploads) var confirmImageUploads var imageData: Data var fileExtension: String var imageManager: ImageUploadManager var uploadApi: ApiClient @State var isUploading: Bool = false var body: some View { VStack(spacing: 0) { ScrollView { if let uiImage = UIImage(data: imageData) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) .clipShape(.rect(cornerRadius: Constants.main.largeItemCornerRadius)) } Spacer() .frame(height: 100) } .scrollIndicators(.hidden) .overlay(alignment: .bottom) { LinearGradient( colors: [palette.background.primary, Color.clear], startPoint: .bottom, endPoint: .top ) .frame(height: 100) .allowsHitTesting(false) } VStack(spacing: 0) { VStack(spacing: 16) { if isUploading { VStack { Text("Uploading...") ProgressView() } .font(.title3) .padding(.vertical, 100) } else { Text("Upload this image to \(uploadApi.host)?") .font(.largeTitle) .multilineTextAlignment(.center) Toggle("Ask to confirm every time", isOn: $confirmImageUploads) .controlSize(.mini) .padding(.horizontal) Button { Task { @MainActor in isUploading = true do { try await imageManager.upload( data: imageData, fileExtension: fileExtension, api: uploadApi ) hapticManager.play(haptic: .success, tier: .low) dismiss() } catch { handleError(error) } isUploading = false } } label: { Text("Upload") .frame(maxWidth: .infinity) } .controlSize(.large) .fixedSize(horizontal: false, vertical: true) .buttonStyle(.borderedProminent) Button { dismiss() } label: { Text("Cancel") .frame(maxWidth: .infinity) } .controlSize(.large) .buttonStyle(.bordered) } } .padding(.top, 15) .padding(.bottom, 20) .background(.themedBackground) } .interactiveDismissDisabled() .animation(.easeOut(duration: 0.1), value: isUploading) } .padding() .presentationBackground(.themedBackground) } } ================================================ FILE: Mlem/App/Views/Pages/VotesListView.swift ================================================ // // VotesListView.swift // Mlem // // Created by Sjmarf on 2024-12-18. // import MlemMiddleware import SwiftUI import Theming struct VotesListView: View { enum Target: Hashable { case post(Post) case comment(Comment) static func == (lhs: Target, rhs: Target) -> Bool { switch (lhs, rhs) { case let (.post(post1), .post(post2)): post1.actorId == post2.actorId case let (.comment(comment1), .comment(comment2)): comment1.actorId == comment2.actorId default: false } } func hash(into hasher: inout Hasher) { switch self { case let .post(post): hasher.combine(post.actorId) case let .comment(comment): hasher.combine(comment.actorId) } } var model: any InteractableProviding { switch self { case let .post(post): post case let .comment(comment): comment } } } @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation let target: Target @State var votes: [PersonVote] = [] @State var page: Int = 1 @State var loadingState: LoadingState = .idle var body: some View { FancyScrollView { LazyVStack(spacing: Constants.main.halfSpacing) { ForEach(votes, id: \.creator.id, content: rowView) EndOfFeedView(loadingState: loadingState, viewType: .turtle) .onAppear { loadNextPage() } } } .environment(\.communityContext, target.model.community.value) .themedGroupedBackground() .navigationTitle("Votes") .navigationBarTitleDisplayMode(.inline) } @ViewBuilder func rowView(_ vote: PersonVote) -> some View { NavigationLink(.person(vote.creator)) { rowViewLabel(vote) } .padding([.vertical, .trailing], Constants.main.halfSpacing) .padding(.leading, Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(person: vote.creator) .padding(.horizontal, Constants.main.standardSpacing) } @ViewBuilder func rowViewLabel(_ vote: PersonVote) -> some View { HStack { FullyQualifiedLinkView(vote.creator, labelStyle: .medium) Spacer() Image(systemName: vote.vote.systemImage) .imageScale(.large) .symbolVariant(.fill) .symbolRenderingMode(.palette) .foregroundStyle(.themedContrastingLabel, vote.vote.color) } } func loadNextPage() { Task { @MainActor in guard loadingState == .idle else { return } loadingState = .loading do { let newVotes: [PersonVote] switch target { case let .post(post): newVotes = try await post.getVotes(page: page, limit: 40) case let .comment(comment): // TODO: handle this better--call refresh first? guard let communityId = comment.community.value_?.id else { assertionFailure("loadNextPage called without resolved community") newVotes = .init() break } newVotes = try await comment.getVotes(page: page, limit: 40, communityId: communityId) } votes.append(contentsOf: newVotes) if newVotes.count < 40 { loadingState = .done } else { loadingState = .idle } page += 1 } catch { handleError(error) loadingState = .idle } } } } ================================================ FILE: Mlem/App/Views/Root/AppDelegate.swift ================================================ // // AppDelegate.swift // Mlem // // Created by tht7 on 02/07/2023. // import Foundation import SwiftUI // TODO: we need to do a bit of work to ensure we also switch tab when responding to these // as currently it launches you into the app, but if the app was already running you're left // on the tab/screen you were on - despite the shortcuts being designed to take you to the "Feeds" tab var shortcutItemToProcess: UIApplicationShortcutItem? class AppDelegate: UIResponder, UIApplicationDelegate, UIWindowSceneDelegate { func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { if let shortcutItem = options.shortcutItem { shortcutItemToProcess = shortcutItem } let sceneConfiguration = UISceneConfiguration(name: "Custom Configuration", sessionRole: connectingSceneSession.role) sceneConfiguration.delegateClass = CustomSceneDelegate.self return sceneConfiguration } } class CustomSceneDelegate: UIResponder, UIWindowSceneDelegate { func windowScene( _ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void ) { shortcutItemToProcess = shortcutItem } } ================================================ FILE: Mlem/App/Views/Root/ContentView+Logic.swift ================================================ // // ContentView+Logic.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import Haptics import MlemMiddleware import Nuke import Rest import SwiftUI extension ContentView { var shouldDisplayToasts: Bool { navigationModel.layers.allSatisfy { !$0.canDisplayToasts } } var avatarRefreshHash: Int { var hasher = Hasher() hasher.combine(appState.firstAccount.avatar) hasher.combine(tabProfileShowAvatar) hasher.combine(colorPalette) hasher.combine(colorScheme) return hasher.finalize() } func loadAvatar(url: URL) async { do { if tabProfileShowAvatar { let urlRequest = mlemUrlRequest(url: url.withIconSize(128)) let imageTask = ImagePipeline.shared.imageTask(with: .init(urlRequest: urlRequest)) let avatarImage = try await imageTask.image .resized(to: .init(width: imageTask.image.size.width / imageTask.image.size.height * 26, height: 26)) .circleMasked .withRenderingMode(.alwaysOriginal) let selectedAvatarImage = try await imageTask.image .resized(to: .init(width: imageTask.image.size.width / imageTask.image.size.height * 26, height: 26)) .circleBorder(color: .init(colorPalette.palette.accent), width: 3.5) .withRenderingMode(.alwaysOriginal) Task { @MainActor in self.avatarImage = avatarImage self.selectedAvatarImage = selectedAvatarImage } } } catch { handleError(error, silent: true) } } func handleHapticError(_ error: HapticError) { handleError(error, silent: !developerMode) } } ================================================ FILE: Mlem/App/Views/Root/ContentView+Tab.swift ================================================ // // ContentView+Tab.swift // Mlem // // Created by Sjmarf on 2025-02-23. // import Foundation import Icons import SwiftUI extension ContentView { enum Tab: CaseIterable { case feeds, inbox, profile, search, settings var defaultLabel: LocalizedStringResource { switch self { case .feeds: "Feeds" case .inbox: "Inbox" case .profile: "Profile" case .search: "Search" case .settings: "Settings" } } func label(appState: AppState, profileLabelType: ProfileTabLabel) -> String { switch self { case .profile: switch profileLabelType { case .nickname: appState.firstAccount.nickname case .instance: appState.firstAccount.host case .anonymous: .init(localized: "Profile") } default: .init(localized: defaultLabel) } } var icon: Icon { switch self { case .feeds: .lemmy.feed case .inbox: .lemmy.inbox case .profile: .lemmy.personAvatar case .search: .general.search case .settings: .general.settings } } } } extension CustomTabItem { init( _ tab: ContentView.Tab, appState: AppState, profileLabelType: ProfileTabLabel, imageOverride: UIImage? = nil, selectedImageOverride: UIImage? = nil, badge: String? = nil, onLongPress: (() -> Void)? = nil, @ViewBuilder content: () -> some View ) { self.init( title: tab.label(appState: appState, profileLabelType: profileLabelType), image: imageOverride ?? .init(icon: tab.icon.representingState(active: false)), selectedImage: selectedImageOverride ?? .init(icon: tab.icon.representingState(active: true)), badge: badge, onLongPress: onLongPress, content: content ) } } ================================================ FILE: Mlem/App/Views/Root/ContentView.swift ================================================ // // ContentView.swift // Mlem // // Created by David Bureš on 25.03.2022. // import Dependencies import Haptics import MlemBackend import MlemMiddleware import Nuke import QuickSwipes import SwiftUI import Theming struct ContentView: View { @Environment(\.scenePhase) var scenePhase @Environment(\.colorScheme) var colorScheme @Dependency(\.persistenceRepository) var persistenceRepository @Setting(\.appearance_palette) var colorPalette @Setting(\.tab_profile_labelType) var tabProfileLabelType @Setting(\.tab_profile_showAvatar) var tabProfileShowAvatar @Setting(\.tab_gestures_longPressAction) var tabLongPressAction @Setting(\.dev_developerMode) var developerMode @Setting(\.behavior_hapticLevel) var hapticLevel @Setting(\.behavior_enableQuickSwipes) var quickSwipesEnabled let cacheCleanTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() let unreadCountTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() // globals var appState: AppState { .main } var tabReselectTracker: TabReselectTracker { .main } var navigationModel: NavigationModel { .main } var filtersTracker: FiltersTracker { .main } var errorsTracker: ErrorsTracker { .main } var backendClient: BackendClient { .main } @State var avatarImage: UIImage? @State var selectedAvatarImage: UIImage? @State var expandedPostHistoryTracker: ExpandedPostHistoryTracker = .init() @State var eventsTracker: EventsTracker = .init() var body: some View { if appState.appRefreshToggle { content .task(id: avatarRefreshHash) { avatarImage = nil selectedAvatarImage = nil if let url = appState.firstAccount.avatar { await loadAvatar(url: url) } } .onReceive(cacheCleanTimer) { _ in appState.cleanCaches() } .onReceive(unreadCountTimer) { _ in Task { @MainActor in try await (appState.firstSession as? UserSession)?.unreadCount?.refresh() } } .navigationSheetModifiers( nextLayer: navigationModel.layers.first, isTopSheet: navigationModel.layers.isEmpty, shareInfo: .init(get: { navigationModel.shareInfo }, set: { navigationModel.shareInfo = $0 }), contentPickerTracker: navigationModel.contentPickerTracker ) .accentColor(ThemedColor.themedAccent.resolve(with: colorPalette.palette)) // deprecated, but .tint colors menu buttons .palette(colorPalette.palette) .environment(tabReselectTracker) .environment(appState) .environment(filtersTracker) .environment(errorsTracker) .environment(expandedPostHistoryTracker) .environment(backendClient) .environment(eventsTracker) .environment(ToastModel.main) .quickSwipesDisabled(!quickSwipesEnabled) .quickSwipeThresholds(primary: 60, secondary: 150, tertiary: 240) .quickSwipeMinimumDrag(20) .quickSwipeCornerRadius(Constants.main.standardSpacing) .quickSwipeIconSize(28) .task(id: BackendClient.main.environment) { await MlemStats.main.loadInstances(forceRefresh: true) } .onChange(of: appState.firstPerson) { // Observe AppState.main.firstPerson to update FiltersTracker as needed // TODO: when Observation adds continous observation monitoring, move this into FiltersTracker filtersTracker.moderatedCommunityActorIds = appState.firstPerson?.moderatedCommunityActorIds ?? .init() } .onChange(of: scenePhase, initial: false) { if AppState.main.firstAccount is UserAccount, scenePhase != .active { Task { do { try await AppState.main.firstApi.flushPostReadQueue() } catch { handleError(error) } } } } .onChange(of: scenePhase) { if scenePhase == .active { eventsTracker.refreshIfStale() } } .hapticConfiguration(maximumHapticTier: hapticLevel, errorHandler: handleHapticError) .environment(AppState.main) .onOpenURL { url in guard url.scheme == "mlem" else { return } var components = URLComponents(url: url, resolvingAgainstBaseURL: false) components?.scheme = "https" guard let targetURL = components?.url else { return } navigationModel.pendingOpenURL = targetURL } } } @ViewBuilder var content: some View { CustomTabView(selectedIndex: Binding(get: { Tab.allCases.firstIndex(of: appState.contentViewTab) ?? 0 }, set: { appState.contentViewTab = Tab.allCases[$0] }), tabs: [ CustomTabItem(.feeds, appState: appState, profileLabelType: tabProfileLabelType) { NavigationSplitRootView(sidebar: .subscriptionList, root: .feeds()) }, CustomTabItem( .inbox, appState: appState, profileLabelType: tabProfileLabelType, badge: (appState.firstSession as? UserSession)?.unreadCount?.badgeLabel.map { String($0) } ) { NavigationLayerView(layer: .init(root: .inbox, model: navigationModel), hasSheetModifiers: false) }, CustomTabItem( .profile, appState: appState, profileLabelType: tabProfileLabelType, imageOverride: avatarImage ?? UIImage(systemName: "person.crop.circle"), selectedImageOverride: selectedAvatarImage ?? UIImage(systemName: "person.crop.circle.fill"), onLongPress: { HapticManager.main.play(haptic: .rigidInfo, tier: .high) switch tabLongPressAction { case .openAccountSwitcher: navigationModel.openSheet(.quickSwitcher) case .switchToMostRecentAccount: // If switch fails (no other accounts), fall back to account switcher. if !appState.switchToMostRecentAccount() { navigationModel.openSheet(.quickSwitcher) } } }, content: { NavigationLayerView(layer: .init(root: .profile, model: navigationModel), hasSheetModifiers: false) } ), CustomTabItem(.search, appState: appState, profileLabelType: tabProfileLabelType) { NavigationLayerView(layer: .init(root: .search, model: navigationModel), hasSheetModifiers: false) }, CustomTabItem(.settings, appState: appState, profileLabelType: tabProfileLabelType) { NavigationLayerView(layer: .init(root: .settings(), model: navigationModel), hasSheetModifiers: false) } ], onSwipeUp: { navigationModel.openSheet(.quickSwitcher) }) .withAccountSwitcherGesture(tabReselectTracker: tabReselectTracker, navigationModel: navigationModel) .overlay(alignment: .bottom) { ToastOverlayView( shouldDisplayNewToasts: shouldDisplayToasts, location: .bottom ) .padding(.bottom, 100) } .ignoresSafeArea() .overlay(alignment: .top) { ToastOverlayView( shouldDisplayNewToasts: shouldDisplayToasts, location: .top ) } } } ================================================ FILE: Mlem/App/Views/Root/Login/LoginCredentialsView.swift ================================================ // // LoginCredentialsView.swift // Mlem // // Created by Sjmarf on 11/05/2024. // import ComponentViews import MlemMiddleware import SwiftUI import Theming struct LoginCredentialsView: View { @Environment(NavigationLayer.self) var navigation @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss @Environment(\.isRootView) var isRootView @State var instance: Instance? let account: UserAccount? @State var upgradeState: LoadingState = .idle @State var usernameOrEmail: String @State var password: String = "" @State var authenticating: Bool = false @State private var failureReason: FailureReason? enum FocusedField { case usernameOrEmail, password } @FocusState private var focused: FocusedField? var showUsernameField: Bool { account == nil } init(instance: Instance) { self.instance = instance self.account = nil self._usernameOrEmail = .init(wrappedValue: "") } init(account: UserAccount) { self.instance = nil self.account = account self._usernameOrEmail = .init(wrappedValue: account.name) } var body: some View { content .frame(maxWidth: .infinity) .themedGroupedBackground() .interactiveDismissDisabled((!usernameOrEmail.isEmpty && showUsernameField) || !password.isEmpty) .toolbar { if navigation.isInsideSheet, isRootView { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) .disabled(authenticating) } } } } @ViewBuilder var content: some View { ScrollView { VStack { if let instance { instanceHeader(instance) } else if let account { reauthHeader(account) .padding(.bottom, 15) } textFields nextButton .padding(.top, 5) if let failureReason { Text(failureReason.label) .foregroundStyle(.red) } } .frame(maxWidth: .infinity) .padding(.horizontal) } .scrollBounceBehavior(.basedOnSize) } @ViewBuilder func instanceHeader(_ instance: Instance) -> some View { CircleCroppedImageView(url: instance.avatar, frame: 50, fallback: .instanceAvatar) Text(instance.displayName) .font(.title) .bold() } @ViewBuilder func reauthHeader(_ account: UserAccount) -> some View { VStack { CircleCroppedImageView(account, frame: 50) Text(account.fullName ?? "Sign In") .font(.title) .bold() .padding(.bottom, 5) Text("Your session has expired. Enter your password to authenticate a new session.") .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } @ViewBuilder var textFields: some View { Grid( alignment: .trailing, horizontalSpacing: 15, verticalSpacing: 0 ) { if showUsernameField { GridRow { Text("Username") .padding([.leading, .vertical]) TextField("Username", text: $usernameOrEmail, prompt: Text(verbatim: "")) .focused($focused, equals: .usernameOrEmail) .onSubmit { focused = .password } .padding(.trailing) } Divider() } GridRow { Text("Password") .padding([.leading, .vertical]) SecureField("Password", text: $password, prompt: Text(verbatim: "")) .focused($focused, equals: .password) .padding(.trailing) .onSubmit(attemptToLogin) .submitLabel(.go) } } .textInputAutocapitalization(.never) .autocorrectionDisabled() .background( RoundedRectangle(cornerRadius: 16) .fill(.themedSecondaryGroupedBackground) ) .paletteBorder(cornerRadius: 16) .onAppear { focused = showUsernameField ? .usernameOrEmail : .password } .onChange(of: usernameOrEmail) { failureReason = nil } .onChange(of: password) { failureReason = nil } } @ViewBuilder var nextButton: some View { Button(action: attemptToLogin) { Text(authenticating ? "Authenticating..." : "Sign In") .padding(.vertical, 10) .frame(maxWidth: .infinity) .transaction { $0.animation = .none } } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 16)) .disabled(usernameOrEmail.isEmpty || password.isEmpty || authenticating) } func attemptToLogin() { guard !usernameOrEmail.isEmpty, !password.isEmpty else { return } if let client = instance?.guestApi ?? account?.api.asGuest() { authenticating = true Task { do { let user = try await AccountsTracker.main.logIn( client: client, usernameOrEmail: usernameOrEmail, password: password ) appState.changeAccount(to: user) if navigation.isTopSheet { navigation.dismissSheet() } } catch { switch error { case let ApiClientError.response(response, _) where response.error == "missing_totp_token": navigation.push(.logIn(.totp(client: client, usernameOrEmail: usernameOrEmail, password: password))) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { authenticating = false } case ApiClientError.invalidSession: failureReason = .incorrectPassword default: handleError(error, silent: true) failureReason = .other } Task { @MainActor in authenticating = false } } } } } } private enum FailureReason { case incorrectPassword case other var label: String { switch self { case .incorrectPassword: "Username or password is incorrect." case .other: "Something went wrong." } } } ================================================ FILE: Mlem/App/Views/Root/Login/LoginInstancePickerView.swift ================================================ // // LoginInstancePickerView.swift // Mlem // // Created by Sjmarf on 10/05/2024. // import ComponentViews import MlemMiddleware import SwiftUI import Theming struct LoginInstancePickerView: View { @Environment(\.palette) var palette @Environment(\.dismiss) var dismiss @Environment(\.isRootView) var isRootView @Environment(NavigationLayer.self) var navigation @State var domain: String = "" @State var connecting: Bool = false @State var invalidInstance: Bool = false @State private var scrollViewContentSize: CGSize = .zero @FocusState private var focused: Bool var body: some View { content .interactiveDismissDisabled(!domain.isEmpty) .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .toolbar { if navigation.isInsideSheet, isRootView { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) .disabled(connecting) } } } } @ViewBuilder var content: some View { let filteredSuggestions = MlemStats.main.instances?.lazy.map(\.host).filter { $0.starts(with: domain) && $0 != domain } ?? [] let showSuggestions = !(filteredSuggestions.isEmpty || domain.isEmpty || !focused) VStack { Image(systemName: "globe") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 50) .foregroundStyle(.themedAccent) Text("Sign In to Lemmy") .font(.title) .bold() Text("Enter your instance's domain name below.") .foregroundStyle(.themedSecondary) .multilineTextAlignment(.center) .padding(.bottom, 5) instanceSuggestionsBox(suggestions: filteredSuggestions) .padding(showSuggestions ? .vertical : .top) if !showSuggestions { nextButton .padding(.top, 5) } if invalidInstance { Text("Failed to connect to \(domain)") .foregroundStyle(.themedNegative) .multilineTextAlignment(.center) } Spacer() } .padding(.horizontal) } @ViewBuilder func instanceSuggestionsBox(suggestions: [String]) -> some View { VStack(spacing: 0) { instanceField if !suggestions.isEmpty, !domain.isEmpty, focused { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(suggestions, id: \.self) { text in Divider() Button { domain = text focused = false attemptToConnect() } label: { Text(attributedString(suggestion: text)) .frame(maxWidth: .infinity, alignment: .leading) } .padding() } } .background( GeometryReader { geo in geometryReaderBackground(geoSize: geo.size) } ) } .frame(maxHeight: scrollViewContentSize.height) .scrollBounceBehavior(.basedOnSize) } } .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground) .clipShape(RoundedRectangle(cornerRadius: 16)) .paletteBorder(cornerRadius: 16) } @ViewBuilder var instanceField: some View { TextField( "Domain", text: $domain, prompt: Text("example.com") ) .disabled(connecting) .focused($focused) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .scrollDismissesKeyboard(.never) .padding() .submitLabel(.go) .onSubmit { if !domain.isEmpty { attemptToConnect() } } .onTapGesture { if !connecting { focused = true } } .onAppear { focused = true } .onChange(of: domain) { invalidInstance = false } } @ViewBuilder var nextButton: some View { Button(action: attemptToConnect) { Text(connecting ? "Connecting..." : "Next") .padding(.vertical, 10) .frame(maxWidth: .infinity) .transaction { $0.animation = .none } } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 16)) .transaction { $0.animation = .none } .disabled(!(domain.contains(/.+\..+$/) || domain.starts(with: "localhost:")) || connecting) } func geometryReaderBackground(geoSize: CGSize) -> some View { Task { @MainActor in scrollViewContentSize = geoSize } return Color.clear } func attemptToConnect() { guard !connecting else { return } var domain = domain if !domain.contains("://") { domain = domain.starts(with: "localhost:") ? "http://\(domain)" : "https://\(domain)" } if let url = URL(string: domain) { focused = false connecting = true let fetchTask = Task { let apiClient = ApiClient.getApiClient(url: url, username: nil) do { let instance = try await apiClient.getMyInstance() Task { @MainActor in navigation.push(.logIn(.instance(instance))) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { connecting = false } } catch { handleError(error, silent: true) Task { @MainActor in connecting = false invalidInstance = true } } } DispatchQueue.main.asyncAfter(deadline: .now() + 5) { fetchTask.cancel() invalidInstance = true } } } func attributedString(suggestion string: String) -> AttributedString { var attributedString = AttributedString(stringLiteral: string) attributedString.foregroundColor = .secondary if string.starts(with: domain) { let range = .. Void @State var showButtons: Bool = false private let lightModeForeground: Color = .init(red: 40 / 255, green: 113 / 255, blue: 127 / 255) var body: some View { VStack { Spacer() if let instance { text(instance) .transition(.scale.combined(with: .opacity)) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showButtons = true } } buttons .opacity(showButtons ? 1 : 0) .scaleEffect(showButtons ? 1 : 0.9) .animation(.bouncy, value: showButtons) } Spacer() Spacer() } .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .overlay(alignment: .topLeading) { CloseButtonView() .padding() } .onAppear { if instance != nil { showButtons = true } } } func text(_ instance: Instance) -> some View { ExpectedView(instance.activeUserCount) { activeUserCount in Text("Join \(numberText(activeUserCount.month)) active users on Lemmy.world") .foregroundStyle(colorScheme == .dark ? .white : .black) .compositingGroup() .font(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) .padding(.horizontal, 20) } } var buttons: some View { VStack { Button(action: submit) { Text("Let's Go") .padding(.vertical, 10) .padding(.horizontal, 50) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 16)) .tint(colorScheme == .dark ? .blue : lightModeForeground) Button {} label: { Text("Choose another instance...") .foregroundStyle(.gray) .opacity(0.5) } .buttonStyle(.empty) .padding(.top, 5) } } func numberText(_ value: Int) -> Text { if colorScheme == .dark { Text(String(value)) .foregroundStyle(.teal.gradient.shadow(.drop(color: .blue, radius: 10))) } else { Text(String(value)) .foregroundStyle(lightModeForeground) } } } ================================================ FILE: Mlem/App/Views/Root/Login/Onboarding/OnboardingUsernameView.swift ================================================ // // OnboardingUsernameView.swift // Mlem // // Created by Sjmarf on 2025-05-24. // import ComponentViews import MlemMiddleware import SwiftUI struct OnboardingUsernameView: View { @Environment(OnboardingModel.self) var model @State var username: String = "" @FocusState var focused: Bool @State var usernameValidity: UsernameValidity? var body: some View { VStack { Text("Choose a Username") .font(.title) .fontWeight(.bold) Text("This cannot be changed later.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) VStack(spacing: 16) { textFieldView nextButtonView } validityWarningView } .padding(.horizontal, 16) .frame(minHeight: 0, maxHeight: .infinity) // Min height is needed here otherwise the keyboard padding doesn't work properly .keyboardAwarePadding(removePaddingOnDismiss: false) .overlay(alignment: .topLeading) { Button("Back", icon: .general.backward) { focused = false model.page = .recommendInstance } .fontWeight(.semibold) .imageScale(.large) .labelStyle(.iconOnly) .padding() } .frame(maxHeight: .infinity) } @ViewBuilder var textFieldView: some View { HStack(spacing: 0) { Text(verbatim: "@") .foregroundStyle(.secondary) TextField("Username", text: $username, prompt: Text(verbatim: "")) .autocorrectionDisabled() .textInputAutocapitalization(.never) .submitLabel(.done) .onSubmit {} .focused($focused) .onAppear { focused = true } } .padding() .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 16)) .task(id: username) { do { usernameValidity = nil if !username.isEmpty { try await Task.sleep(for: .seconds(0.5)) } usernameValidity = try await model.instance?.usernameIsValidForNewAccount?(username) } catch ApiClientError.cancelled { // no-op } catch { handleError(error) } } } @ViewBuilder var nextButtonView: some View { Button(action: submit) { Text("Next") .frame(maxWidth: .infinity) .padding(.vertical, 10) .opacity(usernameValidity == nil ? 0 : 1) .overlay { if usernameValidity == nil { ProgressView() .tint(.themedSecondary) } } } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 16)) .disabled(usernameValidity != .available) } @ViewBuilder var validityWarningView: some View { Text(validityWarningText) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(2, reservesSpace: true) } var validityWarningText: String { guard let usernameValidity else { return " " } if usernameValidity != .available { return String(localized: usernameValidity.label) } else { return " " } } func submit() { guard usernameValidity == .available else { return } model.username = username model.page = .email } } ================================================ FILE: Mlem/App/Views/Root/Login/Onboarding/OnboardingView.swift ================================================ // // OnboardingView.swift // Mlem // // Created by Sjmarf on 2025-05-19. // import MlemMiddleware import SwiftUI import Theming struct OnboardingView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.colorScheme) var colorScheme @Environment(\.palette) var palette @State var model = OnboardingModel() var body: some View { VStack { switch model.page { case .recommendInstance: OnboardingRecommendInstanceView(instance: model.instance) { model.page = .username } .transition(.blurReplace) case .email: OnboardingEmailView() .transition(.blurReplace) case .username: OnboardingUsernameView() .transition(.blurReplace) } } .animation(.easeOut(duration: 0.2), value: model.page) .animation(.bouncy, value: model.instance?.id) .frame(maxWidth: .infinity, maxHeight: .infinity) .background { VStack { switch model.page { case .recommendInstance: image default: ThemedColor.themedGroupedBackground.resolve(with: palette) } } .ignoresSafeArea(.container, edges: .top) .animation(.easeOut(duration: 0.2), value: model.page) } .onAppear { Task { let startTime = Date.now let stub = InstanceStub(api: appState.firstApi, actorId: .init(url: URL(string: "https://lemmy.world")!)!) let instance = try await stub.getInstance() try await Task.sleep(for: .seconds(Date.now.advanced(by: 0.1).timeIntervalSince(startTime))) model.instance = instance } } .ignoresSafeArea(.all, edges: .bottom) .environment(model) } @ViewBuilder var image: some View { if colorScheme == .dark { Image("background.earth") .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: .infinity, alignment: .bottom) .background(.black) } else { Image("background.trees") .resizable() .aspectRatio(contentMode: .fill) .frame(maxHeight: .infinity, alignment: .top) .blur(radius: 5, opaque: true) } } } ================================================ FILE: Mlem/App/Views/Root/Login/SignUpView+EmailConfirmationView.swift ================================================ // // SignUpView+EmailConfirmationView.swift // Mlem // // Created by Sjmarf on 07/09/2024. // import MlemMiddleware import SwiftUI extension SignUpView { struct EmailConfirmationView: View { @Environment(NavigationLayer.self) var navigation @Environment(\.scenePhase) var scenePhase private var timer = Timer.publish(every: 5, tolerance: 0.5, on: .main, in: .common) .autoconnect() let api: ApiClient let email: String let username: String let password: String init(api: ApiClient, email: String, username: String, password: String) { self.api = api self.email = email self.username = username self.password = password } var body: some View { VStack(spacing: Constants.main.doubleSpacing) { Image(icon: .general.email) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 100) .foregroundStyle(.themedAccent) .padding(.bottom) Text("We sent an email to \(email) to verify your email address and activate your account.") .font(.title2) .fontWeight(.semibold) Text("Click on the link in the email to continue.") ProgressView() .tint(.themedSecondary) .controlSize(.large) } .multilineTextAlignment(.center) .padding() .onDisappear { timer.upstream.connect().cancel() } .onReceive(timer) { _ in Task { await attemptToLogIn() } } } func attemptToLogIn() async { do { let token = try await api.getAccountToken( usernameOrEmail: username, password: password, totpToken: nil ) let account = try await AccountsTracker.main.logIn(username: username, url: api.baseUrl, token: token) navigation.dismissSheet() AppState.main.changeAccount(to: account) } catch let ApiClientError.response(response, _) where response.emailNotVerified || response.registrationApplicationIsPending { // no-op } catch { handleError(error) } } } } ================================================ FILE: Mlem/App/Views/Root/Login/SignUpView+Logic.swift ================================================ // // SignUpView+Logic.swift // Mlem // // Created by Sjmarf on 06/09/2024. // import MlemMiddleware import SwiftUI extension SignUpView { var canSubmit: Bool { guard let captchaEnabled = instance.captchaEnabled.value, let applicationQuestion = instance.applicationQuestion.value else { return false } return !(captchaEnabled && captchaAnswer.isEmpty) && usernameValidity == .valid && (applicationQuestion == nil || !applicationQuestionResponse.isEmpty) && (captcha == nil || !captchaAnswer.isEmpty) && password == confirmPassword && password.count >= 10 } func checkUsernameValidity(_ instance: Instance) async { if username.count < 3 { usernameValidity = .tooShort return } if (try? /[a-z_\d]*/.wholeMatch(in: username)) == nil { usernameValidity = .invalidCharacters return } usernameValidity = .checking do { if username.isEmpty { return } do { try await Task.sleep(for: .seconds(0.2)) _ = try await instance.guestApi.getPerson(username: username) usernameValidity = .taken } catch ApiClientError.noEntityFound { usernameValidity = .valid } catch { handleError(error, silent: true) } } } func submit() async { submitting = true do { let response = try await instance.guestApi.signUp( username: username, password: password, confirmPassword: confirmPassword, showNsfw: showNsfw, email: email.isEmpty ? nil : email, captcha: captcha, captchaAnswer: captchaAnswer.isEmpty ? nil : captchaAnswer, applicationQuestionResponse: applicationQuestionResponse.isEmpty ? nil : applicationQuestionResponse ) switch response { case let .canLogIn(token: token): let account = try await AccountsTracker.main.logIn( username: username, url: instance.guestApi.baseUrl, token: token ) AppState.main.changeAccount(to: account) navigation.dismissSheet() return case let .cannotLogIn(reasons: reasons): if reasons.contains(.awaitingApproval) { signInResult = .awaitingApproval } else { signInResult = .awaitingEmail } } } catch { handleError(error) } submitting = false } } ================================================ FILE: Mlem/App/Views/Root/Login/SignUpView+Views.swift ================================================ // // SignUpView+Views.swift // Mlem // // Created by Sjmarf on 06/09/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI extension SignUpView { @ViewBuilder var approvalInfo: some View { VStack(spacing: Constants.main.doubleSpacing) { Image(icon: .lemmy.send) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 100) .foregroundStyle(.themedAccent) .padding(.bottom) Text("Application submitted!") .font(.title2) .fontWeight(.semibold) if email.isEmpty { Text("Once approved, you'll be able to log in to your account from the Settings tab.") } else { Text( // swiftlint:disable:next line_length "You'll receive an email once your application has been approved. Once approved, you can log in to your account from the Settings tab." ) } Button("Done") { navigation.dismissSheet() } .buttonStyle(SubmitButtonStyle()) } .multilineTextAlignment(.center) .padding() } @ViewBuilder var header: some View { Section { VStack { CircleCroppedImageView(instance, frame: 50) Text(instance.displayName) .font(.title) .bold() } .frame(maxWidth: .infinity) .listRowBackground(Color.clear) .listRowInsets(.init()) } header: { // https://stackoverflow.com/a/78618856/17629371 Spacer(minLength: 0).listRowInsets(EdgeInsets()) } } @ViewBuilder var usernameSection: some View { Section("Username") { HStack { TextField("Username", text: $username, prompt: Text( "john_doe", comment: "Translate this into a similar placeholder name in your language." )) .focused($focused, equals: .username) .onSubmit { focused = .email } .autocorrectionDisabled() .textInputAutocapitalization(.never) .task(id: username) { await checkUsernameValidity(instance) } Group { if !username.isEmpty { switch usernameValidity { case .checking: ProgressView() .tint(.themedSecondary) case .valid: Image(icon: .general.success) .foregroundStyle(.themedPositive) case .taken, .tooShort, .invalidCharacters: Image(icon: .general.failure) .foregroundStyle(.themedNegative) } } } .symbolVariant(.circle.fill) } } footer: { if username.isEmpty { Text("Choose wisely - you cannot change this later.") } else { Group { switch usernameValidity { case .invalidCharacters: Text("Username can only contain lowercase letters, numbers and underscores.") case .tooShort: Text("Username must be 3 or more characters.") case .taken: Text("This username is taken.") default: Text(verbatim: "") } } .foregroundStyle(.themedWarning) } } } @ViewBuilder var emailSection: some View { Section("Email") { TextField( "Email", text: $email, // Converting to a String avoids this being rendered as a link prompt: Text(String( localized: "john_doe@example.com", // swiftlint:disable:next line_length comment: "Translate \"john_doe\" into the equivalent placeholder name in your language, and \"example.com\" into a suitable example domain for your locale." )) ) .focused($focused, equals: .email) .onSubmit { focused = .password } .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.emailAddress) } footer: { ExpectedView(instance.emailVerificationRequired) { emailVerificationRequired in if emailVerificationRequired { Text("You are required to provide an email on this instance.") } else { Text("This field is optional.") } } } } @ViewBuilder var passwordSection: some View { if let applicationQuestion = instance.applicationQuestion.value { Section("Password") { SecureField("Password", text: $password) .focused($focused, equals: .password) .onSubmit { focused = .confirmPassword } SecureField("Confirm Password", text: $confirmPassword) .focused($focused, equals: .confirmPassword) .onSubmit { focused = applicationQuestion == nil ? .captchaAnswer : .applicationQuestionResponse } } footer: { if !confirmPassword.isEmpty, password != confirmPassword { Text("Passwords don't match.") .foregroundStyle(.themedWarning) } else if password.count < 10 { // Using interpolation so we don't have to change the localization if this changes Text("Password must be \(10) characters or more.") .foregroundStyle(confirmPassword.isEmpty ? .themedSecondary : .themedWarning) } } } } @ViewBuilder var applicationQuestionSection: some View { if let applicationQuestion = instance.applicationQuestion.value as? String { Section { Markdown(applicationQuestion, configuration: .default(palette: palette)) .padding(.vertical, 8) TextField("Your Answer...", text: $applicationQuestionResponse, axis: .vertical) .focused($focused, equals: .applicationQuestionResponse) .lineLimit(8, reservesSpace: true) } } } @ViewBuilder var captchaSection: some View { if let captchaImage = captcha?.image { Section { captchaImage .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 500, alignment: .leading) .listRowInsets(.init()) TextField("Answer...", text: $captchaAnswer) .focused($focused, equals: .captchaAnswer) .onSubmit { focused = .captchaAnswer } .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Button("Try a different Captcha...") { Task { do { captcha = try await instance.guestApi.getCaptcha() captchaAnswer = "" } catch { handleError(error) } } } .foregroundStyle(.themedAccent) .font(.footnote) } } } @ViewBuilder var applicationQuestionWarning: some View { Section { HStack { Image(icon: .general.warning) .font(.title2) .imageScale(.large) Text("To join this instance, you need to create an application and wait to be accepted.") } .foregroundStyle(.themedCaution) .listRowBackground( RoundedRectangle(cornerRadius: UIDevice.isIos26 ? 26 : 10) .stroke(.themedCaution, lineWidth: 3) .background(.themedCaution.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 10)) ) } } @ViewBuilder var submitButton: some View { ExpectedView(instance.applicationQuestion) { applicationQuestion in Button(String(localized: submitLabel(applicationQuestion))) { Task { await submit() } } .buttonStyle(SubmitButtonStyle()) .disabled(!canSubmit) } } private func submitLabel(_ applicationQuestion: String?) -> LocalizedStringResource { if submitting { return "Submitting..." } return applicationQuestion == nil ? "Sign Up" : "Submit Application" } } private struct SubmitButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .padding(12) .frame(maxWidth: .infinity) .foregroundStyle(.themedContrastingLabel) .background(isEnabled ? .themedAccent : .themedSecondary, in: .rect(cornerRadius: 10)) .opacity(opacity(isPressed: configuration.isPressed)) } func opacity(isPressed: Bool) -> CGFloat { if !isEnabled { return 0.5 } return isPressed ? 0.8 : 1 } } ================================================ FILE: Mlem/App/Views/Root/Login/SignUpView.swift ================================================ // // SignUpView.swift // Mlem // // Created by Sjmarf on 05/09/2024. // import ComponentViews import MlemMiddleware import SwiftUI struct SignUpView: View { enum UsernameValidity { case checking, valid, tooShort, taken, invalidCharacters } enum FocusedField: Hashable { case username, email, password, confirmPassword, applicationQuestionResponse, captchaAnswer } enum SignInResult { case awaitingEmail, awaitingApproval } @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.isRootView) var isRootView @Environment(\.scenePhase) var scenePhase @State var instance: Instance @State var upgradeState: LoadingState = .idle @State var captcha: Captcha? @State var username: String = "" @State var email: String = "" @State var password: String = "" @State var confirmPassword: String = "" @State var applicationQuestionResponse: String = "" @State var showNsfw: Bool = false @State var captchaAnswer: String = "" @State var usernameValidity: UsernameValidity = .tooShort @State var submitting: Bool = false @FocusState var focused: FocusedField? @State var signInResult: SignInResult? var body: some View { VStack { if let captchaEnabled = instance.captchaEnabled.value, let registrationMode = instance.registrationMode.value, captcha != nil || !captchaEnabled { switch signInResult { case .awaitingEmail: EmailConfirmationView( api: instance.guestApi, email: email, username: username, password: password ) case .awaitingApproval: approvalInfo case nil: if registrationMode == .closed { Text("Registrations are closed on this instance.") } else { content } } } else { ProgressView() .tint(.themedSecondary) } } .task(id: instance.captchaEnabled.value) { if captcha == nil, instance.captchaEnabled.value ?? false { do { captcha = try await instance.guestApi.getCaptcha() } catch { handleError(error) } } } .animation(.easeOut(duration: 0.1), value: signInResult) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .navigationBarTitleDisplayMode(.inline) .toolbar { if navigation.isInsideSheet, isRootView { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) { navigation.dismissSheet() } } } } } @ViewBuilder var content: some View { Form { header if instance.applicationQuestion.value is String { applicationQuestionWarning } usernameSection emailSection passwordSection applicationQuestionSection Section { Toggle("Show NSFW Content", isOn: $showNsfw) .tint(.themedWarning) } captchaSection Section { submitButton .listRowBackground(Color.clear) .listRowInsets(.init()) } } .environment(\.defaultMinListHeaderHeight, 0) .scrollDismissesKeyboard(.interactively) .disabled(submitting) } } ================================================ FILE: Mlem/App/Views/Root/MlemApp.swift ================================================ // // MlemApp.swift // Mlem // // Created by Eric Andrews on 2024-02-21. // import AVFAudio import Nuke import SDWebImageWebPCoder import SwiftUI import Media /// Root view for the app @main struct MlemApp: App { init() { var imageConfig = ImagePipeline.Configuration.withDataCache(name: "main", sizeLimit: Constants.main.cacheSize) imageConfig.dataLoadingQueue = OperationQueue(maxConcurrentCount: 8) imageConfig.imageDecodingQueue = OperationQueue(maxConcurrentCount: 8) // Let's use those CORES imageConfig.imageDecompressingQueue = OperationQueue(maxConcurrentCount: 8) // TODO: rate limiting ImagePipeline.shared = ImagePipeline(configuration: imageConfig) // video handling ImageDecoderRegistry.shared.register(MlemVideoDecoder.init) // webp handling ImageDecoderRegistry.shared.register(NukeWebpBridgeDecoder.init) SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) // caching URLCache.shared = Constants.main.urlCache // set up audio do { try AVAudioSession.sharedInstance().setCategory(.playback, options: [.mixWithOthers]) } catch { handleError(error) } } var body: some Scene { WindowGroup { ContentView() } } } extension OperationQueue { convenience init(maxConcurrentCount: Int) { self.init() self.maxConcurrentOperationCount = maxConcurrentCount } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Components/FeedWelcomeView.swift ================================================ // // FeedWelcomeView.swift // Mlem // // Created by Sjmarf on 22/09/2024. // import SwiftUI struct FeedWelcomeView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Setting(\.tip_feedWelcomePrompt) var showWelcomePrompt var body: some View { VStack(spacing: Constants.main.standardSpacing) { HStack(spacing: Constants.main.standardSpacing) { VStack(alignment: .leading) { Text("Welcome to Lemmy!") .fontWeight(.semibold) Text( // swiftlint:disable:next line_length "You are browsing \(appState.firstApi.host) as a guest. If you'd like to vote or reply, you'll need to log in or sign up." ) .font(.footnote) } } .foregroundStyle(.themedAccent) HStack(spacing: Constants.main.standardSpacing) { Button { navigation.openSheet(.logIn(.pickInstance)) } label: { Text("Log In") .frame(maxWidth: 400) .padding(.vertical, 4) } Button { navigation.openSheet(.signUp()) } label: { Text("Sign Up") .frame(maxWidth: 400) .padding(.vertical, 4) } } .buttonStyle(.borderedProminent) } .padding(Constants.main.standardSpacing) .background(.themedAccent.opacity(0.2), in: .rect(cornerRadius: Constants.main.standardSpacing)) .overlay(alignment: .topTrailing) { Button("Dismiss", icon: .general.close) { showWelcomePrompt = false } .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) .labelStyle(.iconOnly) .fontWeight(.semibold) .padding(4) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Components/HiddenReadBannerView.swift ================================================ // // HiddenReadBannerView.swift // Mlem // // Created by Bedir Ekim on 2026-02-17. // import SwiftUI struct HiddenReadBannerView: View { @Setting(\.feed_showRead) var showRead let onDismiss: () -> Void var body: some View { VStack(spacing: Constants.main.standardSpacing) { Text("Looking for something? Read posts are hidden.") .font(.subheadline) .foregroundStyle(.themedAccent) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: Constants.main.standardSpacing) { Button { showRead = true } label: { Text("Show Read") .frame(maxWidth: 400) .padding(.vertical, 4) } .buttonStyle(.borderedProminent) Button { onDismiss() } label: { Text("Dismiss") .frame(maxWidth: 400) .padding(.vertical, 4) } .buttonStyle(.bordered) } } .padding(Constants.main.standardSpacing) .background(.themedAccent.opacity(0.2), in: .rect(cornerRadius: Constants.main.standardSpacing)) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Components/TileScoreView.swift ================================================ // // TileScoreView.swift // Mlem // // Created by Eric Andrews on 2024-08-19. // import Foundation import MlemMiddleware import SwiftUI struct TileScoreView: View { let saved: ExpectedValue let votes: ExpectedValue var body: some View { if let saved = saved.value, let votes = votes.value { Group { postTag(active: saved, icon: .lemmy.saved.representingState(active: true), color: .themedSave) + // saved status Text(verbatim: saved ? " " : "") + // spacing after save Text(Image(systemName: votes.iconName)) + // vote status Text(verbatim: " \(votes.total.abbreviated)") } .lineLimit(1) .font(.caption) .foregroundStyle(votes.iconColor) .contentShape(.rect) } } } extension TileScoreView { init(_ interactable: any InteractableProviding) { self.saved = interactable.saved self.votes = interactable.votes } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Components/UpdateBannerView.swift ================================================ // // UpdateBannerView.swift // Mlem // // Created by Sjmarf on 2025-03-29. // import MlemMiddleware import SwiftUI import Theming struct UpdateBannerView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @AppStorage("lastTestFlightUpdate") var lastTestFlightUpdate: URL? @State var isLoading: Bool = false let url: URL var body: some View { HStack { Text("TestFlight updated!") .fontWeight(.semibold) .foregroundStyle(.themedAccent) .padding(.leading, 5) Spacer() Button(action: submit) { Text("What's New?") .padding(.vertical, 4) .opacity(isLoading ? 0 : 1) .overlay { if isLoading { ProgressView() .tint(.themedContrastingLabel) } } } .buttonStyle(.borderedProminent) } .padding(Constants.main.standardSpacing) .background(.themedAccent.opacity(0.2)) // This avoid being partially transparent when context menu is open .background(.themedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .quickSwipes(trailing: [ BasicAction( id: "dismissTestFlightUpdatePopup", appearance: .init(label: "Dismiss", color: .themedNegative, icon: Icons.close), callback: dismiss ) ]) .contextMenu { Button("Dismiss", icon: .general.close) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { dismiss() } } } .onChange(of: navigation.path) { if case .post(let post, _, _, _) = navigation.path.last, post.allResolvableUrls.contains(url) { dismiss() } } } func dismiss() { lastTestFlightUpdate = url } func submit() { isLoading = true Task { do { let announcementPost = try await appState.firstApi.getPost(url: url) navigation.push(.post(announcementPost)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismiss() } } catch { handleError(error) isLoading = false } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Comments/FeedCommentView.swift ================================================ // // FeedCommentView.swift // Mlem // // Created by Eric Andrews on 2024-07-21. // import Foundation import MlemMiddleware import SwiftUI struct FeedCommentView: View { @Environment(AppState.self) private var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) var navigation @Environment(\.reportContext) var reportContext: Report? @Setting(\.post_size) var settingsPostSize @Setting(\.comment_compact) var compactComments @Setting(\.interactionBar_comment) var commentInteractionBar @Setting(\.interactionBar_commentReport) var commentReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var alternateInteractionBarLayoutForReports: Bool let comment: Comment var overriddenSize: PostSize? @ViewBuilder var embeddedContent: () -> EmbeddedContent init( comment: Comment, overriddenSize: PostSize? = nil, @ViewBuilder embeddedContent: @escaping () -> EmbeddedContent = { EmptyView() } ) { self.comment = comment self.overriddenSize = overriddenSize self.embeddedContent = embeddedContent } var postSize: PostSize { overriddenSize ?? settingsPostSize } var showCompactPostContext: Bool { postSize == .compact || compactComments } var body: some View { content .contentShape(.interaction, .rect) .quickSwipes( comment: comment, configuration: interactionBarConfiguration ) .contextMenu(comment: comment) .paletteBorder(cornerRadius: postSize.cornerRadius) } @ViewBuilder var content: some View { if postSize.tiled { TileCommentView(comment: comment) } else { CommentView(comment: comment, inFeed: true, embeddedContent: embeddedContent) } } func headerUrl(post: Post) -> URL? { switch post.type { case let .media(url), let .embedded(url, _): url case let .link(link): link.thumbnail default: nil } } var interactionBarConfiguration: CommentBarConfiguration { if reportContext != nil, alternateInteractionBarLayoutForReports { return commentReportInteractionBar } return commentInteractionBar } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Comments/TileCommentView.swift ================================================ // // TileCommentView.swift // Mlem // // Created by Eric Andrews on 2024-07-19. // import Foundation import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct TileCommentView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.parentFrameWidth) var parentFrameWidth: CGFloat let comment: Comment @ScaledMetric(relativeTo: .footnote) var titleHeight: CGFloat = 36 // (2 * .footnote height), including built-in spacing @ScaledMetric(relativeTo: .caption) var communityHeight: CGFloat = 16 // .caption height, including built-in spacing let contentHeightModifier: CGFloat = 33 // width cannot go below contentHeightModifier so contentWidth is never negative var width: CGFloat { max(contentHeightModifier, (parentFrameWidth - (Constants.main.standardSpacing * 3)) / 2) } var contentHeight: CGFloat { width - 33 } var frameHeight: CGFloat { width + titleHeight + communityHeight + 17 } // Padding math // Need to satisfy: padding + contentHeightModifier = 17 // // VStack spacing = (2 * Constants.main.standardSpacing) = 20 // External padding = (2 * Constants.main.standardSpacing) = 20 // Internal titleSection padding = (2 * Constants.main.halfSpacing) = 10 // // Total padding = 50 // 50 + contentHeightModifier = 17 // contentHeightModifier = -33 var body: some View { content .frame(width: width, height: frameHeight) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.largeItemCornerRadius)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.largeItemCornerRadius)) } var content: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { ExpectedView(comment.post) { post in titleSection(post: post) .typesettingLanguage(.init(languageCode: .english)) .frame(height: titleHeight, alignment: .topLeading) .padding(Constants.main.halfSpacing) .background { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .fill(.themedTertiaryGroupedBackground) } .paletteBorder(cornerRadius: Constants.main.smallItemCornerRadius) } MarkdownText(comment.content, configuration: .caption(palette: palette)) .frame(height: contentHeight, alignment: .top) .clipped() communityAndInfo } .padding(Constants.main.standardSpacing) } @ViewBuilder func titleSection(post: Post) -> some View { Text(post.title) .lineLimit(2) .foregroundStyle(.themedSecondary) .font(.footnote) .fontWeight(.semibold) .frame(maxWidth: .infinity, alignment: .topLeading) } var replyIcon: Text { Text(Image(icon: .lemmy.reply)) .foregroundStyle(.themedAccent) } var communityAndInfo: some View { HStack(spacing: 6) { if let communityName = comment.community.value?.name { Text(communityName) .lineLimit(1) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.themedSecondary) } Spacer() score } .frame(maxWidth: .infinity) } var score: some View { Menu { ForEach(comment.allMenuActions(appState: appState, navigation: navigation), id: \.id) { action in MenuButton(action: action) } } label: { TileScoreView(comment) } .onTapGesture {} .popupAnchor() } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Header/FeedDescription.swift ================================================ // // SubscribedFeedIcon.swift // Mlem // // Created by Eric Andrews on 2024-06-27. // import Foundation import Icons import MlemMiddleware import SwiftUI import Theming struct FeedDescription { var label: LocalizedStringResource var subtitle: LocalizedStringResource var color: ThemedColor var icon: Icon var iconScaleFactor: CGFloat static var all: FeedDescription = .init( label: "All", subtitle: "Posts from all federated instances", color: .themedFederatedFeed, icon: .lemmy.federatedFeed, iconScaleFactor: 0.6 ) static var local: FeedDescription { .init( label: "Local", subtitle: "Posts from \(AppState.main.firstApi.host) communities", color: .themedLocalFeed, icon: .lemmy.localFeed, iconScaleFactor: 0.55 ) } static var subscribed: FeedDescription = .init( label: "Subscribed", subtitle: "Posts from communities you subscribe to", color: .themedSubscribedFeed, icon: .lemmy.subscribedFeed, iconScaleFactor: 0.5 ) static var moderated: FeedDescription = .init( label: "Moderated", subtitle: "Posts from communities you moderate", color: .themedModeratedFeed, icon: .lemmy.moderatedFeed, iconScaleFactor: 0.5 ) static var saved: FeedDescription = .init( label: "Saved", subtitle: "Your saved posts and comments", color: .themedSavedFeed, icon: .lemmy.savedFeed, iconScaleFactor: 0.55 ) static var popular: FeedDescription = .init( label: "Popular", subtitle: "Posts from popular communities", color: .themedPopularFeed, icon: .lemmy.popularFeed, iconScaleFactor: 0.55 ) static var suggested: FeedDescription = .init( label: "Suggested", subtitle: "A selection of communities curated by \(AppState.main.firstApi.host) admins", color: .themedSuggestedFeed, icon: .lemmy.suggestedFeed, iconScaleFactor: 0.55 ) } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Header/FeedHeaderView.swift ================================================ // // FeedHeaderView.swift // Mlem // // Created by Eric Andrews on 2024-01-28. // import Foundation import SwiftUI struct FeedHeaderView: View { @Environment(AppState.self) var appState enum DropdownStyle { case disabled case enabled(showBadge: Bool) } // Using `Text` rather than `String` here to avoid having to make 4 initializers to handle // all permutations of `String` and `LocalizedStringResource` for `title and `subtitle`. let title: Text let subtitle: Text let image: ImageContent let dropdownStyle: DropdownStyle init( title: Text, subtitle: Text, dropdownStyle: DropdownStyle, @ViewBuilder image: () -> ImageContent ) { self.title = title self.subtitle = subtitle self.dropdownStyle = dropdownStyle self.image = image() } init( feedDescription: FeedDescription, customSubtitle: LocalizedStringResource? = nil, dropdownStyle: DropdownStyle ) where ImageContent == FeedIconView { self.title = Text(feedDescription.label) self.subtitle = Text(customSubtitle ?? feedDescription.subtitle) self.image = FeedIconView(feedDescription: feedDescription, size: Constants.main.feedHeaderSize) self.dropdownStyle = dropdownStyle } var body: some View { VStack(spacing: 0) { HStack(alignment: .center, spacing: Constants.main.standardSpacing) { image .frame(width: Constants.main.feedHeaderSize, height: Constants.main.feedHeaderSize) .padding(.leading, Constants.main.standardSpacing) VStack(alignment: .leading, spacing: 0) { HStack(spacing: Constants.main.halfSpacing) { title .lineLimit(1) .minimumScaleFactor(0.01) .fontWeight(.semibold) .foregroundStyle(.themedPrimary) if case let .enabled(showBadge) = dropdownStyle { Image(icon: .general.dropDown) .foregroundStyle(.themedSecondary) .overlay(alignment: .topTrailing) { if showBadge { Circle() .frame(width: 6, height: 6) .foregroundStyle(.themedWarning) } } } } .font(.title2) subtitle .font(.footnote) .foregroundStyle(.themedSecondary) } .frame(height: Constants.main.feedHeaderSize) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.top, Constants.main.halfSpacing) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Header/FeedIconView.swift ================================================ // // FeedIconView.swift // Mlem // // Created by Eric Andrews on 2024-06-27. // import Foundation import SwiftUI struct FeedIconView: View { @Environment(\.palette) var palette let feedDescription: FeedDescription let size: CGFloat let scaledSize: CGFloat init(feedDescription: FeedDescription, size: CGFloat) { self.feedDescription = feedDescription self.size = size self.scaledSize = size * feedDescription.iconScaleFactor } var body: some View { Circle() .fill(feedDescription.color.gradient(palette: palette)) .frame(width: size, height: size) .overlay { Image(icon: feedDescription.icon) .resizable() .symbolVariant(.fill) .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .frame(width: scaledSize, height: scaledSize) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/CompactPostView.swift ================================================ // // CompactPostView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import Foundation import MlemMiddleware import SwiftUI struct CompactPostView: View { @Setting(\.post_thumbnailLocation) var thumbnailLocation @Setting(\.safety_blurNsfw) var blurNsfw @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.post_showDownvotesCompact) var showDownvotesCompact @Environment(\.communityContext) var communityContext: Community? @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @ScaledMetric(relativeTo: .caption) var titleHostHeightLimit: CGFloat = 40 let post: Post var requireConsistentHeight: Bool = false var readouts: [PostBarConfiguration.ReadoutType] { var readouts: [PostBarConfiguration.ReadoutType] = [.created] readouts.append(contentsOf: showDownvotesCompact ? [.upvote, .downvote, .comment] : [.score, .comment]) readouts.appendIfPresent(post.saved.value ?? false ? .saved : nil) return readouts } var blurred: Bool { switch blurNsfw { case .always: post.nsfw case .outsideCommunity: post.nsfw && !(communityContext?.nsfw ?? false) case .never: false } } var body: some View { content .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground) .environment(\.postContext, post) } var content: some View { HStack(alignment: .top, spacing: Constants.main.standardSpacing) { if thumbnailLocation == .left { ThumbnailImageView( post: post, blurred: blurred, size: .standard, frame: .init(width: Constants.main.thumbnailSize, height: Constants.main.thumbnailSize) ) } VStack(alignment: .leading, spacing: Constants.main.compactSpacing) { HStack(spacing: 4) { if communityContext != nil { ExpectedView(post.creator) { creator in FullyQualifiedLinkView(creator, labelStyle: .small, showAvatar: false) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } else { ExpectedView(post.community) { community in FullyQualifiedLinkView(community, labelStyle: .small, showAvatar: false) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } Spacer() if differentiateWithoutColor, readPostIndicator == .checkmark { ReadCheck(read: post.read) } if post.nsfw { Image(icon: .lemmy.nsfwTag) .foregroundStyle(.themedWarning) .imageScale(.small) } // Allow the tap area to extend outside of the parent HStack a little PostEllipsisMenus(post: post, size: 20) .padding(.vertical, -2) } .padding(.bottom, -2) if requireConsistentHeight { titleAndHostView .frame(height: titleHostHeightLimit, alignment: .top) } else { titleAndHostView } InfoStackView(post: post, readouts: readouts, coloredReadouts: .init(PostBarConfiguration.ReadoutType.allCases)) } .frame(maxWidth: .infinity) if thumbnailLocation == .right { ThumbnailImageView( post: post, blurred: blurred, size: .standard, frame: .init(width: Constants.main.thumbnailSize, height: Constants.main.thumbnailSize) ) } } .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder var titleAndHostView: some View { VStack(alignment: .leading, spacing: Constants.main.compactSpacing) { titleView if let host = post.linkHost { PostLinkHostView(host: host) .font(.caption) } } } @ViewBuilder var titleView: some View { post.taggedTitle(communityContext: communityContext) .symbolVariant(.fill) .multilineTextAlignment(.leading) .imageScale(.small) .foregroundStyle(post.read.value ?? false ? .themedSecondary : .themedPrimary) .font(.subheadline) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // DevCompactPostView(post: Post2.mock(.generic)) // } // #endif ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/Feed Post Components/CrossPostListView.swift ================================================ // // CrossPostListView.swift // Mlem // // Created by Sjmarf on 25/09/2024. // import Haptics import MlemMiddleware import SwiftUI struct CrossPostListView: View { @Environment(AppState.self) private var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) private var navigation let post: Post @State private var isExpanded: Bool = false var body: some View { // does not use ExpectedView because of padding reasons and because the animation is not necessary if let crossPosts = post.crossPosts.value { content(crossPosts) } } @ViewBuilder // swiftlint:disable:next function_body_length func content(_ crossPosts: [Post]) -> some View { if !crossPosts.isEmpty { VStack(spacing: Constants.main.halfSpacing) { Button { hapticManager.play(haptic: .gentleInfo, tier: .low) withAnimation(.easeOut(duration: 0.2)) { isExpanded.toggle() } } label: { HStack { Image(icon: .lemmy.crosspost) .foregroundStyle(.themedSecondary) .fontWeight(.semibold) Text("\(crossPosts.count) Crossposts...") Spacer() HStack(spacing: 2) { Image(icon: .lemmy.comment) Text(String(crossPosts.reduce(0) { $0 + ($1.commentCount.value ?? 0) })) } .font(.footnote) .foregroundStyle(.themedSecondary) } .padding(.horizontal, Constants.main.standardSpacing) .contentShape(.rect) } .buttonStyle(.empty) if isExpanded { Divider() .padding(.vertical, 3) Grid(alignment: .leading) { ForEach(crossPosts) { crossPost in GridRow { ExpectedView(crossPost.community) { community in FullyQualifiedLabelView(community, labelStyle: .medium, blurred: crossPost.nsfw) .frame(maxWidth: .infinity, alignment: .leading) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } ReadoutView(readout: crossPost.createdReadout) if let scoreReadout = crossPost.scoreReadout(showColor: true) { ReadoutView(readout: scoreReadout) } if let commentReadout = crossPost.commentReadout { ReadoutView(readout: commentReadout) } } .contentShape(.rect) .onTapGesture { navigation.push(.post(crossPost)) } } } .padding(.horizontal, Constants.main.standardSpacing) .font(.footnote) .foregroundStyle(.themedSecondary) } } .padding(.vertical, 8) .background(.themedSecondaryGroupedBackground) .contentShape(.rect(cornerRadius: Constants.main.standardSpacing)) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu { Button("Mark Read", icon: .lemmy.markRead) { Task { await markAllAsRead(crossPosts) } } } .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } func markAllAsRead(_ crossPosts: [Post]) async { do { try await post.api.markPostsAsRead(ids: Set(crossPosts.map(\.id))) ToastModel.main.add(.success("Read \(crossPosts.count) posts")) } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/Feed Post Components/PostLinkHostView.swift ================================================ // // PostLinkHostView.swift // Mlem // // Created by Eric Andrews on 2024-05-26. // import Foundation import SwiftUI struct PostLinkHostView: View { let host: String var body: some View { content .lineLimit(1) .imageScale(.small) .foregroundStyle(.themedSecondary) } var content: Text { Text(Image(icon: .general.browser)) + Text(verbatim: " \(host)") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/Feed Post Components/PostTag.swift ================================================ // // PostTag.swift // Mlem // // Created by Eric Andrews on 2024-05-23. // import Foundation import Icons import SwiftUI import Theming func postTag(active: Bool, icon: Icon, color: ThemedColor) -> Text { if active { Text(Image(icon: icon)) .foregroundStyle(color) } else { Text(verbatim: "") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/FeedPostView.swift ================================================ // // FeedPostView.swift // Mlem // // Created by Eric Andrews on 2026-01-05. // import Foundation import MlemMiddleware import SwiftUI /// View for rendering posts in feed struct FeedPostView: View { @Environment(AppState.self) private var appState: AppState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) private var navigation @Environment(FiltersTracker.self) var filtersTracker @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @Environment(\.communityContext) var communityContext @Environment(\.reportContext) var reportContext @State var obscured: Bool @Setting(\.post_size) private var settingsPostSize @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.a11y_readOutlineThickness) var readOutlineThickness @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.interactionBar_postReport) var postReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var alternateInteractionBarLayoutForReports let post: Post let favoredLink: PostViewNavigationLink? let requireConsistentHeight: Bool @State var overridePostSize: PostSize? var postSize: PostSize { overridePostSize ?? settingsPostSize } @ViewBuilder let embeddedContent: () -> EmbeddedContent init( post: Post, overridePostSize: PostSize? = nil, favoredLink: PostViewNavigationLink? = nil, requireConsistentHeight: Bool = false, @ViewBuilder embeddedContent: @escaping () -> EmbeddedContent = { EmptyView() } ) { self.post = post self.favoredLink = favoredLink self.requireConsistentHeight = requireConsistentHeight self.embeddedContent = embeddedContent self._obscured = .init(wrappedValue: FiltersTracker.main.postWouldBeFiltered(post)) self._overridePostSize = .init(wrappedValue: overridePostSize) } var body: some View { Group { if obscured { obscuredContent .onTapGesture { withAnimation { obscured = false } } } else { content .overlay(alignment: .topLeading) { if differentiateWithoutColor, !(post.read.value ?? false), readPostIndicator == .outline { RoundedRectangle(cornerRadius: postSize.cornerRadius) .stroke(lineWidth: .init(readOutlineThickness)) .foregroundStyle(.themedSecondary) } } .contentShape(.contextMenuPreview, .rect(cornerRadius: postSize.cornerRadius)) .quickSwipes(post: post, configuration: interactionBarConfiguration) .contextMenu(post: post) } } .contentShape(.interaction, .rect) .paletteBorder(cornerRadius: postSize.cornerRadius) .onChange(of: filtersTracker.changeHash) { obscured = filtersTracker.postWouldBeFiltered(post) } .onAppear { if shouldRenderCompact() { overridePostSize = .compact } } .onChange(of: settingsPostSize) { if settingsPostSize == .tile { overridePostSize = nil } else if shouldRenderCompact() { overridePostSize = .compact } } .onChange(of: post.read.value) { if shouldRenderCompact() { withAnimation { overridePostSize = .compact } } } } @ViewBuilder var obscuredContent: some View { Text("Hidden by filters") .italic() .foregroundStyle(.themedSecondary) .padding(Constants.main.standardSpacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: postSize.cornerRadius)) } @ViewBuilder var content: some View { switch postSize { case .compact: CompactPostView(post: post, requireConsistentHeight: requireConsistentHeight) case .tile: TilePostView(post: post) case .headline: HeadlinePostView( post: post, favoredLink: favoredLink, requireConsistentHeight: requireConsistentHeight, embeddedContent: embeddedContent ) case .large: LargePostView(post: post, favoredLink: favoredLink) } } var interactionBarConfiguration: PostBarConfiguration { if reportContext != nil, alternateInteractionBarLayoutForReports { return postReportInteractionBar } return postInteractionBar } func shouldRenderCompact() -> Bool { guard settingsPostSize != .tile, settingsPostSize != .compact else { return false } return post.read.value ?? false && ((communityContext == nil && post.pinnedInstance) || (communityContext != nil && post.pinnedCommunity)) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/HeadlinePostBodyView.swift ================================================ // // HeadlinePostBodyView.swift // Mlem // // Created by Sjmarf on 2024-12-17. // import MlemMiddleware import SwiftUI struct HeadlinePostBodyView: View { @Environment(\.communityContext) var communityContext: Community? @Setting(\.post_thumbnailLocation) var thumbnailLocation @Setting(\.safety_blurNsfw) var blurNsfw @ScaledMetric(relativeTo: .headline) var titleHostHeightLimit: CGFloat = 75 let post: Post var requireConsistentHeight: Bool = false var blurred: Bool { switch blurNsfw { case .always: post.nsfw case .outsideCommunity: post.nsfw && !(communityContext?.nsfw ?? false) case .never: false } } var body: some View { HStack(alignment: .top, spacing: Constants.main.standardSpacing) { if thumbnailLocation == .left { ThumbnailImageView( post: post, blurred: blurred, size: .standard, frame: .init(width: thumbnailSize, height: thumbnailSize) ) } if requireConsistentHeight { titleAndHostView .frame(height: titleHostHeightLimit, alignment: .top) } else { titleAndHostView } if thumbnailLocation == .right { Spacer() ThumbnailImageView( post: post, blurred: blurred, size: .standard, frame: .init(width: thumbnailSize, height: thumbnailSize) ) } } } var thumbnailSize: CGFloat { if requireConsistentHeight, titleHostHeightLimit < Constants.main.thumbnailSize * 1.5 { titleHostHeightLimit } else { Constants.main.thumbnailSize } } @ViewBuilder var titleAndHostView: some View { VStack(alignment: .leading, spacing: Constants.main.halfSpacing) { titleView if let host = post.linkHost { PostLinkHostView(host: host) .font(.subheadline) } } } @ViewBuilder var titleView: some View { post.taggedTitle(communityContext: communityContext) .symbolVariant(.fill) .multilineTextAlignment(.leading) .foregroundStyle((post.read.value ?? false) ? .themedSecondary : .themedPrimary) .font(.headline) .imageScale(.small) .fixedSize(horizontal: false, vertical: true) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/HeadlinePostView.swift ================================================ // // HeadlinePostView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import Foundation import MlemMiddleware import SwiftUI struct HeadlinePostView: View { @Setting(\.post_showCreator) var alwaysShowCreator @Setting(\.person_showAvatar) var showPersonAvatar @Setting(\.community_showAvatar) var showCommunityAvatar @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.interactionBar_postReport) var postReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var alternateInteractionBarLayoutForReports @Environment(AppState.self) private var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) var navigation @Environment(\.communityContext) var communityContext: Community? @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @Environment(\.reportContext) private var reportContext: Report? let post: Post let embeddedContent: EmbeddedContent let favoredLink: PostViewNavigationLink? let requireConsistentHeight: Bool init( post: Post, favoredLink: PostViewNavigationLink? = nil, requireConsistentHeight: Bool = false, @ViewBuilder embeddedContent: () -> EmbeddedContent = { EmptyView() } ) { self.post = post self.favoredLink = favoredLink self.requireConsistentHeight = requireConsistentHeight self.embeddedContent = embeddedContent() } var topNavigationLink: PostViewNavigationLink { if let favoredLink { return favoredLink } return communityContext == nil ? .community : .creator } var body: some View { contentView .background(.themedSecondaryGroupedBackground) .environment(\.postContext, post) } var contentView: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { switch topNavigationLink { case .community: communityLink case .creator: personLink } Spacer() if differentiateWithoutColor, readPostIndicator == .checkmark { ReadCheck(read: post.read) } if post.nsfw { Image(icon: .lemmy.nsfwTag) .foregroundStyle(.themedWarning) } PostEllipsisMenus(post: post) } HeadlinePostBodyView(post: post, requireConsistentHeight: requireConsistentHeight) if alwaysShowCreator, communityContext == nil, topNavigationLink != .creator { personLink } embeddedContent } .padding([.top, .horizontal], Constants.main.standardSpacing) InteractionBarView( appState: appState, post: post, configuration: interactionBarConfiguration, navigation: navigation, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) } } @ViewBuilder var personLink: some View { ExpectedView(post.creator) { creator in FullyQualifiedLinkView(creator, labelStyle: .medium) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } @ViewBuilder var communityLink: some View { ExpectedView(post.community) { community in FullyQualifiedLinkView(community, labelStyle: .medium) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } var interactionBarConfiguration: PostBarConfiguration { if reportContext != nil, alternateInteractionBarLayoutForReports { return postReportInteractionBar } return postInteractionBar } } // TODO: update mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // HeadlinePostView(post: Post2.mock(.generic)) // } // #endif ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/LargePostBodyView.swift ================================================ // // LargePostBodyView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct LargePostBodyView: View { @Environment(\.palette) var palette @Environment(\.communityContext) private var communityContext: Community? let post: Post let isPostPage: Bool let shouldBlur: Bool var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { post.taggedTitle(communityContext: communityContext) .foregroundStyle( (post.read.value ?? false && !isPostPage) ? .themedSecondary : .themedPrimary ) .font(.headline) .symbolVariant(.fill) .imageScale(.small) switch post.type { case let .poll(poll): PostPollView(post: post, poll: poll) if post.content != nil { Divider().padding(.horizontal, -Constants.main.standardSpacing) } case let .media(url): mediaView(url) case let .embedded(url, originalLink): VStack(spacing: Constants.main.standardSpacing) { mediaView(url) if isPostPage { OpenInLoopsButton(url: originalLink) } } case let .link(link): WebsitePreviewView(link: link, shouldBlur: shouldBlur) { post.updateRead(true) } default: EmptyView() } if let content = post.content { if isPostPage { MarkdownWithLinkList(content, configuration: shouldBlur ? .defaultBlurred : .default) } else { // Cut down on compute time for very long text posts by only rendering the first 4 blocks MarkdownText( Array([BlockNode](content).prefix(4)), configuration: .dimmed(palette: palette) ) .lineLimit(post.linkUrl == nil ? 8 : 4) } } } .environment(\.postContext, post) } @ViewBuilder func mediaView(_ url: URL) -> some View { MediaView.largeImage(url: url, shouldBlur: shouldBlur) { post.updateRead(true) } .frame(maxWidth: .infinity) } // @Environment(\.openURL) combined with the conditionally displayed url in .embedded causes significant lag // due to openURL-based redraws, so we pull this into its own view to isolate openURL private struct OpenInLoopsButton: View { @Environment(\.openURL) private var openURL let url: URL var body: some View { Button(String(localized: loopsButtonText(originalLink: url))) { openURL(url) } .buttonStyle(.bordered) } func loopsButtonText(originalLink: URL) -> LocalizedStringResource { if let host = originalLink.host() { return "View on \(host)" } else { return "View on original host" } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/LargePostView.swift ================================================ // // LargePostView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct LargePostView: View { @Setting(\.post_showCreator) private var alwaysShowCreator @Setting(\.person_showAvatar) private var showPersonAvatar @Setting(\.community_showAvatar) private var showCommunityAvatar @Setting(\.safety_blurNsfw) var blurNsfw @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.interactionBar_postReport) var postReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var alternateInteractionBarLayoutForReports @Environment(AppState.self) private var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) var navigation @Environment(\.communityContext) private var communityContext @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor @Environment(\.reportContext) private var reportContext: Report? let post: Post let isPostPage: Bool let favoredLink: PostViewNavigationLink? init( post: Post, isPostPage: Bool = false, favoredLink: PostViewNavigationLink? = nil ) { self.post = post self.isPostPage = isPostPage self.favoredLink = favoredLink } var shouldBlur: Bool { switch blurNsfw { case .always: post.nsfw case .outsideCommunity: post.nsfw && !(communityContext?.nsfw ?? false) case .never: false } } var topNavigationLink: PostViewNavigationLink { if let favoredLink { return favoredLink } return communityContext == nil || isPostPage ? .community : .creator } var body: some View { content .background(.themedSecondaryGroupedBackground) .environment(\.postContext, post) } var content: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { switch topNavigationLink { case .community: communityLink case .creator: personLink } Spacer() if !isPostPage, differentiateWithoutColor, readPostIndicator == .checkmark { ReadCheck(read: post.read) } if post.nsfw { Image(icon: .lemmy.nsfwTag) .foregroundStyle(.themedWarning) } if !isPostPage { PostEllipsisMenus(post: post) } } LargePostBodyView(post: post, isPostPage: isPostPage, shouldBlur: shouldBlur) if (alwaysShowCreator && communityContext == nil && topNavigationLink != .creator) || isPostPage { personLink } if showDivider { Divider().padding(.horizontal, -Constants.main.standardSpacing) } } .padding([.top, .horizontal], Constants.main.standardSpacing) InteractionBarView( appState: appState, post: post, configuration: interactionBarConfiguration, navigation: navigation, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) } } var interactionBarConfiguration: PostBarConfiguration { if reportContext != nil, alternateInteractionBarLayoutForReports { return postReportInteractionBar } return postInteractionBar } var showDivider: Bool { !(post.content?.isEmpty ?? true) || post.poll != nil } @ViewBuilder var personLink: some View { ExpectedView(post.creator) { creator in FullyQualifiedLinkView(creator, labelStyle: .medium) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } @ViewBuilder var communityLink: some View { ExpectedView(post.community) { community in FullyQualifiedLinkView(community, labelStyle: .medium) } placeholder: { Text(verbatim: .communityPlaceholder) .redacted(reason: .placeholder) } } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // LargePostView( // post: Post2.mock(.generic), // isPostPage: true, // favoredLink: nil // ) // } // #endif ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/PostPollView.swift ================================================ // // PostPollView.swift // Mlem // // Created by Sjmarf on 2026-01-26. // import ComponentViews import MlemMiddleware import SwiftUI struct PostPollView: View { @Environment(\.hapticManager) var hapticManager @Environment(\.toastModel) var toastModel @Environment(\.colorScheme) var colorScheme let post: Post let poll: PostPoll @State var resultsShownManually: Bool @State var selected: Set init(post: Post, poll: PostPoll) { self.post = post self.poll = poll self._resultsShownManually = .init(initialValue: poll.hasVoted) self._selected = .init(initialValue: .init(poll.choices.filter(\.selected).map(\.id))) } var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(Array(poll.choices.enumerated()), id: \.offset) { _, choice in choiceView(choice) } if !poll.hasEnded { if selected.isEmpty, !poll.hasVoted { showResultsButtonView } else { submitButtonView } } footerView } .fixedSize(horizontal: false, vertical: true) .animation(.snappy(duration: 0.2, extraBounce: 0.2), value: showResults) } @ViewBuilder var showResultsButtonView: some View { Button { resultsShownManually.toggle() hapticManager.play(haptic: .gentleInfo, tier: .low) } label: { Label(resultsShownManually ? "Hide Results" : "Show Results", icon: .lemmy.pollPost) .foregroundStyle(.themedAccent) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 8) .padding(.vertical, 8) .background(.themedAccent.opacity(0.2), in: .rect(cornerRadius: 16)) } .buttonStyle(.plain) } @ViewBuilder var submitButtonView: some View { Button { if !self.poll.hasVoted { self.resultsShownManually = true self.post.voteInPoll(self.selected) hapticManager.play(haptic: .gentleInfo, tier: .low) } } label: { Label( self.poll.hasVoted ? "Submitted" : "Submit", icon: self.poll.hasVoted ? .general.success : .lemmy.send ) .symbolVariant(.fill) .foregroundStyle(self.poll.hasVoted ? .themedSecondary : .themedContrastingLabel) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 8) .padding(.vertical, 8) .background( self.poll.hasVoted ? .themedTertiaryGroupedBackground : .themedAccent, in: .rect(cornerRadius: 16) ) } .buttonStyle(.plain) } @ViewBuilder var footerView: some View { HStack { if let endDate = poll.endDate { Group { if poll.hasEnded { Text("Ended \(endDate, format: .relative(presentation: .named, unitsStyle: .wide))") } else { Text("Ends \(endDate, format: .relative(presentation: .named, unitsStyle: .wide))") } } } Spacer() Text("\(poll.totalVotes) votes") } .padding(.horizontal, 8) .foregroundStyle(.themedSecondary) .font(.footnote) } @ViewBuilder func choiceView(_ choice: PostPollChoice) -> some View { HStack(alignment: .top) { if showCheckboxes { let selected = self.selected.contains(choice.id) Checkbox(isOn: selected) .opacity(!poll.hasVoted || selected ? 1 : 0) } VStack(alignment: .leading, spacing: 2) { Text(choice.label) .padding(.vertical, 2) resultsDetailsView(choice) } } .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .padding(.trailing, 16) .padding(.leading, showCheckboxes ? 8 : 16) .padding(.vertical, 8) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: 16)) .onTapGesture { if poll.hasEnded { toastModel?.add(.basic("Poll has ended", subtitle: "This poll is no longer accepting votes.", duration: 3)) return } if poll.hasVoted { toastModel?.add(.basic("Already voted", subtitle: "You cannot change your vote.", duration: 3)) return } hapticManager.play(haptic: .gentleInfo, tier: .low) if poll.type == .single { if selected == [choice.id] { selected = [] } else { selected = [choice.id] } } else { if selected.contains(choice.id) { selected.remove(choice.id) } else { selected.insert(choice.id) } } } } @ViewBuilder func resultsDetailsView(_ choice: PostPollChoice) -> some View { HStack { resultsBarView(choice) HStack { if showResults { Text(verbatim: "\(choice.percentage(poll: poll))%") .foregroundStyle(.secondary) } else { Text(verbatim: "?") .foregroundStyle(.tertiary) } } .frame(width: showResults ? 35 : 15, alignment: .center) .font(.footnote) } } @ViewBuilder func resultsBarView(_ choice: PostPollChoice) -> some View { GeometryReader { proxy in ZStack(alignment: .leading) { Rectangle() .fill(colorScheme == .dark ? .themedSecondaryGroupedBackground : .themedTertiary.opacity(0.5)) let barWidth = proxy.size.width * CGFloat(choice.voteCount ?? 0) / CGFloat(max(1, poll.totalVotes)) // This creates a half-capsule UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 0, bottomTrailingRadius: .greatestFiniteMagnitude, topTrailingRadius: .greatestFiniteMagnitude ) .fill(.themedAccent) .frame(width: showResults ? barWidth : 0) } .clipShape(.capsule) } .frame(height: 4) } var showCheckboxes: Bool { !poll.hasEnded || poll.hasVoted } var showResults: Bool { poll.hasEnded || resultsShownManually } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/Feed Posts/TilePostView.swift ================================================ // // TilePostView.swift // Mlem // // Created by Eric Andrews on 2024-05-27. // import Foundation import LemmyMarkdownUI import MlemMiddleware import NukeUI import SwiftUI struct TilePostView: View { @Setting(\.a11y_readPostIndicator) var readPostIndicator @Environment(AppState.self) private var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) var navigation @Environment(\.communityContext) var communityContext: Community? @Environment(\.parentFrameWidth) var parentFrameWidth: CGFloat @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor let post: Post // Note that these dimensions above sum to precisely the height of TileCommentView, though due to the grouping of title and community here, we get a bonus 10px for the content // Total height in simplest form is: // width + minTitleHeight + communityHeight + 17 @ScaledMetric(relativeTo: .footnote) var minTitleHeight: CGFloat = 36 // (2 * .footnote height), including built-in spacing @ScaledMetric(relativeTo: .caption) var communityHeight: CGFloat = 16 // .caption height, including built-in spacing let contentHeightModifier: CGFloat = 10 // width cannot go below contentHeightModifier so contentWidth is never negative var width: CGFloat { max(contentHeightModifier, (parentFrameWidth - (Constants.main.standardSpacing * 3)) / 2) } var contentHeight: CGFloat { width - contentHeightModifier } var frameHeight: CGFloat { width + minTitleHeight + communityHeight + 17 } // Padding math // Need to satisfy: padding + contentHeightModifier = 17 // // Title : community spacing = 7 // Title + community external padding = (2 * Constants.main.standardSpacing) = 20 // // Total padding = 27 // 27 + contentHeightModifier = 17 // contentHeightModifier = 10 var body: some View { content .frame(width: width, height: frameHeight) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.largeItemCornerRadius)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.largeItemCornerRadius)) .environment(\.postContext, post) } var content: some View { VStack(alignment: .leading, spacing: 0) { BaseImage(post: post, width: width, height: contentHeight) Divider() VStack(spacing: 7) { titleSection .typesettingLanguage(.init(languageCode: .english)) nameAndInfo } .padding(Constants.main.standardSpacing) } } @ViewBuilder var titleSection: some View { post.taggedTitle(communityContext: communityContext) .symbolVariant(.fill) .lineLimit(post.type.lineLimit) .foregroundStyle(post.read.value ?? false ? .themedSecondary : .themedPrimary) .font(.footnote) .fontWeight(.semibold) .frame(maxWidth: .infinity, minHeight: minTitleHeight, alignment: .topLeading) } var nameAndInfo: some View { HStack(spacing: 6) { Group { if communityContext != nil { ExpectedView(post.creator) { creator in Text(creator.name) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } else { ExpectedView(post.community) { community in Text(community.name) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } } .lineLimit(1) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.themedSecondary) Spacer() if differentiateWithoutColor, readPostIndicator == .checkmark { ReadCheck(read: post.read, tiled: true) } score } .frame(maxWidth: .infinity) } var score: some View { Menu { ForEach(post.allMenuActions( appState: appState, showAllActions: false, navigation: navigation, commentTreeTracker: commentTreeTracker ), id: \.id) { action in MenuButton(action: action) } } label: { TileScoreView(post) } .onTapGesture {} .popupAnchor() } // MARK: - BaseImage struct BaseImage: View { @Environment(\.palette) var palette @Environment(\.communityContext) var communityContext @Setting(\.safety_blurNsfw) var blurNsfw let post: Post let width: CGFloat let height: CGFloat var blurred: Bool { switch blurNsfw { case .always: post.nsfw case .outsideCommunity: post.nsfw && !(communityContext?.nsfw ?? false) case .never: false } } var body: some View { content .overlay { if post.nsfw { Image(icon: .lemmy.nsfwTag) .symbolRenderingMode(.palette) .foregroundStyle(.themedBackground, .themedWarning) .imageScale(.small) .padding(.top, Constants.main.standardSpacing) .padding(.trailing, Constants.main.halfSpacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } } } @ViewBuilder var content: some View { switch post.type { case let .text(text): MarkdownText(text, configuration: .caption(palette: palette)) .foregroundStyle(.themedSecondary) .padding(Constants.main.standardSpacing) .frame(maxWidth: .infinity, maxHeight: height, alignment: .topLeading) .clipped() case .titleOnly, .poll: Image(icon: post.imageFallback.icon) .resizable() .scaledToFit() .foregroundStyle(.themedSecondary) .frame(width: Constants.main.thumbnailSize, height: Constants.main.thumbnailSize) .frame(maxWidth: .infinity, maxHeight: .infinity) case .media, .embedded: ThumbnailImageView( post: post, blurred: blurred, size: .tile, frame: .init(width: width, height: height) ) .clipped() case let .link(link): ThumbnailImageView( post: post, blurred: blurred, size: .tile, frame: .init(width: width, height: height) ) .aspectRatio(contentMode: .fill) .clipped() .overlay { linkHostOverlay(link) } } } func linkHostOverlay(_ link: PostLink) -> some View { PostLinkHostView(host: link.host) .font(.caption) .padding(2) .padding(.horizontal, 4) .background { Capsule() .fill(.regularMaterial) .overlay(Capsule().fill(.themedBackground.opacity(0.25))) } .padding(Constants.main.compactSpacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/FeedsView.swift ================================================ // // FeedsView.swift // Mlem // // Created by Eric Andrews on 2024-01-07. // import Dependencies import Foundation import MlemBackend import MlemMiddleware import SwiftUI import Theming struct FeedsView: View { @Environment(AppState.self) var appState @Environment(FiltersTracker.self) var filtersTracker @Environment(BackendClient.self) var backendClient @Setting(\.post_size) var postSize @Setting(\.feed_showRead) var showRead @Setting(\.tip_feedWelcomePrompt) var showWelcomePrompt @Setting(\.behavior_internetSpeed) var internetSpeed @Setting(\.links_embedLoops) var embedLoops @AppStorage("lastTestFlightUpdate") var lastTestFlightUpdate: URL? @ObservationIgnored @Dependency(\.persistenceRepository) private var persistenceRepository @State var postFeedLoader: AggregatePostFeedLoader? @State var scrollToTopTrigger: Bool = false @State var initialListingType: ListingType? @State var showHiddenReadBanner: Bool = false @State var lastRefreshDate: Date? var feedOptions: [ListingType] { ListingType.cases(for: appState.firstAccount.accountType, api: appState.firstApi) } init(listingType: ListingType? = nil) { _initialListingType = .init(initialValue: listingType) } var body: some View { content .background(ThemedColor.themedGroupedBackground) .themedGroupedBackground() .scrollContentBackground(.hidden) .modifier( FeedSelectionTitleModifier( feedOptions: feedOptions, shouldScrollToTop: true, feedLoader: postFeedLoader, scrollToTopTrigger: $scrollToTopTrigger ) ) .toolbar { // SwiftUI complains if both this and the menu are in the same toolbar if let postFeedLoader { FeedSortPicker(feedLoader: postFeedLoader, showTopTimescaleInIcon: true) } } .conditionalNavigationTitle((postFeedLoader?.feedType.label ?? nil).map(String.init(localized:)) ?? "") .navigationBarTitleDisplayMode(.inline) .onChange(of: showRead) { scrollToTopTrigger.toggle() if showRead { showHiddenReadBanner = false } lastRefreshDate = nil } .onChange(of: appState.firstApi, initial: false) { // ensure we always are showing an appropriate feed if let postFeedLoader { Task { if !ListingType.cases( for: appState.firstAccount.accountType, api: appState.firstApi ).contains(postFeedLoader.feedType) { try await postFeedLoader.changeSortType(to: appState.initialFeedSortType) let newFeedSelection: ListingType = appState.firstAccount.accountType == .guest ? .all : .subscribed if newFeedSelection != postFeedLoader.feedType { await postFeedLoader.changeApi(to: appState.firstApi, context: filtersTracker.filterContext) } try await postFeedLoader.changeFeedType(to: newFeedSelection) } } } } .task { await setupFeedLoader() } .outdatedFeedPopup(feedLoader: postFeedLoader, onManualRefresh: { guard !showRead else { return } let now = Date() if let lastRefresh = lastRefreshDate, now.timeIntervalSince(lastRefresh) < 5 { showHiddenReadBanner = true } lastRefreshDate = now }) .environment(\.feedContext, postFeedLoader?.feedType.feedContext) } @ViewBuilder var content: some View { ZStack { if let postFeedLoader { FancyScrollView(scrollToTopTrigger: $scrollToTopTrigger) { Section { if AccountsTracker.main.isEmpty, showWelcomePrompt, !appState.firstApi.willSendToken { FeedWelcomeView() .padding([.horizontal, .bottom], Constants.main.standardSpacing) } if Bundle.main.isTestFlight, let testflightUrl = backendClient.testflightUpdate, lastTestFlightUpdate != testflightUrl { UpdateBannerView(url: testflightUrl) .padding([.horizontal, .bottom], Constants.main.standardSpacing) } if showHiddenReadBanner, !showRead { HiddenReadBannerView { showHiddenReadBanner = false } .padding([.horizontal, .bottom], Constants.main.standardSpacing) } PostGridView(postFeedLoader: postFeedLoader) } header: { Menu { FeedSelectionMenuView( feedOptions: feedOptions, shouldScrollToTop: false, feedLoader: postFeedLoader, scrollToTopTrigger: $scrollToTopTrigger ) } label: { FeedHeaderView(feedDescription: postFeedLoader.feedType.description, dropdownStyle: .enabled(showBadge: false)) .padding(.bottom, Constants.main.standardSpacing) } .buttonStyle(.plain) } } .animation(.snappy, value: backendClient.testflightUpdate != lastTestFlightUpdate) .animation(.snappy, value: showHiddenReadBanner && !showRead) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.themedGroupedBackground) } } } @MainActor func setupFeedLoader() async { guard postFeedLoader == nil else { return } @Setting(\.behavior_internetSpeed) var internetSpeed @Setting(\.feed_showRead) var showReadPosts @Setting(\.feed_default) var defaultFeed var listingType: ListingType do { if let initialListingType { listingType = initialListingType } else if try await appState.firstApi.supports(.listingType(defaultFeed)) { listingType = defaultFeed } else { listingType = .subscribed } // fallback to local if using guest account and selection requires authenticated account if !(AppState.main.firstAccount is UserAccount), !ListingType.guestCases.contains(listingType) { listingType = .local } postFeedLoader = try await .init( pageSize: internetSpeed.pageSize, sortType: appState.initialFeedSortType, showReadPosts: showReadPosts, filterContext: filtersTracker.filterContext, prefetchingConfiguration: .forPostSize(postSize), urlCache: Constants.main.urlCache, api: appState.firstApi, feedType: listingType ) } catch { handleError(error) } } } private struct FeedSelectionTitleModifier: ViewModifier { let feedOptions: [ListingType] let shouldScrollToTop: Bool var feedLoader: AggregatePostFeedLoader? @Binding var scrollToTopTrigger: Bool @State var isAtTop: Bool = false func body(content: Content) -> some View { content .toolbar { if !isAtTop, let feedLoader { ToolbarTitleMenu { FeedSelectionMenuView( feedOptions: feedOptions, shouldScrollToTop: shouldScrollToTop, feedLoader: feedLoader, scrollToTopTrigger: $scrollToTopTrigger ) } } } .isAtTopSubscriber(isAtTop: $isAtTop) } } private struct FeedSelectionMenuView: View { let feedOptions: [ListingType] let shouldScrollToTop: Bool @Binding var feedSelection: ListingType @Binding var scrollToTopTrigger: Bool var body: some View { ForEach(feedOptions, id: \.self) { feed in Button( String(localized: feed.description.label), icon: feed.description.icon ) { if shouldScrollToTop { scrollToTopTrigger.toggle() // delay feed switch to allow scroll to complete DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { feedSelection = feed } } else { feedSelection = feed } } .symbolVariant(feedSelection == feed ? .fill : .none) } } } extension FeedSelectionMenuView { init( feedOptions: [ListingType], shouldScrollToTop: Bool, feedLoader: AggregatePostFeedLoader, scrollToTopTrigger: Binding ) { self._feedSelection = .init(get: { feedLoader.feedType }, set: { newValue in Task { @MainActor in do { try await feedLoader.changeFeedType(to: newValue) } catch { handleError(error) } } }) self.feedOptions = feedOptions self.shouldScrollToTop = shouldScrollToTop self._scrollToTopTrigger = scrollToTopTrigger } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment(api: .realistic)) { // FeedsView() // .previewNavigationStack(backButtonLabel: "Feeds") // .previewTabBar(selected: .feeds) // } // #endif ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/SectionIndexTitles.swift ================================================ // // SectionIndexTitles.swift // Mlem // // Created by mormaer on 13/08/2023. // // import Dependencies import Haptics import Icons import SwiftUI struct SectionIndexTitles: View { @Environment(HapticManager.self) var hapticManager struct Section: Identifiable { let label: String var icon: Icon? var id: String { label } } let sections: [Section] @Binding var sectionScroller: Int init(sections: [SubscriptionListSection], sectionScroller: Binding) { self.sections = sections.map { .init(label: $0.label, icon: $0.icon) } self._sectionScroller = sectionScroller } @GestureState private var dragLocation: CGPoint = .zero // Track which sidebar label we picked last so we // only send a haptic when selecting a new one @State var lastSelectedLabel: String = "" var body: some View { VStack { ForEach(sections) { communitySection in sectionTitle(for: communitySection) .frame(width: 12, height: 6) } } .overlay { GeometryReader { geo in Color.clear .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .updating($dragLocation) { value, _, _ in // ignore if out of bounds--actually add a tiny bit of padding to the left side to make it feel right guard value.location.x > -20.0, value.location.y >= 0.0, value.location.y <= geo.size.height else { return } // compute which section is currently dragged // height of one section is communitySections.count / geo.size.height // drag is thus (value.location.y / (communitySections.count / geo.size.height )) sections up // then do some algebra to make it prettier and round down to int let sectionIndex = min( Int((value.location.y * Double(sections.count)) / geo.size.height), sections.count - 1 ) let sectionLabel = sections[sectionIndex].label if sectionLabel != lastSelectedLabel { Task { @MainActor in lastSelectedLabel = sectionLabel sectionScroller = sectionIndex hapticManager.play(haptic: .rigidInfo, tier: .low) } } } ) } } } } // Sidebar Label Views @ViewBuilder func sectionTitle(for section: SectionIndexTitles.Section) -> some View { if let icon = section.icon { SectionIndexImage(icon: icon) } else { SectionIndexText(label: section.label) } } struct SectionIndexText: View { let label: String var body: some View { Text(label) .font(.system(size: 11)) .fontWeight(.semibold) .foregroundStyle(.themedPrimary) } } struct SectionIndexImage: View { let icon: Icon var body: some View { Image(icon: icon) .resizable() .frame(width: 8, height: 8) .symbolVariant(.fill) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/SubscriptionListItemView.swift ================================================ // // SubscriptionListItemView.swift // Mlem // // Created by Sjmarf on 07/08/2024. // import MlemMiddleware import SwiftUI struct SubscriptionListItemView: View { @Environment(\.self) var environment @Environment(AppState.self) private var appState @Environment(NavigationLayer.self) private var navigation @Setting(\.subscriptions_sort) private var sort @Setting(\.subscriptions_instanceLocation) private var savedInstanceLocation let community: Community let section: SubscriptionListSection let sectionIndicesShown: Bool var body: some View { SubscriptionListNavigationButton(.community(community), label: label) .contextMenu(community: community) .swipeActions(edge: .trailing) { Button("Unsubscribe", icon: .lemmy.unsubscribe) { SubscribeAction(entity: community).execute(environment: environment) } .buttonStyle(.automatic) .labelStyle(.iconOnly) .tint(.red) } .padding(.trailing, sectionIndicesShown ? 5 : 0) } @ViewBuilder private func label() -> some View { HStack(spacing: 15) { switch instanceLocation(section: section) { case .trailing: CircleCroppedImageView(community, frame: 28) ( Text(community.name) + Text(verbatim: "@\(community.host)") .foregroundStyle(.secondary) .font(.footnote) ) .lineLimit(1) case .bottom: CircleCroppedImageView(community, frame: 36) VStack(alignment: .leading, spacing: 0) { Text(community.name) .lineLimit(1) Text(verbatim: "@\(community.host)") .foregroundStyle(.secondary) .font(.footnote) } case .disabled: CircleCroppedImageView(community, frame: 28) Text(community.name) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } private func instanceLocation(section: SubscriptionListSection) -> InstanceLocation { switch sort { case .alphabetical: savedInstanceLocation case .instance: section.label == String(localized: "Other") ? .trailing : .disabled } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/SubscriptionListNavigationButton.swift ================================================ // // SubscriptionListNavigationButton.swift // Mlem // // Created by Sjmarf on 19/09/2024. // import ComponentViews import SwiftUI struct SubscriptionListNavigationButton: View { @Environment(NavigationLayer.self) var navigation let destination: NavigationPage @ViewBuilder var label: () -> Content init(_ destination: NavigationPage, @ViewBuilder label: @escaping () -> Content) { self.destination = destination self.label = label } var body: some View { MultiplatformView(phone: { NavigationLink(destination, label: label) }, pad: { Button(action: { navigation.path = [] navigation.root = destination }, label: label) .buttonStyle(.empty) }) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/SubscriptionListView.swift ================================================ // // SubscriptionListView.swift // Mlem // // Created by Sjmarf on 11/05/2024. // import Icons import MlemMiddleware import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct SubscriptionListView: View { @Environment(AppState.self) private var appState @Environment(NavigationLayer.self) private var navigation @Environment(TabReselectTracker.self) var tabReselectTracker @Setting(\.subscriptions_sort) private var sort @State var noDetail: Bool = false var feedOptions: [ListingType] { ListingType.cases(for: appState.firstAccount.accountType, api: appState.firstApi) } @State var sectionScroller: Int = 0 @State var errorDetails: ErrorDetails? @Weak var form: UICollectionView? var detailDisplayed: Bool { if UIDevice.isPad { noDetail ? false : navigation.path.isEmpty } else { navigation.path.isEmpty } } var subscriptions: SubscriptionList? { (appState.firstSession as? UserSession)?.subscriptions } var sectionIndicesShown: Bool { !UIDevice.isPad && sort == .alphabetical && (subscriptions?.communities.count ?? 0) > 10 } var body: some View { Group { // TODO: iOS 18 deprecation remove compatibility shim if #available(iOS 26, *) { content .listSectionIndexVisibility(sectionIndicesShown ? .visible : .hidden) } else { content } } .listStyle(.sidebar) .navigationTitle("Feeds") } @ViewBuilder var content: some View { let sections = subscriptions?.visibleSections(sort: sort) ?? [] Form(tint: .themedPrimary) { // TODO: iOS 18 deprecation remove compatibility shim if #available(iOS 26, *) { feeds .sectionIndexLabel("★") } else { feeds } if AccountsTracker.main.isEmpty { Section { signedOutInfoView .listRowBackground(Color.clear) } } if let errorDetails { Section { ErrorView(errorDetails) .frame(maxWidth: .infinity) .listRowBackground(Color.clear) } } else { ForEach(sections) { section in SubscriptionListSectionView(section: section, sectionIndicesShown: sectionIndicesShown) .id(section.label) } .scrollTargetLayout() } } .introspect(.form, on: .iOS(.v17, .v18)) { introspectedForm in form = introspectedForm } .onChange(of: sectionScroller) { form?.scrollToItem(at: .init(row: 0, section: sectionScroller), at: .centeredVertically, animated: false) } .foregroundStyle(.themedPrimary) .overlay(alignment: .trailing) { if !UIDevice.isIos26, sectionIndicesShown { SectionIndexTitles( sections: sections, sectionScroller: $sectionScroller ) } } .toolbar { if !(subscriptions?.communities.isEmpty ?? true) { Menu("Sort", icon: sort.icon) { ForEach(SubscriptionListSort.allCases, id: \.self) { item in Toggle( item.label, icon: item.icon, isOn: .init( get: { sort == item }, set: { _ in sort = item } ) ) } } } } .onChange(of: tabReselectTracker.flag) { // normal reselect tracker does not work here thanks to NavigationSplitView, so we need to implement a custom one if detailDisplayed, tabReselectTracker.flag { tabReselectTracker.reset() form?.scrollToItem(at: .init(row: 0, section: 0), at: .bottom, animated: true) } } .onChange(of: (appState.firstSession as? UserSession)?.subscriptionListErrorDetails) { if let details = (appState.firstSession as? UserSession)?.subscriptionListErrorDetails { errorDetails = details } } .scrollIndicators(sectionIndicesShown ? .hidden : .visible) .refreshable { do { try await subscriptions?.refresh() errorDetails = nil } catch { errorDetails = handleErrorWithDetails(error) } } .background(.themedBackground) } @ViewBuilder var feeds: some View { Section { ForEach(feedOptions, id: \.hashValue) { feedOption in SubscriptionListNavigationButton(.feeds(feedOption)) { HStack(spacing: 15) { FeedIconView( feedDescription: feedOption.description, size: appState.firstSession is GuestSession ? 36 : 28 ) VStack(alignment: .leading) { Text(feedOption.description.label) if appState.firstSession is GuestSession { Text(feedOption.description.subtitle) .font(.footnote) .foregroundStyle(.themedSecondary) } } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } } } } @ViewBuilder var signedOutInfoView: some View { VStack { Image(systemName: "list.bullet") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 50) .foregroundStyle(.themedTertiary) .padding(.bottom, 5) Text("Your subscriptions live here.") .font(.title2) .fontWeight(.semibold) Text("Log in or sign up to view your subscriptions.") HStack { Button { navigation.openSheet(.logIn(.pickInstance)) } label: { Text("Log In") .frame(minWidth: 80) } Button { navigation.openSheet(.signUp()) } label: { Text("Sign Up") .frame(minWidth: 80) } } .tint(.themedSecondary) .buttonStyle(.bordered) } .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .foregroundStyle(.themedSecondary) } } private struct SubscriptionListSectionView: View { let section: SubscriptionListSection let sectionIndicesShown: Bool // TODO: iOS 18 deprecation remove compatibility shim var body: some View { if #available(iOS 26, *) { content .sectionIndexLabel(section.showInScroller ? section.label : nil) } else { content } } var content: some View { Section(section.label) { ForEach(section.communities) { (community: Community) in SubscriptionListItemView( community: community, section: section, sectionIndicesShown: sectionIndicesShown ) } } } } enum SubscriptionListSort: String, CaseIterable, Codable { case alphabetical case instance var label: LocalizedStringResource { switch self { case .alphabetical: "Name" case .instance: "Instance" } } var icon: Icon { switch self { case .alphabetical: .lemmy.alphabeticalSort case .instance: .settings.qualifiedLabel } } } struct SubscriptionListSection: Identifiable { let label: String var icon: Icon? let communities: [Community] let showInScroller: Bool init(label: String, icon: Icon? = nil, communities: [Community], showInScroller: Bool = true) { self.label = label self.icon = icon self.communities = communities self.showInScroller = showInScroller } var id: String { label } } private extension SubscriptionList { func visibleSections(sort: SubscriptionListSort) -> [SubscriptionListSection] { var sections: [SubscriptionListSection] = .init() if !favorites.isEmpty { sections.append(.init( label: String(localized: "Favorites"), icon: .lemmy.favorited, communities: favorites, showInScroller: false)) } switch sort { case .alphabetical: for section in alphabeticSections.sorted(by: { $0.key ?? "~" < $1.key ?? "~" }) { sections.append(.init(label: section.key ?? "#", communities: section.value)) } case .instance: for section in instanceSections.sorted(by: { $0.key ?? "~" < $1.key ?? "~" }) { sections.append(.init(label: section.key ?? String(localized: "Other"), communities: section.value)) } } return sections } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Feeds/VisitAgainView.swift ================================================ // // SavedFeedView.swift // Mlem // // Created by Eric Andrews on 2024-01-07. // import Dependencies import Foundation import MlemBackend import MlemMiddleware import SwiftUI import Theming struct VisitAgainView: View { @Environment(AppState.self) var appState @Environment(FiltersTracker.self) var filtersTracker @Environment(BackendClient.self) var backendClient let filter: GetContentFilter @State var mixedFeedLoader: DualSourceMixedFeedLoader @State var postsFeedLoader: PostChildFeedLoader @State var commentsFeedLoader: CommentChildFeedLoader @State var selectedContentType: PersonContentType = .all @State var scrollToTopTrigger: Bool = false init(filter: GetContentFilter) { // need to grab some stuff from app storage to initialize with @Setting(\.behavior_internetSpeed) var internetSpeed @Setting(\.post_size) var postSize let feedLoaders = DualSourceMixedFeedLoader.setup( api: AppState.main.firstApi, pageSize: internetSpeed.pageSize, sortType: .new, filter: filter ) self._mixedFeedLoader = .init(wrappedValue: feedLoaders.savedFeedLoader) self._postsFeedLoader = .init(wrappedValue: feedLoaders.postFeedLoader) self._commentsFeedLoader = .init(wrappedValue: feedLoaders.commentFeedLoader) self.filter = filter } var body: some View { content .themedGroupedBackground() .scrollContentBackground(.hidden) .conditionalNavigationTitle(filter.label) .navigationBarTitleDisplayMode(.inline) .outdatedFeedPopup(feedLoader: mixedFeedLoader) .environment(\.feedContext, .saved) } @ViewBuilder var content: some View { FancyScrollView(scrollToTopTrigger: $scrollToTopTrigger) { BubblePicker(PersonContentType.allCases, selected: $selectedContentType, label: \.label) PersonContentGridView(feedLoader: .standard(selectedFeedLoader, contentType: selectedContentType)) } } var selectedFeedLoader: StandardFeedLoader { switch selectedContentType { case .all: mixedFeedLoader case .posts: postsFeedLoader case .comments: commentsFeedLoader } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Inbox/InboxView+Types.swift ================================================ // // InboxView+Types.swift // Mlem // // Created by Sjmarf on 2024-12-16. // import Icons import SwiftUI import Theming extension InboxView { enum Feed: CaseIterable, Identifiable { case inbox, modMail var id: Feed { self } var label: LocalizedStringResource { switch self { case .inbox: "Inbox" case .modMail: "Mod Mail" } } func subtitle(isAdmin: Bool) -> LocalizedStringResource { switch self { case .inbox: return "Replies, mentions and messages" case .modMail: if isAdmin { return "Reports and Registration Applications" } else { return "Reports from communities you moderate" } } } var icon: Icon { switch self { case .inbox: .lemmy.inbox case .modMail: .lemmy.moderation } } var color: ThemedColor { switch self { case .inbox: .themedInbox case .modMail: .themedModeration } } } enum Tab: CaseIterable, Identifiable { case all, replies, mentions, messages var id: Tab { self } var label: LocalizedStringResource { switch self { case .all: "All" case .replies: "Replies" case .mentions: "Mentions" case .messages: "Messages" } } } enum ModTab: CaseIterable, Identifiable { case reports, applications var id: ModTab { self } var label: LocalizedStringResource { switch self { case .reports: "Reports" case .applications: "Applications" } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Inbox/InboxView+Views.swift ================================================ // // InboxView+Views.swift // Mlem // // Created by Sjmarf on 22/09/2024. // import MlemMiddleware import SwiftUI extension InboxView { @ViewBuilder var inboxFeedView: some View { LazyVStack(spacing: 0, pinnedViews: UIDevice.isIos26 ? [] : [.sectionHeaders]) { Section { ForEach(feedLoader.items, id: \.inboxId) { notification in Group { switch notification.content { case let .message(message): MessageView(message: message, notification: notification) case let .reply(comment), let .mention(comment): ReplyView(notification: notification, comment: comment) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) .onAppear { do { try inboxFeedLoader.loadIfThreshold(notification) } catch { handleError(error) } } } EndOfFeedView(feedLoader: feedLoader, viewType: .cartoon) } header: { if appState.firstApi.supports(.viewMentionsAndPrivateMessages, defaultValue: false) { sectionHeader } } } .animation(.easeOut(duration: 0.1), value: feedLoader.items.isEmpty) .padding( .top, appState.firstApi.supports(.viewMentionsAndPrivateMessages, defaultValue: false) ? 0 : Constants.main.standardSpacing ) } @ViewBuilder var modMailFeedView: some View { LazyVStack(spacing: 0) { if appState.firstApi.isAdmin { BubblePicker( ModTab.allCases, selected: $selectedModTab, label: \.label, value: { tab in if let unreadCount = (appState.firstSession as? UserSession)?.unreadCount { switch tab { case .reports: return unreadCount.reportTotal case .applications: return unreadCount.registrationApplications } } return 0 } ) } ForEach(currentModFeedLoader.items, id: \.inboxId) { item in Group { switch item { case let .application(application): RegistrationApplicationView(application: application) case let .report(report): ReportView(report: report) } } .padding([.horizontal, .bottom], Constants.main.standardSpacing) .onAppear { do { try currentModFeedLoader.loadIfThreshold(item) } catch { handleError(error) } } } EndOfFeedView(feedLoader: currentModFeedLoader, viewType: .cartoon) } .padding(.top, Constants.main.standardSpacing) .animation(.easeOut(duration: 0.1), value: currentModFeedLoader.items.isEmpty) } @ViewBuilder var sectionHeader: some View { BubblePicker( Tab.allCases, selected: $selectedTab, label: \.label, value: { tab in if let unreadCount = (appState.firstSession as? UserSession)?.unreadCount { switch tab { case .all: return unreadCount.personalTotal case .replies: return unreadCount.replies case .mentions: return unreadCount.mentions case .messages: return unreadCount.messages } } return 0 } ) .background(.themedGroupedBackground.opacity(headerPinned ? 1 : 0)) .background(.bar) } @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { if #available(iOS 26, *) { if showRead { hideReadButton } else { hideReadButton .buttonStyle(.glassProminent) } } else { hideReadButton } } if selectedFeed == .inbox { MarkAllAsReadButton() } } @ViewBuilder var hideReadButton: some View { Button { showRead.toggle() let message: LocalizedStringResource = showRead ? "Showing Read" : "Hiding Read" toastModel.add(.success(message)) } label: { Label("Hide Read", icon: .general.filterMenu) .symbolVariant(showRead ? .none : .fill) } } @ViewBuilder var headerView: some View { let availableFeeds = availableFeeds Menu { if availableFeeds.count > 1 { Picker("Feed", selection: $selectedFeed) { ForEach(availableFeeds) { feedType in Label(String(localized: feedType.label), icon: feedType.icon) .tag(feedType) } } } } label: { FeedHeaderView( feedDescription: .init( label: selectedFeed.label, subtitle: selectedFeed.subtitle(isAdmin: appState.firstApi.isAdmin), color: selectedFeed.color, icon: selectedFeed.icon, iconScaleFactor: 0.5 ), dropdownStyle: availableFeeds.count > 1 ? .enabled(showBadge: showBadge) : .disabled ) } } @ViewBuilder var signedOutInfoView: some View { VStack { Image(icon: .lemmy.inbox) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 80) .foregroundStyle(.themedAccent) Text(AccountsTracker.main.isEmpty ? "Log in or sign up to view your inbox." : "Switch account to view your inbox.") .font(.title2) .padding(.horizontal) .fontWeight(.semibold) .padding(.bottom, Constants.main.halfSpacing) if AccountsTracker.main.isEmpty { HStack { infoViewButton("Log In") { navigation.openSheet(.logIn(.pickInstance)) } infoViewButton("Sign Up") { navigation.openSheet(.signUp()) } } } else { infoViewButton("Switch Account") { navigation.openSheet(.quickSwitcher) } } } .buttonStyle(.borderedProminent) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.horizontal) } @ViewBuilder private func infoViewButton(_ title: LocalizedStringResource, callback: @escaping () -> Void) -> some View { Button(action: callback) { Text(title) .padding(.vertical, 4) .padding(.horizontal, 8) .frame(minWidth: 100) } } var showBadge: Bool { guard let unreadCount = (appState.firstSession as? UserSession)?.unreadCount else { return false } switch selectedFeed { case .inbox: return unreadCount.moderationTotal > 0 case .modMail: return unreadCount.personalTotal > 0 } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Inbox/InboxView.swift ================================================ // // InboxView.swift // Mlem // // Created by Sjmarf on 19/05/2024. // import Haptics import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct InboxView: View { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(FiltersTracker.self) var filtersTracker @Environment(ToastModel.self) var toastModel @Setting(\.inbox_showRead) var showRead @State var headerPinned: Bool = false @State var selectedFeed: Feed = .inbox @State var selectedTab: Tab = .all @State var selectedModTab: ModTab = .reports @State var applications: [RegistrationApplication]? @State var reports: [Report]? @State var replyFeedLoader: ReplyChildFeedLoader @State var mentionFeedLoader: MentionChildFeedLoader @State var messageFeedLoader: MessageChildFeedLoader @State var inboxFeedLoader: InboxFeedLoader @State var reportFeedLoader: ReportChildFeedLoader @State var applicationFeedLoader: ApplicationChildFeedLoader @State var modMailFeedLoader: ModMailFeedLoader @State var showRefreshPopup: Bool = false init() { @Setting(\.behavior_internetSpeed) var internetSpeed @Setting(\.inbox_showRead) var showRead let inboxFeedLoaders = InboxFeedLoader.setup( api: AppState.main.firstApi, pageSize: internetSpeed.pageSize, sortType: .new, showRead: showRead ) self._replyFeedLoader = .init(wrappedValue: inboxFeedLoaders.replyFeedLoader) self._mentionFeedLoader = .init(wrappedValue: inboxFeedLoaders.mentionFeedLoader) self._messageFeedLoader = .init(wrappedValue: inboxFeedLoaders.messageFeedLoader) self._inboxFeedLoader = .init(wrappedValue: inboxFeedLoaders.inboxFeedLoader) let modMailFeedLoaders = ModMailFeedLoader.setup( api: AppState.main.firstApi, pageSize: internetSpeed.pageSize, sortType: .new, showRead: showRead ) self._reportFeedLoader = .init(wrappedValue: modMailFeedLoaders.reportFeedLoader) self._applicationFeedLoader = .init(wrappedValue: modMailFeedLoaders.applicationFeedLoader) self._modMailFeedLoader = .init(wrappedValue: modMailFeedLoaders.modMailFeedLoader) } var feedLoader: StandardFeedLoader { if appState.firstApi.supports(.viewMentionsAndPrivateMessages, defaultValue: false) { switch selectedTab { case .all: inboxFeedLoader case .replies: replyFeedLoader case .mentions: mentionFeedLoader case .messages: messageFeedLoader } } else { replyFeedLoader } } var currentModFeedLoader: StandardFeedLoader { switch selectedModTab { case .applications: applicationFeedLoader case .reports: reportFeedLoader } } var availableFeeds: [Feed] { if appState.isModOrAdmin, appState.firstApi.supports(.viewReports, defaultValue: false) { return [.inbox, .modMail] } return [.inbox] } var body: some View { if appState.firstSession is GuestSession { signedOutInfoView } else { content .themedGroupedBackground() .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .loadFeed(inboxFeedLoader) .loadFeed(modMailFeedLoader, shouldLoad: appState.firstApi.supports(.viewReports, defaultValue: false)) .onChange(of: appState.firstApi, initial: false) { if appState.firstAccount is UserAccount { Task { await inboxFeedLoader.changeApi(to: appState.firstApi, context: filtersTracker.filterContext) await modMailFeedLoader.changeApi(to: appState.firstApi, context: filtersTracker.filterContext) } showRefreshPopup = true } } .onChange(of: showRead, initial: false) { Task { do { if showRead { try await inboxFeedLoader.showRead() if appState.firstApi.supports(.viewReports, defaultValue: false) { try await modMailFeedLoader.showRead() } } else { try await inboxFeedLoader.hideRead() if appState.firstApi.supports(.viewReports, defaultValue: false) { try await modMailFeedLoader.hideRead() } } } catch { handleError(error) } } } .refreshable { _ = await Task { await refresh() }.result } .onChange(of: (appState.firstSession as? UserSession)?.unreadCount?.refreshNumber ?? 0) { oldValue, newValue in // The newValue > oldValue check stops the popup from appearing when the user switches accounts. // This is a little janky, but it works if newValue > oldValue, feedLoader.loadingState != .loading { showRefreshPopup = true } } .overlay(alignment: .bottom) { RefreshPopupView("Inbox is outdated", isPresented: $showRefreshPopup) { Task { @MainActor in await refresh() } } } } } @ViewBuilder var content: some View { FancyScrollView(reselectAction: toggleFeed) { VStack(spacing: 0) { headerView GeometryReader { geo in Color.red.preference( key: ScrollOffsetKey.self, value: geo.frame(in: .named("inboxScrollView")).origin.y >= 0 ) } .frame(width: 0, height: 0) .onPreferenceChange(ScrollOffsetKey.self, perform: { value in if value != headerPinned { if UIDevice.isIos26, headerPinned { return } headerPinned = value } }) switch selectedFeed { case .inbox: inboxFeedView case .modMail: modMailFeedView } } } .coordinateSpace(name: "inboxScrollView") } private func refresh() async { do { if selectedFeed == .modMail, !appState.isModOrAdmin { selectedFeed = .inbox } switch selectedFeed { case .inbox: try await inboxFeedLoader.refresh(clearBeforeRefresh: false) if appState.firstApi.supports(.viewReports, defaultValue: false) { Task { try await modMailFeedLoader.refresh(clearBeforeRefresh: false) } } case .modMail: try await modMailFeedLoader.refresh(clearBeforeRefresh: false) Task { try await inboxFeedLoader.refresh(clearBeforeRefresh: false) } } } catch { handleError(error) } } private func toggleFeed() { selectedFeed = selectedFeed == .inbox && appState.isModOrAdmin ? .modMail : .inbox } } private struct ScrollOffsetKey: PreferenceKey { typealias Value = Bool static var defaultValue = false static func reduce(value: inout Value, nextValue: () -> Value) {} } ================================================ FILE: Mlem/App/Views/Root/Tabs/Inbox/MarkAllAsReadButton.swift ================================================ // // MarkAllAsReadButton.swift // Mlem // // Created by Sjmarf on 2025-08-15. // import Haptics import SwiftUI struct MarkAllAsReadButton: ToolbarContent { @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @State var animationPlaying: Bool = false @State var phaseAnimatorTrigger: Bool = false var body: some ToolbarContent { Group { if newMessagesExist || animationPlaying || !UIDevice.isIos26 { ToolbarItem(placement: .topBarTrailing) { PhaseAnimator([0, 1], trigger: phaseAnimatorTrigger) { value in Button { hapticManager.play(haptic: .gentleInfo, tier: .low) animationPlaying = true phaseAnimatorTrigger.toggle() Task { do { try await appState.firstApi.markAllAsRead() try await Task.sleep(for: .seconds(0.25)) } catch { handleError(error) } animationPlaying = false } } label: { if UIDevice.isIos26 { label(value: value) } else { label(value: value) .background(.bar, in: .capsule) } } .opacity((newMessagesExist || value != 0) ? 1 : 0) } } } } } var newMessagesExist: Bool { !animationPlaying && ((appState.firstSession as? UserSession)?.unreadCount?.personalTotal ?? 0) != 0 } @ViewBuilder func label(value: Int) -> some View { HStack { Image(icon: .lemmy.markRead) .imageScale(.small) Text("All") } .opacity((value == 0 && newMessagesExist) ? 1 : 0) .overlay { if value != 0 { Image(icon: .general.success) .imageScale(.small) .fontWeight(.semibold) } } .fixedSize() .padding(.vertical, 2) .padding(.horizontal, 10) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Profile/Profile View.swift ================================================ // // Profile Tab View.swift // Mlem // // Created by Jake Shirley on 6/26/23. // import Dependencies import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct ProfileView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation var body: some View { if let person = appState.firstPerson { PersonView(person: person, isProfileTab: true, visitContext: nil) .toolbar { if person.api.supports(.editProfile, defaultValue: false) { ToolbarItem(placement: .secondaryAction) { Button("Edit", icon: .general.edit) { navigation.openSheet(.settings(.profile)) } } } } .id(person.actorId) } else if let instance = appState.firstSession.instance { InstanceView(instance: instance, visitContext: nil) .id(instance.actorId) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AboutMlemView.swift ================================================ // // AboutMlemView.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import ComponentViews import Haptics import SwiftUI import Theming struct AboutMlemView: View { @Environment(\.palette) var palette @Environment(HapticManager.self) var hapticManager @Environment(ToastModel.self) var toastModel var body: some View { Form { Section {} header: { appHeaderView .listRowBackground(palette.groupedBackground.primary) .foregroundStyle(.themedPrimary) } .textCase(nil) .listRowInsets(.init(top: 50, leading: 0, bottom: 15, trailing: 0)) Section { Link(destination: URL(string: "https://mlem.group")!) { FormChevron { Label("Website", icon: .general.website) } .foregroundStyle(.themedPrimary) } .gradientTint(.themedColorfulAccent(2)) Link(destination: URL(string: "https://lemmy.ml/c/mlemapp")!) { FormChevron { Label("Lemmy Community", icon: .lemmy.community) } .foregroundStyle(.themedPrimary) } .gradientTint(.themedColorfulAccent(3)) Link(destination: URL(string: "https://matrix.to/#/#mlemappspace:matrix.org")!) { FormChevron { Label("Matrix Room", image: "matrix.logo") } .foregroundStyle(.themedPrimary) } .tint(Color.black.gradient) // not ThemedColor because white tint turns this into white square Link(destination: URL(string: "https://github.com/mlemgroup/mlem")!) { FormChevron { Label("GitHub Repository", image: "github.logo") } .foregroundStyle(.themedPrimary) } .tint(Color.black.gradient) // not ThemedColor because white tint turns this into white square } Section { NavigationLink("Privacy Policy", icon: .settings.privacy, destination: .settings(.document(.privacyPolicy))) .gradientTint(.themedColorfulAccent(2)) NavigationLink("EULA", icon: .settings.eula, destination: .settings(.document(.eula))) .gradientTint(.themedColorfulAccent(0)) NavigationLink("Licenses", icon: .settings.licence, destination: .settings(.licences)) .gradientTint(.themedColorfulAccent(4)) } } .buttonStyle(.plain) .labelStyle(.squircle) .navigationTitle("About Mlem") } @ViewBuilder func linkView(_ title: LocalizedStringResource, systemImage: String, destination: String) -> some View { Link(destination: URL(string: destination)!) { HStack { Spacer() Image(icon: .general.forward) .imageScale(.small) .foregroundStyle(.themedTertiary) } .contentShape(.rect) } } @ViewBuilder var appHeaderView: some View { VStack(spacing: Constants.main.standardSpacing) { Image("logo") .resizable() .frame(width: 120, height: 120) .clipShape(.circle) Button { let pasteboard = UIPasteboard.general pasteboard.string = "Mlem \(versionString)" hapticManager.play(haptic: .lightSuccess, tier: .low) toastModel.add(.success("Copied")) } label: { HStack { Text(String("Mlem \(versionString)")) Image(icon: .general.copy) .symbolVariant(.fill) .imageScale(.small) } } .foregroundStyle(.themedSecondary) .buttonStyle(.empty) .frame(maxWidth: .infinity) } .frame(maxWidth: .infinity) } var versionString: String { var result = "n/a" if let releaseVersion = Bundle.main.releaseVersionNumber { result = releaseVersion } if let buildVersion = Bundle.main.buildVersionNumber { result.append(" (\(buildVersion))") } return result } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccessibilitySettingsView.swift ================================================ // // AccessibilitySettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-19. // import SwiftUI import Theming struct AccessibilitySettingsView: View { @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor: Bool @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.a11y_websiteThumbnailIcon) var websiteThumbnailIcon @Setting(\.a11y_showSettingsIcons) var showSettingsIcons @Setting(\.a11y_zoomSliderLocation) var zoomSliderLocation @Setting(\.media_animatedAvatars) var animatedAvatars @Setting(\.a11y_showInteractionBarButtonBackground) var showInteractionBarButtonBackground var body: some View { Form { SettingsHeaderView( title: "Accessibility", description: "Customize Mlem to work best for you. Some features are tied to system-wide accessibility settings.", icon: .settings.accessibility ) .gradientTint(.themedColorfulAccent(2)) if differentiateWithoutColor { Section { NavigationLink( "Post Read Indicator", value: .init(localized: readPostIndicator.label), fallbackValue: "", icon: .settings.readIndicatorSetting, destination: .settings(.postReadIndicator) ) } header: { Text("Differentiate Without Color") } } Section { Toggle("Website Thumbnail Indicator", icon: .general.browser, isOn: $websiteThumbnailIcon) Toggle("Settings Icons", icon: .settings.settingsIcons, isOn: $showSettingsIcons) } header: { Text("Non-Text Indicators") } if #available(iOS 18, *) { Section { NavigationLink( "Animated Avatars", value: .init(localized: animatedAvatars.label), fallbackValue: "", icon: .general.playCircle, destination: .settings(.animatedAvatars) ) } header: { Text("Reduce Motion") } } Section { Toggle("Distinguish Interaction Bar", icon: .general.circle, isOn: $showInteractionBarButtonBackground) } header: { Text("Contrast") } Section { NavigationLink( "Slide to Zoom Images", value: .init(localized: zoomSliderLocation.label), fallbackValue: "", icon: .settings.zoomSlider, destination: .settings(.zoomSlider) ) } header: { Text("Gestures") } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Accessibility") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountAdvancedSettingsView.swift ================================================ // // AccountAdvancedSettingsView.swift // Mlem // // Created by Sjmarf on 13/10/2024. // import SwiftUI struct AccountAdvancedSettingsView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @State var isBot: Bool = false init() { guard let person = AppState.main.firstPerson else { return } _isBot = .init(wrappedValue: person.isBot) } var body: some View { Form { if let updateSettings = appState.firstPerson?.updateSettings { Section { Toggle("Bot Account", icon: .lemmy.botFlair, isOn: $isBot) .tint(.themedColorfulAccent(5)) .onChange(of: isBot) { Task { do { try await updateSettings(.init(isBot: isBot)) } catch { handleError(error) isBot = appState.firstPerson?.isBot ?? false } } } } footer: { Text("Bot accounts are unable to vote.") } } if let userAccount = appState.firstAccount as? UserAccount { Section { Button("Refresh Token") { navigation.openSheet(.logIn(.reauth(userAccount))) } } } } .withConditionalLabelStyle() .navigationTitle("Advanced") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountAgeVisibilitySettingsView.swift ================================================ // // AccountAgeVisibilitySettingsView.swift // Mlem // // Created by Sjmarf on 2025-04-23. // import SwiftUI struct AccountAgeVisibilitySettingsView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.palette) var palette @Setting(\.person_ageVisibility) var accountAgeVisibility var body: some View { Form { previewSection Section { Text("Choose whether to show a user's account age next to their username.") .multilineTextAlignment(.center) } Picker("Show Account Age", selection: $accountAgeVisibility) { ForEach(AccountAgeFlairVisibility.allCases, id: \.self) { visibility in Text(visibility.label) .tag(visibility) } } .labelsHidden() .pickerStyle(.inline) } .contentMargins(.top, 16) .navigationTitle("Show Account Age") } @ViewBuilder var previewSection: some View { Section { UnevenRoundedRectangle( cornerRadii: .init(topLeading: 16, bottomLeading: 0, bottomTrailing: 10, topTrailing: 0) ) .fill(.themedTertiaryGroupedBackground) .strokeBorder(colorScheme == .light ? .themedSecondaryGroupedBackground : .clear, lineWidth: 2) .frame(height: 100) .overlay(alignment: .topLeading) { HStack(spacing: 0) { CircleCroppedImageView(url: nil, frame: 30, fallback: .personAvatar) .opacity(0.8) HStack(spacing: 5) { Image(icon: .lemmy.newAccountFlair) .symbolVariant(.fill) .imageScale(.small) Text(flairString) .fontWeight(.semibold) } .padding(.horizontal, 10) .foregroundStyle(.themedAccountAgeColor(0)) labelText .lineLimit(1) .fixedSize(horizontal: false, vertical: true) .foregroundStyle(.themedSecondary) .opacity(0.8) .mask { LinearGradient(colors: [.black, .black.opacity(0.5)], startPoint: .leading, endPoint: .trailing) } .offset(y: -1) } .padding([.top, .leading], 20) .font(.title2) } .padding([.top, .leading], 20) .listRowInsets(.init()) } } var flairString: String { let components = DateComponents(day: 5) let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated return formatter.string(from: components) ?? "" } var labelText: Text { let string = String( localized: "john@example.com", // swiftlint:disable:next line_length comment: "Translate \"john\" into the equivalent placeholder name in your language, and \"example.com\" into a suitable example domain for your locale. The placeholder name should be as short as possible, as this string is displayed in contexts where there may not be much space horizontally." ) let parts = string.split(separator: "@") guard parts.count == 2 else { assertionFailure() return Text(string) } return Text(parts[0]) + Text(verbatim: "@\(parts[1])").foregroundColor(palette.label.tertiary) } } enum AccountAgeFlairVisibility: String, Codable, CaseIterable { case always, newAccountsOnly, never var label: LocalizedStringResource { switch self { case .always: "Always" case .newAccountsOnly: "For New Accounts Only" case .never: "Never" } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountContentSettingsView.swift ================================================ // // AccountGeneralSettingsView.swift // Mlem // // Created by Sjmarf on 13/10/2024. // import SwiftUI import MlemMiddleware struct AccountContentSettingsView: View { @Environment(AppState.self) var appState @State var showNsfw: Bool = false @State var showBotAccounts: Bool = false @State var sendNotificationsToEmail: Bool = false init() { guard let person = AppState.main.firstPerson else { return } _showNsfw = .init(wrappedValue: person.showNsfw.value_ ?? false) _showBotAccounts = .init(wrappedValue: person.showBotAccounts.value_ ?? false) _sendNotificationsToEmail = .init(wrappedValue: person.sendNotificationsToEmail.value_ ?? false) } var body: some View { if let updateSettings = appState.firstPerson?.updateSettings { content(updateSettings: updateSettings) } else { ProgressView() } } // swiftlint:disable:next function_body_length func content(updateSettings: @escaping (Person.ProfileSettings) async throws -> Void) -> some View { Form { Section { Toggle("Show NSFW Content", icon: .settings.blurNsfw, isOn: $showNsfw) .tint(.themedWarning) .onChange(of: showNsfw) { Task { do { try await updateSettings(.init(showNsfw: showNsfw)) } catch { handleError(error) showNsfw = appState.firstPerson?.showNsfw.value_ ?? false } } } } footer: { Text("Show content flagged as Not Safe For Work.") } Section { Toggle("Show Bot Accounts", icon: .lemmy.botFlair, isOn: $showBotAccounts) .onChange(of: showBotAccounts) { Task { do { try await updateSettings(.init(showBotAccounts: showBotAccounts)) } catch { handleError(error) showBotAccounts = appState.firstPerson?.showBotAccounts.value_ ?? false } } } } Section { Toggle("Send Notifications to Email", icon: .general.email, isOn: $sendNotificationsToEmail) .onChange(of: sendNotificationsToEmail) { Task { do { try await updateSettings(.init(sendNotificationsToEmail: sendNotificationsToEmail)) } catch { handleError(error) sendNotificationsToEmail = appState.firstPerson?.sendNotificationsToEmail.value_ ?? false } } } .disabled(appState.firstPerson?.email == nil) } footer: { if let email = appState.firstPerson?.email.value as? String { Text("Notifications will be sent to \(email).") } else { Text("You don't have an email attached to this account.") } } Section { NavigationLink( "Discussion Languages", icon: .settings.language, destination: .settings(.accountLanguages) ) } } .withConditionalLabelStyle() .navigationTitle("Content & Notifications") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountEmailSettingsView.swift ================================================ // // AccountEmailSettingsView.swift // Mlem // // Created by Sjmarf on 13/10/2024. // import SwiftUI import MlemMiddleware struct AccountEmailSettingsView: View { @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss @State var email: String = "" @State var isSubmitting: Bool = false @FocusState var isFocused init() { guard let person = AppState.main.firstPerson else { return } _email = .init(wrappedValue: person.email.value as? String ?? "") } var showToolbarOptions: Bool { email != appState.firstPerson?.email.value } var body: some View { Form { TextField("Email", text: $email) .focused($isFocused) .onAppear { isFocused = true } } .navigationBarBackButtonHidden(showToolbarOptions) .disabled(isSubmitting) .toolbar { if showToolbarOptions { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { email = appState.firstPerson?.email as? String ?? "" } .disabled(isSubmitting) } ToolbarItem(placement: .topBarTrailing) { if isSubmitting { ProgressView() } else if let updateSettings = appState.firstPerson?.updateSettings { Button("Save") { Task { @MainActor in await submit(updateSettings: updateSettings) } } } } } } } @MainActor func submit(updateSettings: (Person.ProfileSettings) async throws -> Void) async { isSubmitting = true do { try await updateSettings(.init(email: email)) } catch { handleError(error) email = appState.firstPerson?.email as? String ?? "" } isSubmitting = false } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountListSettingsView.swift ================================================ // // AccountListSettingsView.swift // Mlem // // Created by Sjmarf on 08/05/2024. // import MlemMiddleware import SwiftUI struct AccountListSettingsView: View { @Environment(AppState.self) var appState @Setting(\.accounts_keepPlace) var keepPlace @Setting(\.accounts_preferredListRowComplication) var preferredListRowComplication var accounts: [UserAccount] { AccountsTracker.main.userAccounts } var body: some View { Form { headerView AccountListView() Section { Toggle("Reload on Switch", icon: .lemmy.switchAccountAndReload, isOn: $keepPlace.invert()) Toggle( "Show Response Times", icon: .general.time, isOn: .init( get: { preferredListRowComplication == .responseTime }, set: { preferredListRowComplication = $0 ? .responseTime : .lastUsed } ) ) } } .withConditionalLabelStyle() .hiddenNavigationTitle("Accounts") } @ViewBuilder var headerView: some View { // empty section disables background Section {} header: { VStack(alignment: .center) { Group { if accounts.count >= 2 { AvatarStackView( urls: accounts.map(\.avatar), fallback: .personAvatar, height: 64, spacing: 42, outlineWidth: 1 ) } else { Image(systemName: "person.3.fill") .resizable() .aspectRatio(contentMode: .fit) .symbolRenderingMode(.hierarchical) .foregroundStyle(.blue) } } .frame(height: 64) Text("Accounts") .font(.title) .fontWeight(.bold) } .frame(maxWidth: .infinity) .foregroundStyle(.themedPrimary) // override default .secondary style } .textCase(nil) // override default all-caps .listRowInsets(.init(top: 40, leading: 0, bottom: 0, trailing: 0)) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountLocalSettingsView.swift ================================================ // // AccountLocalSettingsView.swift // Mlem // // Created by Sjmarf on 2024-12-02. // import SwiftUI struct AccountLocalSettingsView: View { @Environment(AppState.self) var appState @State var isShowingFavoriteDeletionWarning: Bool = false @State var isShowingClearVisitHistoryWarning: Bool = false @State var isShowingDisableVisitHistoryWarning: Bool = false var body: some View { Form { AccountNicknameFieldView() if let userSession = AppState.main.firstSession as? UserSession { communityFavoritesSection(userSession) Section { visitHistoryToggle(userSession) if let visitHistory = userSession.visitHistory { clearVisitHistoryButton(userSession, visitHistory: visitHistory) } } } } .navigationTitle("Local Options") } @ViewBuilder func communityFavoritesSection(_ session: UserSession) -> some View { Section { Button("Delete Community Favorites", icon: .general.delete, role: .destructive) { isShowingFavoriteDeletionWarning = true } .disabled(session.account.favorites.isEmpty) .tint(.themedWarning) .confirmationDialog( "Delete Community Favorites", isPresented: $isShowingFavoriteDeletionWarning ) { Button("Delete", role: .destructive) { for community in session.subscriptions.favorites { guard let updateFavorite = community.updateFavorite else { assertionFailure("updateFavorite not present yet") return } updateFavorite(false) } } } message: { Text("Are you sure you want to delete all community favorites for this account? This cannot be undone.") } } footer: { if session.account.favorites.isEmpty { Text("This account has no favorite communities.") } else { Text("This account has \(session.account.favorites.count) favorite communities.") } } } @ViewBuilder func visitHistoryToggle(_ session: UserSession) -> some View { Toggle( "Remember Search History", isOn: .init( get: { session.account.visitHistoryEnabled }, set: { newValue in if newValue || (session.visitHistory?.isEmpty ?? true) { Task { @MainActor in try await session.setVisitHistoryEnabled(newValue) } } else { isShowingDisableVisitHistoryWarning = true } } ) ) .confirmationDialog( "Turn off search history?", isPresented: $isShowingDisableVisitHistoryWarning, titleVisibility: .visible ) { Button("Turn Off", role: .destructive) { Task { @MainActor in do { try await session.setVisitHistoryEnabled(false) } catch { handleError(error) } } } Button("Cancel", role: .cancel) {} } message: { Text("This will clear your recent searches, which cannot be undone.") } } @ViewBuilder func clearVisitHistoryButton(_ session: UserSession, visitHistory: VisitHistory) -> some View { if let visitHistory = session.visitHistory { Button("Clear Search History", icon: .general.delete, role: .destructive) { isShowingClearVisitHistoryWarning = true } .tint(.themedWarning) .confirmationDialog( "Clear search history?", isPresented: $isShowingClearVisitHistoryWarning, titleVisibility: .visible ) { Button("Clear", role: .destructive) { visitHistory.clear() Task { do { try await session.saveVisitHistory() } catch { handleError(error) } } } Button("Cancel", role: .cancel) {} } message: { Text("This action cannot be undone.") } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountNicknameFieldView.swift ================================================ // // AccountNicknameFieldView.swift // Mlem // // Created by Sjmarf on 2024-12-02. // import SwiftUI struct AccountNicknameFieldView: View { @Environment(AppState.self) var appState @Setting(\.tab_profile_labelType) var tabProfileLabelType @State var nickname: String init() { self.nickname = AppState.main.firstAccount.storedNickname ?? "" } var body: some View { Section("Nickname") { TextField( "Nickname", text: $nickname, prompt: Text(appState.firstAccount.name) ) .onSubmit { AppState.main.firstAccount.setNickname(nickname) } } footer: { if tabProfileLabelType == .nickname { Text("The name shown in the account switcher and tab bar.") } else { Text("The name shown in the account switcher.") } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountSettingsView.swift ================================================ // // AccountSettingsView.swift // Mlem // // Created by Sjmarf on 09/05/2024. // import SwiftUI import Theming struct AccountSettingsView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss @State private var showingSignOutConfirmation: Bool = false var body: some View { Form { // empty section disables background Section {} header: { Group { if let userAccount = appState.firstSession as? UserSession { ProfileHeaderView(userAccount.person) } else { ProfileHeaderView(appState.firstSession.instance) } } .foregroundStyle(.themedPrimary) // override default .secondary style } .textCase(nil) // override default all-caps .listRowInsets(.init(top: 10, leading: 0, bottom: 0, trailing: 0)) if appState.firstSession is UserSession { Section { if appState.firstAccount.siteSoftware?.supports(.editProfile) ?? false { NavigationLink( "My Profile", icon: .lemmy.person, destination: .settings(.profile) ) .gradientTint(.themedColorfulAccent(5)) } if appState.firstAccount.siteSoftware?.supports(.editAccountSettings) ?? false { NavigationLink( "Sign-In & Security", icon: .general.security, destination: .settings(.accountSignIn) ) .gradientTint(.themedColorfulAccent(2)) NavigationLink( "Content & Notifications", icon: .lemmy.post, destination: .settings(.accountContent) ) .gradientTint(.themedColorfulAccent(0)) NavigationLink( "Advanced", icon: .settings.advanced, destination: .settings(.accountAdvanced) ) .gradientTint(.themedNeutralAccent) } } Section { NavigationLink( "Block List", icon: .lemmy.block, destination: .blockList ) .gradientTint(.themedNegative) } Section { NavigationLink( "Local Options", icon: .settings.localAccountOptions, destination: .settings(.accountLocal) ) .gradientTint(.themedColorfulAccent(2)) } footer: { Text("These options are stored locally in Mlem and not on your Lemmy account.") } } else { AccountNicknameFieldView() } Group { Section { Button { appState.firstAccount.signOut() } label: { Text(signOutLabel) .frame(maxWidth: .infinity) } .confirmationDialog(String(localized: signOutPrompt), isPresented: $showingSignOutConfirmation) { Button(String(localized: signOutLabel), role: .destructive) { appState.firstAccount.signOut() } } message: { Text(signOutPrompt) } } if let account = appState.firstAccount as? UserAccount { Section { Button(role: .destructive) { navigation.openSheet(.deleteAccount(account)) } label: { Text("Delete Account") .frame(maxWidth: .infinity) } } } } .tint(.themedWarning) } .labelStyle(.squircle) .navigationTitle(Text("Account")) } var title: String { if let userAccount = appState.firstSession as? UserSession { return userAccount.person?.displayName ?? "Account" } else { return appState.firstSession.instance?.displayName ?? "User" } } var subtitle: String { if let userAccount = appState.firstSession as? UserSession { return userAccount.person?.fullNameWithPrefix ?? "Loading..." } return appState.firstSession.instance?.name ?? "Loading..." } var signOutLabel: LocalizedStringResource { appState.firstAccount is UserAccount ? "Sign Out" : "Remove" } var signOutPrompt: LocalizedStringResource { if appState.firstAccount is UserAccount { "Really sign out of \(appState.firstAccount.nickname)?" } else { "Really remove \(appState.firstAccount.nickname)?" } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AccountSignInSettingsView.swift ================================================ // // AccountSignInSettingsView.swift // Mlem // // Created by Sjmarf on 13/10/2024. // import SwiftUI struct AccountSignInSettingsView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation var body: some View { Form { Section { NavigationLink(.settings(.accountChangeEmail)) { HStack { Text("Email") Spacer() Text(appState.firstPerson?.email.value as? String ?? "") .foregroundStyle(.themedSecondary) } } } Section { Button("Change Password", icon: .general.security) { navigation.openSheet(.settings(.accountChangePassword)) } } } .withConditionalLabelStyle() .navigationTitle("Sign-In & Security") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AdvancedSettingsView.swift ================================================ // // AdvancedSettingsView.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import Nuke import SwiftUI struct AdvancedSettingsView: View { var body: some View { Form { Section { HStack { Text("Cache") Spacer() TimelineView(.periodic(from: .now, by: 0.5)) { _ in Text(ByteCountFormatter.string(fromByteCount: Int64(URLCache.shared.currentDiskUsage), countStyle: .file)) .foregroundStyle(.themedSecondary) } } } header: { Text("Disk Usage") } footer: { // Nesting "500 MB" so we can change it later without re-localizing Text("Images are cached on your device for fast reuse. The maximum cache size is around \("500 MB").") } Button("Clear Cache") { URLCache.shared.removeAllCachedResponses() ImagePipeline.shared.cache.removeAll() ToastModel.main.add(.success("Cache Cleared")) } Section { NavigationLink("Developer", destination: .settings(.developer)) } } .navigationTitle("Advanced") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/AnimatedAvatarSettingsView.swift ================================================ // // AnimatedAvatarSettingsView.swift // Mlem // // Created by Eric Andrews on 2025-03-15. // import SwiftUI import Theming struct AnimatedAvatarSettingsView: View { @Setting(\.media_animatedAvatars) var animatedAvatars var body: some View { Form { SettingsHeaderView( title: "Animated Avatars", description: "Some users set animated media as their avatar. Control whether these avatars should play their animations.", icon: .general.playCircle ) .gradientTint(.themedColorfulAccent(4)) Picker("Animate Avatars...", selection: $animatedAvatars) { ForEach(AnimatedAvatarBehavior.allCases, id: \.self) { location in Label(location.label.key, icon: location.icon) .symbolVariant(.circle) .tag(location) } } .labelsHidden() .pickerStyle(.inline) } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Animated Avatars") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/BlockListView.swift ================================================ // // BlockListView.swift // Mlem // // Created by Sjmarf on 2024-11-09. // import MlemMiddleware import SwiftUI import Theming struct BlockListView: View { @Environment(AppState.self) var appState enum Tab: CaseIterable, Identifiable { case people, communities, instances var id: Self { self } var label: LocalizedStringResource { switch self { case .people: "Users" case .communities: "Communities" case .instances: "Instances" } } } @State var selectedTab: Tab = .people @State var people: [Person] = [] @State var communities: [Community] = [] @State var instances: [Instance] = [] var body: some View { FancyScrollView { BubblePicker(availableTabs, selected: $selectedTab, label: \.label, value: { tab in guard let blockList = (appState.firstSession as? UserSession)?.blocks else { return 0 } switch tab { case .people: return blockList.personCount case .communities: return blockList.communityCount case .instances: return blockList.instanceCount } }) switch selectedTab { case .people: SearchResultsView(results: people.filter(\.blocked_.realizedValue)) { person in PersonListRow(person, showBlockStatus: false) } case .communities: SearchResultsView(results: communities.filter(\.blocked_.realizedValue)) { community in CommunityListRow(community, showBlockStatus: false) } case .instances: SearchResultsView(results: instances.filter(\.blocked_.realizedValue)) { instance in InstanceListRow(instance, showBlockStatus: false) } } } .themedGroupedBackground() .onAppear { Task { @MainActor in do { let result = try await appState.firstApi.getBlocked() people = result.people communities = result.communities instances = result.instances } catch { handleError(error) } } } .navigationTitle("Block List") } var availableTabs: [Tab] { var output: [Tab] = [.people, .communities] if appState.firstApi.supports(.viewInstanceBlockList, defaultValue: false) { output.append(.instances) } return output } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ChangePasswordView.swift ================================================ // // ChangePasswordView.swift // Mlem // // Created by Sjmarf on 2025-01-25. // import Haptics import MlemMiddleware import SwiftUI struct ChangePasswordView: View { enum ViewState { case initial, waiting, success } @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss @State private var viewState: ViewState = .initial @State var newPassword: String = "" @State var confirmNewPassword: String = "" @State var currentPassword: String = "" enum FocusedField { case newPassword, confirmNewPassword, currentPassword } @FocusState private var focusedField: FocusedField? var canSave: Bool { !currentPassword.isEmpty && !newPassword.isEmpty && newPassword == confirmNewPassword && (10 ... 60 ~= newPassword.count) } var body: some View { Form { Group { Section { SecureField("New Password", text: $newPassword) .focused($focusedField, equals: .newPassword) SecureField("Confirm New Password", text: $confirmNewPassword) .focused($focusedField, equals: .confirmNewPassword) } Section { SecureField("Current Password", text: $currentPassword) .focused($focusedField, equals: .currentPassword) } } .disabled(viewState != .initial) Section { Button(action: submit) { switch viewState { case .initial: Text("Save") .transition(.scale(scale: 0.9).combined(with: .opacity)) case .waiting: ProgressView() .transition(.scale(scale: 0.9).combined(with: .opacity)) case .success: Image(icon: .general.success) .symbolVariant(.circle.fill) .foregroundStyle(.themedPositive) .transition(.scale(scale: 0.9).combined(with: .opacity)) } } .animation(.easeOut(duration: 0.1), value: viewState) .frame(maxWidth: .infinity) .disabled(!canSave) } Section { Button("Cancel") { dismiss() } .frame(maxWidth: .infinity) .disabled(viewState != .initial) } footer: { Group { if !newPassword.isEmpty { if newPassword != confirmNewPassword { Text("Passwords don't match.") } else if !(10 ... 60 ~= newPassword.count) { Text("New password must be between \(10) and \(60) characters long.") } } } .foregroundStyle(.themedWarning) } } .onAppear { focusedField = .newPassword } } func submit() { if viewState == .initial { Task { @MainActor in do { viewState = .waiting try await appState.firstApi.changePassword( newPassword: newPassword, confirmNewPassword: confirmNewPassword, oldPassword: currentPassword ) AccountsTracker.main.saveAccounts(ofType: .user) viewState = .success hapticManager.play(haptic: .success, tier: .high) try? await Task.sleep(for: .seconds(0.5)) dismiss() // Catch separately to prevent the token expiry sheet opening in this view } catch ApiClientError.invalidSession { ToastModel.main.add(.failure("Current password is incorrect")) viewState = .initial } catch let ApiClientError.response(response, _) where response.error == "invalid_password" { ToastModel.main.add(.failure("New password is invalid")) viewState = .initial } catch { handleError(error) viewState = .initial } } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/CommentJumpButtonSettingsView.swift ================================================ // // CommentJumpButtonSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-22. // import SwiftUI struct CommentJumpButtonSettingsView: View { @Environment(\.colorScheme) var colorScheme @Setting(\.comment_jumpButton) var jumpButton var body: some View { Form { SettingsHeaderView( title: "Jump Button", description: "Tap on the Jump Button whilst viewing a comment thread to scroll to the next comment." ) { Image(icon: .lemmy.jumpButton) .font(.title) .fontWeight(.semibold) .foregroundStyle(.themedSecondary) .aspectRatio(contentMode: .fill) .padding(25) .background( Circle() .stroke(.themedTertiary.opacity(0.3), lineWidth: 3) .background(.ultraThinMaterial) .clipShape(.circle) ) .compositingGroup() .shadow(color: colorScheme == .dark ? .black.opacity(0.5) : .clear, radius: 10, y: 5) } Section { Toggle( "Jump Button", icon: .lemmy.jumpButton, isOn: .init(get: { jumpButton != .none }, set: { jumpButton = $0 ? .bottomTrailing : .none }) ) .symbolVariant(.circle) } if jumpButton != .none { Section("Alignment") { Picker("Jump Button", selection: $jumpButton) { ForEach(pickerCases, id: \.self) { location in Label(location.label.key, icon: location.icon) .symbolVariant(.circle) } } .labelsHidden() .pickerStyle(.inline) } } } .withConditionalLabelStyle() .contentMargins(.top, 16) .animation(.easeOut(duration: 0.1), value: jumpButton == .none) .hiddenNavigationTitle("Jump Button") } var pickerCases: [CommentJumpButtonLocation] { [.bottomLeading, .bottomCenter, .bottomTrailing] } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/CommentMaximumDepthSettingsView.swift ================================================ // // CommentMaximumDepthSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-21. // import SwiftUI struct CommentMaximumDepthSettingsView: View { @Setting(\.comment_maxDepth) var maxCommentDepth var body: some View { Form { Section { HStack { Label("Maximum Comment Depth", icon: .settings.commentDepth) Spacer() Text(String(maxCommentDepth)) .foregroundStyle(.themedSecondary) .monospaced() } Slider( value: .init( get: { Double(maxCommentDepth) }, set: { maxCommentDepth = Int($0) } ), in: 1.0 ... 12.0, step: 1 ) } footer: { Text("The number of child comments that can appear in a chain before the \"More Replies\" button is shown.") } } .navigationTitle("Maximum Depth") .withConditionalLabelStyle() } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/CommentSettingsView.swift ================================================ // // CommentSettingsView.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import SwiftUI struct CommentSettingsView: View { @Setting(\.comment_compact) var compactComments @Setting(\.comment_gestures_tapToCollapse) var tapCommentsToCollapse @Setting(\.comment_maxDepth) var maxCommentDepth @Setting(\.comment_jumpButton) var jumpButton @Setting(\.comment_showDownvotesCompact) var showDownvotesCompact @Setting(\.interactionBar_comment) var commentInteractionBar var body: some View { Form { Section("Size") { HStack { sizePickerItem("Large", isOn: false) sizePickerItem("Compact", isOn: true) } } Section { NavigationLink(.settings(.interactionBar(.comment))) { SettingsInteractionBarSummaryView(configuration: commentInteractionBar) } NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.comment))) } Section { NavigationLink( "Jump Button", value: .init(localized: jumpButton.label), fallbackValue: "", icon: .settings.jumpButton, destination: .settings(.commentJumpButton) ) NavigationLink( "Maximum Depth", value: String(maxCommentDepth), fallbackValue: "", icon: .settings.commentDepth, destination: .settings(.commentMaximumDepth) ) if compactComments { Toggle("Show Downvotes Separately", icon: .lemmy.votes, isOn: $showDownvotesCompact) } } Section { Toggle("Tap to Collapse", icon: .general.collapse, isOn: $tapCommentsToCollapse) } } .withConditionalLabelStyle() .navigationTitle("Comments") } var sizePickerCommentPreviewDepths: [CGFloat] { [0, 1, 2, 1, 2, 3, 2, 1, 2, 2] } @ViewBuilder func sizePickerItem(_ titleKey: LocalizedStringResource, isOn: Bool) -> some View { DevicePickerItem(titleKey, item: isOn, selected: $compactComments, scale: 1.2) { VStack(spacing: 3) { ForEach(Array(sizePickerCommentPreviewDepths.enumerated()), id: \.offset) { _, depth in RoundedRectangle(cornerRadius: 2) .frame(height: isOn ? 10 : 15) .padding(.leading, depth * 4) } } .padding(.top, 4) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/CommunitySettingsView.swift ================================================ // // CommunitySettingsView.swift // Mlem // // Created by Sjmarf on 2026-03-05. // import SwiftUI struct CommunitySettingsView: View { @Setting(\.community_showAvatar) var showCommunityAvatar var body: some View { Form { SettingsHeaderView( title: "Communities", description: "Customize the appearance of communities.", icon: .lemmy.community ) .gradientTint(.themedCommunityAccent) Section { NavigationLink("Subscription List", destination: .settings(.subscriptionList)) NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.community))) } Section { Toggle("Community Avatar", icon: .lemmy.community, isOn: $showCommunityAvatar) .symbolVariant(.circle) } footer: { Text("Choose whether to show community avatars on posts.") } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Communities") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/ConditionalLabelStyleViewModifier.swift ================================================ // // ConditionalLabelStyleViewModifier.swift // Mlem // // Created by Eric Andrews on 2025-08-25. // import SwiftUI import Icons private struct ConditionalLabelStyleViewModifier: ViewModifier { func body(content: Content) -> some View { content .labelStyle(ConditionalIconLabelStyle()) .toggleStyle(ConditionalIconToggleStyle()) } } extension View { func withConditionalLabelStyle() -> some View { modifier(ConditionalLabelStyleViewModifier()) } } private struct ConditionalIconToggleStyle: ToggleStyle { @Setting(\.a11y_showSettingsIcons) var showSettingsIcons func makeBody(configuration: Configuration) -> some View { Toggle(isOn: Binding(get: { configuration.isOn }, set: { configuration.isOn = $0 })) { configuration.label .labelStyle(ConditionalIconLabelStyle()) } } } private struct ConditionalIconLabelStyle: LabelStyle { @Setting(\.a11y_showSettingsIcons) var showSettingsIcons func makeBody(configuration: Configuration) -> some View { Label { configuration.title } icon: { if showSettingsIcons { configuration.icon.foregroundStyle(.themedAccent) } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/DevicePickerItem.swift ================================================ // // DevicePickerItem.swift // Mlem // // Created by Sjmarf on 2025-01-21. // import Haptics import SwiftUI struct DevicePickerItem: View { @Environment(HapticManager.self) var hapticManager let title: String let item: Item let scale: CGFloat @Binding var selected: Item @ViewBuilder var screenContent: () -> ScreenContent init( _ titleKey: LocalizedStringResource, item: Item, selected: Binding, scale: CGFloat = 1.0, @ViewBuilder screenContent: @escaping () -> ScreenContent ) { self.title = .init(localized: titleKey) self.item = item self.scale = scale self._selected = selected self.screenContent = screenContent } var isSelected: Bool { item == selected } var body: some View { VStack { SettingsDeviceView(selected: isSelected, scale: scale, screenContent: screenContent) Text(title) .lineLimit(1) .foregroundStyle(isSelected ? .themedContrastingLabel : .themedPrimary) .padding(.vertical, 5) .padding(.horizontal, 10) .background(isSelected ? .themedAccent : .clear, in: .capsule) } .onTapGesture { hapticManager.play(haptic: .gentleInfo, tier: .low) withAnimation(.easeOut(duration: 0.1)) { selected = item } } .frame(maxWidth: .infinity) .font(.footnote) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/SettingsDeviceView.swift ================================================ // // SettingsDeviceView.swift // Mlem // // Created by Sjmarf on 2025-01-17. // import SwiftUI struct SettingsDeviceView: View { @Environment(\.colorScheme) var colorScheme var screenContent: ScreenContent var selected: Bool var screenPadding: Bool var scale: CGFloat var accentColor: AnyShapeStyle { if selected { return .init(.tint) } return .init(.themedNeutralAccent) } init( selected: Bool = false, screenPadding: Bool = true, scale: CGFloat = 1.0, @ViewBuilder screenContent: @escaping () -> ScreenContent ) { self.selected = selected self.screenPadding = screenPadding self.screenContent = screenContent() self.scale = scale } var body: some View { frameView .aspectRatio(aspectRatio, contentMode: .fit) .frame(maxWidth: (UIDevice.isPad ? 100 : 40) * scale) .compositingGroup() .opacity(colorScheme == .dark && !selected ? 0.6 : 1) } var aspectRatio: CGFloat { if UIDevice.isPad { return 3 / 4 } if UIDevice.frameType == .noNotch { return 9 / 16 } return 9 / 19 } var frameCornerRadiusScaleFactor: CGFloat { if UIDevice.isPad { return 1 / 12 } if UIDevice.frameType == .noNotch { return 1 / 8 } return 1 / 6 } var generalPadding: CGFloat { scale * 3 } var frameView: some View { GeometryReader { geometry in RoundedRectangle(cornerRadius: geometry.size.width * frameCornerRadiusScaleFactor) .fill(accentColor.opacity(0.1)) .strokeBorder(accentColor, lineWidth: 2) .overlay(alignment: .top) { if UIDevice.frameType != .noNotch { notchView(geometry: geometry) .padding(.top, 2) } } .background { let radius = geometry.size.width * frameCornerRadiusScaleFactor - 4 Color.clear .background(alignment: .top) { VStack(spacing: generalPadding) { screenContent .foregroundStyle(accentColor) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: true) } } .clipShape(.rect(cornerRadius: radius)) .mask { LinearGradient( colors: .init(repeating: .black, count: 8) + [ .black.opacity(0.9), .black.opacity(0.7), .black.opacity(0.5) ], startPoint: .top, endPoint: .bottom ) } .padding(screenPadding ? 2 + generalPadding : 2) .padding(.top, geometry.size.height / 15 - 2) } } } @ViewBuilder func notchView(geometry: GeometryProxy) -> some View { if UIDevice.frameType == .dynamicIsland { Capsule() .fill(accentColor) .frame(height: geometry.size.height / 25) .padding(.horizontal, geometry.size.width / 2.8) .padding(.top, 2 * scale) } else { UnevenRoundedRectangle( cornerRadii: .init( topLeading: 0, bottomLeading: geometry.size.width * frameCornerRadiusScaleFactor / 5, bottomTrailing: geometry.size.width * frameCornerRadiusScaleFactor / 5, topTrailing: 0 ) ) .fill(accentColor) .frame(height: geometry.size.height / 15 - 2) .padding(.horizontal, geometry.size.width / (UIDevice.frameType == .narrowNotch ? 3 : 4)) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/SettingsHeaderView.swift ================================================ // // SettingsHeaderView.swift // Mlem // // Created by Sjmarf on 2025-01-17. // import Icons import SwiftUI struct SettingsHeaderView: View { let title: String let description: String? let icon: IconView init( title: LocalizedStringResource, description: LocalizedStringResource?, @ViewBuilder icon: @escaping () -> IconView ) { self.title = .init(localized: title) if let description { self.description = .init(localized: description) } else { self.description = nil } self.icon = icon() } var body: some View { Section { VStack(alignment: .leading, spacing: 4) { icon .symbolVariant(.fill) .padding(.bottom, 11) Text(title) .font(.title2) .fontWeight(.bold) if let description { Text(description) .foregroundStyle(.themedSecondary) } } .frame(maxWidth: .infinity, alignment: .leading) } } } struct SettingsHeaderIconView: View { let icon: Icon var body: some View { Image(icon: icon) .font(.title) .symbolVariant(.fill) .imageScale(.large) .foregroundStyle(.themedContrastingLabel) .frame(width: 60, height: 60) .background(.tint, in: .rect(cornerRadius: 15)) } } // - MARK: Alternative Initializers extension SettingsHeaderView { init( title: LocalizedStringResource, description: LocalizedStringResource?, icon: Icon ) where IconView == SettingsHeaderIconView { self.init(title: title, description: description) { SettingsHeaderIconView(icon: icon) } } @_disfavoredOverload init( title: LocalizedStringResource, description: some StringProtocol, icon: Icon ) where IconView == SettingsHeaderIconView { self.title = .init(localized: title) self.description = String(description) self.icon = SettingsHeaderIconView(icon: icon) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/SettingsInteractionBarSummaryView.swift ================================================ // // SettingsInteractionBarSummaryView.swift // Mlem // // Created by Sjmarf on 2025-01-18. // import SwiftUI struct SettingsInteractionBarSummaryView: View { var title: LocalizedStringResource = "Interaction Bar" var configuration: Configuration var body: some View { HStack(spacing: 4) { Text(title) .frame(maxWidth: .infinity, alignment: .leading) ForEach(configuration.all, id: \.self) { item in HStack(spacing: 0) { switch item { case let .action(action): Image(systemName: action.appearance.barIcon) .frame(width: 24, height: 24) case let .counter(counter): if let appearance = counter.appearance.leading { Image(systemName: appearance.barIcon) .frame(width: 24, height: 24) } if let appearance = counter.appearance.trailing { Image(systemName: appearance.barIcon) .frame(width: 24, height: 24) } } } .font(.footnote) .fontDesign(.rounded) .fontWeight(.semibold) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: 5)) } .foregroundStyle(.themedSecondary) .lineLimit(1) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/SquircleLabelStyle.swift ================================================ // // SquircleLabelStyle.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import SwiftUI struct SquircleLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(alignment: .center, spacing: 16) { configuration.icon .font(.body) .symbolVariant(.fill) .foregroundStyle(.themedContrastingLabel) .frame(width: Constants.main.settingsIconSize, height: Constants.main.settingsIconSize) .background(.tint) .clipShape(.rect(cornerRadius: Constants.main.smallItemCornerRadius)) .accessibilityHidden(true) configuration.title } } } extension LabelStyle where Self == SquircleLabelStyle { static var squircle: SquircleLabelStyle { .init() } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Components/ThemeLabel.swift ================================================ // // ThemeLabel.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import SwiftUI struct ThemeLabel: View { var title: LocalizedStringResource var color1: Color var color2: Color? var outlineColor: Color = .secondary var body: some View { Label { Text(title) } icon: { if let color2 { color1 .frame(width: Constants.main.settingsIconSize, height: Constants.main.settingsIconSize) .overlay { ThemeTriangle().fill(color2) } .clipShape(RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius)) .overlay { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .stroke(outlineColor, lineWidth: 1) } } else { color1 .frame(width: Constants.main.settingsIconSize, height: Constants.main.settingsIconSize) .clipShape(RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius)) .overlay { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .stroke(outlineColor, lineWidth: 1) } } } } } extension ThemeLabel { init(title: LocalizedStringResource? = nil, palette: PaletteOption) { self.init( title: title ?? palette.label, color1: palette.palette.accent, color2: palette.palette.background.primary ) } } private struct ThemeTriangle: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) return path } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ContextMenuSettingsView.swift ================================================ // // ContextMenuSettingsView.swift // Mlem // // Created by Sjmarf on 2026-02-22. // import ComponentViews import Actions import SwiftUI struct ContextMenuSettingsView: View { @Binding var configuration: [ActionSeed] var body: some View { Form { ForEach(configuration, id: \.key) { seed in Label(seed.label) .foregroundStyle(seed.label.isDestructive ? .themedWarning : .themedPrimary) } .onMove { fromOffsets, toOffset in configuration.move(fromOffsets: fromOffsets, toOffset: toOffset) } .onDelete { offsets in configuration.remove(atOffsets: offsets) } ForEach(Array(Configuration.availableActions.sections.enumerated()), id: \.offset) { _, seeds in drawerActionSectionView(seeds) } } .toolbar { CloseButtonToolbarItem(ios18Label: .xmark) } .navigationTitle("Customize Context Menu") .navigationBarTitleDisplayMode(.inline) .environment(\.editMode, .constant(.active)) } @ViewBuilder func drawerActionSectionView(_ seeds: [ActionSeed]) -> some View { Section { ForEach(seeds, id: \.key, content: drawerActionRowView) } } @ViewBuilder func drawerActionRowView(_ seed: ActionSeed) -> some View { Button { withAnimation { configuration.append(seed) } } label: { HStack { Label(seed.label) .foregroundStyle(seed.label.isDestructive ? .themedWarning : .themedPrimary) Spacer() if !configuration.contains(seed) { Image(icon: .general.add) .symbolVariant(.circle.fill) .foregroundStyle(.themedAccent) .imageScale(.large) } } } .buttonStyle(.plain) .disabled(configuration.contains(seed)) } } extension ContextMenuSettingsView { init(_ keyPath: ReferenceWritableKeyPath) { self.init(configuration: .init(get: { Settings.get(keyPath).contextMenu }, set: { newValue in Settings.mutate(keyPath) { $0.contextMenu = newValue } })) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/DefaultFeedSettingsView.swift ================================================ // // DefaultFeedSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-31. // import MlemMiddleware import SwiftUI struct DefaultFeedSettingsView: View { @Setting(\.feed_default) var defaultFeed var body: some View { Form { SettingsHeaderView( title: "Default Feed", description: "Choose which feed is shown when the app opens." ) {} Picker("Default Feed", selection: $defaultFeed) { ForEach(ListingType.allCases, id: \.self) { item in Label { Text(item.description.label) } icon: { FeedIconView(feedDescription: item.description, size: 30) } } } .pickerStyle(.inline) .labelsHidden() } .contentMargins(.top, 16) .hiddenNavigationTitle("Default Feed") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/DeveloperSettingsView.swift ================================================ // // DeveloperSettingsView.swift // Mlem // // Created by Sjmarf on 22/09/2024. // import Dependencies import FediverseEvents import MlemBackend import MlemMiddleware import SwiftUI import Theming // Strings in this view are intentionally left unlocalized; we shouldn't // be burdening translators with these when they'll never be used struct DeveloperSettingsView: View { @Environment(BackendClient.self) var backendClient @Environment(EventsTracker.self) var eventsTracker @Environment(NavigationLayer.self) var navigation @Dependency(\.persistenceRepository) var persistenceRepository @Setting(\.tip_feedWelcomePrompt) var showFeedWelcomePrompt @Setting(\.dev_developerMode) var developerMode @Setting(\.dev_errorTimeout) var errorToastTimeout @AppStorage("lastTestFlightUpdate") var lastTestFlightUpdate: URL? @State var backendStatus: BackendHealthCheck? @State var lastBackendStatusCheck: Date? var body: some View { Form { Section { Toggle(String("Developer Mode"), isOn: $developerMode) NavigationLink(String("Error Log"), destination: .settings(.errorLog)) } errorToastTimeoutSection Section { if let backendStatus { if backendStatus.unhealthyReasons.isEmpty { backendStatusRow(isHealthy: true) } else { backendStatusRow(isHealthy: false) ForEach(Array(backendStatus.unhealthyReasons.enumerated()), id: \.offset) { _, reason in Text(reason) .padding(.leading, Constants.main.standardSpacing) .foregroundStyle(.themedNegative) } } } else { backendStatusRow(isHealthy: nil) } Button("Refresh") { checkBackendStatus() } } header: { Text(verbatim: "Backend") } footer: { if let lastBackendStatusCheck { Text(verbatim: "Refreshed \(lastBackendStatusCheck.formatted(date: .abbreviated, time: .standard))") } else { Text(verbatim: "Refreshing...") } } .onAppear { checkBackendStatus() } #if DEBUG Section { Toggle(String("Use QC Mlem Backend"), isOn: .init(get: { backendClient.environment == .qualityControl }, set: { backendClient.changeEnvironment(to: $0 ? .qualityControl : .production) })) Toggle(String("Use QC Events API"), isOn: .init(get: { eventsTracker.environment == .qualityControl }, set: { eventsTracker.changeEnvironment(to: $0 ? .qualityControl : .production) })) } footer: { Text(verbatim: "These settings will be cleared when the app restarts.") } Section { Button(String("Trigger Onboarding")) { navigation.showFullScreenCover(.onboarding) } Button(String("Reset Feed Welcome Banner")) { showFeedWelcomePrompt = true } Button(String("Reset Feed TestFlight Banner")) { lastTestFlightUpdate = nil } Button(String("Create Error")) { handleError(ApiClientError.insufficientPermissions) } Button(String("Create Silent Error")) { handleError(ApiClientError.noEntityFound, silent: true) } } header: { Text(verbatim: "Debug Tools") } #endif Button(String("Reset Settings State")) { do { try persistenceRepository.deleteAllSystemSettings() } catch { handleError(error) } } } .navigationTitle("Developer") } @ViewBuilder private var errorToastTimeoutSection: some View { Section { HStack { Text(String("Error Toast Timeout")) Spacer() Group { if errorToastTimeout == 100_000 { Image(systemName: "infinity") } else { Text(String(format: "%.1f", errorToastTimeout) + "s") } } .foregroundStyle(.themedSecondary) } Slider( value: .init( get: { errorToastTimeout == 100_000 ? 10 : errorToastTimeout }, set: { errorToastTimeout = ($0 == 10 ? 100_000 : $0) } ), in: 0.5...10 ) } footer: { Text(String("Default: 1.5s")) } } @ViewBuilder private func backendStatusRow(isHealthy: Bool?) -> some View { HStack { Text(verbatim: "Status") Spacer() if let isHealthy { Image(icon: .general.circle) .foregroundStyle(isHealthy ? .themedPositive : .themedNegative) .symbolVariant(.fill) } else { ProgressView() } } } private func checkBackendStatus() { Task { do { backendStatus = try await backendClient.healthCheck() } catch { handleError(error) backendStatus = nil } lastBackendStatusCheck = .now } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Discussion Languages/DiscussionLanguageSettingsView.swift ================================================ // // DiscussionLanguageSettingsView.swift // Mlem // // Created by Eric Andrews on 2025-02-06. // import MlemMiddleware import SwiftUI struct DiscussionLanguageSettingsView: View { @Environment(NavigationLayer.self) var navigation @State var instance: Instance? @State var person: Person? @State var submitting: Int? init() { if let firstInstance = AppState.main.firstApi.myInstance { self._instance = .init(wrappedValue: firstInstance) } if let firstPerson = AppState.main.firstPerson { self._person = .init(wrappedValue: firstPerson) } } var body: some View { Form { SettingsHeaderView( title: "Discussion Languages", description: "Choose which languages appear in your feed. Posts and comments in other languages will be hidden.", icon: .settings.language ) if let person, let instance { ExpectedView(person.discussionLanguageIds) { languageIds in Section { let selectedLanguages = instance.languages(withIds: languageIds) ForEach(selectedLanguages, id: \.languageCode) { language in LanguageListRowBody(language: language) .contextMenu { Button("Remove", icon: .general.signOut, role: .destructive) { Task { await updateDiscussionLanguages(with: language, languages: languageIds) } } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button("Remove", role: .destructive) { Task { await updateDiscussionLanguages(with: language, languages: languageIds) } } .buttonStyle(.automatic) .tint(.red) } } Button("Add Language...") { navigation.openSheet(.languagePicker(selectedLanguages: Set(selectedLanguages)) { newLanguage in Task { await updateDiscussionLanguages(with: newLanguage, languages: languageIds) } }) } } } } } .contentMargins(.top, 16) .hiddenNavigationTitle("Discussion Languages") } func updateDiscussionLanguages(with language: Locale.Language, languages: Set) async { defer { submitting = nil } guard let person, let instance else { assertionFailure() return } guard let id = instance.getLanguageId(for: language) else { assertionFailure() return } guard let updateSettings = person.updateSettings else { assertionFailure() return } var newLangs = languages if newLangs.contains(id) { newLangs.remove(id) } else { newLangs.insert(id) } do { try await updateSettings(.init(discussionLanguageIds: newLangs)) } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Discussion Languages/LanguageListRowBody.swift ================================================ // // LanguageListRowBody.swift // Mlem // // Created by Sjmarf on 2025-03-01. // import SwiftUI struct LanguageListRowBody: View { @Environment(\.locale) private var userLocale let language: Locale.Language var body: some View { let code = language.languageCode?.identifier ?? "" let locale = Locale(languageCode: language.languageCode) VStack(alignment: .leading) { Text(locale.localizedString(forLanguageCode: code)?.capitalized ?? "") Text(userLocale.localizedString(forLanguageCode: code) ?? "") .font(.footnote) .foregroundStyle(.secondary) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Discussion Languages/LanguagePickerSheetView.swift ================================================ // // LanguagePickerSheetView.swift // Mlem // // Created by Sjmarf on 2025-02-28. // import SwiftUI struct LanguagePickerSheetView: View { @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss @Environment(\.locale) private var userLocale let selectedLanguages: Set let callback: (Locale.Language) -> Void @State var query: String = "" var allLanguages: [Locale.Language] { appState.firstSession.instance?.allLanguages.value ?? [] } var suggestedLanguages: [Locale.Language] { let deviceLanguageCodes = Locale.preferredLanguages.compactMap { $0.split(separator: "-").first }.map(String.init).uniqued() var validDeviceLanguages: Set = .init() for language in allLanguages { let code = language.languageCode?.identifier ?? "" if deviceLanguageCodes.contains(code) { validDeviceLanguages.insert(code) } } return deviceLanguageCodes .filter { validDeviceLanguages.contains($0) } .map(Locale.Language.init) .filter { !selectedLanguages.contains($0) } } var searchResults: [Locale.Language] { allLanguages.filter { language in let code = language.languageCode?.identifier ?? "" let locale = Locale(languageCode: language.languageCode) if let name = locale.localizedString(forLanguageCode: code) { if name.localizedCaseInsensitiveContains(query) { return true } } if let name = userLocale.localizedString(forLanguageCode: code) { if name.localizedCaseInsensitiveContains(query) { return true } } return false } } var body: some View { Form { if query.isEmpty { if !suggestedLanguages.isEmpty { Section("Suggested Languages") { ForEach(suggestedLanguages, id: \.languageCode, content: languageRow) } } Section("All Languages") { ForEach(allLanguages, id: \.languageCode, content: languageRow) } } else { Section { ForEach(searchResults, id: \.languageCode, content: languageRow) } } } .contentMargins(.top, searchResults.isEmpty ? nil : 16) .navigationTitle("Choose Language") .navigationBarTitleDisplayMode(.inline) .withSheetSearch(query: $query) } func languageRow(_ language: Locale.Language) -> some View { LanguageListRowBody(language: language) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) .onTapGesture { callback(language) dismiss() } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/DiscussionLanguageSettingsView.swift ================================================ // // DiscussionLanguageSettingsView.swift // Mlem // // Created by Eric Andrews on 2025-02-06. // import MlemMiddleware import SwiftUI struct DiscussionLanguageSettingsView: View { @State var instance: (any Instance3Providing)? @State var person: (any Person4Providing)? @State var submitting: Int? init() { if let firstInstance = AppState.main.firstApi.myInstance { self._instance = .init(wrappedValue: firstInstance) } if let firstPerson = AppState.main.firstPerson { self._person = .init(wrappedValue: firstPerson) } } var body: some View { Form { SettingsHeaderView( title: "Discussion Languages", description: "Choose which languages appear in your feed. Posts and comments in other languages will be hidden.", systemImage: Icons.language ) if let languages = person?.discussionLanguages, !languages.contains(0) { Section { Label("You will not see most content if Undetermined is not selected.", systemImage: Icons.warningFill) .foregroundStyle(.themedWarning) } } Section { if let instance, let person { ForEach(instance.allLanguages, id: \.id) { language in Button { if submitting == nil { submitting = language.id Task { await updateDiscussionLanguages(with: language.id) } } } label: { HStack { Text(language.name) Spacer() if submitting == language.id { ProgressView() } else if person.discussionLanguages.contains(language.id) { Image(systemName: Icons.success) .foregroundStyle(.themedAccent) } } .contentShape(.rect) } .buttonStyle(.plain) } } else { ProgressView() .task { do { try await (person, instance, _) = AppState.main.firstApi.getMyPerson() } catch { handleError(error) } } } } } .contentMargins(.top, 16) } func updateDiscussionLanguages(with id: Int) async { defer { submitting = nil } guard let person else { assertionFailure("No person found") return } var newLangs = person.discussionLanguages if newLangs.contains(id) { newLangs.remove(id) } else { newLangs.insert(id) } do { try await person.person4.updateSettings(discussionLanguages: newLangs) } catch { handleError(error) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/EmbeddingSettingsView.swift ================================================ // // EmbeddingsSettingsView.swift // Mlem // // Created by Eric Andrews on 2025-01-22. // import SwiftUI struct EmbeddingSettingsView: View { @Setting(\.links_embedLoops) var embedLoops var body: some View { Form { SettingsHeaderView( title: "Embedded Content", description: "Display linked media from supported hosts in-app rather than as a link.", icon: .general.embedding ) // TODO: use loops.video logo directly (hence why this is not in Icons) Toggle(String("loops.video"), systemImage: "repeat", isOn: $embedLoops) } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Embedded Content") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ErrorLogView.swift ================================================ // // ErrorLogView.swift // Mlem // // Created by Eric Andrews on 2024-12-29. // import Rest import SwiftUI import Theming struct ErrorLogView: View { @Environment(ErrorsTracker.self) var errorsTracker @Environment(NavigationLayer.self) var navigation var body: some View { FancyScrollView { LazyVStack(spacing: Constants.main.standardSpacing) { if errorsTracker.errors.isEmpty { Text(verbatim: "No errors") .foregroundStyle(.themedSecondary) } ForEach(Array(errorsTracker.errors.enumerated()), id: \.offset) { _, errorDetails in errorView(errorDetails) } .padding(.horizontal, Constants.main.standardSpacing) } } .themedGroupedBackground() .navigationTitle(String("Error Log")) .toolbar { if !errorsTracker.errors.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button { Task { if let url = await downloadTextToFileSystem( fileName: "mlem_error_log.txt", text: errorsTracker.createErrorLog() ) { navigation.model?.shareInfo = .init(url: url) } else { ToastModel.main.add(.failure(String("Failed to share error log"))) } } } label: { Image(icon: .general.share) } } } } } @ViewBuilder func errorView(_ details: ErrorDetails) -> some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { Text(details.title ?? "Error") .fontWeight(.semibold) Spacer() Button { UIPasteboard.general.string = details.errorText() ToastModel.main.add(.success(String("Copied"))) } label: { Text(Image(icon: .general.copy)) .font(.subheadline) .foregroundStyle(.themedAccent) } } Text(details.errorText(includingLocation: false)) .font(.caption) .monospaced() if let location = details.location { HStack(alignment: .top, spacing: 2) { Image(systemName: "arrow.turn.down.right") .offset(y: 2) Text(location) .monospaced() } .font(.caption) } Text(details.when.formatted(date: .abbreviated, time: .standard)) .font(.caption) .foregroundStyle(.themedSecondary) } .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.mediumItemCornerRadius)) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ExternalLinkSettingsView.swift ================================================ // // ExternalLinkSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-28. // import SwiftUI struct ExternalLinkSettingsView: View { @Setting(\.links_openInBrowser) var openLinksInBrowser @Setting(\.links_readerMode) var openLinksInReaderMode var body: some View { Form { Section("Open External Links") { Picker("Open External Links", selection: $openLinksInBrowser) { Label("In Mlem", icon: .settings.inApp).tag(false) Label("In Default Browser", icon: .general.browser).tag(true) } .pickerStyle(.inline) .labelsHidden() } Section { Toggle("Open in Reader", icon: .settings.reader, isOn: $openLinksInReaderMode) .disabled(openLinksInBrowser) } footer: { Text("Automatically enable Reader for supported webpages. You can only enable this when using the in-app browser.") } } .navigationTitle("External Links") .withConditionalLabelStyle() } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/FiltersSettingsView.swift ================================================ // // FiltersSettingsView.swift // Mlem // // Created by Eric Andrews on 2024-12-22. // import Dependencies import SwiftUI import os struct FiltersSettingsView: View { @Setting(\.filters_keywordFilterEnabled) var keywordFilterEnabled @Setting(\.filters_literalFilterEnabled) var literalFilterEnabled @Environment(FiltersTracker.self) var filtersTracker @Environment(NavigationLayer.self) var navigation @State var newKeyword: String = "" @State var newLiteral: String = "" @State var legacyWarningDisplayed: Bool = false var headerDescription: String { var output = String(localized: "Hide posts containing certain words, phrases, or character sequences from your feed.") if AccountsTracker.main.highestLevelAccountType >= .moderator { output += " " // swiftlint:disable:next line_length output += String(localized: "If you are a moderator or administrator of a filtered post, it will appear in your feed but require you to tap to view its content.") } return output } var body: some View { Form { SettingsHeaderView( title: "Filters", description: headerDescription, icon: .settings.keywordFilter ) Section("Keywords") { keywordSection } footer: { // swiftlint:disable:next line_length Text("Hide posts with titles containing these whole words or phrases. Ignores case and punctuation (e.g., the keyword \"john\" will also filter \"John's\").") } Section("Literals") { literalSection } footer: { Text("Hide posts with titles containing containing these precise character sequences.") } } .scrollDismissesKeyboard(.interactively) .withConditionalLabelStyle() .navigationTitle("Filters") .versionAwareDialog("Deprecated Format", isPresented: $legacyWarningDisplayed) { Button("Re-Export") { export() } Button("Close", role: .cancel) {} } message: { // swiftlint:disable:next line_length Text("These filters were saved by an older version of Mlem, and will not be compatible with future versions. To preserve compatibility, re-export your filters.") } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu("More...", icon: .general.toolbarMenu) { Button("Export...", icon: .general.export) { export() } Button("Import...", icon: .general.import) { navigation.showFilePicker(types: [.plainText, .json]) { data in do { let jsonData = try JSONDecoder().decode(ExportableFilters.self, from: data) Task { @MainActor in await filtersTracker.resetFilteredKeywords(to: jsonData.rawKeywords) filtersTracker.resetFilteredLiterals(to: jsonData.literals) } } catch { // TODO: Mlem 2.5 remove legacy compatibility let text = String(data: data, encoding: .utf8) ?? "" await filtersTracker.resetFilteredKeywords( to: Set(text.split(separator: "\n").map(String.init)) ) legacyWarningDisplayed = true } } } } } } } @ViewBuilder var keywordSection: some View { Toggle("Enable", icon: .settings.keywordFilter, isOn: $keywordFilterEnabled) TextField("New Keyword...", text: $newKeyword) .textCase(.lowercase) .textInputAutocapitalization(.never) .submitLabel(.done) .onSubmit { saveNewKeyword() } ForEach(filtersTracker.rawKeywords.sorted(by: <), id: \.self) { keyword in HStack { Text(keyword) Spacer() // using a Button to do this makes the whole row register tap gestures :/ Image(icon: .general.delete) .foregroundStyle(.themedWarning) .onTapGesture { deleteKeyword(keyword) } } } } func saveNewKeyword() { guard !newKeyword.isEmpty else { return } let cleanedKeyword = newKeyword.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) Task { await filtersTracker.addFilteredKeyword(cleanedKeyword) newKeyword = "" } } func deleteKeyword(_ keyword: String) { guard filtersTracker.rawKeywords.contains(keyword) else { return } Task { await filtersTracker.removeFilteredKeyword(keyword) } } @ViewBuilder var literalSection: some View { Toggle("Enable", icon: .settings.keywordFilter, isOn: $literalFilterEnabled) TextField("New Literal...", text: $newLiteral) .textCase(.lowercase) .textInputAutocapitalization(.never) .submitLabel(.done) .onSubmit { saveNewLiteral() } ForEach(filtersTracker.literals.sorted(by: <), id: \.self) { literal in HStack { Text(literal) Spacer() // using a Button to do this makes the whole row register tap gestures :/ Image(icon: .general.delete) .foregroundStyle(.themedWarning) .onTapGesture { deleteLiteral(literal) } } } } func saveNewLiteral() { guard !newLiteral.isEmpty else { return } Task { await filtersTracker.addFilteredLiteral(newLiteral) newLiteral = "" } } func deleteLiteral(_ literal: String) { guard filtersTracker.literals.contains(literal) else { return } Task { await filtersTracker.removeFilteredLiteral(literal) } } func export() { do { let jsonData = try JSONEncoder().encode(ExportableFilters( rawKeywords: filtersTracker.rawKeywords, literals: filtersTracker.literals)) Task { if let jsonString = String(data: jsonData, encoding: .utf8), let url = await downloadTextToFileSystem( fileName: "filters.json", text: jsonString) { navigation.model?.shareInfo = .init(url: url) } else { ToastModel.main.add(.failure()) } } } catch { handleError(error) } } } private struct ExportableFilters: Codable { let rawKeywords: Set let literals: Set } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/GeneralSettingsView.swift ================================================ // // GeneralSettingsView.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import Dependencies import SwiftUI import Theming struct GeneralSettingsView: View { // behavior @Setting(\.behavior_upvoteOnSave) var upvoteOnSave @Setting(\.feed_markReadOnScroll) var markReadOnScroll @Setting(\.behavior_infiniteScroll) var infiniteScroll @Setting(\.feed_default) var defaultFeed @Setting(\.behavior_hapticLevel) var hapticLevel @Setting(\.markdown_wrapCodeBlockLines) var wrapCodeBlockLines @Setting(\.person_ageVisibility) var accountAgeVisibility @Setting(\.media_animatedAvatars) var animatedAvatars @Setting(\.events_showEvents) var showEvents // gestures @Setting(\.behavior_enableQuickSwipes) var swipeActionsEnabled @Setting(\.navigation_swipeAnywhere) var swipeAnywhereToNavigate // avatars @Setting(\.person_showAvatar) var showPersonAvatar @Setting(\.community_showAvatar) var showCommunityAvatar var body: some View { Form { SettingsHeaderView( title: "General", description: "Manage your overall setup for Mlem.", icon: .settings.general ) .gradientTint(.themedNeutralAccent) Section { NavigationLink( "Default Feed", value: .init(localized: defaultFeed.label), fallbackValue: "", icon: .lemmy.feed, destination: .settings(.defaultFeed) ) NavigationLink( "Haptics", value: .init(localized: hapticLevel?.label ?? "None"), fallbackValue: "", icon: .general.haptics, destination: .settings(.haptics) ) } Section { Toggle("Upvote on Save", icon: .settings.upvoteOnSave, isOn: $upvoteOnSave) Toggle("Mark Read on Scroll", icon: .settings.markReadOnScroll, isOn: $markReadOnScroll) Toggle("Infinite Scroll", icon: .settings.infiniteScroll, isOn: $infiniteScroll) Toggle("Wrap Code Block Lines", icon: .markdown.inlineCode, isOn: $wrapCodeBlockLines) } Section { Toggle( "Swipe Actions", icon: .settings.swipeActions, isOn: .init( get: { swipeActionsEnabled }, set: { swipeActionsEnabled = $0 if $0 { swipeAnywhereToNavigate = false } } ) ) if !UIDevice.isIos26 { Toggle( "Swipe Anywhere to Navigate", icon: .settings.swipeAnywhere, isOn: .init( get: { swipeAnywhereToNavigate }, set: { swipeAnywhereToNavigate = $0 if $0 { swipeActionsEnabled = false } } ) ) } } Section { NavigationLink( "Show Account Age", value: .init(localized: accountAgeVisibility.label), fallbackValue: "", icon: .lemmy.newAccountFlair, destination: .settings(.accountAgeVisibility) ) } Section { Toggle("User Avatar", icon: .lemmy.person, isOn: $showPersonAvatar) .symbolVariant(.circle) Toggle("Community Avatar", icon: .lemmy.community, isOn: $showCommunityAvatar) .symbolVariant(.circle) if #available(iOS 18, *) { NavigationLink( "Animated Avatars", value: .init(localized: animatedAvatars.label), fallbackValue: "", icon: .general.playCircle, destination: .settings(.animatedAvatars) ) } } Section { Toggle("Show Events", icon: .lemmy.event, isOn: $showEvents) } NavigationLink( "Import/Export Settings", icon: .general.import, destination: .settings(.importExportSettings) ) } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("General") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/HapticSettingsView.swift ================================================ // // HapticSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-31. // import Haptics import SwiftUI import Theming struct HapticSettingsView: View { @Setting(\.behavior_hapticLevel) var hapticLevel var body: some View { Form { SettingsHeaderView( title: "Haptics", description: "Customize how often Mlem plays haptic feedback.", icon: .general.haptics ) .gradientTint(.themedColorfulAccent(1)) Picker("Haptic Level", selection: $hapticLevel) { ForEach(HapticTier.allCases, id: \.self) { level in Text(level.label) .tag(level as HapticTier?) } Text("None").tag(nil as HapticTier?) } .pickerStyle(.inline) .labelsHidden() } .contentMargins(.top, 16) .withConditionalLabelStyle() .hiddenNavigationTitle("Haptics") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Icon/AlternateIcon.swift ================================================ // // AlternateIcon.swift // Mlem // // Created by tht7 on 28/06/2023. // import Foundation struct AlternateIcon: Identifiable { var id: String? let name: String init(id: String?, name: LocalizedStringResource) { self.id = id self.name = String(localized: name) } @_disfavoredOverload init(id: String?, name: String) { self.id = id self.name = name } } struct AlternateIconGroup { let authorName: String let collapsed: Bool let icons: [AlternateIcon] } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Icon/AlternateIconCell.swift ================================================ // // AlternateIconCell.swift // Mlem // // Created by tht7 on 28/06/2023. // import SwiftUI struct AlternateIconCell: View { let icon: AlternateIcon let setAppIcon: (_ id: String?) async -> Void let selected: Bool var body: some View { Button { Task(priority: .userInitiated) { await setAppIcon(icon.id) } } label: { AlternateIconLabel(icon: icon, selected: selected) }.accessibilityElement(children: .combine) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Icon/AlternateIconLabel.swift ================================================ // // AlternateIconCell.swift // Mlem // // Created by tht7 on 28/06/2023. // import SwiftUI struct AlternateIconLabel: View { let icon: AlternateIcon let selected: Bool var body: some View { VStack { getImage() .resizable() .scaledToFit() .frame(width: Constants.main.appIconSize, height: Constants.main.appIconSize) .cornerRadius(Constants.main.appIconCornerRadius) .padding(3) .shadow(radius: 2, x: 0, y: 2) .overlay { if selected { ZStack { RoundedRectangle(cornerRadius: Constants.main.appIconCornerRadius) .stroke(.themedSecondaryGroupedBackground, lineWidth: 5) .padding(2) RoundedRectangle(cornerRadius: Constants.main.appIconCornerRadius + 2) .stroke(.themedAccent, lineWidth: 3) } } } Text(icon.name) .multilineTextAlignment(.center) .font(.footnote) .foregroundStyle(selected ? .themedAccent : .themedSecondary) } } func getImage() -> Image { let iconId: String if let id = icon.id { iconId = "\(id).preview" } else { iconId = "logo" } return .init(uiImage: .init(named: iconId) ?? .init()) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/Icon/IconSettingsView.swift ================================================ // // Alternate Icons.swift // Mlem // // Created by tht7 on 28/06/2023. // import SwiftUI import Theming // struct AlternateIcons: View { struct IconSettingsView: View { @State var currentIcon: String? = UIApplication.shared.alternateIconName let icons: [AlternateIconGroup] = [ .init(authorName: "Sjmarf", collapsed: false, icons: [ .init(id: nil, name: "Default"), .init(id: "icon.sjmarf.pink", name: "Pink"), .init(id: "icon.sjmarf.orange", name: "Orange"), .init(id: "icon.sjmarf.green", name: "Green"), .init(id: "icon.sjmarf.alien", name: "Alien"), .init(id: "icon.sjmarf.silver", name: "Silver"), .init(id: "icon.sjmarf.ocean", name: "Ocean"), .init(id: "icon.sjmarf.pride", name: "Pride") ]), // .init(authorName: "Eric Andrews", collapsed: false, icons: [ .init(id: "icon.eric.lemmy", name: "Lemmy") ]) ] var body: some View { FancyScrollView { VStack(spacing: 32) { ForEach(icons, id: \.authorName) { group in if !group.icons.isEmpty { CollapsibleSection(group.authorName, collapsed: group.collapsed) { LazyVGrid(columns: .init(repeating: GridItem(.flexible()), count: 4), spacing: 10, content: { ForEach(group.icons) { icon in AlternateIconCell( icon: icon, setAppIcon: setAppIcon, selected: currentIcon == icon.id ) } }) .padding(.vertical, 15) .padding(.horizontal, 12) } } } } .padding(.vertical) } .themedGroupedBackground() .navigationTitle("App Icon") } @MainActor func setAppIcon(_ id: String?) async { do { try await UIApplication.shared.setAlternateIconName(id) currentIcon = id } catch { // do nothing! } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ImageViewerDismissSettingsView.swift ================================================ // // ImageViewerDismissSettingsView.swift // Mlem // // Created by Sjmarf on 2026-03-20. // import SwiftUI struct ImageViewerDismissSettingsView: View { @Setting(\.imageViewer_dismissThreshold) var dismissThreshold @State var sliderValue: Double init() { let threshold = Settings.get(\.imageViewer_dismissThreshold) self._sliderValue = .init(initialValue: 21 - Double(threshold)) } var body: some View { Form { headerView sliderView } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Dismiss Sensitivity") } @ViewBuilder var headerView: some View { SettingsHeaderView( title: "Dismiss Sensitivity", description: "Choose how far you have to drag to dismiss the image viewer.", icon: .settings.imageViewerDismissSensitivity ) .gradientTint(.themedColorfulAccent(5)) } @ViewBuilder var sliderView: some View { Section { VStack(spacing: 5) { HStack { Text("Low") Spacer() Text("High") } .font(.footnote) .foregroundStyle(.themedSecondary) // I tried using Binding(get: set:) here, but it caused haptics to be // spammed if you move the handle to either end of the slider. Slider( value: $sliderValue, in: 1...20 ) { pressed in if !pressed { self.dismissThreshold = 21 - Int(sliderValue.rounded()) } } } } footer: { Button { self.dismissThreshold = 10 sliderValue = 21 - 10 } label: { // `Label` has too wide spacing HStack(spacing: 5) { Image(icon: .general.refresh) Text("Reset") } } .font(.footnote) .labelStyle(.titleAndIcon) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ImageViewerSettingsView.swift ================================================ // // ImageViewerSettingsView.swift // Mlem // // Created by Sjmarf on 2026-03-20. // import SwiftUI struct ImageViewerSettingsView: View { @Setting(\.a11y_zoomSliderLocation) var zoomSliderLocation @Setting(\.imageViewer_showControls) var showControls @Setting(\.imageViewer_showCloseButton) var showCloseButton @Setting(\.imageViewer_showZoomIndicator) var showZoomIndicator @Setting(\.imageViewer_dismissThreshold) var dismissThreshold var body: some View { Form { headerView controlsSectionView gesturesSectionView } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Image Viewer") } @ViewBuilder var headerView: some View { SettingsHeaderView( title: "Image Viewer", description: "Customize the image viewer's buttons and gestures.", icon: .settings.imageViewer ) .gradientTint(.themedColorfulAccent(5)) } @ViewBuilder var controlsSectionView: some View { Section("Controls") { NavigationLink( "Show Controls", value: .init(localized: showControls.label), fallbackValue: "", icon: .general.circle, destination: .settings(.imageViewerControls) ) Toggle("Close Button", icon: .general.close, isOn: $showCloseButton) Toggle("Zoom Indicator", icon: .general.search, isOn: $showZoomIndicator) } } @ViewBuilder var gesturesSectionView: some View { Section("Gestures") { NavigationLink( "Dismiss Sensitivity", value: .init(localized: dismissSensitivityLabel), fallbackValue: "", icon: .settings.imageViewerDismissSensitivity, destination: .settings(.imageViewerDismissSensitivity) ) NavigationLink( "Slide to Zoom", value: .init(localized: zoomSliderLocation.label), fallbackValue: "", icon: .settings.zoomSlider, destination: .settings(.zoomSlider) ) } } var dismissSensitivityLabel: LocalizedStringResource { switch dismissThreshold { case 1: "Highest" case 2...6: "High" case 10: "Default" case 15...19: "Low" case 20: "Lowest" default: "Medium" } } } enum ShowImageViewerControls: String, Codable, CaseIterable { case immediately, onTap, never var label: LocalizedStringResource { switch self { case .immediately: "Immediately" case .onTap: "When I Tap" case .never: "Never" } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ImageViewerShowControlsSettingsView.swift ================================================ // // ImageViewerShowControlsSettingsView.swift // Mlem // // Created by Sjmarf on 2026-03-20. // import SwiftUI struct ImageViewerShowControlsSettingsView: View { @Setting(\.imageViewer_showControls) var showControls var body: some View { Form { SettingsHeaderView( title: "Show Controls", description: "Choose when the image viewer controls should appear.", icon: .settings.imageViewerControls ) .gradientTint(.themedColorfulAccent(5)) Picker("Show Controls", selection: $showControls) { ForEach(ShowImageViewerControls.allCases, id: \.self) { value in Text(value.label) } } .pickerStyle(.inline) .labelsHidden() } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Show Controls") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ImportExportSettingsView.swift ================================================ // // ImportExportSettingsPage.swift // Mlem // // Created by Eric Andrews on 2024-09-06. // import Dependencies import Foundation import SwiftUI // DO NOT use Picker in this page! It refuses to change the theme until it gets re-rendered. All other components pick up theme changes correctly. struct ImportExportSettingsView: View { @Dependency(\.persistenceRepository) var persistenceRepository @Environment(NavigationLayer.self) var navigation @State var importingSettingsFile: Bool = false // these are tracked as state vars so they can be updated as appropriate @State var v1SettingsExist: Bool = false @State var v2SettingsExist: Bool = false var body: some View { content .withConditionalLabelStyle() .onAppear { v1SettingsExist = persistenceRepository.systemSettingsExists(.v1_user) v2SettingsExist = persistenceRepository.systemSettingsExists(.v2_user) } .fileImporter( isPresented: $importingSettingsFile, allowedContentTypes: [.json] ) { result in do { let fileUrl = try result.get() if let fileData = readSettings(from: fileUrl) { let importedSettings = try JSONDecoder().decode(SettingsValues.self, from: fileData) Settings.reinit(with: importedSettings) ToastModel.main.add(.success("Imported Settings")) } else { assertionFailure("Failed to import settings") ToastModel.main.add(.failure("Failed to import settings")) } } catch { handleError(error) } } } var content: some View { Form { Section("Save and Restore") { Button("Save Settings", icon: .settings.saveSettings) { Task { await Settings.save(to: .v2_user) v2SettingsExist = persistenceRepository.systemSettingsExists(.v2_user) } } Button("Restore Settings", icon: .settings.restoreSettings) { Task { @MainActor in Settings.restore(from: .v2_user) } } .disabled(!v2SettingsExist) } footer: { Text("Save the current settings and restore them later.") } Section { Button("Export Settings", icon: .general.export) { Task { let data = try Settings.encoded() let fileUrl = FileManager.default.temporaryDirectory.appending(path: "settings.json") try data.write(to: fileUrl, options: .atomic) navigation.model?.shareInfo = .init(url: fileUrl) } } Button("Import Settings", icon: .general.import) { importingSettingsFile = true } } } } func readSettings(from fileUrl: URL) -> Data? { let accessing = fileUrl.startAccessingSecurityScopedResource() // ensure we relinquish access defer { if accessing { fileUrl.stopAccessingSecurityScopedResource() } } do { return try Data(contentsOf: fileUrl, options: .mappedIfSafe) } catch { handleError(error) return nil } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InboxBadgeSettingsView.swift ================================================ // // InboxBadgeSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-17. // import MlemMiddleware import SwiftUI struct InboxBadgeSettingsView: View { @Setting(\.tab_inbox_badgeIncludedTypes) var tabInboxBadgeIncludedTypes var body: some View { Form { headerView Section { toggle(forType: .reply) toggle(forType: .mention) toggle(forType: .message) } if AccountsTracker.main.highestLevelAccountType >= .moderator { Section { toggle(forType: .postReport) toggle(forType: .commentReport) if AccountsTracker.main.highestLevelAccountType == .admin { toggle(forType: .messageReport) toggle(forType: .registrationApplication) } } } } .contentMargins(.top, 16, for: .scrollContent) .withConditionalLabelStyle() .hiddenNavigationTitle("Notification Badge") } @ViewBuilder var headerView: some View { SettingsHeaderView( title: "Notification Badge", description: "Configure which types of notification should be included in the notification badge." ) { Image(icon: .lemmy.inbox) .resizable() .symbolVariant(.fill) .aspectRatio(contentMode: .fit) .frame(width: 64) .foregroundStyle(.tertiary) .padding([.trailing, .top], 20) .overlay(alignment: .topTrailing) { Text(verbatim: "1") .font(.title2) .foregroundStyle(.themedContrastingLabel) .aspectRatio(1, contentMode: .fit) .padding(10) .background(.themedWarning, in: .circle) } .padding(.top, -10) } } @ViewBuilder func toggle(forType type: InboxItemType) -> some View { Toggle(type.label, icon: type.icon, isOn: .init( get: { tabInboxBadgeIncludedTypes.contains(type) }, set: { if $0 { tabInboxBadgeIncludedTypes.insert(type) } else { tabInboxBadgeIncludedTypes.remove(type) } } )) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InboxSettingsView.swift ================================================ // // InboxSettingsView.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import SwiftUI import Theming struct InboxSettingsView: View { @Setting(\.tab_inbox_badgeIncludedTypes) var tabInboxBadgeIncludedTypes @Setting(\.interactionBar_reply) var replyInteractionBar var body: some View { Form { SettingsHeaderView( title: "Inbox", // swiftlint:disable:next line_length description: "Customize the interaction bar for inbox items, and choose which types of notification are included in the tab bar badge.", icon: .lemmy.inbox ) .gradientTint(.themedInbox) Section { NavigationLink(.settings(.interactionBar(.inboxNotification))) { SettingsInteractionBarSummaryView(configuration: replyInteractionBar) } NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.inboxNotification))) } if AccountsTracker.main.highestLevelAccountType >= .moderator { Section { NavigationLink( "Mod Mail Action Layouts", icon: .settings.interactionBar, destination: .settings(.modMailInteractionBar) ) } } Section { NavigationLink( "Notification Badge", value: tabInboxBadgeIncludedTypes.label(accountType: AccountsTracker.main.highestLevelAccountType), fallbackValue: .init(localized: "Some"), icon: .settings.unreadBadge, destination: .settings(.inboxBadge) ) } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Inbox") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InteractionBarEditor/InteractionBarEditorView+Logic.swift ================================================ // // InteractionBarEditorView+Logic.swift // Mlem // // Created by Sjmarf on 18/08/2024. // import SwiftUI import Theming extension InteractionBarEditorView { // MARK: - Definitions enum ConfigurationType { case post, comment } enum DropLocation: Equatable { case bar(Int) case tray var index: Int? { switch self { case let .bar(index): index default: nil } } } @Observable class TrayItem: Equatable { let item: Configuration.Item private(set) var opacity: CGFloat init(item: Configuration.Item, visible: Bool) { self.item = item self.opacity = visible ? 1 : 0 } /// Toggles opacity to 1 func show() { opacity = 1 } /// Toggles opacity to 0 func hide() { opacity = 0 } static func == (lhs: TrayItem, rhs: TrayItem) -> Bool { lhs.item == rhs.item } } @Observable class BarItem: Equatable { let item: Configuration.Item? /// Controls the width of the barItem view private(set) var maxWidth: CGFloat? /// Controls the opacity of the barItem view private(set) var opacity: CGFloat /// If this BarItem is replacing another one (i.e., when moving a widget on the bar), this points to the old /// BarItem, allowing the barItem view to smoothly animate the ancestor out when it appears. weak var ancestor: BarItem? /// Uniquely identifies this BarItem. This is needed to allow two `BarItem`s with the same `item` /// to exist at once on the bar (used when moving bar items) without relying on index-based identification let uuid: UUID = .init() init(item: Configuration.Item?, expanded: Bool, visible: Bool, ancestor: BarItem? = nil) { self.item = item self.maxWidth = expanded ? nil : 0 self.opacity = visible ? 1 : 0 self.ancestor = ancestor } /// Expands maxWidth to default func expand() { maxWidth = nil } /// Reduces maxWidth to 0 func collapse() { maxWidth = 0 } /// Toggles opacity to 1 func show() { opacity = 1 } /// Toggles opacity to 0 func hide() { opacity = 0 } static func == (lhs: BarItem, rhs: BarItem) -> Bool { lhs.uuid == rhs.uuid } } // MARK: - Helper Computed Vars var allowNewItemInsertion: Bool { if let trayPickedUpItem { let currentScore = barItems.reduce(0) { $0 + ($1.item?.score ?? 0) } return currentScore + trayPickedUpItem.item.score <= 6 } return true } var showInfoCapsule: Bool { !allowNewItemInsertion || trayPickedUpItem != nil } var isDraggingItem: Bool { trayPickedUpItem != nil || barPickedUpItem != nil } var barPickedUpIndex: Int? { barPickedUpItem?.index } // MARK: - Drag Gestures func barItemDragGesture(item: BarItem, index: Int) -> some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .named("editor")) .onChanged { gesture in if barPickedUpItem == nil { hapticManager.play(haptic: .firmInfo, tier: .low) barPickedUpItem = (item, index) if let trayItem = trayItems.first(where: { $0.item == item.item }) { withAnimation(.easeOut(duration: barAnimationDuration)) { trayItem.hide() } } } dragLocation = gesture.location dragTranslation = gesture.translation } .onEnded { _ in completeDrag() } } func trayItemDragGesture(trayItem: TrayItem) -> some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .named("editor")) .onChanged { gesture in if trayPickedUpItem == nil { hapticManager.play(haptic: .firmInfo, tier: .low) trayPickedUpItem = trayItem } dragLocation = gesture.location dragTranslation = gesture.translation } .onEnded { _ in completeDrag() } } func completeDrag() { defer { self.barPickedUpItem = nil self.dropLocation = nil self.trayPickedUpItem = nil } guard let dropLocation else { return } if let trayPickedUpItem { guard case let .bar(targetIndex) = dropLocation else { return } addToBar(trayPickedUpItem, at: targetIndex) } else if let barPickedUpItem { if let trayItem = trayItems.first(where: { $0.item == barPickedUpItem.barItem.item }) { withAnimation(.easeOut(duration: barAnimationDuration)) { trayItem.show() } } switch dropLocation { case let .bar(targetIndex): moveOnBar(barItem: barPickedUpItem.barItem, from: barPickedUpItem.index, to: targetIndex) case .tray: removeFromBar(barItem: barPickedUpItem.barItem, barItemIndex: barPickedUpItem.index) } } } // MARK: - State Updates func addToBar(_ trayItem: TrayItem, at index: Int) { guard allowNewItemInsertion else { assertionFailure("Item insertion disabled") return } hapticManager.play(haptic: .firmInfo, tier: .high) let newItem: BarItem = .init(item: trayItem.item, expanded: false, visible: true) barItems.insert(newItem, at: index) // gently fade the tray item back in trayItem.hide() withAnimation(.easeOut(duration: trayItemDuration)) { trayItem.show() } // recompute infoStackAlignment with actual barItems, since these animations all play nice withAnimation(.easeInOut(duration: barAnimationDuration)) { infoStackAlignment = computeInfoStackAlignment( infoStackIndex: infoStackIndex(), totalItems: barItems.count ) } updateConfiguration() } func moveOnBar(barItem: BarItem, from sourceIndex: Int, to targetIndex: Int) { // noop on move to current location or immediately after current location guard targetIndex != sourceIndex, targetIndex != sourceIndex + 1 else { return } hapticManager.play(haptic: .firmInfo, tier: .high) let newItem: BarItem = .init(item: barItem.item, expanded: false, visible: true, ancestor: barItem) barItem.hide() if targetIndex == barItems.count { barItems.append(newItem) } else { barItems.insert(newItem, at: targetIndex) } // recompute infoStackAlignment with projected info stack location let infoStackIndex = infoStackIndex() let newInfoStackAlignment: Alignment? if barItem.item == nil { // if moving info stack itself, can compute alignment based on whether moving to beginning or end if targetIndex == 0 { newInfoStackAlignment = barItems.count == 1 ? .center : .leading } else if targetIndex == barItems.count - 1 { newInfoStackAlignment = .trailing } else { newInfoStackAlignment = .center } } else { if sourceIndex < infoStackIndex { if targetIndex > infoStackIndex { // moving widget from left to right of info stack, projected infostack index is current - 1 newInfoStackAlignment = computeInfoStackAlignment( infoStackIndex: infoStackIndex - 1, totalItems: barItems.count ) } else { // widget not moving "over" the info stack, no change newInfoStackAlignment = nil } } else { if targetIndex < infoStackIndex { // moving widget from right to left of info stack, projected infostack index is current + 1 newInfoStackAlignment = computeInfoStackAlignment( infoStackIndex: infoStackIndex + 1, totalItems: barItems.count ) } else { // widget not moving "over" the info stack, no change newInfoStackAlignment = nil } } } if let newInfoStackAlignment { withAnimation(.easeInOut(duration: barAnimationDuration)) { infoStackAlignment = newInfoStackAlignment } } // wait for animation to complete, then remove original item from barItems DispatchQueue.main.asyncAfter(deadline: .now() + barAnimationDuration) { barItems.removeAll(where: { $0 == barItem }) updateConfiguration() } } func removeFromBar(barItem: BarItem, barItemIndex: Int) { // no removing the info stack guard barItem.item != nil else { return } hapticManager.play(haptic: .firmInfo, tier: .high) // recompute infoStackAlignment with projected info stack location let infoStackIndex = infoStackIndex() let newInfoStackAlignment: Alignment if barItemIndex < infoStackIndex { // removing item to the left of info stack: shift info stack left newInfoStackAlignment = computeInfoStackAlignment(infoStackIndex: infoStackIndex - 1, totalItems: barItems.count - 1) } else { // removing item to the right of info stack: info stack index unchanged, but barItems.count still decreases newInfoStackAlignment = computeInfoStackAlignment(infoStackIndex: infoStackIndex, totalItems: barItems.count - 1) } // smoothly animate away barItem.hide() withAnimation(.easeInOut(duration: barAnimationDuration)) { barItem.collapse() if newInfoStackAlignment != infoStackAlignment { infoStackAlignment = newInfoStackAlignment } } // wait for animation to complete, then remove from barItems DispatchQueue.main.asyncAfter(deadline: .now() + barAnimationDuration) { barItems.removeAll(where: { $0 == barItem }) updateConfiguration() } } func updateConfiguration() { guard let infoStackIndex = barItems.firstIndex(where: { $0.item == nil }) else { assertionFailure("Could not find info stack in barItems") return } configuration = .init( leading: barItems[.. ThemedColor { if let dropLocation, trayPickedUpItem == trayItem || (barPickedUpItem?.barItem.item == trayItem.item && dropLocation == .tray) { return .themedAccent } return .themedTertiary } func infoStackIndex() -> Int { guard let ret = barItems.firstIndex(where: { $0.item == nil }) else { assertionFailure("could not find infoStack index") return 0 } return ret } } func computeInfoStackAlignment(infoStackIndex: Int, totalItems: Int) -> Alignment { if infoStackIndex == 0 { return totalItems == 1 ? .center : .leading } else if infoStackIndex == totalItems - 1 { return .trailing } else { return .center } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InteractionBarEditor/InteractionBarEditorView+Views.swift ================================================ // // InteractionBarEditorView+Views.swift // Mlem // // Created by Eric Andrews on 2025-01-27. // import ComponentViews import Flow import SwiftUI import Theming // swiftlint:disable file_length extension InteractionBarEditorView { // MARK: - Previews @ViewBuilder var contentPreview: some View { VStack(alignment: .leading, spacing: 0) { Group { switch configurationType { case .post: postPreviewBody case .comment: commentPreviewBody } } .opacity(0.75) .padding([.top, .horizontal], Constants.main.standardSpacing) interactionBar .frame(height: Constants.main.barIconHitbox) } .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.mediumItemCornerRadius)) .paletteBorder(cornerRadius: Constants.main.mediumItemCornerRadius) } @ViewBuilder var postPreviewBody: some View { HStack(alignment: .top, spacing: 8) { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .fill(.themedAccent.opacity(0.6)) .frame(width: Constants.main.thumbnailSize, height: Constants.main.thumbnailSize) .overlay { Image(systemName: "mountain.2.fill") .font(.system(size: 23)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 5) { MockTextView() .frame(maxWidth: .infinity) .frame(height: 15) MockTextView() .frame(maxWidth: 200) .frame(height: 15) } } } @ViewBuilder var commentPreviewBody: some View { HStack(spacing: 7) { Image(icon: .lemmy.personAvatar) .resizable() .scaledToFit() .symbolVariant(.circle.fill) .symbolRenderingMode(.palette) .foregroundStyle(palette.contrastingLabel, palette.neutralAccent.gradient) .frame(width: Constants.main.smallAvatarSize, height: Constants.main.smallAvatarSize) .compositingGroup() .opacity(0.5) MockTextView(beginOpacity: 0.4, endOpacity: 0.3) .frame(maxWidth: 200) .frame(height: 13) } VStack(alignment: .leading, spacing: 5) { MockTextView() .frame(maxWidth: .infinity) .frame(height: 15) MockTextView() .frame(maxWidth: 250) .frame(height: 15) } } @ViewBuilder var interactionBar: some View { HStack(spacing: 0) { ForEach(Array(barItems.enumerated()), id: \.element.uuid) { index, item in if dropLocation?.index == index, barPickedUpIndex != index, barPickedUpIndex != index - 1 { dropIndicator(index: index) } barItem(item, index: index) } if dropLocation?.index == barItems.count, barPickedUpIndex != barItems.count - 1 { dropIndicator(index: barItems.count) } } } @ViewBuilder func barItem(_ barItem: BarItem, index: Int) -> some View { itemLabel(barItem.item) .offset(barPickedUpIndex == index ? dragTranslation : .zero) .background { if barPickedUpIndex == index, dragTranslation != .zero { Capsule() .fill(.themedAccent.opacity(0.2)) .stroke(.themedAccent) .padding(4) } } .overlay { GeometryReader { geometry in Color.clear .contentShape(.rect) .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: dragLocation) { guard allowNewItemInsertion, isDraggingItem else { return } let frame = geometry.frame(in: .named("editor")) // if outside of bar zone, reset newHoveredDropLocation guard dragLocation.y <= frame.maxY + 30 else { dropLocation = .tray return } // check if within this item's hitbox if dragLocation.x > frame.minX, dragLocation.x < frame.maxX { // determine whether hovered over the left or the right side, update hoveredDropIndex accordingly dropLocation = .bar(dragLocation.x < frame.midX ? index : index + 1) } } } } .gesture(barItemDragGesture(item: barItem, index: index)) .onAppear { withAnimation(.easeOut(duration: barAnimationDuration)) { barItem.ancestor?.collapse() barItem.expand() } } .frame(maxWidth: barItem.maxWidth) .opacity(barItem.opacity) .zIndex(barPickedUpIndex == index ? 2 : 0) } // MARK: - Palette @ViewBuilder var tray: some View { HFlow(horizontalAlignment: .center, verticalAlignment: .center, distributeItemsEvenly: true) { ForEach(trayItems, id: \.item) { trayItem($0) } } } @ViewBuilder func trayItem(_ trayItem: TrayItem) -> some View { itemLabel(trayItem.item) .opacity(trayItem.opacity) .geometryGroup() .offset(trayPickedUpItem == trayItem ? dragTranslation : .zero) .background { Group { switch trayItem.item { case let .action(action): InteractionBarActionLabelView(action.appearance) case let .counter(counter): counterLabel(counter.appearance) .fixedSize() } } .opacity(0.2) .background { Capsule() .fill(trayItemOutlineColor(trayItem).opacity(0.2)) .stroke(trayItemOutlineColor(trayItem)) .background(.themedSecondaryGroupedBackground, in: .capsule) } } .gesture(trayItemDragGesture(trayItem: trayItem)) .zIndex(trayPickedUpItem == trayItem ? 2 : 0) } @ViewBuilder var readoutSelectors: some View { HFlow(spacing: Constants.main.standardSpacing) { ForEach(Array(Configuration.ReadoutType.allCases.enumerated()), id: \.offset) { _, readout in let isActive = configuration.readouts.contains(readout) let disabled = !readout.compatibleWith(otherReadouts: Set(configuration.readouts)) Button { if isActive { if let index = configuration.readouts.firstIndex(of: readout) { configuration.readouts.remove(at: index) } } else { // Insert and sort the new `ReadoutType`. In future these could be re-arrangable too // but I need to think about how the UI would work configuration.readouts = Configuration.ReadoutType.allCases.filter { configuration.readouts.contains($0) || $0 == readout } } hapticManager.play(haptic: .gentleInfo, tier: .low) } label: { let color: ThemedColor = disabled ? .themedPrimary : .themedAccent HStack(spacing: 2) { Image(icon: readout.appearance.icon.representingState(active: false)) if readout.appearance.label != "" { Text(readout.appearance.label) } } .font(.footnote) .foregroundStyle(isActive ? .themedContrastingLabel : color) .padding(.horizontal, 12) .padding(.vertical, 8) .background { Capsule().fill(isActive ? color : color.opacity(0.2)).stroke(color) } .transaction { $0.animation = nil } } .buttonStyle(.plain) .disabled(disabled) } } } // MARK: - General Page Views @ViewBuilder var header: some View { SettingsHeaderView( title: "Interaction Bar", description: "Tap and hold items to add, remove, or rearrange them." ) {} .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.largeItemCornerRadius)) } @ViewBuilder var infoCapsule: some View { if !allowNewItemInsertion { Text("Too many items") .padding(7.5) .padding(.horizontal, 5) .foregroundStyle(.themedNegative) .background { Capsule() .fill(.themedNegative.opacity(0.2)) .stroke(.themedNegative) .background(.themedSecondaryGroupedBackground, in: .capsule) } .frame(height: infoCapsuleHeight) } else if let trayPickedUpItem { Group { switch trayPickedUpItem.item { case let .action(action): HStack { Image(systemName: action.appearance.barIcon) Text(action.appearance.label) } case let .counter(counter): HStack { counterLabel(counter.appearance) .fixedSize() Text(counter.appearance.label) } } } .padding(7.5) .padding(.horizontal, 5) .background { Capsule() .fill(.themedSecondaryGroupedBackground) .stroke(.themedTertiary) } .frame(height: infoCapsuleHeight) } else { Color.clear.frame(height: infoCapsuleHeight) } } @ViewBuilder var buttons: some View { HStack { Button("Reset") { assert(!(isReport && Configuration.reportDefault == nil), "isReport is true but no reportDefault found") let defaultConfiguration: Configuration = isReport ? .reportDefault ?? .default : .default var newConfiguration = configuration newConfiguration.leading = defaultConfiguration.leading newConfiguration.trailing = defaultConfiguration.trailing newConfiguration.readouts = defaultConfiguration.readouts self.configuration = newConfiguration infoStackAlignment = computeInfoStackAlignment( infoStackIndex: configuration.leading.count, totalItems: configuration.all.count ) barItems = (configuration.leading + [nil] + configuration.trailing).map { item in .init(item: item, expanded: true, visible: true) } } .buttonStyle(.plain) .foregroundStyle(.secondary) Spacer() Button("Apply to All") { showingApplyToAllConfirmation = true } .confirmationDialog( "Really apply this configuration to all interaction bars?", isPresented: $showingApplyToAllConfirmation, titleVisibility: .visible ) { Button("Yes") { postInteractionBar = postInteractionBar.applying(other: configuration, types: [.bar]) commentInteractionBar = commentInteractionBar.applying(other: configuration, types: [.bar]) replyInteractionBar = replyInteractionBar.applying(other: configuration, types: [.bar]) // reports intentionally omitted } } } .padding(.horizontal) } // MARK: - Helpers @ViewBuilder func counterLabel(_ appearance: CounterAppearance) -> some View { let paddingEdges: Edge.Set = { if appearance.leading == nil { return .leading } if appearance.trailing == nil { return .trailing } return [] }() HStack(spacing: 0) { if let leading = appearance.leading { InteractionBarActionLabelView(leading) } Text(appearance.value?.description ?? "") .monospacedDigit() .foregroundStyle(.themedPrimary) .padding(paddingEdges, Constants.main.standardSpacing) if let trailing = appearance.trailing { InteractionBarActionLabelView(trailing) } } .padding(paddingEdges, 6) } @ViewBuilder func itemLabel(_ item: Configuration.Item?) -> some View { Group { switch item { case let .action(action): InteractionBarActionLabelView(action.appearance) case let .counter(counter): counterLabel(counter.appearance) .fixedSize() default: infoStack .frame(maxWidth: .infinity) } } .background { Capsule() .fill(.themedSecondaryGroupedBackground.opacity(0.85)) } .geometryGroup() } @ViewBuilder var infoStack: some View { HStack(spacing: 12) { ForEach(configuration.readouts, id: \.hashValue) { readout in HStack(spacing: 2) { Image(icon: readout.appearance.icon.representingState(active: false)) Text(readout.appearance.label) } .font(.footnote) .lineLimit(1) } } .foregroundStyle(.themedSecondary) .frame(maxWidth: .infinity, alignment: infoStackAlignment) .padding(Constants.main.standardSpacing) } @ViewBuilder func dropIndicator(index: Int) -> some View { Capsule() .fill(.themedAccent) .frame(width: 2, height: 40) .padding(-2) .frame(width: 0) .onAppear { hapticManager.play(haptic: .gentleInfo, tier: .low) } } } // swiftlint:enable file_length ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InteractionBarEditor/InteractionBarEditorView.swift ================================================ // // InteractionBarEditorView.swift // Mlem // // Created by Sjmarf on 15/08/2024. // import Flow import Haptics import SwiftUI import Theming struct InteractionBarEditorView: View { @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.interactionBar_comment) var commentInteractionBar @Setting(\.interactionBar_reply) var replyInteractionBar @State var configuration: Configuration { didSet { onSet(configuration) } } @State var trayItems: [TrayItem] = .init() @State var barItems: [BarItem] = .init() @State var barPickedUpItem: (barItem: BarItem, index: Int)? @State var trayPickedUpItem: TrayItem? /// Current entity the dragged item is hovered over. -1 indicates the tray. @State var dropLocation: DropLocation? @State var dragLocation: CGPoint = .zero @State var dragTranslation: CGSize = .zero @State var infoStackAlignment: Alignment @State var showingApplyToAllConfirmation: Bool = false let onSet: (Configuration) -> Void let configurationType: ConfigurationType let isReport: Bool let barAnimationDuration: CGFloat = 0.15 let trayItemDuration: CGFloat = 0.5 @ScaledMetric(relativeTo: .body) var baseInfoCapsuleHeight: CGFloat = 22 var infoCapsuleHeight: CGFloat { baseInfoCapsuleHeight + Constants.main.doubleSpacing } init(configuration: Configuration, isReport: Bool, onSet: @escaping (Configuration) -> Void) { self.onSet = onSet self.configuration = configuration self.isReport = isReport let configurationItems: [Configuration.Item?] = configuration.leading + [nil] + configuration.trailing self.configurationType = configuration is PostBarConfiguration ? .post : .comment let newBarItems: [BarItem] = configurationItems.map { .init(item: $0, expanded: true, visible: true) } let newInfoStackIndex = newBarItems.firstIndex(where: { $0.item == nil }) assert(newInfoStackIndex != nil, "could not find infoStack index") self._barItems = .init(wrappedValue: newBarItems) self._infoStackAlignment = .init(wrappedValue: computeInfoStackAlignment( infoStackIndex: newInfoStackIndex ?? 0, totalItems: newBarItems.count ) ) } init(setting: ReferenceWritableKeyPath, isReport: Bool) { self.init(configuration: Settings.get(setting), isReport: isReport) { Settings.set(setting, to: $0) } } var body: some View { VStack(spacing: Constants.main.standardSpacing) { header buttons Spacer() infoCapsule contentPreview.zIndex(barPickedUpItem == nil ? 0 : 1) Divider() readoutSelectors Divider() tray.zIndex(trayPickedUpItem == nil ? 0 : 1) Button("More Widgets...") { navigation.openSheet(.settings(configuration.widgetPickerPage($configuration))) } } .onChange(of: configuration.availableWidgets, initial: true) { onSet(configuration) trayItems = Configuration.Item.allCases .filter { configuration.availableWidgets.contains($0) } .map { TrayItem(item: $0, visible: true) } } .frame(maxWidth: .infinity) .padding(Constants.main.standardSpacing) .padding(.bottom, Constants.main.standardSpacing) .themedGroupedBackground() .coordinateSpace(.named("editor")) .hiddenNavigationTitle("Interaction Bar") } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment) { // NavigationStack { // InteractionBarEditorView(configuration: PostBarConfiguration.default, isReport: false, onSet: { _ in }) // } // } // #endif ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/InteractionBarEditor/InteractionBarWidgetPickerView.swift ================================================ // // InteractionBarWidgetPickerView.swift // Mlem // // Created by Eric Andrews on 2025-02-12. // import ComponentViews import SwiftUI struct InteractionBarWidgetPickerView: View { @Environment(\.dismiss) var dismiss @Binding var configuration: Configuration var body: some View { Form { Section { Text("Choose which widgets to display in your palette.") .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) } Section("Actions") { ForEach(Array(Configuration.ActionType.allCases), id: \.self) { item in widgetButton(.action(item)) } } Section("Counters") { ForEach(Array(Configuration.CounterType.allCases), id: \.self) { item in widgetButton(.counter(item)) } } } .toolbar { CloseButtonToolbarItem() } .contentMargins(.top, 0) } @ViewBuilder func widgetButton(_ item: Configuration.Item) -> some View { let selected = configuration.availableWidgets.contains(item) let (label, icon): (String, String) = switch item { case let .action(action): (action.appearance.label, action.appearance.barIcon) case let .counter(counter): (.init(localized: counter.appearance.label), counter.appearance.singleIcon) } Button { if selected { configuration.availableWidgets.remove(item) } else { configuration.availableWidgets.insert(item) } } label: { HStack { Label { Text(label) } icon: { Image(systemName: icon) .foregroundStyle(selected ? .themedAccent : .themedSecondary) } Spacer() if selected { Image(icon: .general.success) .foregroundStyle(.themedAccent) .contentTransition(.symbolEffect(.replace, options: .speed(2))) } } .frame(maxWidth: .infinity) .contentShape(.rect) } .buttonStyle(.plain) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/LinkSettingsView.swift ================================================ // // LinkSettingsView.swift // Mlem // // Created by Sjmarf on 27/06/2024. // import SwiftUI import Theming struct LinkSettingsView: View { @Setting(\.links_openInBrowser) var openLinksInBrowser @Setting(\.links_readerMode) var openLinksInReaderMode @Setting(\.links_shareMode) var linkSharingMode @Setting(\.links_displayMode) var tappableLinksDisplayMode @Setting(\.comment_compact) var compactComments @Setting(\.links_embedLoops) var embedLoops @Setting(\.behavior_autoplayMedia) var autoplayMedia @Setting(\.behavior_muteVideos) var muteVideos var body: some View { Form { SettingsHeaderView( title: "Media & Links", description: "Manage how Mlem handles links and control how images and videos are displayed.", icon: .general.image ) .gradientTint(.themedColorfulAccent(4)) Section { NavigationLink( "Open External Links", value: .init(localized: externalLinksNavigationLinkValue), fallbackValue: "", icon: .settings.openExternalLinks, destination: .settings(.externalLinks) ) NavigationLink( "Share Links", value: .init(localized: sharingLinksNavigationLinkValue), fallbackValue: "", icon: .general.share, destination: .settings(.sharingLinks) ) NavigationLink( "Tappable Links", value: tappableLinksDisplayMode == .disabled ? "Off" : "On", fallbackValue: "", icon: .settings.tappableLinks, destination: .settings(.tappableLinks) ) } Section { NavigationLink( "Image Viewer", icon: .settings.imageViewer, destination: .settings(.imageViewer) ) } Section { Toggle("Autoplay", icon: .general.playCircle, isOn: $autoplayMedia) Toggle("Mute Videos", icon: .general.muted, isOn: $muteVideos) } Section { NavigationLink( "Embedded Content", value: embedLoops ? "On" : "Off", fallbackValue: "", icon: .general.embedding, destination: .settings(.embedding) ) } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Media & Links") } var externalLinksNavigationLinkValue: LocalizedStringResource { if openLinksInBrowser { "In Browser" } else { openLinksInReaderMode ? "In Reader" : "In Mlem" } } var sharingLinksNavigationLinkValue: LocalizedStringResource { switch linkSharingMode { case .myInstance: "My Instance" case .originalInstance: "Original Instance" case .lemmyverse: "Universal" case .askEveryTime: "Ask Every Time" } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/LongPressActionSettingsView.swift ================================================ // // LongPressActionSettingsView.swift // Mlem // // Created by Bedir Ekim on 21.05.2025. // import SwiftUI import Theming struct LongPressActionSettingsView: View { @Setting(\.tab_gestures_longPressAction) private var longPressAction: TabBarLongPressAction @Environment(\.palette) var palette var body: some View { Form { SettingsHeaderView( title: "Long Press Action", description: "Choose which action to perform when you tap and hold the profile icon.", icon: .settings.longPress ) .tint(ThemedColor.themedColorfulAccent(2).gradient(palette: palette)) Section { Picker("Long Press Action", selection: $longPressAction) { ForEach(TabBarLongPressAction.allCases, id: \.rawValue) { action in Label(String(localized: action.label), icon: action.icon) .symbolVariant(.circle) .tag(action) } } .labelsHidden() .pickerStyle(.inline) } footer: { Text("Swiping up on the tab bar will always open the account switcher.") } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Long Press Action") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ModMailInteractionBarSettingsView.swift ================================================ // // ModMailInteractionBarSettingsView.swift // Mlem // // Created by Sjmarf on 2025-02-06. // import SwiftUI struct ModMailInteractionBarSettingsView: View { @Setting(\.interactionBar_postReport) var postReportInteractionBar @Setting(\.interactionBar_commentReport) var commentReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var useAlternateLayout var body: some View { Form { SettingsHeaderView( title: "Mod Mail Action Layouts", // swiftlint:disable:next line_length description: "Choose whether to use alternate interaction bar and swipe action layouts for post and comment reports in Mod Mail." ) {} Section { Toggle("Use Alternate Layouts", isOn: $useAlternateLayout) } if useAlternateLayout { Section("Posts") { NavigationLink(.settings(.interactionBar(.postReport))) { SettingsInteractionBarSummaryView( title: "Interaction Bar", configuration: postReportInteractionBar ) } NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.postReport))) } Section("Comments") { NavigationLink(.settings(.interactionBar(.commentReport))) { SettingsInteractionBarSummaryView( title: "Interaction Bar", configuration: commentReportInteractionBar ) } NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.commentReport))) } } } .animation(.easeOut(duration: 0.1), value: useAlternateLayout) .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Mod Mail Action Layouts") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ModeratorActionSeparationSettingsView.swift ================================================ // // ModeratorActionSeparationSettingsView.swift // Mlem // // Created by Sjmarf on 2025-02-01. // import SwiftUI struct ModeratorActionSeparationSettingsView: View { @Setting(\.menus_modActionGrouping) var moderatorActionGrouping var body: some View { Form { SettingsHeaderView( title: "Moderator Actions", description: "Customize how moderator actions are separated from regular actions in context menus." ) {} Section { Picker("Separate Actions Using", icon: .settings.menuItems, selection: $moderatorActionGrouping) { ForEach(ModeratorActionGrouping.allCases, id: \.self) { item in Label(item.label.key, icon: item.icon) } } .pickerStyle(.inline) .labelsHidden() } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Moderator Actions") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ModeratorSettingsView.swift ================================================ // // ModeratorSettingsView.swift // Mlem // // Created by Sjmarf on 01/10/2024. // import Icons import SwiftUI import Theming struct ModeratorSettingsView: View { @Setting(\.menus_modActionGrouping) var moderatorActionGrouping @Setting(\.menus_allModActions) var showAllModActions @Setting(\.tab_inbox_badgeIncludedTypes) var tabInboxBadgeIncludedTypes var body: some View { Form { SettingsHeaderView( title: "Moderation", description: "Manage settings related to content moderation.", icon: .lemmy.moderation ) .gradientTint(.themedModeration) Section { NavigationLink( "Moderator Actions", value: .init(localized: moderatorActionGrouping.label), fallbackValue: "", icon: .settings.menuItems, destination: .settings(.separateModeratorActions) ) } Section { Toggle("Show All Actions in Feed", icon: .general.menu, isOn: $showAllModActions) .symbolVariant(.circle) } footer: { Text("When disabled, some moderator actions will only be accessible from the post page.") } Section { NavigationLink( "Notification Badge", value: tabInboxBadgeIncludedTypes.label(accountType: AccountsTracker.main.highestLevelAccountType), fallbackValue: .init(localized: "Some"), icon: .settings.unreadBadge, destination: .settings(.inboxBadge) ) } Section { NavigationLink( "Mod Mail Action Layouts", icon: .settings.interactionBar, destination: .settings(.modMailInteractionBar) ) } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Moderation") } } enum ModeratorActionGrouping: String, Codable, CaseIterable { case divider, separateMenu init?(rawValue: String) { switch rawValue { // Decode v1 case case "none", "divider", "disclosureGroup": self = .divider case "separateMenu": self = .separateMenu default: return nil } } var label: LocalizedStringResource { switch self { case .divider: "Divider" case .separateMenu: "Separate Menu" } } var icon: Icon { switch self { case .divider: .general.remove case .separateMenu: .lemmy.moderation } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PostReadIndicatorSettingsView.swift ================================================ // // PostReadIndicatorSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-19. // import ComponentViews import Haptics import SwiftUI import Theming struct PostReadIndicatorSettingsView: View { @Environment(HapticManager.self) var hapticManager @Setting(\.a11y_readPostIndicator) var readPostIndicator @Setting(\.a11y_readOutlineThickness) var readOutlineThickness @State var readBarThicknessSlider: Double init() { @Setting(\.a11y_readOutlineThickness) var readOutlineThickness _readBarThicknessSlider = .init(wrappedValue: Double(readOutlineThickness)) } var body: some View { Form { SettingsHeaderView( title: "Read Indicator", // swiftlint:disable:next line_length description: "Read posts are shown with dimmed title text. If you like, you can choose an additional way of indicating read status.", icon: .settings.readIndicatorSetting ) .gradientTint(.themedSecondary) Section { Toggle( "Additional Read Indicator", isOn: .init( get: { readPostIndicator != .none }, set: { readPostIndicator = $0 ? .checkmark : .none } ) ) } footer: { Text("This is turned on by default because Differentiate Without Color is enabled in System Settings.") } if readPostIndicator != .none { Section { HStack { pickerItem(for: .checkmark) pickerItem(for: .outline) } } } if readPostIndicator == .outline { Section { outlineThicknessSlider } } } .animation(.easeOut(duration: 0.1), value: readPostIndicator) .contentMargins(.top, 16) .hiddenNavigationTitle("Read Indicator") } @ViewBuilder var outlineThicknessSlider: some View { VStack(alignment: .leading) { Text("Outline Thickness") Slider( value: $readBarThicknessSlider, in: 1 ... 5, step: 1 ) { Text("Outline Thickness") } minimumValueLabel: { Text(verbatim: "1") } maximumValueLabel: { Text(verbatim: "5") } onEditingChanged: { editing in if !editing { readOutlineThickness = Int(readBarThicknessSlider) } } } } @ViewBuilder func pickerItem(for style: ReadPostIndicator) -> some View { VStack(spacing: Constants.main.standardSpacing) { preview(for: style) HStack { Text(style.label) Checkbox(isOn: style == readPostIndicator) } } .frame(maxWidth: .infinity) .onTapGesture { hapticManager.play(haptic: .gentleInfo, tier: .low) readPostIndicator = style } } @ViewBuilder func preview(for style: ReadPostIndicator) -> some View { UnevenRoundedRectangle( cornerRadii: .init(topLeading: 0, bottomLeading: 0, bottomTrailing: 0, topTrailing: 15) ) .fill(.themedSecondaryGroupedBackground) .stroke(style == .outline ? .themedSecondary : .clear, lineWidth: 2) .overlay(alignment: .topTrailing) { HStack { if style == .checkmark { Image(icon: .general.success) .foregroundStyle(.themedSecondary) } Image(icon: .general.menu) } .font(.title2) .frame(height: 30) .padding(.top, 15) .padding(.trailing, 20) } .padding([.top, .trailing], 20) .padding([.bottom, .leading], -2) .background(.themedGroupedBackground) .frame(width: 120, height: 80) .clipShape(.rect(cornerRadius: 10)) .overlay { RoundedRectangle(cornerRadius: 10) .stroke(.themedTertiary, lineWidth: 1) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PostSettingsView+PostSizePicker.swift ================================================ // // PostSettingsView+PostSizePicker.swift // Mlem // // Created by Sjmarf on 2025-01-18. // import Flow import SwiftUI extension PostSettingsView { struct PostSizePicker: View { @Setting(\.post_size) var postSize var body: some View { Section("Size") { ViewThatFits { HStack(spacing: 0) { largeItem headlineItem tiledItem compactItem } VStack { HStack { largeItem; headlineItem } HStack { tiledItem; compactItem } } } .listRowInsets(.init(top: 16, leading: 5, bottom: 16, trailing: 5)) } } @ViewBuilder var largeItem: some View { DevicePickerItem(PostSize.large.label, item: .large, selected: $postSize) { VStack(spacing: 3) { ForEach(0 ..< 2) { _ in RoundedRectangle(cornerRadius: 2) .overlay { RoundedRectangle(cornerRadius: 1) .opacity(0.5) .padding(.horizontal, 3) .padding(.top, 8) .padding(.bottom, 6) .blendMode(.destinationOut) } .compositingGroup() .aspectRatio(3 / 4, contentMode: .fit) } } .padding(.top, 4) } } @ViewBuilder var headlineItem: some View { DevicePickerItem(PostSize.headline.label, item: .headline, selected: $postSize) { VStack(spacing: 3) { ForEach(0 ..< 7) { _ in RoundedRectangle(cornerRadius: 2) .frame(height: 18) .overlay(alignment: .topLeading) { RoundedRectangle(cornerRadius: 1) .opacity(0.5) .frame(width: 7, height: 7) .padding(.top, 5) .padding(.leading, 2) .blendMode(.destinationOut) } .compositingGroup() } } .padding(.top, 4) } } @ViewBuilder var tiledItem: some View { DevicePickerItem(PostSize.tile.label, item: .tile, selected: $postSize) { VStack(spacing: 3) { ForEach(0 ..< 5) { _ in HStack(spacing: 3) { ForEach(0 ..< 2) { _ in Rectangle() .overlay(alignment: .topLeading) { RoundedRectangle(cornerRadius: 1) .opacity(0.5) .padding(.bottom, 6) .blendMode(.destinationOut) } .clipShape(.rect(cornerRadius: 2)) .aspectRatio(3 / 4, contentMode: .fit) } } } } .padding(.top, 4) } } @ViewBuilder var compactItem: some View { DevicePickerItem(PostSize.compact.label, item: .compact, selected: $postSize) { VStack(spacing: 3) { ForEach(0 ..< 7) { _ in RoundedRectangle(cornerRadius: 2) .frame(height: 11) .overlay(alignment: .leading) { RoundedRectangle(cornerRadius: 1) .opacity(0.5) .aspectRatio(1, contentMode: .fit) .padding(2) .blendMode(.destinationOut) } .compositingGroup() } } .padding(.top, 4) } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PostSettingsView.swift ================================================ // // PostSettingsView.swift // Mlem // // Created by Eric Andrews on 2024-05-27. // import Foundation import SwiftUI // note: this is a very lazy categorization of "properties that affect posts" struct PostSettingsView: View { @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor: Bool @Setting(\.post_size) var postSize @Setting(\.post_allowMultipleColumns) var allowMultipleColumns @Setting(\.post_thumbnailLocation) var thumbnailLocation @Setting(\.post_showCreator) var showCreator @Setting(\.post_showSubscribedStatus) var showSubscribedStatus @Setting(\.post_showDownvotesCompact) var showDownvotesCompact @Setting(\.post_gestures_tapToCollapse) var tapPostsToCollapse @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.a11y_readPostIndicator) var readPostIndicator var body: some View { Form { PostSizePicker() if UIDevice.isPad { Toggle("Multiple Columns", systemImage: "square.grid.2x2", isOn: $allowMultipleColumns) } Section { NavigationLink(.settings(.interactionBar(.post))) { SettingsInteractionBarSummaryView(configuration: postInteractionBar) } NavigationLink("Swipe Actions", destination: .settings(.swipeActions(.post))) } Section { NavigationLink( "Subscription Indicator", value: showSubscribedStatus ? .init(localized: "On") : .init(localized: "Off"), fallbackValue: "", icon: .lemmy.subscribedFeed, destination: .settings(.postSubscriptionIndicator) ) if postSize == .headline || postSize == .compact { NavigationLink( "Thumbnail", value: .init(localized: thumbnailLocation.label), fallbackValue: "", icon: .settings.thumbnail, destination: .settings(.postThumbnail) ) } if postSize == .compact { Toggle("Show Downvotes Separately", icon: .lemmy.votes, isOn: $showDownvotesCompact) } if differentiateWithoutColor { NavigationLink( "Read Indicator", value: .init(localized: readPostIndicator.label), fallbackValue: "", icon: .settings.readIndicatorSetting, destination: .settings(.postReadIndicator) ) } } Section { Toggle("Tap to Collapse", icon: .general.collapse, isOn: $tapPostsToCollapse) } if postSize != .tile, postSize != .compact { Section { Toggle("Always Show Usernames", icon: .settings.author, isOn: $showCreator) } } } .withConditionalLabelStyle() .navigationTitle("Posts") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PostSubscriptionIndicatorSettingsView.swift ================================================ // // PostSubscriptionIndicatorSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-18. // import SwiftUI struct PostSubscriptionIndicatorSettingsView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.palette) var palette @Setting(\.post_showSubscribedStatus) var showSubscribedStatus var body: some View { Form { previewSection Section { Toggle("Subscription Indicator", isOn: $showSubscribedStatus) } } .contentMargins(.top, 16) .navigationTitle("Subscription Indicator") } @ViewBuilder var previewSection: some View { Section { UnevenRoundedRectangle( cornerRadii: .init(topLeading: 16, bottomLeading: 0, bottomTrailing: 10, topTrailing: 0) ) .fill(.themedTertiaryGroupedBackground) .strokeBorder(colorScheme == .light ? .themedSecondaryGroupedBackground : .clear, lineWidth: 2) .frame(height: 100) .overlay(alignment: .topLeading) { HStack(spacing: 0) { CircleCroppedImageView(url: nil, frame: 30, fallback: .communityAvatar) .opacity(0.8) Circle() .fill(.themedSecondary) .frame(width: showSubscribedStatus ? 10 : 0, height: 10) .opacity(showSubscribedStatus ? 10 : 0) .padding(.leading, showSubscribedStatus ? 12 : 5) .padding(.trailing, showSubscribedStatus ? 10 : 5) labelText .lineLimit(1) .fixedSize(horizontal: false, vertical: true) .font(.title2) .foregroundStyle(.themedSecondary) .opacity(0.8) .mask { LinearGradient(colors: [.black, .black.opacity(0.5)], startPoint: .leading, endPoint: .trailing) } .offset(y: -1) } .padding([.top, .leading], 20) .animation(.bouncy, value: showSubscribedStatus) } .padding([.top, .leading], 20) .listRowInsets(.init()) } } var labelText: Text { let string = String(localized: "news@example.com") let parts = string.split(separator: "@") guard parts.count == 2 else { assertionFailure() return Text(string) } return Text(parts[0]) + Text(verbatim: "@\(parts[1])").foregroundColor(palette.label.tertiary) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PostThumbnailSettingsView.swift ================================================ // // PostThumbnailSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-18. // import ComponentViews import SwiftUI struct PostThumbnailSettingsView: View { @Setting(\.post_thumbnailLocation) var thumbnailLocation @Setting(\.a11y_websiteThumbnailIcon) var websiteThumbnailIcon // capsule color gradient configuration let gradientBegin: CGFloat = 0.55 let gradientEnd: CGFloat = 0.45 var body: some View { Form { Section { alignmentPreview(location: thumbnailLocation) .animation(.easeInOut(duration: 0.2), value: thumbnailLocation) } Section { Picker("Thumbnail Location", selection: $thumbnailLocation) { ForEach(ThumbnailLocation.allCases, id: \.self) { location in Label(location.label.key, icon: location.icon) .tag(location) } } .labelsHidden() .pickerStyle(.inline) } Section { Toggle("Website Icon", icon: .general.browser, isOn: $websiteThumbnailIcon) } footer: { Text("Indicate link thumbnails with an icon.") } } .withConditionalLabelStyle() .navigationTitle("Thumbnail") } @ViewBuilder func alignmentPreview(location: ThumbnailLocation) -> some View { HStack(spacing: 8) { thumbnailView(active: location == .left) GeometryReader { geometry in VStack(alignment: .leading, spacing: 5) { MockTextView() .frame(width: geometry.size.width / 2, height: geometry.size.height / 6) MockTextView(beginOpacity: 0.65, endOpacity: 0.55) .frame(width: geometry.size.width * 4 / 5, height: geometry.size.height / 4) MockTextView() .frame(width: geometry.size.width / 3, height: geometry.size.height / 6) } .foregroundStyle(.themedSecondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 2) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.leading, location == .left ? 0 : -8) thumbnailView(active: location == .right) } .aspectRatio(8 / 2, contentMode: .fit) .padding(8) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.mediumItemCornerRadius)) } @ViewBuilder func thumbnailView(active: Bool) -> some View { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .fill(.themedAccent.opacity(0.6)) .frame(maxHeight: .infinity) .aspectRatio(.init(width: active ? 1 : 0, height: 1), contentMode: .fit) .overlay { Image(systemName: "mountain.2.fill") .font(.system(size: 30)) .foregroundStyle(.white) .opacity(active ? 0.9 : 0) } .overlay { Image(icon: .general.browser) .resizable() .frame(width: 20, height: 20) .foregroundStyle(.white) .background(.ultraThinMaterial, in: .circle) .padding(6) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .opacity(websiteThumbnailIcon ? 1 : 0) .opacity(active ? 1 : 0) .animation(.easeIn(duration: 0.2), value: websiteThumbnailIcon) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PrivacyBypassImageProxySettingsView.swift ================================================ // // PrivacyBypassImageProxySettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-25. // import SwiftUI import Theming struct PrivacyBypassImageProxySettingsView: View { @Setting(\.privacy_autoBypassImageProxy) var bypassImageProxy var body: some View { Form { SettingsHeaderView( title: "Bypass Image Proxy", // swiftlint:disable:next line_length description: "Some instances proxy images to protect your privacy. In certain cases, this causes image loading to fail. You can bypass the image proxy and load directly, but this will expose your IP address to the image host.", icon: .lemmy.imageProxy ) .gradientTint(.themedColorfulAccent(4)) Section("Bypass Image Proxy...") { Picker("Bypass Image Proxy", selection: $bypassImageProxy) { Label("Automatically", icon: .general.success) .symbolVariant(.circle) .tag(true) Label("Ask First", systemImage: "questionmark.circle") .tag(false) } .pickerStyle(.inline) .labelsHidden() } } .contentMargins(.top, 16) .withConditionalLabelStyle() .hiddenNavigationTitle("Bypass Image Proxy") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/PrivacySettingsView.swift ================================================ // // PrivacySettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-25. // import SwiftUI import Theming struct PrivacySettingsView: View { @Setting(\.privacy_autoBypassImageProxy) var bypassImageProxy @Setting(\.behavior_confirmImageUploads) var confirmImageUploads @Setting(\.post_webPreview_showIcon) var showFavicons var body: some View { Form { SettingsHeaderView( title: "Privacy", description: "Manage how Mlem interacts with Lemmy instances and other websites.", icon: .settings.privacy ) .gradientTint(.themedColorfulAccent(2)) Section { Toggle("Confirm Image Uploads", icon: .settings.confirmImageUploads, isOn: $confirmImageUploads) } footer: { Text("When enabled, Mlem will ask you to confirm your choice before uploading an image to your instance.") } Section { NavigationLink( "Bypass Image Proxy", value: .init(localized: bypassImageProxyNavigationLinkValue), fallbackValue: "", icon: .lemmy.imageProxy, destination: .settings(.privacyBypassImageProxy) ) } Section { Toggle("Hide Website Icons", systemImage: "camera.macro.circle", isOn: $showFavicons.invert()) } footer: { // swiftlint:disable:next line_length Text("Mlem uses a Google API to fetch website icon URLs. If you'd prefer not to use this, you can choose to hide website icons.") } Section { NavigationLink( "Mlem Privacy Policy", icon: .settings.privacy, destination: .settings(.document(.privacyPolicy)) ) } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Privacy") } var bypassImageProxyNavigationLinkValue: LocalizedStringResource { bypassImageProxy ? "Automatically" : "Ask First" } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ProfileSettingsView.swift ================================================ // // ProfileSettingsView.swift // Mlem // // Created by Sjmarf on 2024-12-03. // import ComponentViews import MlemMiddleware import SwiftUI struct ProfileSettingsView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss let person: Person @State var profileDetails: ProfileDetails @State var bioTextView: UITextView = .init() @State var markdownToolbarEditorModel: MarkdownEditorToolbarModel = .init() @State var uploadHistory: ImageUploadHistoryManager = .init() @State var avatarManager: ImageUploadManager = .init() @State var bannerManager: ImageUploadManager = .init() @State var isSubmitting: Bool = false init(person: Person) { self.person = person self._profileDetails = .init(wrappedValue: person.profileDetails()) bioTextView.text = person.description ?? "" } var displayNameText: Binding { .init(get: { profileDetails.displayName ?? "" }, set: { newValue in if newValue == person.displayName || newValue.isEmpty { profileDetails.displayName = nil } }) } var minTextEditorHeight: CGFloat { UIFont.preferredFont(forTextStyle: .body).lineHeight * 6 + 20 } var body: some View { Form { if person.api.supports(.editDisplayName, defaultValue: true) { displayNameSection } Section("Biography") { MarkdownTextEditor( onChange: { profileDetails.description = $0 }, prompt: "Write a bit about yourself...", textView: bioTextView, insets: .init( top: Constants.main.standardSpacing, left: Constants.main.standardSpacing, bottom: Constants.main.standardSpacing, right: Constants.main.standardSpacing ), firstResponder: false, sizingOffset: 10, content: { MarkdownEditorToolbarView( textView: bioTextView, uploadHistory: uploadHistory, model: markdownToolbarEditorModel ) } ) .frame( maxWidth: .infinity, minHeight: minTextEditorHeight, maxHeight: .infinity, alignment: .topLeading ) .listRowInsets(.init()) } avatarSection bannerSection } .onAppear { markdownToolbarEditorModel.imageUploadApi = person.api } .navigationTitle("My Profile") .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) .navigationBarBackButtonHidden(showToolbarOptions) .interactiveDismissDisabled(showToolbarOptions) .toolbar { if showToolbarOptions { ToolbarItem(placement: .topBarLeading) { Button { profileDetails = person.profileDetails() bioTextView.text = profileDetails.description } label: { if #available(iOS 26, *) { Label("Discard", icon: .general.delete) } else { Text("Cancel") } } .disabled(isSubmitting) } ToolbarItem(placement: .topBarTrailing) { if isSubmitting { ProgressView() } else { saveButtonView } } } else if navigation.isInsideSheet { CloseButtonToolbarItem() } } } var showToolbarOptions: Bool { profileDetails != person.profileDetails() } @ViewBuilder var displayNameSection: some View { Section("Display Name") { TextField("Display Name", text: displayNameText, prompt: Text(person.name)) .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Text("The name that is displayed on your profile. This is not the same as your username, which cannot be changed.") } } @ViewBuilder var avatarSection: some View { Section { HStack(spacing: 15) { CircleCroppedImageView(url: profileDetails.avatar, frame: 48, fallback: .personAvatar) Text("Avatar") Spacer() CircleImageUploadButton(imageManager: avatarManager, url: $profileDetails.avatar, api: person.api) } .onChange(of: avatarManager.image?.url) { profileDetails.avatar = avatarManager.image?.url } } } @ViewBuilder var bannerSection: some View { Section { VStack(spacing: 0) { if let bannerUrl = profileDetails.banner { MediaView( url: bannerUrl, contentMode: .fill, enableContextMenu: true, enableImageViewer: true ) .frame(height: 150) .clipped() } else { palette.label.secondary.opacity(0.5) .frame(height: 150) } HStack(spacing: 15) { Text("Banner") Spacer() CircleImageUploadButton(imageManager: bannerManager, url: $profileDetails.banner, api: person.api) } .padding(.horizontal, 15) .padding(.vertical, 10) } .onChange(of: bannerManager.image?.url) { profileDetails.banner = bannerManager.image?.url } } .listRowInsets(.init()) } @ViewBuilder var saveButtonView: some View { Button { Task { @MainActor in await submit() } } label: { if #available(iOS 26, *) { Label("Save", icon: .general.success) } else { Text("Save") } } .glassProminentButtonStyle() } @MainActor func submit() async { isSubmitting = true do { try await person.updateProfile(profileDetails) if let session = appState.firstSession as? UserSession, session.person === person { try await session.updateAccount() } else { assertionFailure() } dismiss() } catch { handleError(error) } isSubmitting = false } } private struct CircleImageUploadButton: View { let imageManager: ImageUploadManager @Binding var url: URL? let api: ApiClient var body: some View { Group { if url != nil { Button { url = nil } label: { Image(icon: .general.delete) .resizable() .symbolVariant(.circle.fill) } } else { switch imageManager.state { case .uploading: ProgressView() .controlSize(.extraLarge) default: ImageUploadMenu(imageManager: imageManager, imageUploadApi: api) { Image(icon: .general.add) .resizable() .symbolVariant(.circle.fill) } } } } .aspectRatio(contentMode: .fit) .frame(height: 36) .symbolRenderingMode(.hierarchical) .fontWeight(.regular) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SafetyBlurNsfwSettingsView.swift ================================================ // // SafetyBlurNsfwSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-24. // import SwiftUI import Theming struct SafetyBlurNsfwSettingsView: View { @Setting(\.safety_blurNsfw) var blurNsfw var body: some View { Form { headerView Picker("Blur NSFW Content", selection: $blurNsfw) { ForEach(NsfwBlurBehavior.allCases, id: \.self) { type in Label(String(localized: type.label), icon: type.icon) .symbolVariant(.circle) } } .pickerStyle(.inline) .labelsHidden() } .contentMargins(.top, 16) .withConditionalLabelStyle() .hiddenNavigationTitle("Blur NSFW Content") } @ViewBuilder var headerView: some View { SettingsHeaderView( title: "Blur NSFW Content", description: "Choose when Not Safe For Work content should be blurred.", icon: .general.hide ) .gradientTint(.themedWarning) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SafetySettingsView.swift ================================================ // // SafetySettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-24. // import SwiftUI import Theming struct SafetySettingsView: View { @Environment(FiltersTracker.self) var filtersTracker @Setting(\.safety_blurNsfw) var blurNsfw @Setting(\.filters_keywordFilterEnabled) var keywordFilterEnabled @Setting(\.safety_enableNsfwCommunityWarning) var showNsfwCommunityWarning @Setting(\.safety_enableModlogWarning) var showModlogWarning var body: some View { Form { SettingsHeaderView( title: "Safety & Filtering", // swiftlint:disable:next line_length description: "Customize how content is displayed in your feed. Choose which types of content are blurred, and apply filters to hide posts from the feed altogether.", icon: .settings.safety ) .gradientTint(.themedColorfulAccent(3)) Section { NavigationLink( "Blur NSFW Content", value: .init(localized: blurNsfw.label), fallbackValue: "", icon: .settings.blurNsfw, destination: .settings(.safetyBlurNsfw) ) NavigationLink( "Content Warnings", value: String(localized: contentWarningsNavigationLinkValue), fallbackValue: "", icon: .general.warning, destination: .settings(.safetyWarnings) ) } Section { NavigationLink( "Filters", value: .init(localized: filtersNavigationLinkValue), fallbackValue: "", icon: .settings.keywordFilter, destination: .settings(.filters) ) } } .contentMargins(.top, 16) .withConditionalLabelStyle() .hiddenNavigationTitle("Safety & Filtering") } var contentWarningsNavigationLinkValue: LocalizedStringResource { switch (showNsfwCommunityWarning, showModlogWarning) { case (true, true): "All" case (true, false): "NSFW Communities" case (false, true): "Modlogs" case (false, false): "None" } } var filtersNavigationLinkValue: LocalizedStringResource { var sum = 0 if filtersTracker.keywordFilterEnabled && !filtersTracker.rawKeywords.isEmpty { sum += 1 } if filtersTracker.literalFilterEnabled && !filtersTracker.literals.isEmpty { sum += 1 } return sum > 0 ? "\(sum) Active" : "None" } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SafetyWarningsSettingsView.swift ================================================ // // SafetyWarningsSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-24. // import SwiftUI import Theming struct SafetyWarningsSettingsView: View { @Setting(\.safety_enableNsfwCommunityWarning) var showNsfwCommunityWarning @Setting(\.safety_enableModlogWarning) var showModlogWarning var body: some View { Form { SettingsHeaderView( title: "Content Warnings", description: "Choose whether to show a warning when opening a page that is likely to contain sensitive content.", icon: .general.warning ) .gradientTint(.themedWarning) Section("Show warnings when opening...") { Toggle("NSFW Communities", icon: .lemmy.community, isOn: $showNsfwCommunityWarning) Toggle("Modlogs", icon: .lemmy.modlog, isOn: $showModlogWarning) } } .contentMargins(.top, 16) .withConditionalLabelStyle() .hiddenNavigationTitle("Content Warnings") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SettingsView.swift ================================================ // // SettingsView.swift // Mlem // // Created by Sjmarf on 07/05/2024. // import MlemMiddleware import SwiftUI import Theming struct SettingsView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Setting(\.appearance_palette) var colorPalette @State var currentIcon: String? = UIApplication.shared.alternateIconName var accounts: [UserAccount] { AccountsTracker.main.userAccounts } var body: some View { Form { Section { accountSettingsLink accountListLink } Section { NavigationLink( "General", icon: .settings.general, destination: .settings(.general) ) .gradientTint(.themedNeutralAccent) NavigationLink( "Privacy", icon: .settings.privacy, destination: .settings(.privacy) ) .gradientTint(.themedColorfulAccent(2)) NavigationLink( "Safety & Filtering", icon: .settings.safety, destination: .settings(.safety) ) .gradientTint(.themedColorfulAccent(3)) NavigationLink( "Accessibility", icon: .settings.accessibility, destination: .settings(.accessibility) ) .gradientTint(.themedColorfulAccent(2)) NavigationLink( "Media & Links", icon: .general.image, destination: .settings(.links) ) .gradientTint(.themedColorfulAccent(4)) NavigationLink( "Sorting", icon: .settings.sorting, destination: .settings(.sorting) ) .gradientTint(.themedColorfulAccent(5)) if AccountsTracker.main.highestLevelAccountType >= .moderator { NavigationLink( "Moderation", icon: .lemmy.moderation, destination: .settings(.moderation) ) .gradientTint(.themedModeration) .symbolVariant(.fill) } } Section { appIconSettingsLink NavigationLink(.settings(.theme)) { ThemeLabel(title: "Theme", palette: colorPalette) } .labelStyle(.automatic) } Section { NavigationLink("Posts", icon: .lemmy.post, destination: .settings(.post)) .gradientTint(.themedPostAccent) NavigationLink("Comments", icon: .lemmy.comment, destination: .settings(.comment)) .gradientTint(.themedCommentAccent) NavigationLink("Inbox", icon: .lemmy.inbox, destination: .settings(.inbox)) .gradientTint(.themedInbox) NavigationLink("Communities", icon: .lemmy.community, destination: .settings(.community)) .gradientTint(.themedCommunityAccent) NavigationLink("Tab Bar", icon: .settings.tabBar, destination: .settings(.tabBar)) .gradientTint(.themedColorfulAccent(5)) } Section { NavigationLink("About Mlem", icon: .general.info, destination: .settings(.about)) .gradientTint(.themedColorfulAccent(2)) NavigationLink("Advanced", icon: .settings.advanced, destination: .settings(.advanced)) .gradientTint(.themedNeutralAccent) } } .labelStyle(.squircle) .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) } @ViewBuilder var accountSettingsLink: some View { NavigationLink(.settings(.account)) { let account = appState.firstSession HStack(spacing: 23) { CircleCroppedImageView(account.account, frame: 54) .padding(.vertical, -6) .padding(.leading, 3) VStack(alignment: .leading, spacing: 3) { Text(account is UserSession ? account.account.nickname : "Guest") .font(.title2) Text(accountSettingsLinkSubtitle) .foregroundStyle(.secondary) .font(.caption) } Spacer() } } } @ViewBuilder var appIconSettingsLink: some View { NavigationLink(.settings(.icon)) { Label { Text("App Icon") } icon: { let icon = AlternateIcon(id: currentIcon, name: String("")) AlternateIconLabel(icon: icon, selected: true).getImage() .resizable() .scaledToFit() .frame(width: Constants.main.settingsIconSize, height: Constants.main.settingsIconSize) .cornerRadius(Constants.main.smallItemCornerRadius) } } .onChange(of: UIApplication.shared.alternateIconName) { currentIcon = UIApplication.shared.alternateIconName } } var accountSettingsLinkSubtitle: String { "@\(appState.firstSession.account.host)" } @ViewBuilder var accountListLink: some View { NavigationLink(.settings(.accounts)) { HStack(spacing: 10) { AvatarStackView( urls: accounts.prefix(4).map(\.avatar), fallback: .personAvatar, height: 28, spacing: accounts.count <= 3 ? 18 : 14, outlineWidth: 0.7, showPlusIcon: accounts.count == 1 ) .frame(height: 28) .frame(minWidth: 80) .padding(.leading, -10) Text("Accounts") Spacer() Text(String(accounts.count)) .foregroundStyle(.secondary) } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SharingLinksSettingsView.swift ================================================ // // SharingLinksSettingsView.swift // Mlem // // Created by Sjmarf on 2025-03-09. // import ComponentViews import Icons import SwiftUI import Theming struct SharingLinksSettingsView: View { @Setting(\.links_shareMode) var linkSharingMode @Setting(\.a11y_showSettingsIcons) var showSettingsIcons var body: some View { Form { SettingsHeaderView( title: "Share Links", // swiftlint:disable:next line_length description: "In the Fediverse, many different links can point to the same piece of content. Choose which site to use when sharing content.", icon: .general.share ) .gradientTint(.themedColorfulAccent(3)) pickerItemView( mode: .myInstance, title: "My Instance", description: "Share links using the instance you are currently connected to.", icon: .lemmy.instance ) pickerItemView( mode: .originalInstance, title: "Original Instance", description: "Share links using the instance that the content originated from.", icon: .settings.author ) pickerItemView( mode: .lemmyverse, title: "Universal Link", description: "Share links using \("https://lemmyverse.link"). When someone opens the link, they can choose which instance to use.", icon: .general.website ) pickerItemView( mode: .askEveryTime, title: "Ask Every Time", description: "Every time I share a link, show a popup asking which instance to use.", icon: .settings.ask ) } .contentMargins(.top, 16) .withConditionalLabelStyle() .animation(.easeInOut(duration: 0.1), value: linkSharingMode) .hiddenNavigationTitle("Share Links") } @ViewBuilder func pickerItemView( mode: LinkSharingMode, title: LocalizedStringResource, description: LocalizedStringResource, icon: Icon ) -> some View { HStack(alignment: .top) { if showSettingsIcons { Image(icon: icon) .foregroundStyle(.themedAccent) .frame(width: 30) .padding(.top, 2) } VStack(alignment: .leading) { Text(title) Text(description) .font(.footnote) .foregroundStyle(.themedSecondary) } .frame(maxWidth: .infinity, alignment: .leading) Checkbox(isOn: linkSharingMode == mode) } .contentShape(.rect) .onTapGesture { linkSharingMode = mode } .listRowInsets(.init(top: 10, leading: showSettingsIcons ? 10 : 16, bottom: 10, trailing: 16)) } } enum LinkSharingMode: String, Codable, CaseIterable { case myInstance, originalInstance, lemmyverse, askEveryTime } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SortingSettingsView.swift ================================================ // // SortingSettingsView.swift // Mlem // // Created by Sjmarf on 10/08/2024. // import MlemMiddleware import SwiftUI import Theming struct SortingSettingsView: View { @Setting(\.post_defaultSort) var legacyDefaultPostSort @Setting(\.post_fallbackSort) var legacyFallbackPostSort @Setting(\.comment_defaultSort) var legacyDefaultCommentSort var defaultPostSort: PostSortType { get { .init(legacyDefaultPostSort) } nonmutating set { legacyDefaultPostSort = newValue.v3ApiType ?? .hot } } var fallbackPostSort: PostSortType { get { .init(legacyFallbackPostSort) } nonmutating set { legacyFallbackPostSort = newValue.v3ApiType ?? .hot } } var defaultCommentSort: CommentSortType { get { .init(legacyDefaultCommentSort) } nonmutating set { legacyDefaultCommentSort = newValue.v3CommentApiType } } var body: some View { Form { SettingsHeaderView( title: "Sorting", description: "Choose the default sort mode for posts and comments.", icon: .settings.sorting ) .gradientTint(.themedColorfulAccent(5)) Section { HStack { Text("Posts") Spacer() FeedSortPicker(sort: .init( get: { defaultPostSort }, set: { defaultPostSort = $0 } )) .foregroundStyle(.themedAccent) .frame(minHeight: 50) .buttonStyle(.bordered) } if !defaultPostSort.supportedByAllSoftwares { HStack { Text("Fallback") Spacer() FeedSortPicker(sort: .init( get: { fallbackPostSort }, set: { fallbackPostSort = $0 } )) .foregroundStyle(.themedAccent) .frame(minHeight: 50) .buttonStyle(.bordered) } } } footer: { if !defaultPostSort.supportedByAllSoftwares { // swiftlint:disable:next line_length Text("The \"\(defaultPostSort.label())\" sort mode is only available on some instances. On unsupported instances, the \"Fallback\" sort mode will be used instead.") } } Section { HStack { Text("Comments") Spacer() Menu(defaultCommentSort.label(timeRangeFormat: .topOnly), icon: defaultCommentSort.icon) { Picker("Sort", selection: .init(get: { defaultCommentSort }, set: { defaultCommentSort = $0 })) { ForEach(CommentSortType.legacyCases, id: \.self) { item in Label(item.label(timeRangeFormat: .topOnly), icon: item.icon) } } } .foregroundStyle(.themedAccent) .frame(minHeight: 50) .buttonStyle(.bordered) } } } .contentMargins(.top, 16) .hiddenNavigationTitle("Sorting") } } private extension PostSortType { var supportedByAllSoftwares: Bool { // This assumes that sort types won't be *removed* once they are added. // This is fine for now but may need updating in future SiteSoftwareType.allCases.allSatisfy { softwareType in SiteSoftware(type: softwareType, version: softwareType.minimumSupportedVersion) .supports(.postSortType(self)) } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SubscriptionListSettingsView.swift ================================================ // // SubscriptionListSettingsView.swift // Mlem // // Created by Sjmarf on 23/06/2024. // import SwiftUI import Theming struct SubscriptionListSettingsView: View { @Setting(\.subscriptions_sort) private var sort @Setting(\.subscriptions_instanceLocation) var instanceLocation @Setting(\.navigation_sidebarVisibleByDefault) var sidebarVisibleByDefault var body: some View { Form { SettingsHeaderView( title: "Subscription List", description: "Customize how your subscription list is sorted.", icon: .lemmy.subscriptionList ) .gradientTint(.themedColorfulAccent(4)) Section("Sort by...") { Picker("Sort by...", selection: $sort) { ForEach(SubscriptionListSort.allCases, id: \.self) { item in Label(String(localized: item.label), icon: item.icon) } } .labelsHidden() .pickerStyle(.inline) } if sort == .alphabetical { Section("Row Size") { Picker("Row Size", icon: .settings.qualifiedLabel, selection: $instanceLocation) { Label("Large", icon: .settings.postSizeLarge).tag(InstanceLocation.bottom) Label("Compact", icon: .settings.postSizeCompact).tag(InstanceLocation.trailing) } .labelsHidden() .pickerStyle(.inline) } } if UIDevice.isPad { Toggle("Show Sidebar on App Launch", icon: .settings.sidebar, isOn: $sidebarVisibleByDefault) } } .animation(.easeOut(duration: 0.1), value: sort) .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Subscription List") } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/SwipeActionEditorView.swift ================================================ // // NewSwipeActionEditorView.swift // Mlem // // Created by Sjmarf on 2026-03-04. // import Actions import SwiftUI struct SwipeActionEditorView: View { @Binding var configuration: ActionSeedSwipeConfiguration let onReset: () -> Void let onApplyToAll: (() -> Void)? let allActions: [ActionSeed] @State var showingApplyToAllConfirmation: Bool = false var body: some View { Form { ActionListView( title: "Left", actions: Binding(get: { configuration.leading }, set: { configuration.leading = $0 }), allActions: allActions ) ActionListView( title: "Right", actions: Binding(get: { configuration.trailing }, set: { configuration.trailing = $0 }), allActions: allActions ) Button("Reset", action: onReset) if let onApplyToAll { Button("Apply to All") { showingApplyToAllConfirmation = true } .confirmationDialog( "Really apply this configuration to all other content types?", isPresented: $showingApplyToAllConfirmation, titleVisibility: .visible ) { Button("Yes", action: onApplyToAll) } } } .environment(\.editMode, .constant(.active)) .navigationTitle("Swipe Actions") } } extension SwipeActionEditorView { init( _ keyPath: ReferenceWritableKeyPath, onApplyToAll onApplyToAllConfiguration: ((Configuration) -> Void)? = nil ) { let onApplyToAll: (() -> Void)? if let onApplyToAllConfiguration { onApplyToAll = { onApplyToAllConfiguration(Settings.get(keyPath)) } } else { onApplyToAll = nil } self.init( configuration: .init( get: { Settings.get(keyPath).swipes }, set: { newValue in Settings.mutate(keyPath) { $0.swipes = newValue } } ), onReset: { var configuration = Settings.get(keyPath) configuration.swipes = Configuration.defaultSwipes Settings.set(keyPath, to: configuration) }, onApplyToAll: onApplyToAll, allActions: Configuration.availableActions.all ) } } private struct ActionListView: View { let title: LocalizedStringResource @Binding var actions: [ActionSeed] var allActions: [ActionSeed] var body: some View { Section(title) { ForEach(actions, id: \.hashValue) { action in HStack { Label(action.label.title, icon: action.label.icon) .symbolVariant(.fill) .gradientTint(action.label.color) Spacer() } .tag(action) } .onMove { old, new in actions.move(fromOffsets: old, toOffset: new) } .onDelete { offsets in actions.remove(atOffsets: offsets) } .labelStyle(.squircle) addButtonView .disabled(actions.count >= 3) } } @ViewBuilder var addButtonView: some View { Menu("Add", icon: .general.add) { ForEach(allActions, id: \.self) { action in Button(action.label.title, icon: action.label.icon) { actions.append(action) } .disabled(actions.contains(action)) } } } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/TabBarSettingsView.swift ================================================ // // TabBarSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-05. // import Icons import SwiftUI import Theming struct TabBarSettingsView: View { @Environment(AppState.self) var appState @Setting(\.tab_profile_labelType) var profileTabLabel: ProfileTabLabel @Setting(\.tab_profile_showAvatar) var showUserAvatar: Bool @Setting(\.tab_gestures_longPressAction) var longPressAction: TabBarLongPressAction @Setting(\.tab_inbox_badgeIncludedTypes) var tabInboxBadgeIncludedTypes var account: any Account { appState.firstAccount } var body: some View { Form { SettingsHeaderView( title: "Tab Bar", description: "Customize the appearance of the tab bar.", icon: .settings.tabBar ) .gradientTint(.themedColorfulAccent(5)) Section("Profile Tab Label") { Picker("Profile Tab Label", selection: $profileTabLabel) { profileTabLabelItem("Name", value: account.nickname, icon: .lemmy.alphabeticalSort) .tag(ProfileTabLabel.nickname) profileTabLabelItem("Instance", value: account.host, icon: .settings.qualifiedLabel) .tag(ProfileTabLabel.instance) profileTabLabelItem("Anonymous", value: .init(localized: "Profile"), icon: .general.circle) .tag(ProfileTabLabel.anonymous) } .labelsHidden() .pickerStyle(.inline) } Section { Toggle("Show Avatar", icon: .lemmy.person, isOn: $showUserAvatar) .symbolVariant(.circle) } if !UIDevice.isIos26 { Section { NavigationLink( "Long Press Action", value: .init(localized: longPressAction.label), fallbackValue: "", icon: .settings.longPress, destination: .settings(.longPressAction) ) } } Section { NavigationLink( "Notification Badge", value: tabInboxBadgeIncludedTypes.label(accountType: AccountsTracker.main.highestLevelAccountType), fallbackValue: .init(localized: "Some"), icon: .settings.unreadBadge, destination: .settings(.inboxBadge) ) } } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Tab Bar") } @ViewBuilder func profileTabLabelItem(_ title: LocalizedStringKey, value: String, icon: Icon) -> some View { Label { VStack(alignment: .leading) { Text(title) Text(value) .font(.footnote) .foregroundStyle(.themedSecondary) } } icon: { Image(icon: icon) } } } enum ProfileTabLabel: String, Codable, CaseIterable { case nickname, instance, anonymous } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/TappableLinksSettingsView.swift ================================================ // // TappableLinksSettingsView.swift // Mlem // // Created by Sjmarf on 2025-01-28. // import SwiftUI struct TappableLinksSettingsView: View { @Setting(\.links_displayMode) var tappableLinksDisplayMode var body: some View { Form { Section { Toggle( "Tappable Links", icon: .settings.tappableLinks, isOn: Binding( get: { tappableLinksDisplayMode != .disabled }, set: { newValue in withAnimation(.easeOut(duration: 0.1)) { tappableLinksDisplayMode = newValue ? .large : .disabled } } ) ) } if tappableLinksDisplayMode != .disabled { Section("Show Full URL") { Picker("Show Full URL", icon: .markdown.inlineCode, selection: $tappableLinksDisplayMode) { Text("Automatic").tag(TappableLinksDisplayMode.contextual) Text("Always").tag(TappableLinksDisplayMode.large) Text("Never").tag(TappableLinksDisplayMode.compact) } .pickerStyle(.inline) .labelsHidden() } footer: { if tappableLinksDisplayMode != .disabled { Text("If set to \"Automatic\", the full URL will be hidden in compact comments.") } } } } .navigationTitle("Tappable Links") .withConditionalLabelStyle() } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ThemeSettingsView.swift ================================================ // // ThemeSettingsView.swift // Mlem // // Created by Sjmarf on 11/05/2024. // import SwiftUI struct ThemeSettingsView: View { @Setting(\.appearance_interfaceStyle) var interfaceStyle @Setting(\.appearance_palette) var colorPalette // convenience var supportedModes: UIUserInterfaceStyle { colorPalette.supportedModes } var body: some View { Form { Section { // When a single-mode theme is selected, the picker will _display_ that mode as selected but not actually change the settings value // so that it reverts the the actual settings value when a multi-mode theme is selected Picker("Style", selection: supportedModes == .unspecified ? $interfaceStyle : .constant(supportedModes)) { ForEach(UIUserInterfaceStyle.optionCases, id: \.self) { style in interfaceStyleLabel(for: style) } } .labelsHidden() .pickerStyle(.inline) } footer: { if supportedModes != .unspecified { Text("The \(colorPalette.label) theme only supports \(supportedModes.label.lowercased()) mode.") } } Picker("Theme", selection: $colorPalette) { ForEach(PaletteOption.allCases, id: \.rawValue) { item in ThemeLabel(palette: item) .tag(item) } .labelStyle(.titleAndIcon) } .labelsHidden() .pickerStyle(.inline) } .withConditionalLabelStyle() .navigationTitle("Theme") } @ViewBuilder func interfaceStyleLabel(for style: UIUserInterfaceStyle) -> some View { Label(style.label, icon: style.icon) .foregroundStyle( supportedModes == .unspecified || supportedModes == style ? .themedPrimary : .themedSecondary ) } } ================================================ FILE: Mlem/App/Views/Root/Tabs/Settings/ZoomSliderSettingsView.swift ================================================ // // ZoomSliderSettingsView.swift // Mlem // // Created by Eric Andrews on 2025-02-02. // import SwiftUI enum AnimationPhase: CaseIterable { case slideUp, slideDown, hide, show var circleOffset: CGFloat { switch self { case .slideUp: -50 case .slideDown: 50 case .hide: 50 case .show: 50 } } var circleOpacity: CGFloat { switch self { case .slideUp: 1 case .slideDown: 1 case .hide: 0 case .show: 1 } } var imageScale: CGFloat { switch self { case .slideUp: 2.0 case .slideDown: 0.8 case .hide: 0.8 case .show: 0.8 } } var imageSize: CGFloat { switch self { case .slideUp: 400 case .slideDown: 150 case .hide: 150 case .show: 150 } } } struct ZoomSliderSettingsView: View { @Setting(\.a11y_zoomSliderLocation) var zoomSliderLocation var body: some View { Form { SettingsHeaderView( title: "Slide to Zoom", description: "Zoom the image viewer with a slide gesture on the selected side." ) { ZoomSliderAnimation() } Picker("Location", selection: $zoomSliderLocation) { ForEach(ZoomSliderLocation.allCases, id: \.self) { location in Label(location.label.key, icon: location.icon) .tag(location) } } .labelsHidden() .pickerStyle(.inline) } .withConditionalLabelStyle() .contentMargins(.top, 16) .hiddenNavigationTitle("Slide to Zoom") } } struct ZoomSliderAnimation: View { var body: some View { RoundedRectangle(cornerRadius: 10) .fill(.black) .frame(width: 72, height: 152) .phaseAnimator(AnimationPhase.allCases) { content, phase in content .overlay { Image(systemName: "bird.fill") .resizable() .scaledToFit() .scaleEffect(phase.imageScale) .foregroundStyle(.white.opacity(0.8)) } .clipped() .overlay(alignment: .leading) { Circle() .frame(width: 10, height: 10) .foregroundStyle(.themedAccent) .opacity(phase.circleOpacity) .offset(y: phase.circleOffset) .padding(.leading, 4) } .clipShape(RoundedRectangle(cornerRadius: 10)) } animation: { phase in switch phase { case .hide: .easeOut(duration: 1.0) case .show: .easeOut(duration: 0.1) default: .easeInOut(duration: 0.75) } } .overlay { RoundedRectangle(cornerRadius: 10) .strokeBorder(.themedNeutralAccent, lineWidth: 2) } } } ================================================ FILE: Mlem/App/Views/Root/TransitionView.swift ================================================ // // AccountTransitionView.swift // Mlem // // Created by Eric Andrews on 2023-10-02. // import Foundation import SwiftUI struct TransitionView: View { let account: any Account @State var accountNameOpacity: CGFloat = .zero var body: some View { let text = text() let lines = lines(string: text) VStack(alignment: .center, spacing: 24) { Text(lines[0]) if lines.count == 2 { Text(lines[1]) .opacity(accountNameOpacity) } } .accessibilityElement(children: .ignore) .accessibilityLabel(text.replacingOccurrences(of: "%@", with: account.nickname)) .onAppear { withAnimation(.easeIn(duration: 0.5)) { accountNameOpacity = 1.0 } } .font(.largeTitle) .bold() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 50) } func text() -> String { let resource: LocalizedStringResource if account is UserAccount { resource = .init("Welcome %@", comment: "Example: \"Welcome John\"") } else { resource = .init("Welcome to %@", comment: "Example: \"Welcome to lemmy.world\"") } return .init(localized: resource) } // Return type will either be of length 1 or 2 func lines(string: String) -> [String] { if string.hasSuffix(" %@") { return [String(string.dropLast(3)), account.nickname] } if string.hasPrefix("%@ ") { return [account.nickname, String(string.dropFirst(3))] } return [string.replacingOccurrences(of: "%@", with: account.nickname)] } } ================================================ FILE: Mlem/App/Views/Shared/AccountPickerMenu.swift ================================================ // // AccountPickerMenu.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import SwiftUI struct AccountPickerMenu: View { var accountsTracker: AccountsTracker { .main } @Binding var account: UserAccount let content: Content init(account: Binding, @ViewBuilder content: () -> Content) { self._account = account self.content = content() } var body: some View { Menu { Picker("Switch Account", selection: $account) { ForEach(accountsTracker.userAccounts, id: \.actorId) { account in Button {} label: { Label(account) Text(verbatim: "@\(account.host)") } .tag(account) } } .pickerStyle(.inline) } label: { // This `Button` wrapper is necessary, otherwise the `Picker` won't work. Button(action: {}, label: { content }) } .buttonStyle(.plain) } } ================================================ FILE: Mlem/App/Views/Shared/Accounts/AccountListView+Logic.swift ================================================ // // AccountListView+Logic.swift // Mlem // // Created by Sjmarf on 22/12/2023. // import MlemMiddleware import SwiftUI extension AccountListView { var accounts: [any Account] { let accountSort = accountsTracker.userAccounts.count == 2 ? .custom : accountSort switch accountSort { case .custom: return accountsTracker.userAccounts case .name: return accountsTracker.userAccounts.sorted { $0.nicknameSortKey < $1.nicknameSortKey } case .instance: return accountsTracker.userAccounts.sorted { $0.instanceSortKey < $1.instanceSortKey } case .mostRecent: return accountsTracker.userAccounts.sorted { left, right in if appState.firstSession.actorId == left.actorId { return true } else if appState.firstSession.actorId == right.actorId { return false } return left.activityState.lastUsed ?? .distantPast > right.activityState.lastUsed ?? .distantPast } } } func getNameCategory(account: any Account) -> String { guard let first = account.nickname.first else { return "Unknown" } if first.isLetter { return String(first.lowercased()) } return "*" } var accountGroups: [AccountGroup] { switch accountSort { case .custom: return [.init(header: "Custom", accounts: accountsTracker.userAccounts)] case .name: return Dictionary( grouping: accountsTracker.userAccounts, by: { getNameCategory(account: $0) } ).map { AccountGroup(header: $0, accounts: $1.sorted { $0.nicknameSortKey < $1.nicknameSortKey }) } .sorted { $0.header < $1.header } case .instance: let dict = Dictionary( grouping: accountsTracker.userAccounts, by: \.host ) let uniqueInstances = dict.filter { $1.count == 1 }.values.map { $0.first! } var array = dict .filter { $1.count > 1 } .map { AccountGroup(header: $0, accounts: $1.sorted { $0.nicknameSortKey < $1.nicknameSortKey }) } .sorted { $0.header < $1.header } array.append( AccountGroup( header: "Other", accounts: uniqueInstances.sorted { $0.instanceSortKey < $1.instanceSortKey } ) ) return array case .mostRecent: var today = [any Account]() var last30Days = [any Account]() var older = [any Account]() for account in accountsTracker.userAccounts { if account.actorId == appState.firstSession.actorId { continue } var dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: .now) dateComponents.hour = 0 dateComponents.minute = 0 dateComponents.second = 0 let todayDate = Calendar.current.date(from: dateComponents) ?? .distantFuture if let date = account.activityState.lastUsed { if date > todayDate { today.append(account) } else if date.timeIntervalSinceNow <= 60 * 60 * 24 * 7 { last30Days.append(account) } else { older.append(account) } } else { older.append(account) } } var groups = [AccountGroup]() today.sort { $0.activityState.lastUsed ?? .distantPast > $1.activityState.lastUsed ?? .distantPast } today.prepend(appState.firstSession.account) if !today.isEmpty { groups.append( AccountGroup( header: "Today", accounts: today ) ) } if !last30Days.isEmpty { groups.append( AccountGroup( header: "Last \(30) days", accounts: last30Days.sorted { $0.activityState.lastUsed ?? .distantPast > $1.activityState.lastUsed ?? .distantPast } ) ) } if !older.isEmpty { groups.append( AccountGroup( header: "Older", accounts: older.sorted { $0.activityState.lastUsed ?? .distantPast > $1.activityState.lastUsed ?? .distantPast } ) ) } return groups } } func reorderAccount(fromOffsets: IndexSet, toOffset: Int) { accountsTracker.userAccounts.move(fromOffsets: fromOffsets, toOffset: toOffset) accountsTracker.saveAccounts(ofType: .user) } func listRowComplications(withInstance: Bool) -> Set { var complications: Set = [.unreadCount, .isActive] if withInstance { complications.insert(.instance) } switch preferredListRowComplication { case .lastUsed: complications.insert(.lastUsed) case .responseTime: complications.insert(.responseTime) } return complications } func fetchUnreadCounts() { for account in accountsTracker.allAccounts { Task { let startTime = Date.now let unreadCount = try? await account.api.getUnreadCount(alwaysMakeCalls: true) self.unreadCountResponses[account.actorId] = .init( unreadCount: unreadCount, responseTime: Date.now.timeIntervalSince(startTime) ) } } } } ================================================ FILE: Mlem/App/Views/Shared/Accounts/AccountListView.swift ================================================ // // AccountListView.swift // Mlem // // Created by Sjmarf on 22/12/2023. // import MlemMiddleware import SwiftUI import Icons /// This view is a component used as a child of ``QuickSwitcherView`` and ``AccountListSettingsView``. struct AccountListView: View { @Setting(\.accounts_sort) var accountSort @Setting(\.accounts_grouped) var groupAccountSort @Setting(\.accounts_preferredListRowComplication) var preferredListRowComplication @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss var accountsTracker: AccountsTracker { .main } @State var isSwitching: Bool = false @State private var isShowingAddAccountDialogue: Bool = false @State var isFetchingUnreadCounts: Bool = false @State var unreadCountResponses: [ActorIdentifier: UnreadCountResponse] = [:] struct UnreadCountResponse { let unreadCount: UnreadCount? let responseTime: TimeInterval } struct AccountGroup { let header: String let accounts: [any Account] init(header: LocalizedStringResource, accounts: [any Account]) { self.header = .init(localized: header) self.accounts = accounts } @_disfavoredOverload init(header: some StringProtocol, accounts: [any Account]) { self.header = String(header) self.accounts = accounts } } let isQuickSwitcher: Bool init(isQuickSwitcher: Bool = false) { self.isQuickSwitcher = isQuickSwitcher } var shouldAllowReordering: Bool { (accountSort == .custom || accountsTracker.userAccounts.count == 2) && !isQuickSwitcher } var body: some View { Group { if !isSwitching { if accountsTracker.userAccounts.count > 3, groupAccountSort { groupedUserAccountList } else if accounts.isEmpty { Text("You don't have any accounts.") .foregroundStyle(.themedSecondary) } else { Section { ForEach(accounts, id: \.actorId) { account in accountListRow(account: account) } .onMove(perform: shouldAllowReordering ? reorderAccount : nil) } header: { topHeader() } } if let account = (appState.firstSession as? GuestSession)?.account, !account.isSaved { Section { AccountListRow(account: account, isSwitching: $isSwitching) } } Section { ForEach(accountsTracker.guestAccounts, id: \.actorId) { account in accountListRow(account: account) } } addAccountButton } } .onAppear { if !isFetchingUnreadCounts { isFetchingUnreadCounts = true fetchUnreadCounts() } } } @ViewBuilder var groupedUserAccountList: some View { ForEach(Array(accountGroups.enumerated()), id: \.offset) { offset, group in Section { ForEach(group.accounts, id: \.actorId) { account in accountListRow( account: account, withInstanceComplication: accountSort != .instance || group.header == "Other" ) } } header: { if offset == 0 { topHeader(text: group.header) } else { Text(group.header) } } } } @ViewBuilder var addAccountButton: some View { Section { Button { isShowingAddAccountDialogue = true } label: { Label("Add Account", icon: .general.add) .labelStyle(.titleAndIcon) } .confirmationDialog("Choose Account Type", isPresented: $isShowingAddAccountDialogue) { Button("Log In") { navigation.openSheet(.logIn()) } Button("Sign Up") { navigation.openSheet(.signUp()) } Button("Add Guest") { navigation.openSheet(.instancePicker(callback: { instance in if let url = URL(string: "https://\(instance.host)") { if let guest = try? GuestAccount.getGuestAccount(url: url) { if !guest.isSaved { AccountsTracker.main.addAccount(account: guest) } AppState.main.changeAccount(to: guest) if navigation.isInsideSheet { dismiss() } } } })) } } } } @ViewBuilder func accountListRow(account: any Account, withInstanceComplication: Bool = true) -> some View { let responseData = unreadCountResponses[account.actorId] AccountListRow( account: account, unreadCount: responseData?.unreadCount?.badgeLabel, responseTime: responseData?.responseTime, complications: listRowComplications(withInstance: withInstanceComplication), isSwitching: $isSwitching ) } @ViewBuilder func topHeader(text: String? = nil) -> some View { HStack { if let text { Text(text) } if !isQuickSwitcher, accountsTracker.userAccounts.count > 2 { Spacer() sortModeMenu() } } } @ViewBuilder func sortModeMenu() -> some View { Menu { Picker("Sort", selection: $accountSort) { ForEach(AccountSortMode.allCases, id: \.self) { sortMode in Label(String(localized: sortMode.label), systemImage: sortMode.systemImage).tag(sortMode) } } .onChange(of: accountSort) { if accountSort == .custom { groupAccountSort = false } } if accountsTracker.userAccounts.count > 3 { Divider() Toggle("Grouped", icon: .lemmy.groupAccountSort, isOn: $groupAccountSort) .disabled(accountSort == .custom) } } label: { HStack(alignment: .center, spacing: 2) { Text("Sort by: \(accountSort.label)") .font(.caption) .textCase(nil) Image(systemName: "chevron.down") .imageScale(.small) } .fontWeight(.semibold) .foregroundStyle(.themedAccent) } .textCase(nil) .labelStyle(.titleAndIcon) // Override `.conditional` label style from parent view } } enum PreferredAccountListRowComplication: String, Codable { case lastUsed, responseTime } ================================================ FILE: Mlem/App/Views/Shared/Accounts/QuickSwitcherView.swift ================================================ // // QuickSwitcherView.swift // Mlem // // Created by Eric Andrews on 2024-02-21. // import Foundation import SwiftUI struct QuickSwitcherView: View { @Environment(\.scenePhase) var scenePhase @Environment(NavigationLayer.self) var navigation var body: some View { Form { AccountListView(isQuickSwitcher: true) } .onChange(of: scenePhase) { // when app moves into background, hide the account switcher. This prevents the app from reopening with the switcher presented. if scenePhase != .active, navigation.isTopSheet { navigation.dismissSheet() } } .presentationBackground(.themedGroupedBackground) } } ================================================ FILE: Mlem/App/Views/Shared/Avatar/AvatarBannerView.swift ================================================ // // AvatarBannerView.swift // Mlem // // Created by Sjmarf on 09/05/2024. // import MlemMiddleware import NukeUI import Rest import SwiftUI struct AvatarBannerView: View { @Setting(\.media_animatedAvatars) var animatedAvatars var model: (any ProfileProviding)? var fallback: MediaView.Fallback var showEmptyBanner: Bool = false var showBanner: Bool = true var showAvatar: Bool = true init(_ model: T?, showEmptyBanner: Bool = false) { self.model = model self.fallback = T.avatarFallback self.showEmptyBanner = showEmptyBanner } init(_ model: any ProfileProviding, showEmptyBanner: Bool = false) { self.model = model self.fallback = Swift.type(of: model).avatarFallback self.showEmptyBanner = showEmptyBanner } init(_ model: (any ProfileProviding)?, fallback: MediaView.Fallback, showEmptyBanner: Bool = false) { self.model = model self.fallback = fallback self.showEmptyBanner = showEmptyBanner } static let bannerHeight: CGFloat = 170 static let avatarOverdraw: CGFloat = 40 static let avatarSize: CGFloat = 108 static let avatarPadding: CGFloat = Constants.main.standardSpacing var body: some View { Group { if model?.banner != nil || showEmptyBanner, showBanner { ZStack(alignment: .bottom) { VStack { LazyImage(request: imageRequest) { state in VStack { if let image = state.image { image .resizable() .aspectRatio(contentMode: .fill) .clipped() } else { Color(uiColor: .secondarySystemFill) } } .frame(minWidth: 0, maxWidth: .infinity) .frame(height: AvatarBannerView.bannerHeight) .clipped() .clipShape(RoundedRectangle(cornerRadius: Constants.main.mediumItemCornerRadius)) .mask { ZStack(alignment: .bottom) { Color.black if showAvatar { Circle() .frame( width: AvatarBannerView.avatarSize + AvatarBannerView.avatarPadding * 2, height: AvatarBannerView.avatarSize + AvatarBannerView.avatarPadding * 2 ) .offset(y: AvatarBannerView.avatarOverdraw + AvatarBannerView.avatarPadding) .blendMode(.destinationOut) } } .compositingGroup() } } Spacer() } .overlay { if showAvatar { avatarView .frame(maxHeight: .infinity, alignment: .bottom) } } } .frame(height: AvatarBannerView.bannerHeight + (showAvatar ? AvatarBannerView.avatarOverdraw : 0)) } else { if showAvatar { avatarView .padding(.top) } } } } var imageRequest: ImageRequest? { if let url = model?.banner { .init(urlRequest: mlemUrlRequest(url: url)) } else { nil } } var avatarView: some View { CircleCroppedImageView( url: model?.avatar, frame: AvatarBannerView.avatarSize, fallback: fallback, enableAnimation: animatedAvatars != .never ) } } ================================================ FILE: Mlem/App/Views/Shared/Avatar/AvatarStackView.swift ================================================ // // AvatarStackView.swift // Mlem // // Created by Sjmarf on 08/05/2024. // import SwiftUI struct AvatarStackView: View { let urls: [URL?] let fallback: MediaView.Fallback let height: CGFloat let spacing: CGFloat let outlineWidth: CGFloat var showPlusIcon: Bool = false var body: some View { HStack(spacing: 0) { Spacer().aspectRatio(1 / 2, contentMode: .fit) HStack(spacing: spacing) { ForEach(showPlusIcon ? urls : urls.dropLast(), id: \.self) { url in avatarView(url: url) .frame(maxWidth: 0) .padding(outlineWidth) .mask { Rectangle() .subtracting(.circle.offset(x: spacing)) .aspectRatio(contentMode: .fill) } } if showPlusIcon { plusIconView .frame(maxWidth: 0) .padding(outlineWidth) } else { avatarView(url: urls.last ?? nil) .frame(maxWidth: 0) .padding(outlineWidth) } } Spacer().aspectRatio(1 / 2, contentMode: .fit) } } @ViewBuilder var plusIconView: some View { Image(systemName: "plus.circle.fill") .resizable() .aspectRatio(contentMode: .fill) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary, Color(uiColor: .tertiaryLabel)) } @ViewBuilder func avatarView(url: URL?) -> some View { CircleCroppedImageView( url: url, frame: height, fallback: fallback ) .aspectRatio(contentMode: .fill) } } #Preview { AvatarStackView( urls: .init(repeating: nil, count: 3), fallback: .personAvatar, height: 64, spacing: 48, outlineWidth: 1 ) .frame(height: 64) } ================================================ FILE: Mlem/App/Views/Shared/Avatar/AvatarView.swift ================================================ ================================================ FILE: Mlem/App/Views/Shared/Avatar/ProfileHeaderView.swift ================================================ // // ProfileHeaderView.swift // Mlem // // Created by Sjmarf on 30/05/2024. // import Icons import MlemMiddleware import SwiftUI struct ProfileHeaderView: View { @Environment(AppState.self) var appState var profilable: (any ProfileProviding)? var fallback: MediaView.Fallback var blockedOverride: Bool? init(_ profilable: T?, blockedOverride: Bool? = nil) { self.profilable = profilable self.fallback = T.avatarFallback self.blockedOverride = blockedOverride } init(_ profilable: any ProfileProviding, blockedOverride: Bool? = nil) { self.profilable = profilable self.fallback = Swift.type(of: profilable).avatarFallback self.blockedOverride = blockedOverride } init(_ profilable: (any ProfileProviding)?, fallback: MediaView.Fallback, blockedOverride: Bool? = nil) { self.profilable = profilable self.fallback = fallback self.blockedOverride = blockedOverride } var body: some View { VStack(spacing: Constants.main.standardSpacing) { AvatarBannerView(profilable, fallback: fallback) Button { (profilable as? any CommunityOrPerson)?.copyFullNameWithPrefix() } label: { VStack(spacing: Constants.main.halfSpacing) { HStack { Text(profilable?.displayName ?? "") .foregroundStyle(.themedPrimary) if blockedOverride ?? profilable?.blocked.realizedValue ?? false { Image(icon: .general.hide) .foregroundStyle(.themedSecondary) } } .font(.title) .fontWeight(.semibold) .lineLimit(1) .minimumScaleFactor(0.01) Text(subtitle) .font(.caption) .foregroundStyle(.themedSecondary) } } .buttonStyle(.plain) } .frame(maxWidth: .infinity) } var subtitle: String { if let instance = profilable as? Instance { return "\(instance.host) • \(instance.software.value?.label ?? "")" } return (profilable as? any CommunityOrPerson)?.fullNameWithPrefix ?? profilable?.host ?? "" } } ================================================ FILE: Mlem/App/Views/Shared/Avatar/SimpleAvatarView.swift ================================================ // // SimpleAvatarView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import Nuke import SwiftUI struct SimpleAvatarView: View { @Environment(Palette.self) var palette @State private var uiImage: UIImage @State private var loading: Bool let url: URL? let type: AvatarType init( url: URL?, type: AvatarType ) { self.url = url self.type = type self._uiImage = .init(wrappedValue: .init()) self._loading = .init(wrappedValue: url != nil) } var defaultImage: UIImage { .init(systemName: Icons.user)! .applyingSymbolConfiguration(.init( font: .systemFont(ofSize: 17), scale: .large ))! .withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal) } var body: some View { Group { if url == nil { Image(uiImage: defaultImage) } else { Image(uiImage: uiImage) .task(loadImage) } } } @Sendable func loadImage() async { guard let url else { return } do { let imageTask = ImagePipeline.shared.imageTask(with: url) let image = try await imageTask.image uiImage = image.circleMasked ?? image loading = false } catch { print(error) } } } ================================================ FILE: Mlem/App/Views/Shared/Bubble Picker/BubblePickerView.swift ================================================ // // BubblePickerView.swift // Mlem // // Created by Sjmarf on 18/09/2023. // import Dependencies import Haptics import SwiftUI enum DividerPlacement { case top, bottom } struct BubblePickerItemFrame: Hashable { let width: CGFloat let offset: CGFloat static var zero: Self { .init(width: 0, offset: 0) } } struct BubblePicker: View { @Environment(HapticManager.self) var hapticManager @Binding var selected: Value // currentTabIndex is used to drive the capsule animation; it is tracked separately from selected so that the capsule animations can be triggered independently of any animation (or lack thereof) that is desired on selected @State var currentTabIndex: Int @State var selectedTabFrame: BubblePickerItemFrame? let tabs: [Value] let dividers: Set let label: (Value) -> LocalizedStringResource let value: (Value) -> Int? let spaceName: String = UUID().uuidString let animation: Animation = .interactiveSpring(response: 0.2, dampingFraction: 0.8) init( _ tabs: [Value], selected: Binding, withDividers: Set = .init(), label: @escaping (Value) -> LocalizedStringResource, value: @escaping (Value) -> Int? = { _ in nil } ) { let initialIndex = tabs.firstIndex(of: selected.wrappedValue) self._selected = selected self._currentTabIndex = .init(wrappedValue: initialIndex ?? 0) self.tabs = tabs self.dividers = withDividers self.label = label self.value = value // gracefully handle cases where selected tab is not found if initialIndex == nil { Task { @MainActor in selected.wrappedValue = tabs[0] } } } var body: some View { VStack(spacing: 0) { if dividers.contains(.top) { Divider() } ScrollViewReader { scrollProxy in ScrollView(.horizontal) { buttonStack(scrollProxy: scrollProxy, isSelectionIndicator: false) .overlay { buttonStack(isSelectionIndicator: true) .background(.themedAccent) .allowsHitTesting(false) .mask(alignment: .leading) { if let selectedTabFrame { Capsule() .offset(x: selectedTabFrame.offset + Constants.main.standardSpacing) .frame(width: max(selectedTabFrame.width - Constants.main.doubleSpacing, 0), height: 30) .animation(animation, value: selectedTabFrame) } else { Color.clear } } } .coordinateSpace(name: spaceName) } .scrollIndicators(.hidden) .onChange(of: selected) { let newIndex = tabs.firstIndex(of: selected) ?? 0 currentTabIndex = newIndex withAnimation(animation) { scrollProxy.scrollTo(newIndex) } } .id(tabs.hashValue) } if dividers.contains(.bottom) { Divider() } } } /// Builds the HStack containing the actual buttons /// - Parameter scrollProxy: scrollProxy to handle scrolling horizontally to the selected view. If present, the stack will create buttons and apply a ChildSizeReader to them to populate the size information for the masking; otherwise the stack will use inert labels. @ViewBuilder func buttonStack( scrollProxy: ScrollViewProxy? = nil, isSelectionIndicator: Bool ) -> some View { // Use negative spacing as well as padding the HStack's children so that scrollTo leaves extra space around each tab HStack(spacing: -Constants.main.doubleSpacing) { ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in if let scrollProxy { ChildSizeReader( size: tab == selected ? Binding( get: { selectedTabFrame ?? .zero }, set: { selectedTabFrame = $0 } ) : nil, spaceName: spaceName ) { bubbleButton( index: index, tab: tab, scrollProxy: scrollProxy, isSelectionIndicator: isSelectionIndicator ) } .id("\(isSelectionIndicator)\(value(tab) ?? -1)") } else { bubbleButtonLabel(tab: tab, isSelectionIndicator: isSelectionIndicator) } } } } @ViewBuilder func bubbleButton( index: Int, tab: Value, scrollProxy: ScrollViewProxy, isSelectionIndicator: Bool ) -> some View { Button { selected = tab hapticManager.play(haptic: .gentleInfo, tier: .low) } label: { bubbleButtonLabel(tab: tab, isSelectionIndicator: isSelectionIndicator) } .buttonStyle(.empty) .id(index) } @ViewBuilder func bubbleButtonLabel( tab: Value, isSelectionIndicator: Bool ) -> some View { AnyView(HStack(spacing: 8) { let value = value(tab) Text(label(tab)) .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(isSelectionIndicator ? .themedContrastingLabel : .themedPrimary) if let value { Text(value.abbreviated) .monospacedDigit() .font(.subheadline) .fontWeight(.medium) .foregroundStyle(isSelectionIndicator ? .themedContrastingLabel : .themedSecondary) .opacity(isSelectionIndicator ? 0.8 : 1) } }) .padding(.horizontal, 22) .frame(minHeight: 50) .contentShape(.rect) } } // #Preview { // @State var selected: InstanceViewTab = .administration // return BubblePicker( // InstanceViewTab.allCases, // selected: $selected, // label: { $0.label }, // value: { item in // switch item { // case .about: // 0 // case .administration: // 5 // case .details: // 9_950_000 // case .uptime: // 10_000_000 // default: // nil // } // } // ) // } ================================================ FILE: Mlem/App/Views/Shared/Bubble Picker/ChildSizeReader.swift ================================================ // // ChildSizeReader.swift // Mlem // // Created by Eric Andrews on 2024-03-22. // // adapted from https://stackoverflow.com/questions/56573373/swiftui-get-size-of-child import Foundation import SwiftUI struct ChildSizeReader: View { init( size: Binding?, spaceName: String, @ViewBuilder content: @escaping () -> Content ) { self.selectedSize = size self.spaceName = spaceName self.content = content } var selectedSize: Binding? @State var size: BubblePickerItemFrame = .zero let spaceName: String let content: () -> Content var body: some View { ZStack { content() .background( GeometryReader { proxy in Color.clear .preference(key: SizePreferenceKey.self, value: .init( width: proxy.size.width, offset: proxy.frame(in: .named(spaceName)).minX )) } ) } .onPreferenceChange(SizePreferenceKey.self) { if size == .zero { size = $0 } } .onChange(of: onChangeHash, initial: true) { selectedSize?.wrappedValue = size } } var onChangeHash: Int { var hasher = Hasher() hasher.combine(selectedSize == nil) hasher.combine(size) return hasher.finalize() } } private struct SizePreferenceKey: PreferenceKey { typealias Value = BubblePickerItemFrame static var defaultValue: Value = .init(width: .zero, offset: .zero) static func reduce(value _: inout Value, nextValue: () -> Value) { _ = nextValue() } } ================================================ FILE: Mlem/App/Views/Shared/BypassProxyWarningSheet.swift ================================================ // // BypassProxyWarningSheet.swift // Mlem // // Created by Eric Andrews on 2024-09-13. // import Foundation import SwiftUI struct BypassProxyWarningSheet: View { @Setting(\.privacy_autoBypassImageProxy) var autoBypassImageProxy @Environment(\.dismiss) var dismiss let callback: () -> Void var body: some View { VStack(spacing: Constants.main.doubleSpacing) { WarningView( icon: .lemmy.imageProxy, text: "Bypass Image Proxy?", inList: false, overrideColor: .themedCaution ) // swiftlint:disable:next line_length Text("Some instances proxy images to protect your privacy. In certain cases, this causes image loading to fail. You can bypass the image proxy and load directly, but this will expose your IP address to the image host.") Button { callback() dismiss() } label: { Text("Load This Image Directly") .padding(.vertical, Constants.main.halfSpacing) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) VStack(spacing: Constants.main.halfSpacing) { Button { autoBypassImageProxy = true callback() dismiss() } label: { Text("Always Allow Direct Loading") .padding(.vertical, Constants.main.halfSpacing) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) Text("Mlem will always try to load from the proxy first.") .font(.footnote) .foregroundStyle(.secondary) } Button { dismiss() } label: { Text("Cancel") .padding(.vertical, Constants.main.halfSpacing) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } .padding(Constants.main.doubleSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/CommentBodyView.swift ================================================ // // CommentBodyView.swift // Mlem // // Created by Sjmarf on 09/08/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct CommentBodyView: View { @Environment(\.exposeRemovedContent) var exposeRemovedContent @Setting(\.comment_compact) var compactComments let comment: Comment var body: some View { Group { if comment.deleted { missingContentMessage("Comment was deleted") } else if comment.removed { if exposeRemovedContent { MarkdownWithLinkList(comment.content, configuration: .removedContent, showLinkCaptions: !compactComments) } else { missingContentMessage("Comment was removed") } } else { MarkdownWithLinkList(comment.content, showLinkCaptions: !compactComments) } } .fixedSize(horizontal: false, vertical: true) } @ViewBuilder func missingContentMessage(_ label: LocalizedStringResource) -> some View { Text(label) .italic() .foregroundStyle(.themedSecondary) } } ================================================ FILE: Mlem/App/Views/Shared/CommentView.swift ================================================ // // CommentView.swift // Mlem // // Created by Sjmarf on 25/06/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct CommentView: View { @Environment(AppState.self) var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) var navigation @Environment(\.communityContext) var communityContext: Community? @Environment(\.reportContext) private var reportContext: Report? @Environment(\.palette) private var palette @Setting(\.comment_compact) var compactComments @Setting(\.comment_showDownvotesCompact) var showDownvotesCompact @Setting(\.menus_modActionGrouping) var moderatorActionGrouping @Setting(\.interactionBar_comment) var commentInteractionBar @Setting(\.interactionBar_commentReport) var commentReportInteractionBar @Setting(\.interactionBar_alternateReportLayout) var alternateInteractionBarLayoutForReports private let indent: CGFloat = 10 let comment: Comment /// If the `CommentView` is rendered in an `ExpandedPostView`, this object can be used to access collapsed state etc. let treeNode: CommentTreeNode? let embeddedContent: EmbeddedContent let inFeed: Bool let highlight: Bool let depthOffset: Int var compactReadouts: [CommentBarConfiguration.ReadoutType] { var readouts: [CommentBarConfiguration.ReadoutType] = [.created] readouts.append(contentsOf: showDownvotesCompact ? [.upvote, .downvote, .comment] : [.score, .comment]) readouts.appendIfPresent(comment.saved.value ?? false ? .saved : nil) return readouts } init( comment: Comment, treeNode: CommentTreeNode? = nil, inFeed: Bool = false, // flag to suppress threading/collapsing behavior highlight: Bool = false, depthOffset: Int = 0, @ViewBuilder embeddedContent: () -> EmbeddedContent = { EmptyView() } ) { self.comment = comment self.treeNode = treeNode self.inFeed = inFeed self.highlight = highlight self.depthOffset = depthOffset self.embeddedContent = embeddedContent() } var depth: Int { inFeed ? 0 : comment.depth - depthOffset } var collapsed: Bool { treeNode?.collapsed ?? false } var compact: Bool { compactComments && reportContext == nil } @ViewBuilder var body: some View { VStack(spacing: Constants.main.standardSpacing) { if inFeed { feedHeader .padding(.trailing, Constants.main.standardSpacing) } HStack(spacing: 0) { CommentBarView(depth: comment.depth, inFeed: inFeed) VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: Constants.main.compactSpacing) { if !inFeed { authorAndMenu.padding(.top, Constants.main.standardSpacing) } if !collapsed { CommentBodyView(comment: comment) .padding(.trailing, 2) embeddedContent } if compact, !collapsed { InfoStackView( comment: comment, readouts: compactReadouts, coloredReadouts: .init(CommentBarConfiguration.ReadoutType.allCases) ) .layoutPriority(1) } } .padding(.horizontal, Constants.main.standardSpacing) .padding(.leading, 2) if !compact, !collapsed { InteractionBarView( appState: appState, navigation: navigation, comment: comment, configuration: interactionBarConfiguration, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) } } .padding(.bottom, collapsed || compact ? Constants.main.standardSpacing : 0) } } .background(highlight ? palette.accent.opacity(0.2) : .clear) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.interaction, .rect) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .environment(\.commentContext, comment) .environment(\.communityContext, comment.community.value) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } var feedHeader: some View { VStack(spacing: Constants.main.standardSpacing) { authorAndMenu ExpectedView(comment.post) { post in FooterLinkView(title: post.title, subtitle: nil) .frame(maxWidth: .infinity) } } .padding([.leading, .top], Constants.main.standardSpacing) } var authorAndMenu: some View { HStack(spacing: 0) { ExpectedView(comment.creator) { creator in FullyQualifiedLinkView(creator, labelStyle: .small) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } Spacer() Group { if collapsed { if let saved = comment.saved.value, let votes = comment.votes.value { Group { postTag(active: saved, icon: .lemmy.saved.representingState(active: true), color: .themedSave) + Text(verbatim: " ") + postTag(active: votes.myVote != .none, icon: .init(votes.iconName), color: votes.iconColor) } .lineLimit(1) .font(.caption) } Image(icon: .general.expand) .frame(height: 10) .imageScale(.small) } else { ellipsisMenus .frame(height: 10) } } .padding(.leading, Constants.main.standardSpacing) } } var ellipsisMenus: some View { HStack { if comment.shouldShowLoadingSymbol(for: commentInteractionBar) { ProgressView() } switch moderatorActionGrouping { case .separateMenu: if comment.canModerate { EllipsisMenu(icon: .lemmy.moderation, size: 24, comment: comment, type: [.moderator]) } EllipsisMenu(size: 24, comment: comment, type: [.basic]) case .divider: EllipsisMenu(size: 24, comment: comment) } } } var interactionBarConfiguration: CommentBarConfiguration { if reportContext != nil, alternateInteractionBarLayoutForReports { return commentReportInteractionBar } return commentInteractionBar } } struct CommentBarView: View { let depth: Int var inFeed: Bool = false var body: some View { Capsule() .fill(inFeed ? .themedTertiary : .themedCommentIndentColor(depth)) .frame(width: 3) .frame(maxHeight: .infinity) .padding(.leading, 8) .padding(.bottom, 8) .padding(.top, inFeed ? 0 : 8) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // CommentView(comment: Comment2.mock(.generic)) // } // #endif ================================================ FILE: Mlem/App/Views/Shared/ContentLoader.swift ================================================ // // ContentLoader.swift // Mlem // // Created by Eric Andrews on 2024-05-13. // import Foundation import MlemMiddleware import os import Semaphore import SwiftUI // TODO: Unified Community, Modlog remove this struct ContentLoader: View { @Environment(AppState.self) var appState: AppState @State var proxy: ContentLoaderProxy let resolveIfModelExternal: Bool @ViewBuilder var content: (_ proxy: ContentLoaderProxy) -> Content var upgradeOperation: ((_ model: Model, _ api: ApiClient) async throws -> Void)? init( model: Model, resolveIfModelExternal: Bool = true, @ViewBuilder content: @escaping (_ proxy: ContentLoaderProxy) -> Content, upgradeOperation: ((_ model: Model, _ api: ApiClient) async throws -> Void)? = nil ) { self._proxy = .init(wrappedValue: ContentLoaderProxy(model: model)) self.resolveIfModelExternal = resolveIfModelExternal self.upgradeOperation = upgradeOperation self.content = content } var body: some View { content(proxy) .animation(.easeOut(duration: 0.2), value: proxy.model.wrappedValue is Model.MinimumRenderable) .task { @MainActor in if resolveIfModelExternal || !proxy.model.isUpgraded, proxy.upgradeState == .idle { await proxy.upgradeModel( api: resolveIfModelExternal ? appState.firstApi : nil, upgradeOperation: upgradeOperation ) } } .onChange(of: appState.firstApi) { if proxy.upgradeState != .loading { // This code is needed here despite also being in `upgradeModel` to // ensure that `upgradeState` is changed fast enough proxy.upgradeState = .loading Task { @MainActor in await proxy.upgradeModel(api: appState.firstApi, upgradeOperation: upgradeOperation) } } } } } @Observable @MainActor class ContentLoaderProxy { private let log: Logger = .mlemLogger() fileprivate enum UpgradeState: String { case idle, loading, done, failed } fileprivate var model: Model fileprivate var upgradeState: UpgradeState = .idle var error: Error? private let loadingSemaphore: AsyncSemaphore = .init(value: 1) init(model: Model) { self.model = model } var entity: (Model.MinimumRenderable)? { model.wrappedValue as? Model.MinimumRenderable } var isLoading: Bool { upgradeState == .loading } @MainActor func upgradeModel( api: ApiClient? = nil, upgradeOperation: ((_ model: Model, _ api: ApiClient) async throws -> Void)? ) async { // critical function, only one thread allowed! await loadingSemaphore.wait() defer { loadingSemaphore.signal() } upgradeState = .loading do { do { guard let modelApi = (model.wrappedValue as? any ContentModel)?.api else { assertionFailure() return } if let upgradeOperation { try await upgradeOperation(model, api ?? modelApi) } else { try await model.upgrade(api: api ?? modelApi, upgradeOperation: nil) } } catch ApiClientError.noEntityFound { log.info("No entity found, upgrading from local") if !model.isUpgraded { try await model.upgradeFromLocal() } } upgradeState = .done } catch ApiClientError.cancelled { // if the task is cancelled, reset upgradeState--upgrade will be retried on next render upgradeState = .idle } catch { upgradeState = .failed handleError(error, silent: true) self.error = error } } } ================================================ FILE: Mlem/App/Views/Shared/CustomTabBarController.swift ================================================ // // CustomTabBarController.swift // Mlem // // Created by Sjmarf on 11/04/2024. // import Dependencies import Foundation import os import SwiftUI import Theming class CustomTabBarController: UITabBarController, UITabBarControllerDelegate { private let log: Logger = .mlemLogger() @Binding var selectedIndexBinding: Int let swipeGestureCallback: () -> Void let palette: Theming.Palette init( selectedIndex: Binding, swipeGestureCallback: @escaping () -> Void, palette: Theming.Palette, nibName: String? = nil, bundle: Bundle? = nil ) { self.swipeGestureCallback = swipeGestureCallback self._selectedIndexBinding = selectedIndex self.palette = palette super.init(nibName: nibName, bundle: bundle) } // This is used for Storyboard, and wont ever be called as long as we dont use Storyboard @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() delegate = self hidesBottomBarWhenPushed = true tabBar.tintColor = UIColor(palette.accent) let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureTriggered(_:))) tabBar.addGestureRecognizer(longPressRecognizer) let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipeGestureTriggered(_:))) swipeGestureRecognizer.direction = .up tabBar.addGestureRecognizer(swipeGestureRecognizer) } @objc func longPressGestureTriggered(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began else { return } guard let tabBar = recognizer.view as? UITabBar else { return } guard let tabBarItems = tabBar.items else { return } guard let viewControllers else { return } guard tabBarItems.count == viewControllers.count else { return } let loc = recognizer.location(in: tabBar) for (index, item) in tabBarItems.enumerated() { guard let view = item.value(forKey: "view") as? UIView else { continue } guard view.frame.contains(loc) else { continue } let item: CustomTabViewHostingController? if let navigationController = viewControllers[index] as? UINavigationController { item = navigationController.viewControllers.first as? CustomTabViewHostingController } else { item = viewControllers[index] as? CustomTabViewHostingController } item?.item.onLongPress?() break } } @objc func swipeGestureTriggered(_ recognizer: UISwipeGestureRecognizer) { if !UIDevice.isIos26 { swipeGestureCallback() } } func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { guard !TabReselectTracker.main.blockTabSwitch else { return false } TabReselectTracker.main.reset() // reset to prevent unconsumed actions from blocking the reselect flag if tabBarController.selectedViewController === viewController, let item = viewController as? CustomTabViewHostingController { log.debug("\(item.item.title) tab re-selected") TabReselectTracker.main.signal() return TabReselectTracker.main.consumers == 0 } selectedIndexBinding = viewControllers?.firstIndex(of: viewController) ?? 0 return true } } ================================================ FILE: Mlem/App/Views/Shared/CustomTabItem.swift ================================================ // // TabBarElement.swift // Mlem // // Created by Sjmarf on 11/04/2024. // import SwiftUI struct CustomTabItem { var content: AnyView var title: String var image: UIImage? var selectedImage: UIImage? var badge: String? var onLongPress: (() -> Void)? @_disfavoredOverload // This ensures that the other initialiser takes priority init( title: String, image: UIImage?, selectedImage: UIImage? = nil, badge: String? = nil, onLongPress: (() -> Void)? = nil, @ViewBuilder content: () -> some View ) { self.title = title self.image = image self.selectedImage = selectedImage ?? image self.onLongPress = onLongPress self.badge = badge self.content = AnyView(content()) } init( title: LocalizedStringResource, image: UIImage?, selectedImage: UIImage? = nil, badge: String? = nil, onLongPress: (() -> Void)? = nil, @ViewBuilder content: () -> some View ) { self.init( title: String(localized: title), image: image, selectedImage: selectedImage, badge: badge, onLongPress: onLongPress, content: content ) } } ================================================ FILE: Mlem/App/Views/Shared/CustomTabView.swift ================================================ // // UITabBarWrapper.swift // Mlem // // Created by Sjmarf on 11/04/2024. // import Foundation import SwiftUI import Theming struct CustomTabView: UIViewControllerRepresentable { let tabs: [CustomTabItem] let swipeGestureCallback: () -> Void @Binding var selectedIndex: Int init(selectedIndex: Binding, tabs: [CustomTabItem], onSwipeUp: @escaping () -> Void) { self.tabs = tabs self.swipeGestureCallback = onSwipeUp self._selectedIndex = selectedIndex } func makeUIViewController( context: UIViewControllerRepresentableContext ) -> UITabBarController { let tabBarController = CustomTabBarController( selectedIndex: $selectedIndex, swipeGestureCallback: swipeGestureCallback, palette: context.environment.palette ) tabBarController.viewControllers = tabs.enumerated().map { CustomTabViewHostingController(item: $1, index: $0) } return tabBarController } func updateUIViewController( _ uiViewController: UITabBarController, context: UIViewControllerRepresentableContext ) { if let controller = uiViewController as? CustomTabBarController { Task.detached { @MainActor in for (tabData, tabBarItem) in zip(tabs, controller.tabBar.items ?? []) { tabBarItem.title = tabData.title tabBarItem.badgeValue = tabData.badge tabBarItem.image = tabData.image tabBarItem.selectedImage = tabData.selectedImage tabBarItem.badgeColor = UIColor(ThemedColor.themedWarning.resolve(in: context.environment)) } controller.tabBar.tintColor = UIColor(ThemedColor.themedAccent.resolve(in: context.environment)) } } withObservationTracking { _ = AppState.main.contentViewTab } onChange: { if let controller = uiViewController as? CustomTabBarController { Task.detached { @MainActor in controller.selectedIndex = selectedIndex } } } } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject { var parent: CustomTabView init(_ controller: CustomTabView) { self.parent = controller } } } ================================================ FILE: Mlem/App/Views/Shared/CustomTabViewHostingController.swift ================================================ // // TabBarHostingController.swift // Mlem // // Created by Sjmarf on 11/04/2024. // import Foundation import SwiftUI class CustomTabViewHostingController: UIHostingController { let item: CustomTabItem init(item: CustomTabItem, index: Int) { self.item = item super.init(rootView: item.content) self.tabBarItem = UITabBarItem( title: item.title, image: nil, selectedImage: nil ) } @available(*, unavailable) @MainActor dynamic required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ================================================ FILE: Mlem/App/Views/Shared/EllipsisMenu.swift ================================================ // // EllipsisMenu.swift // Mlem // // Created by Eric Andrews on 2024-05-24. // import Actions import Foundation import Icons import SwiftUI struct EllipsisMenu: View { let content: Content let icon: Icon let size: CGFloat init(icon: Icon = .general.menu, size: CGFloat, @ViewBuilder content: @escaping () -> Content) { self.icon = icon self.size = size self.content = content() } var body: some View { Menu { content } label: { Image(icon: icon) .frame(width: 24, height: size) .contentShape(.rect) } .popupAnchor() .buttonStyle(.empty) .onTapGesture {} // prevent NavigationLink from disabling menu (thanks Swift) } } extension EllipsisMenu { init( icon: Icon = .general.menu, size: CGFloat, @ActionBuilder actions: @escaping () -> [any Action] ) where Content == MenuButtons { self.icon = icon self.size = size self.content = MenuButtons(actions: actions) } } ================================================ FILE: Mlem/App/Views/Shared/EndOfFeedView.swift ================================================ // // EndOfFeedView.swift // Mlem // // Created by Eric Andrews on 2024-08-19. // import Foundation import MlemMiddleware import SwiftUI import Theming struct EndOfFeedViewContent { // This is a `LocalizedStringResource` because different languages // may want to change this icon in order to have it match the locale-specific idiom let icon: LocalizedStringResource let message: LocalizedStringResource } enum EndOfFeedViewType { case hobbit, cartoon, turtle var viewContent: EndOfFeedViewContent { switch self { case .hobbit: return EndOfFeedViewContent( icon: .init( "end.of.feed.icon.1", defaultValue: "figure.climbing", // swiftlint:disable:next line_length comment: "This is the key for an icon that appears next to the \"I think I've found the bottom!\" text. It is localized so that you can change the icon to fit better with your translation of the text." ), message: "I think I've found the bottom!" ) case .cartoon: return EndOfFeedViewContent( icon: .init( "end.of.feed.icon.2", defaultValue: "figure.wave", // swiftlint:disable:next line_length comment: "This is the key for an icon that appears next to the \"That's all, folks!\" text. It is localized so that you can change the icon to fit better with your translation of the text." ), message: "That's all, folks!" ) case .turtle: return EndOfFeedViewContent( icon: .init( "end.of.feed.icon.3", defaultValue: "tortoise", // swiftlint:disable:next line_length comment: "This is the key for an icon that appears next to the \"It's turtles all the way down\" text. It is localized so that you can change the icon to fit better with your translation of the text." ), message: "It's turtles all the way down" ) } } } struct EndOfFeedView: View { @Setting(\.dev_developerMode) var developerMode @State var showLoadMore: Bool = false let loadingState_: FeedLoadingState? let viewType: EndOfFeedViewType let feedLoader: (any FeedLoading)? var loadingState: FeedLoadingState { assert(loadingState_ != nil || feedLoader != nil, "either loadingState_ or feedLoader must be defined") return loadingState_ ?? feedLoader?.loadingState ?? .done } @_disfavoredOverload init(loadingState: LoadingState, viewType: EndOfFeedViewType) { self.init(loadingState: .init(from: loadingState), viewType: viewType) } init(loadingState: FeedLoadingState, viewType: EndOfFeedViewType) { self.loadingState_ = loadingState self.feedLoader = nil self.viewType = viewType } init(feedLoader: any FeedLoading, viewType: EndOfFeedViewType) { self.loadingState_ = nil self.feedLoader = feedLoader self.viewType = viewType } var body: some View { Group { switch loadingState { case .idle: Group { if showLoadMore, let feedLoader { Button("Load More") { Task { do { try await feedLoader.loadMoreItems() } catch { handleError(error) } } } .tint(.themedAccent) .buttonStyle(.bordered) } else { Group { if developerMode { Text(verbatim: "IDLE") } else { ProgressView() } } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if loadingState == .idle { showLoadMore = true } } } } } case .loading, .initial: ProgressView() case .done: HStack { Image(systemName: .init(localized: viewType.viewContent.icon)) Text(viewType.viewContent.message) } .foregroundStyle(.themedSecondary) } } .frame(minHeight: 100) .onChange(of: loadingState, initial: true) { if loadingState != .idle { showLoadMore = false } } } } ================================================ FILE: Mlem/App/Views/Shared/ErrorView.swift ================================================ // // Error View.swift // Mlem // // Created by David Bureš on 19.06.2022. // import Combine import MlemMiddleware import SwiftUI import UniformTypeIdentifiers struct ErrorView: View { @Setting(\.dev_developerMode) var developerMode @State var errorDetails: ErrorDetails @State private var showingFullError: Bool = false @State private var refreshInProgress: Bool = false var timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common) .autoconnect() init(_ errorDetails: ErrorDetails) { _errorDetails = State(wrappedValue: errorDetails) } var body: some View { VStack(spacing: 15) { if showingFullError { errorDetails(errorDetails.errorText()) } else { if let icon = errorDetails.icon { Image(icon: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 50) } Text(errorDetails.title ?? "Something went wrong.") .font(.title3.bold()) .foregroundStyle(.themedPrimary) if let body = errorDetails.body { Text(body) .multilineTextAlignment(.center) } if !errorDetails.autoRefresh, let refresh = errorDetails.refresh { Button { Task { refreshInProgress = true if await refresh() { timer.upstream.connect().cancel() } refreshInProgress = false } } label: { HStack(spacing: 10) { Text(errorDetails.buttonText ?? "Try Again") if refreshInProgress { ProgressView() } } } .tint(.themedSecondary) .buttonStyle(.bordered) .animation(.default, value: refreshInProgress) } } if errorDetails.error != nil, errorDetails.title == nil || developerMode { Button(showingFullError ? "Hide Details" : "Show Details") { showingFullError.toggle() } .buttonStyle(.plain) .foregroundStyle(.themedTertiary) } } .padding() .foregroundColor(.secondary) .onDisappear { timer.upstream.connect().cancel() } .onReceive(timer) { _ in if errorDetails.autoRefresh, let refresh = errorDetails.refresh { Task { if await refresh() { timer.upstream.connect().cancel() } } } } .animation(.default, value: showingFullError) .padding() } @ViewBuilder func errorDetails(_ errorText: String) -> some View { VStack { Text(errorText) .foregroundStyle(.red) Divider() Button { UIPasteboard.general.setValue( errorText, forPasteboardType: UTType.plainText.identifier ) } label: { Label("Copy", systemImage: "square.on.square") } .buttonStyle(.plain) } .padding(Constants.main.standardSpacing) .background(Color(.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: Constants.main.standardSpacing)) } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/CommentPage.swift ================================================ // // CommentPage.swift // Mlem // // Created by Sjmarf on 27/09/2024. // import MlemMiddleware import SwiftUI import Theming struct CommentPage: View { @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.dismiss) var dismiss let comment: Comment let initialComments: [Comment]? @State var tracker: CommentTreeTracker let showViewPostButton: Bool let exposeRemovedContent: Bool init( comment: Comment, initialComments: [Comment]?, showViewPostButton: Bool = false, exposeRemovedContent: Bool = false ) { self.comment = comment self.showViewPostButton = showViewPostButton self.initialComments = initialComments self.exposeRemovedContent = exposeRemovedContent self._tracker = .init(wrappedValue: .init(root: .comment(comment, parentCount: 1))) } var body: some View { ExpectedView(comment.post) { post in content(post: post) } } // swiftlint:disable:next function_body_length func content(post: Post) -> some View { ExpandedPostView( post: post, tracker: $tracker, scrollTargetedComment: comment ) { if showViewPostButton || tracker.nodes.first?.comment.depth != 0 { HStack(spacing: Constants.main.standardSpacing) { if tracker.nodes.first?.comment.depth != 0 { Button { tracker.root = .comment(comment, parentCount: currentDepth + 1) Task { await tracker.refresh() } } label: { HStack { Text("Show Parent") if tracker.loadingState == .loading { ProgressView() } else { Image(systemName: "chevron.up") } } .animation(.easeOut(duration: 0.1), value: tracker.loadingState == .loading) } } if showViewPostButton { Button { navigation.push(.post(post)) } label: { HStack { Text("View All") Image(icon: .general.forward) } } } } .buttonStyle(.capsule) .padding(.horizontal, Constants.main.standardSpacing) } } .refreshable { _ = await Task { @MainActor in await tracker.refresh() }.value } .themedGroupedBackground() .onAppear { Task { await tracker.load() } } .environment(\.exposeRemovedContent, exposeRemovedContent) } var currentDepth: Int { switch tracker.root { case let .comment(_, currentDepth): currentDepth default: 0 } } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/CommentStubResolutionPage.swift ================================================ // // CommentStubResolutionPage.swift // Mlem // // Created by Eric Andrews on 2026-01-11. // import MlemMiddleware import SwiftUI import Theming struct CommentStubResolutionPage: View { @Environment(NavigationLayer.self) var navigation let stub: CommentStub let comments: [Comment]? let showViewPostButton: Bool let exposeRemovedContent: Bool @State var upgradeError: Error? var body: some View { content .themedGroupedBackground() } @ViewBuilder var content: some View { if let upgradeError { ErrorView(.init( error: upgradeError, refresh: fetchComment )) } else { ProgressView() .task { await fetchComment() } } } @discardableResult func fetchComment() async -> Bool { do { // TODO: NOW make this smoother try await navigation.replace(.comment( stub.asComment(), comments: comments, showViewPostButton: showViewPostButton, exposeRemovedContent: exposeRemovedContent )) return true } catch { upgradeError = error return false } } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/CommentTreeTracker.swift ================================================ // // ExpandedPostView+Logic.swift // Mlem // // Created by Sjmarf on 12/07/2024. // import MlemMiddleware import os import SwiftUI @Observable class CommentTreeTracker: Hashable { private let log: Logger = .mlemLogger() enum Root { case post(Post) case comment(Comment, parentCount: Int) var wrappedValue: any InteractableProviding & ActorIdentifiable { switch self { case let .post(post): post case let .comment(comment, _): comment } } var depth: Int { switch self { case .post: -1 case let .comment(comment, parentCount): max(0, comment.depth - parentCount) } } } private(set) var nodes: [CommentTreeNode] = [] private(set) var nodesKeyedByActorId: [ActorIdentifier: CommentTreeNode] = [:] var loadingState: LoadingState = .idle var errorDetails: ErrorDetails? var root: Root var sort: CommentSortType = .init(Settings.get(\.comment_defaultSort)) init(root: Root) { self.root = root } var proposedDepthOffset: Int { switch root { case .comment: if let first = nodes.first, first.comment.depth > 0 { return first.comment.depth - 1 } return 0 default: return 0 } } private var appState: AppState { .main } func getNode(actorId: ActorIdentifier) -> CommentTreeNode? { nodesKeyedByActorId[actorId] } func hasNode(actorId: ActorIdentifier) -> Bool { return nodesKeyedByActorId.keys.contains(actorId) } @MainActor func load(ensuringPresenceOf ensuredComment: (any CommentResolvable)? = nil) async { guard loadingState == .idle else { return } loadingState = .loading do { var newComments = try await fetchComments(page: 1) if let ensuredComment { let comment = try await ensuredComment.asComment() let api = root.wrappedValue.api if !nodesKeyedByActorId.keys.contains(comment.actorId) { // Find the first parent of the ensured comment that isn't in `newComments`. // This will be the starting point for the second page of comments to load. let idsToSearch = comment.parentCommentIds + [comment.id] let firstAbsentParentId = idsToSearch.first( where: { id in !newComments.contains(where: { $0.id == id }) } ) if let firstAbsentParentId { let extraComments = try await api.getComments( parentId: firstAbsentParentId, sort: sort, page: 1, maxDepth: 8, limit: 999 ) newComments.append(contentsOf: extraComments) } } } if let first = newComments.first, first.api != root.wrappedValue.api { resolveCommentTree(comments: newComments) } else { await buildCommentTree(comments: newComments) } loadingState = .done errorDetails = nil } catch { handleFailure(error) } } @MainActor private func fetchComments(page: Int) async throws -> [Comment] { switch root { case let .post(post): return try await post.getComments( sort: sort, page: page, maxDepth: Settings.get(\.comment_maxDepth), limit: 50 ) case let .comment(comment, parentCount): return try await comment.getChildren( sort: sort, includedParentCount: parentCount, page: page, maxDepth: min(8, Settings.get(\.comment_maxDepth)) + parentCount, limit: 999 ) } } private func handleFailure(_ error: Error) { var details = handleErrorWithDetails(error) details?.refresh = { await self.load() return self.loadingState == .done } errorDetails = details loadingState = .idle } @MainActor func refresh() async { errorDetails = nil loadingState = .idle await load() } func clear() { nodes.removeAll() nodesKeyedByActorId.removeAll() loadingState = .idle } func insertCreatedComment(_ comment: Comment, parent: Comment? = nil) { let wrapper = CommentTreeNode(comment) nodesKeyedByActorId[comment.actorId] = wrapper if let parent { assert(!comment.parentCommentIds.isEmpty) nodesKeyedByActorId[parent.actorId]?.addChild(wrapper) } else { assert(comment.parentCommentIds.isEmpty) nodes.prepend(wrapper) } } @MainActor func insertAdditionalComments(comments newComments: [Comment]) async { await buildCommentTree(comments: newComments, clear: false) } func getThread(preceding target: Comment, limit: Int) -> [Comment] { var cur = nodesKeyedByActorId[target.actorId] var ret: [Comment] = .init() while ret.count < limit, let curNode = cur { ret.prepend(curNode.comment) cur = curNode.parent } assert(ret.count > 0, "Could not build thread from \(target.actorId)") return ret } @MainActor private func buildCommentTree(comments newComments: [Comment], clear: Bool = true) async { var output: [CommentTreeNode] = clear ? [] : nodes var commentsKeyedById: [Int: CommentTreeNode] = [:] var commentsKeyedByActorId: [ActorIdentifier: CommentTreeNode] = clear ? [:] : nodesKeyedByActorId let sortedComments = newComments.sorted { $0.depth < $1.depth } for comment in sortedComments { if commentsKeyedByActorId.keys.contains(comment.actorId) { commentsKeyedById[comment.id] = commentsKeyedByActorId[comment.actorId] continue } let wrapper: CommentTreeNode = .init(comment) commentsKeyedById[comment.id] = wrapper commentsKeyedByActorId[comment.actorId] = wrapper if let parentId = comment.parentCommentIds.last, comment.depth > root.depth { if let parent = commentsKeyedById[parentId] { parent.addChild(wrapper) } } else { output.append(wrapper) } } nodes = output nodesKeyedByActorId = commentsKeyedByActorId } private func resolveCommentTree(comments newComments: [Comment]) { var commentsKeyedById: [Int: CommentTreeNode] = [:] for comment in newComments { if let existing = nodesKeyedByActorId[comment.actorId] { existing.comment = comment commentsKeyedById[comment.id] = existing } else { let wrapper: CommentTreeNode = .init(comment) commentsKeyedById[comment.id] = wrapper nodesKeyedByActorId[comment.actorId] = wrapper if let parentId = comment.parentCommentIds.last { if let parent = commentsKeyedById[parentId] { parent.addChild(wrapper) } else { assertionFailure("This should never happen because the API returns comments in order of depth asc.") } } else { nodes.append(wrapper) } } } } static func == (lhs: CommentTreeTracker, rhs: CommentTreeTracker) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/ExpandedPostHistoryTracker.swift ================================================ // // ExpandedPostHistoryTracker.swift // Mlem // // Created by Sjmarf on 2025-03-22. // import Foundation import MlemMiddleware // This needs to be Observable in order for it to work in the envrionment, // but we aren't actually using any Observable properties at present @Observable class ExpandedPostHistoryTracker { @ObservationIgnored private var postActorIds: [ActorIdentifier] = [] @ObservationIgnored private var postToCommentMap: [ActorIdentifier: ActorIdentifier] = [:] func insert(postActorId: ActorIdentifier, commentActorId: ActorIdentifier) { if let index = postActorIds.firstIndex(of: postActorId) { postActorIds.remove(at: index) } postActorIds.append(postActorId) postToCommentMap[postActorId] = commentActorId if postActorIds.count > 10 { let removedId = postActorIds.removeFirst() postToCommentMap[removedId] = nil } } func retrieve(for postActorId: ActorIdentifier) -> ActorIdentifier? { postToCommentMap[postActorId] } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/ExpandedPostView+Logic.swift ================================================ // // ExpandedPostView+Logic.swift // Mlem // // Created by Sjmarf on 24/08/2024. // import MlemMiddleware import SwiftUI extension ExpandedPostView { struct AnchorsKey: PreferenceKey { // swiftlint:disable:next nesting typealias Value = [ActorIdentifier?: Anchor] static var defaultValue: Value { [:] } static func reduce(value: inout Value, nextValue: () -> Value) { value.merge(nextValue()) { $1 } } } enum PreviousVisitRecord { case firstVisit case revisit(topVisibleCommentAtLastVisit: ActorIdentifier) var isRevisit: Bool { switch self { case .revisit: true default: false } } var commentActorId: ActorIdentifier? { switch self { case let .revisit(topVisibleCommentAtLastVisit: value): value default: nil } } } enum CommentTreeViewType: Hashable { case comment(CommentTreeNode) case unloadedComments(comment: CommentTreeNode, count: Int) func hash(into hasher: inout Hasher) { switch self { case let .comment(comment): hasher.combine(1) hasher.combine(comment.actorId) case let .unloadedComments(comment, _): hasher.combine(2) hasher.combine(comment.actorId) } } } var hasNoComments: Bool { if tracker.loadingState == .done { return tracker.nodesKeyedByActorId.count == 0 } return (post.commentCount.value ?? -1) == 0 } var showLoadingSymbol: Bool { // Don't need to show ProgressView if there's nothing to scroll to if scrollTargetedComment == nil { return false } return !scrolledToScrollTargetedComment } func showScrollToLastVisitButton(post: Post) -> Bool { guard (post.commentCount.value_ ?? 0) > 10 else { return false } var commentId = previousVisitRecord?.commentActorId if topVisibleItem.isAtPost, commentId == nil { commentId = topVisibleItem.furthestVisitedComment } guard let commentId else { return false } let nodes = tracker.nodes.reduce([]) { $0 + $1.tree(hideIfCollapsed: false) } let index = nodes.firstIndex { $0.actorId == commentId } guard let index else { return false } return index > 1 } func togglePostCollapsed(post: Post, scrollProxy: ScrollViewProxy) { withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { postCollapsed.toggle() if postCollapsed { scrollProxy.scrollTo(post.actorId) } } } func generateViewTree(for nodes: [CommentTreeNode]) -> [CommentTreeViewType] { nodes.reduce([]) { $0 + generateViewTree(for: $1) } } func generateViewTree(for node: CommentTreeNode) -> [CommentTreeViewType] { let comment = node.comment if comment.shouldHideInFeed { return [] } if node.collapsed { return [.comment(node)] } var output: [CommentTreeViewType] = node.children.reduce([.comment(node)]) { $0 + generateViewTree(for: $1) } let directChildCount = node.children.reduce(comment.commentCount.value_ ?? 0) { $0 - ($1.comment.commentCount.value_ ?? 0) } if node.children.count < directChildCount { output.append(.unloadedComments(comment: node, count: (comment.commentCount.value_ ?? 0) - output.count)) } return output } func topCommentRow(of anchors: AnchorsKey.Value, in proxy: GeometryProxy) -> ActorIdentifier? { var yBest = CGFloat.infinity var ret: ActorIdentifier? for (row, anchor) in anchors { let y = proxy[anchor].y guard y >= 0, y < yBest else { continue } ret = row yBest = y } return ret } func updateAnchors(_ anchors: AnchorsKey.Value, in proxy: GeometryProxy) { topVisibleItem.wrappedValue = topCommentRow(of: anchors, in: proxy) if (topVisibleItem.wrappedValue == post.actorId) != topVisibleItem.isAtPost { topVisibleItem.isAtPost.toggle() } updateHistory() } private func updateHistory() { if let commentActorId = topVisibleItem.wrappedValue, topVisibleItem.wrappedValue != post.actorId { expandedPostHistoryTracker.insert(postActorId: post.actorId, commentActorId: commentActorId) if let furthestVisitedComment = topVisibleItem.furthestVisitedComment { let nodes = tracker.nodes.reduce([]) { $0 + $1.tree(hideIfCollapsed: false) } let furthestVisitedCommentIndex = nodes.firstIndex { $0.actorId == furthestVisitedComment } let newVisitedCommentIndex = nodes.firstIndex { $0.actorId == commentActorId } if let furthestVisitedCommentIndex, let newVisitedCommentIndex { if furthestVisitedCommentIndex > newVisitedCommentIndex { return } } } topVisibleItem.furthestVisitedComment = commentActorId } } func scrollToLastVisitedPosition() { if let furthestVisitedComment = topVisibleItem.furthestVisitedComment { jumpButtonTarget = furthestVisitedComment return } switch previousVisitRecord { case let .revisit(topVisibleCommentAtLastVisit: topVisibleCommentAtLastVisit): jumpButtonTarget = topVisibleCommentAtLastVisit default: break } } func scrollToNextComment() { if let topVisibleItem = topVisibleItem.wrappedValue { if topVisibleItem == post.actorId, let first = tracker.nodes.first { jumpButtonTarget = first.actorId return } if let comment = tracker.nodesKeyedByActorId[topVisibleItem] { if let topLevelIndex = tracker.nodes.firstIndex(of: comment.topParent) { guard topLevelIndex + 1 < tracker.nodes.count else { return } jumpButtonTarget = tracker.nodes[topLevelIndex + 1].actorId } } } } func scrollToPreviousComment() { if let topVisibleItem = topVisibleItem.wrappedValue, topVisibleItem != post.actorId { if let comment = tracker.nodesKeyedByActorId[topVisibleItem] { if var topLevelIndex = tracker.nodes.firstIndex(of: comment.topParent) { if topLevelIndex < 0 || comment == tracker.nodes.first { jumpButtonTarget = post.actorId } else { if comment.parent == nil { topLevelIndex -= 1 } jumpButtonTarget = tracker.nodes[topLevelIndex].actorId } } else { assertionFailure() } } } } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/ExpandedPostView+Views.swift ================================================ // // ExpandedPostView+Views.swift // Mlem // // Created by Sjmarf on 11/10/2024. // import MlemMiddleware import SwiftUI extension ExpandedPostView { @ViewBuilder var noCommentsView: some View { VStack(spacing: 5) { Image(icon: .lemmy.noContent) .font(.title) .foregroundStyle(.tertiary) Text("No comments found") .fontWeight(.semibold) } .multilineTextAlignment(.center) .foregroundStyle(.themedSecondary) .frame(maxWidth: .infinity) } @ViewBuilder func commentTree(tracker: CommentTreeTracker, scrollProxy: ScrollViewProxy) -> some View { ForEach(generateViewTree(for: tracker.nodes), id: \.hashValue) { item in Group { switch item { case let .comment(node): nodeView(node: node, depthOffset: tracker.proposedDepthOffset, scrollProxy: scrollProxy) case let .unloadedComments(comment, _): MoreRepliesButton(tracker: tracker, commentTreeNode: comment) } } .padding(.horizontal, Constants.main.standardSpacing) .padding(.top, compactComments ? Constants.main.halfSpacing : Constants.main.standardSpacing) } } @ViewBuilder func nodeView(node: CommentTreeNode, depthOffset: Int, scrollProxy: ScrollViewProxy) -> some View { let comment = node.comment CommentView( comment: comment, treeNode: node, highlight: [scrollTargetedComment?.actorId, highlightedComment?.actorId].contains(comment.actorId), depthOffset: depthOffset ) .onTapGesture { if tapCommentsToCollapse { withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { node.collapsed.toggle() } } } .onChange(of: node.collapsed) { _, isCollapsed in guard isCollapsed else { return } withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .default) { scrollProxy.scrollTo(comment.actorId) } } .quickSwipes(comment: comment, configuration: commentInteractionBar) .contextMenu(comment: comment) .popupAnchor() .paletteBorder(cornerRadius: Constants.main.standardSpacing) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(1000 - Double(comment.depth)) .anchorPreference( key: AnchorsKey.self, value: .center ) { [comment.actorId: $0] } .padding(.leading, CGFloat(comment.depth - depthOffset) * 10) .id(comment.actorId) } @ViewBuilder func sortPicker(tracker: CommentTreeTracker) -> some View { Menu("Sort", icon: tracker.sort.icon) { ForEach(CommentSortType.legacyCases, id: \.self) { item in if post.api.supports(.commentSortType(item), defaultValue: true) { Toggle( item.label(timeRangeFormat: .topOnly), icon: item.icon, isOn: .init( get: { tracker.sort == item }, set: { _ in tracker.sort = item tracker.clear() Task { await tracker.load() } } ) ) } } } } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/ExpandedPostView.swift ================================================ // // ExpandedPostView.swift // Mlem // // Created by Sjmarf on 03/09/2024. // import Actions import MlemMiddleware import SwiftUI @Observable class TopVisibleItemContainer { // This doesn't need to trigger view updates @ObservationIgnored var wrappedValue: ActorIdentifier? var furthestVisitedComment: ActorIdentifier? var isAtPost: Bool = true } struct ExpandedPostView: View { @Environment(AppState.self) var appState @Environment(ExpandedPostHistoryTracker.self) var expandedPostHistoryTracker @Environment(NavigationLayer.self) var navigation @Environment(\.palette) var palette @Environment(\.dismiss) var dismiss @Setting(\.comment_jumpButton) var jumpButton @Setting(\.comment_compact) var compactComments @Setting(\.post_gestures_tapToCollapse) var tapPostsToCollapse @Setting(\.comment_gestures_tapToCollapse) var tapCommentsToCollapse @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.interactionBar_comment) var commentInteractionBar @State var post: Post let highlightedComment: Comment? let content: Content @State var isLoading: Bool = false @Binding var tracker: CommentTreeTracker @State var scrollTargetedComment: Comment? @State var scrolledToScrollTargetedComment: Bool = false @State var jumpButtonTarget: ActorIdentifier? @State var topVisibleItem: TopVisibleItemContainer = .init() @State var postCollapsed: Bool = false @State var previousVisitRecord: PreviousVisitRecord? init( post: Post, tracker: Binding?, highlightedComment: Comment? = nil, scrollTargetedComment: Comment? = nil, @ViewBuilder content: () -> Content = { EmptyView() } ) { self.post = post self.highlightedComment = highlightedComment self.content = content() self._tracker = tracker ?? .constant(.init(root: .post(post))) self._scrollTargetedComment = .init(wrappedValue: scrollTargetedComment) } var body: some View { // Using a `ZStack` here rather than `if`/`else` because there needs to // be a delay between the `content()` appearing and calling `scrollTo` VStack { viewContent .themedGroupedBackground() .reloadOnAccountSwitch(entity: $post, isLoading: $isLoading) { newPost in tracker.root = .post(newPost) tracker.loadingState = .idle Task { await tracker.load(ensuringPresenceOf: scrollTargetedComment) } } .externalApiWarning(entity: post, isLoading: isLoading) .task { await tracker.load(ensuringPresenceOf: scrollTargetedComment) if post.api == appState.firstApi { post.updateRead(true) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .conditionalNavigationTitle(post.community.value?.name ?? "") .navigationBarTitleDisplayMode(.inline) .overlay { VStack { if showLoadingSymbol { ZStack { palette.groupedBackground.primary .ignoresSafeArea() ProgressView() .tint(.secondary) } .transition(.opacity) } } .animation(.easeOut(duration: 0.1), value: showLoadingSymbol) } .refreshable { _ = await Task { @MainActor in do { try await post.refresh() await tracker.refresh() } catch { handleError(error) } }.value } } @ViewBuilder var viewContent: some View { GeometryReader { geo in ScrollViewReader { proxy in FancyScrollView { VStack( alignment: .leading, spacing: 0 ) { postView(post, scrollProxy: proxy) .padding(.horizontal, Constants.main.standardSpacing) content .padding(.top, compactComments ? Constants.main.halfSpacing : Constants.main.standardSpacing) if let errorDetails = tracker.errorDetails { ErrorView(errorDetails) .frame(maxWidth: .infinity) } else if hasNoComments { noCommentsView .padding(.top, Constants.main.doubleSpacing) } else { switch tracker.loadingState { case .done: LazyVStack(spacing: 0) { commentTree(tracker: tracker, scrollProxy: proxy) } .geometryGroup() default: ProgressView() .tint(.themedSecondary) .padding(.top, 50) // This prevents the tab bar going transparent whilst the comments are loading .padding(.bottom, 500) .frame(maxWidth: .infinity) } } } .animation(.easeInOut(duration: 0.1), value: tracker.loadingState == .loading) .animation(.easeInOut(duration: 0.1), value: tracker.errorDetails == nil) .animation(.easeInOut(duration: 0.4), value: scrollTargetedComment?.actorId) .padding(.bottom, 80) .id(tracker.proposedDepthOffset) .transition(.opacity) .animation(.easeInOut(duration: 0.4), value: tracker.proposedDepthOffset) } .onChange(of: tracker.loadingState, initial: true) { if tracker.loadingState == .done, let scrollTargetedComment { // Without a slight delay here, `scrollTo` can sometimes fail. I'm not sure why this is. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { proxy.scrollTo(scrollTargetedComment.actorId, anchor: .center) scrolledToScrollTargetedComment = true } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.scrollTargetedComment = nil } } } .onChange(of: jumpButtonTarget) { if let jumpButtonTarget { withAnimation { proxy.scrollTo(jumpButtonTarget, anchor: .top) } self.jumpButtonTarget = nil } } .overlay(alignment: jumpButton.alignment) { JumpButtonsView( showJumpButton: (tracker.nodes.count) > 1, topVisibleItem: topVisibleItem, scrollToLastVisitedPosition: showScrollToLastVisitButton(post: post) ? scrollToLastVisitedPosition : nil, scrollToNextComment: scrollToNextComment, scrollToPreviousComment: scrollToPreviousComment ) } .onPreferenceChange(AnchorsKey.self) { updateAnchors($0, in: geo) } .onAppear { if previousVisitRecord == nil { if let actorId = expandedPostHistoryTracker.retrieve(for: post.actorId) { previousVisitRecord = .revisit(topVisibleCommentAtLastVisit: actorId) } else { previousVisitRecord = .firstVisit } } } .toolbar { toolbarContent(post: post, scrollProxy: proxy) } } } .environment(tracker) .environment(\.feedContext, .post) } @ViewBuilder func toolbarContent(post: Post, scrollProxy: ScrollViewProxy) -> some View { sortPicker(tracker: tracker) if post.shouldShowLoadingSymbol() { ProgressView() } else { ToolbarEllipsisMenu { ControlGroup { ActionButtons { _ in PostBarConfiguration.availableActions.all.compactMap { $0.createAction(post) } } } .controlGroupStyle(.compactMenu) if !tapPostsToCollapse { Section { Button( postCollapsed ? "Expand Post" : "Collapse Post", icon: postCollapsed ? .general.expand : .general.collapse ) { togglePostCollapsed(post: post, scrollProxy: scrollProxy) } } } } } } @ViewBuilder func postView(_ post: Post, scrollProxy: ScrollViewProxy) -> some View { Group { if postCollapsed { HStack { post.taggedTitle(communityContext: post.community.value) .font(.headline) .symbolVariant(.fill) .background(.themedSecondaryGroupedBackground) Spacer() Image(icon: .general.expand) .frame(height: 10) } .imageScale(.small) .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground) } else { LargePostView(post: post, isPostPage: true) } } .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .quickSwipes(post: post, configuration: postInteractionBar) .contextMenu(post: post) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .onTapGesture { if tapPostsToCollapse || postCollapsed { togglePostCollapsed(post: post, scrollProxy: scrollProxy) } } .id(post.actorId) .transition(.opacity) .anchorPreference( key: AnchorsKey.self, value: .center ) { [post.actorId: $0] } } } private struct JumpButtonsView: View { @Setting(\.comment_jumpButton) var jumpButton var showJumpButton: Bool var topVisibleItem: TopVisibleItemContainer var scrollToLastVisitedPosition: (() -> Void)? var scrollToNextComment: () -> Void var scrollToPreviousComment: () -> Void var body: some View { VStack(spacing: 0) { if let scrollToLastVisitedPosition, topVisibleItem.isAtPost, showJumpButton { JumpButtonView( icon: .lemmy.jumpToLastPositionButton, onShortPress: scrollToLastVisitedPosition, onLongPress: nil ) } if jumpButton != .none, showJumpButton { JumpButtonView( onShortPress: scrollToNextComment, onLongPress: scrollToPreviousComment ) } } .padding(Constants.main.standardSpacing) .animation(.default, value: topVisibleItem.isAtPost) } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/MoreRepliesButton.swift ================================================ // // MoreRepliesButton.swift // Mlem // // Created by Sjmarf on 2024-11-28. // import SwiftUI struct MoreRepliesButton: View { @Environment(NavigationLayer.self) var navigation let tracker: CommentTreeTracker let commentTreeNode: CommentTreeNode @State var isLoading: Bool = false var body: some View { Button { isLoading = true Task { @MainActor in do { try await loadComments() } catch { handleError(error) } isLoading = false } } label: { HStack { CommentBarView(depth: commentTreeNode.comment.depth + 1) HStack { Text("More Replies") Image(icon: .general.forward) } .frame(maxWidth: .infinity) .padding(.vertical, 8) .opacity(isLoading ? 0 : 1) .overlay { if isLoading { ProgressView() } } .foregroundStyle(.themedAccent) } .background( .themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing) ) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } .padding(.leading, CGFloat(commentTreeNode.comment.depth + 1 - tracker.proposedDepthOffset) * 10) .buttonStyle(.plain) } func loadComments() async throws { let comments = try await commentTreeNode.comment.getChildren( sort: tracker.sort, includedParentCount: 0, page: 1, maxDepth: Settings.get(\.comment_maxDepth), limit: 999 ) guard let maxDepth = comments.last?.depth else { return } // Do we want this threshold to change depending on screen size? Could be tricky if we load comments // and then the user makes the window less wide (e.g. on iPad), in which case we'd need to hide // the comments that exceed the new maximum width. if maxDepth > 12 { var comments = comments if let parent = commentTreeNode.parent { comments.prepend(parent.comment) } navigation.push(.comment( commentTreeNode.comment, comments: comments, showViewPostButton: false)) } else { await tracker.insertAdditionalComments(comments: comments) } } } ================================================ FILE: Mlem/App/Views/Shared/ExpandedPost/PostStubResolutionPage.swift ================================================ // // PostStubResolutionPage.swift // Mlem // // Created by Sjmarf on 27/09/2024. // import MlemMiddleware import SwiftUI import Theming // TODO: NOW just make this an ExpectedView? // Or expanded post page itself take expectedValue...? struct PostStubResolutionPage: View { @Environment(NavigationLayer.self) var navigation let stub: PostStub @State var upgradeError: Error? var body: some View { content .themedGroupedBackground() } @ViewBuilder var content: some View { if let upgradeError { ErrorView(.init( error: upgradeError, refresh: fetchPost )) } else { ProgressView() .task { await fetchPost() } } } @discardableResult func fetchPost() async -> Bool { do { let post = try await stub.getPost() navigation.replace(.post(post)) return true } catch { upgradeError = error return false } } } ================================================ FILE: Mlem/App/Views/Shared/ExpectedViews/ExpectedTextView.swift ================================================ // // ExpectedTextView.swift // Mlem // // Created by Eric Andrews on 2025-12-19. // import SwiftUI import MlemMiddleware struct ExpectedText: View { let text: ExpectedValue private let placeholder: String init(_ text: ExpectedValue, expectedLength: Int = 15) { self.text = text self.placeholder = String(repeating: "a", count: expectedLength) } var body: some View { ZStack { // ZStack to make the animation work correctliy if let text = text.value { Text(text) .transition(.scale) } else { Text(verbatim: placeholder) .redacted(reason: .placeholder) .transition(.opacity) } } .animation(.interactiveSpring, value: text.value != nil) } } ================================================ FILE: Mlem/App/Views/Shared/ExpectedViews/ExpectedView.swift ================================================ // // ExpectedView.swift // Mlem // // Created by Eric Andrews on 2025-12-28. // import SwiftUI import MlemMiddleware /// View for animating content appearance when a given ValueProviding resolves. /// Intended for tightly scoped, small views; may cause rendering issues on more complex views. struct ExpectedView: View { let value: any ValueProviding @ViewBuilder let view: (Value) -> Content @ViewBuilder let placeholder: () -> Placeholder init( _ value: any ValueProviding, view: @escaping (Value) -> Content, placeholder: @escaping () -> Placeholder = { EmptyView() } ) { self.value = value self.view = view self.placeholder = placeholder } var body: some View { ZStack { if let value = value.value { view(value) .transition(.scale) } else { placeholder() } } .animation(.interactiveSpring, value: value.value == nil) } } ================================================ FILE: Mlem/App/Views/Shared/ExpectedViews/String+Placeholders.swift ================================================ // // String+Placeholders.swift // Mlem // // Created by Eric Andrews on 2026-01-12. // extension String { static var personPlaceholder: Self { "user@placeholder" } static var communityPlaceholder: Self { "community@placeholder" } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportableCommentEditorView.swift ================================================ // // ExportableCommentEditorView.swift // Mlem // // Created by Eric Andrews on 2025-11-26. // import ComponentViews import SwiftUI import MlemMiddleware import Theming import os struct ExportableCommentEditorView: View { @Environment(AppState.self) var appState @Environment(\.colorScheme) var colorScheme @Setting(\.appearance_palette) var palette @Setting(\.comment_createImage_showPost) var showPost: Bool @Setting(\.comment_createImage_showCreator) var showCreator: Bool @Setting(\.comment_createImage_showStats) var showStats: Bool @Setting(\.comment_createImage_colorScheme) var overrideColorScheme: UIUserInterfaceStyle @Setting(\.post_createImage_showCommunity) var postShowCommunity @Setting(\.post_createImage_showCreator) var postShowCreator @Setting(\.post_createImage_showStats) var postShowStats @State var commentLoader: ExportableCommentLoader init(comment: Comment, commentTreeTracker: CommentTreeTracker?) { self.commentLoader = .init(comment: comment, tracker: commentTreeTracker) } @State var threadLength: Int = 1 { didSet { guard let allParents = commentLoader.data?.comments else { assertionFailure("Cannot modify thread length without thread") return } comments = allParents.suffix(threadLength) } } @State var comments: [Comment] = .init() var overriddenColorScheme: ColorScheme { switch overrideColorScheme { case .unspecified: colorScheme case .light: .light case .dark: .dark default: .light } } var body: some View { if let error = commentLoader.error { ErrorView(error) } else if let data = commentLoader.data { content(data: data) } else { ProgressView() .task { await commentLoader.load() } } } // swiftlint:disable:next function_body_length func content(data: ExportableCommentData) -> some View { ScrollView { exportableComment(data: data) .padding(.bottom, 200) } .presentationBackground(.themedGroupedBackground) .overlay(alignment: .bottom) { ExportableViewControlOverlay { createImageFromView(exportableComment(data: data)) } } .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Menu("Details", icon: .general.configure) { Section(threadLength > 1 ? "Comments" : "Comment") { Toggle("Creator", icon: .lemmy.person, isOn: $showCreator) Toggle("Stats", icon: .lemmy.votes, isOn: $showStats) } if data.comments.count > 1 { ControlGroup("Parent Comments") { Button("Remove Comment", icon: .general.remove) { assert(threadLength > 1, "Cannot decrease thread length below 1") threadLength -= 1 } .disabled(threadLength == 1) Text(verbatim: "\(threadLength - 1)") Button("Add Comment", icon: .general.add) { assert( threadLength < min(8, data.comments.count), "Cannot increase thread length beyond \(min(8, data.comments.count))" ) threadLength += 1 } .disabled(threadLength == min(8, data.comments.count)) } .labelStyle(.iconOnly) .controlGroupStyle(.compactMenu) } Section("Post") { Toggle("Show Post", icon: .lemmy.post, isOn: $showPost) if showPost { Toggle("Community", icon: .lemmy.community, isOn: $postShowCommunity) Toggle("Creator", icon: .lemmy.person, isOn: $postShowCreator) Toggle("Stats", icon: .lemmy.votes, isOn: $postShowStats) } } if palette.supportedModes == .unspecified { Menu("Color Scheme", icon: overrideColorScheme.icon) { Picker("Color Scheme", selection: $overrideColorScheme) { ForEach(UIUserInterfaceStyle.optionCases, id: \.self) { style in Label(style.label, icon: style.icon) } } } } } .menuActionDismissBehavior(.disabled) } } } @ViewBuilder func exportableComment(data: ExportableCommentData) -> some View { ExportableCommentView( comments: data.thread(length: threadLength), appState: appState, colorScheme: overriddenColorScheme ) .allowsHitTesting(false) } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportableCommentLoader.swift ================================================ // // ExportableCommentLoader.swift // Mlem // // Created by Eric Andrews on 2025-12-10. // import SwiftUI import MlemMiddleware /// Class to load and handle data required to display an exportable comment @Observable class ExportableCommentLoader { var data: ExportableCommentData? var error: ErrorDetails? let rootComment: Comment let tracker: CommentTreeTracker? init(comment: Comment, tracker: CommentTreeTracker?) { self.rootComment = comment self.tracker = tracker } func load() async { do { try await rootComment.refresh() var comments: [Comment] if let tracker { await tracker.load(ensuringPresenceOf: rootComment) comments = tracker.getThread(preceding: rootComment, limit: 8) } else { comments = [rootComment] } Task { @MainActor in self.data = .init(comments: comments) } } catch { self.error = handleErrorWithDetails(error) } } } struct ExportableCommentData { let comments: [Comment] func thread(length: Int) -> [Comment] { comments.suffix(length) } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportableCommentView.swift ================================================ // // ExportableCommentView.swift // Mlem // // Created by Eric Andrews on 2025-11-26. // import SwiftUI import MlemMiddleware struct ExportableCommentView: View { @Setting(\.appearance_palette) var colorPalette @Setting(\.comment_createImage_showPost) var showPost: Bool @Setting(\.comment_createImage_showCreator) var showCreator: Bool @Setting(\.comment_createImage_showStats) var showStats: Bool @Setting(\.post_createImage_showCommunity) var postShowCommunity @Setting(\.post_createImage_showCreator) var postShowCreator @Setting(\.post_createImage_showStats) var postShowStats let comments: [Comment] // Anything environment-dependent must be passed in because ImageRenderer doesn't work with @Environment let appState: AppState let colorScheme: ColorScheme let infoStackReadouts: [CommentBarConfiguration.ReadoutType] = [.upvote, .downvote, .created, .comment] var showBars: Bool { showPost || comments.count > 1 } var animationHashValue: Int { var hasher = Hasher() hasher.combine(showPost) hasher.combine(comments.count) hasher.combine(showCreator) hasher.combine(showStats) hasher.combine(postShowCommunity) hasher.combine(postShowCreator) hasher.combine(postShowStats) return hasher.finalize() } var body: some View { content .background(.themedGroupedBackground) .animation(.snappy, value: animationHashValue) .environment(appState) .palette(colorPalette.palette) .environment(\.colorScheme, colorScheme) } var content: some View { VStack(spacing: -Constants.main.standardSpacing) { if showPost, let rootComment = comments.last { ExpectedView(rootComment.post) { post in ExportablePostView( post: post, appState: appState, colorScheme: colorScheme ) .transition(.move(edge: .top).combined(with: .opacity)) } } ForEach(Array(comments.enumerated()), id: \.element.actorId) { index, comment in commentContent(comment: comment, depth: index) .geometryGroup() .padding(.leading, CGFloat(index * 10)) .transition(.scale) } } } func commentContent(comment: Comment, depth: Int) -> some View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { if showCreator { ExpectedView(comment.creator) { creator in FullyQualifiedLabelView(creator, labelStyle: .medium, showFlairs: false) .transition(.scale.combined(with: .opacity)) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } } CommentBodyView(comment: comment) if showStats { Divider() InfoStackView(readouts: infoStackReadouts.compactMap { comment.readout(type: $0, showColor: false) }) .transition(.move(edge: .top).combined(with: .scale)) } } .padding(.leading, showBars ? 11 : 0) .padding(Constants.main.standardSpacing) } .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .overlay(alignment: .leading) { // CommentBarView's maxHeight: .infinity sometimes causes scaling problems when the post is shown, putting // it in an overlay forces it to respect the correct parent scaling if showBars { CommentBarView(depth: depth) .transition(.move(edge: .leading).combined(with: .scale)) } } .padding(Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportablePostEditorView.swift ================================================ // // ExportablePostEditorView.swift // Mlem // // Created by Eric Andrews on 2025-10-10. // import ComponentViews import Haptics import Media import MlemMiddleware import Nuke import SwiftUI import Theming struct ExportablePostEditorView: View { @Environment(NavigationLayer.self) var navigation @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(\.colorScheme) var colorScheme @Setting(\.appearance_palette) var palette @Setting(\.post_createImage_showCommunity) var showCommunity @Setting(\.post_createImage_showCreator) var showCreator @Setting(\.post_createImage_showStats) var showStats @Setting(\.post_createImage_colorScheme) var overrideColorScheme let post: Post var overriddenColorScheme: ColorScheme { switch overrideColorScheme { case .unspecified: colorScheme case .light: .light case .dark: .dark default: .light } } var body: some View { ScrollView { exportablePost .padding(.bottom, 200) } .presentationBackground(.themedGroupedBackground) .overlay(alignment: .bottom) { ExportableViewControlOverlay { createImageFromView(exportablePost) } } .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView(ios18Label: .cancel) } ToolbarItem(placement: .topBarTrailing) { Menu("Details", icon: .general.configure) { Toggle("Community", icon: .lemmy.community, isOn: $showCommunity) Toggle("Creator", icon: .lemmy.person, isOn: $showCreator) Toggle("Stats", icon: .lemmy.votes, isOn: $showStats) if palette.supportedModes == .unspecified { Menu("Color Scheme", icon: overrideColorScheme.icon) { Picker("Color Scheme", selection: $overrideColorScheme) { ForEach(UIUserInterfaceStyle.optionCases, id: \.self) { style in Label(style.label, icon: style.icon) } } } } } .menuActionDismissBehavior(.disabled) } } } var exportablePost: some View { ExportablePostView( post: post, appState: appState, colorScheme: overriddenColorScheme ) .allowsHitTesting(false) } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportablePostView.swift ================================================ // // ExportablePostView.swift // Mlem // // Created by Eric Andrews on 2025-09-24. // import MlemMiddleware import SwiftUI struct ExportablePostView: View { @Setting(\.appearance_palette) var colorPalette @Setting(\.post_createImage_showCommunity) var showCommunity @Setting(\.post_createImage_showCreator) var showCreator @Setting(\.post_createImage_showStats) var showStats let post: Post // Anything environment-dependent must be passed in because ImageRenderer doesn't work with @Environment let appState: AppState let colorScheme: ColorScheme let infoStackReadouts: [PostBarConfiguration.ReadoutType] = [.upvote, .downvote, .created, .comment] var animationHashValue: Int { var hasher = Hasher() hasher.combine(showCommunity) hasher.combine(showCreator) hasher.combine(showStats) return hasher.finalize() } var body: some View { content .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .padding(Constants.main.standardSpacing) .background(.themedGroupedBackground) .animation(.snappy, value: animationHashValue) .environment(appState) .palette(colorPalette.palette) .environment(\.colorScheme, colorScheme) } var content: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { if showCommunity { ExpectedView(post.community) { community in FullyQualifiedLabelView(community, labelStyle: .medium, showFlairs: false) .transition(.scale.combined(with: .opacity)) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } LargePostBodyView(post: post, isPostPage: true, shouldBlur: false) if showCreator { ExpectedView(post.creator) { creator in FullyQualifiedLabelView(creator, labelStyle: .medium, showFlairs: false) .transition(.scale.combined(with: .opacity)) } placeholder: { Text(verbatim: .communityPlaceholder).redacted(reason: .placeholder) } } if showStats { Divider() InfoStackView(readouts: infoStackReadouts.compactMap { post.readout(type: $0, showColor: false) }) .transition(.move(edge: .top).combined(with: .scale)) } } .padding(Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/ExportableViews/ExportableViewComponents.swift ================================================ // // ExportableViewComponents.swift // Mlem // // Created by Eric Andrews on 2025-11-27. // import SwiftUI struct ExportableViewControlOverlay: View { // let snapshot: UIImage? let createSnapshot: () -> UIImage? var body: some View { Group { if #available(iOS 26, *) { content .glassEffect(.regular.interactive(), in: .capsule) } else { content .background(.regularMaterial, in: .capsule) } } .padding(Constants.main.standardSpacing) } var content: some View { HStack { saveButton shareButton } .font(.title2) .labelStyle(.iconOnly) .buttonStyle(.plain) .padding(.horizontal, Constants.main.halfSpacing) } @ViewBuilder var saveButton: some View { Button("Save", icon: .general.import) { Task { guard let imageData = createSnapshot()?.pngData() else { assertionFailure("Rendering failed") ToastModel.main.add(.failure("Failed")) return } do { try await ImageSaver().writeImageToPhotoAlbum(imageData: imageData) ToastModel.main.add(.success("Image Saved")) } catch { handleError(error) } } } .padding(Constants.main.standardSpacing) .contentShape(.rect) } @ViewBuilder var shareButton: some View { ShareLink( item: TransferableUIImage(createImage: createSnapshot), preview: SharePreview( "Image", image: TransferableUIImage(createImage: createSnapshot) )) .padding(Constants.main.standardSpacing) .contentShape(.rect) } } private struct TransferableUIImage: Transferable { var createImage: () -> UIImage? enum TranferableUIImageError: Error { case generationFailed } static var transferRepresentation: some TransferRepresentation { DataRepresentation(exportedContentType: .png) { item in guard let imageData = item.createImage()?.pngData() else { throw TranferableUIImageError.generationFailed } return imageData } } } ================================================ FILE: Mlem/App/Views/Shared/FancyScrollView.swift ================================================ // // FancyScrollView.swift // Mlem // // Created by Sjmarf on 30/05/2024. // import SwiftUI import Theming struct IsAtTopPreferenceKey: PreferenceKey { static var defaultValue: Bool = true static func reduce(value: inout Bool, nextValue: () -> Bool) {} } struct FancyScrollView: View { @Environment(\.dismiss) var dismiss @ViewBuilder var content: () -> Content @Binding var scrollToTopTrigger: Bool // TODO: investigate unifying this and isAtTop var reselectAction: (() -> Void)? @State var isAtTop: Bool = true private let topId: String = "scrollToTop" init( scrollToTopTrigger: Binding = .constant(false), reselectAction: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content ) { self.content = content self._scrollToTopTrigger = scrollToTopTrigger self.reselectAction = reselectAction } var body: some View { ScrollViewReader { proxy in ScrollView { VStack(spacing: 0) { GeometryReader { geo in Color.clear.preference( key: IsAtTopPreferenceKey.self, // This must be `Int` to account for floating point error value: Int(geo.frame(in: .named("scrollView")).origin.y) >= 0 ) .id(topId) } .frame(width: 0, height: 0) content() } } .environment(\.scrollProxy, proxy) .onReselectTab { if isAtTop { if let reselectAction { reselectAction() } else { dismiss() } } else { withAnimation { proxy.scrollTo(topId) } } } .onChange(of: scrollToTopTrigger) { withAnimation { proxy.scrollTo(topId) } } .coordinateSpace(name: "scrollView") .onPreferenceChange(IsAtTopPreferenceKey.self) { offset in if offset != isAtTop { isAtTop = offset } } } } } ================================================ FILE: Mlem/App/Views/Shared/FeedFilterButtonStyle.swift ================================================ // // FeedFilterButtonStyle.swift // Mlem // // Created by Sjmarf on 2025-01-04. // import Icons import SwiftUI struct FeedFilterButtonStyle: ButtonStyle { @Environment(\.palette) var palette let isOn: Bool var icon: Icon? = .general.dropDown @ScaledMetric(relativeTo: .footnote) var height: CGFloat = 32 var iconRequiresCircle: Bool { switch icon { case .general.dropDown, .general.close: true default: false } } func makeBody(configuration: Configuration) -> some View { HStack(spacing: 4) { configuration.label if let icon { Image(icon: icon) .symbolRenderingMode(.hierarchical) .padding(.trailing, 8) .symbolVariant(iconRequiresCircle ? .circle.fill : .fill) } } .frame(height: height) .foregroundStyle(isOn ? .themedContrastingLabel : .themedAccent) .font(.footnote) .padding(icon == nil ? .horizontal : .leading, 12) .background( Capsule() .fill(isOn ? palette.accent : .clear) .strokeBorder(.themedAccent, lineWidth: isOn ? 0 : 1) ) } } extension ButtonStyle where Self == FeedFilterButtonStyle { @MainActor static func feedFilter( isOn: Bool = false, icon: Icon? = .general.dropDown ) -> FeedFilterButtonStyle { .init(isOn: isOn, icon: icon) } } ================================================ FILE: Mlem/App/Views/Shared/FeedToolbarOptions.swift ================================================ // // FeedToolbarOptions.swift // Mlem // // Created by Sjmarf on 2025-03-16. // import SwiftUI struct FeedToolbarOptions: ToolbarContent { @Environment(AppState.self) var appState @Environment(ToastModel.self) var toastModel @Setting(\.post_size) var postSize @Setting(\.feed_showRead) var showRead @Setting(\.safety_blurNsfw) var blurNsfw var body: some ToolbarContent { ToolbarItemGroup(placement: .secondaryAction) { SwiftUI.Section { Button(showRead ? "Hide Read" : "Show Read", icon: .settings.hideRead) { showRead.toggle() let message: LocalizedStringResource = showRead ? "Showing Read" : "Hiding Read" toastModel.add(.success(message)) } Menu { Picker("Post Size", selection: $postSize) { ForEach(PostSize.allCases, id: \.self) { item in Label(String(localized: item.label), icon: item.icon) } } } label: { Label("Post Size", icon: .settings.postSize) } if appState.firstPerson?.showNsfw.value ?? false { Toggle( "Blur NSFW", icon: .settings.blurNsfw, isOn: .init(get: { blurNsfw != .never }, set: { blurNsfw = $0 ? .always : .never }) ) } } } } } ================================================ FILE: Mlem/App/Views/Shared/FooterLinkView.swift ================================================ // // MarkdownFooterLinkView.swift // Mlem // // Created by Sjmarf on 2024-11-10. // import MlemMiddleware import SwiftUI struct FooterLinkView: View { let title: String let subtitle: String? var body: some View { VStack(alignment: .leading, spacing: 5) { Text(title) .frame(maxWidth: .infinity, alignment: .leading) .font(.subheadline) .fontWeight(.semibold) .multilineTextAlignment(.leading) .lineLimit(2) if let subtitle { Text(subtitle) .font(.footnote) .lineLimit(1) } } .foregroundStyle(.themedSecondary) .padding(Constants.main.standardSpacing) .background(.themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) } } ================================================ FILE: Mlem/App/Views/Shared/Form/ActiveUserCountView.swift ================================================ // // ActiveUserCountView.swift // Mlem // // Created by Sjmarf on 08/08/2024. // import MlemMiddleware import SwiftUI struct ActiveUserCountView: View { let activeUserCount: ActiveUserCount var body: some View { FormSection { VStack(spacing: 8) { Text("Active Users") .foregroundStyle(.themedSecondary) HStack(spacing: 16) { section(.init(month: 6), value: activeUserCount.sixMonths) section(.init(month: 1), value: activeUserCount.month) section(.init(weekOfMonth: 1), value: activeUserCount.week) section(.init(day: 1), value: activeUserCount.day) } } .padding(.vertical, Constants.main.standardSpacing) } } @ViewBuilder func section(_ components: DateComponents, value: Int) -> some View { VStack { Text(value.abbreviated) .font(.title3) .fontWeight(.semibold) Text(formatter.string(from: components) ?? "") .foregroundStyle(.themedSecondary) } .frame(maxWidth: .infinity) } var formatter: DateComponentsFormatter { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 1 return formatter } } ================================================ FILE: Mlem/App/Views/Shared/Form/CollapsibleSection.swift ================================================ // // CollapsibleSection.swift // Mlem // // Created by Sjmarf on 02/01/2024. // import SwiftUI import Theming struct CollapsibleSection: View { @Environment(\.palette) var palette var header: String? var footer: String? @ViewBuilder var content: () -> Content @State private var collapsed: Bool init( _ header: String? = nil, footer: String? = nil, collapsed: Bool = false, @ViewBuilder _ content: @escaping () -> Content ) { self.header = header self.footer = footer self.content = content self._collapsed = State(wrappedValue: collapsed) } var body: some View { VStack(alignment: .leading, spacing: 0) { if let header { HStack { Text(header) .textCase(.uppercase) .opacity(0.5) Spacer() Image(icon: .general.dropDown) .fontWeight(.semibold) .foregroundStyle(Color.accentColor) .rotationEffect(Angle(degrees: collapsed ? -90 : 0)) } .font(.footnote) .contentShape(.rect) .onTapGesture { withAnimation(.default) { collapsed.toggle() }} .padding(.vertical, 6) .padding(.horizontal, 16) } if !collapsed { palette.groupedBackground.primary .frame(height: 1.5) VStack { content() } if let footer { Text(footer) .textCase(.uppercase) .font(.footnote) .padding(.horizontal, 16) .foregroundStyle(.themedSecondary) } } } .background(.themedSecondaryGroupedBackground) .clipShape(RoundedRectangle(cornerRadius: Constants.main.mediumItemCornerRadius)) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 16) } } ================================================ FILE: Mlem/App/Views/Shared/Form/FormReadout.swift ================================================ // // FormReadout.swift // Mlem // // Created by Sjmarf on 08/08/2024. // import SwiftUI struct FormReadout: View { let label: LocalizedStringResource let value: Int init(_ label: LocalizedStringResource, value: Int) { self.label = label self.value = value } var body: some View { FormSection { VStack(spacing: Constants.main.halfSpacing) { Text(label) .foregroundStyle(.themedSecondary) Text(value.abbreviated) .font(.title) .fontWeight(.semibold) .foregroundStyle(.tint) } .padding(.vertical, Constants.main.standardSpacing) } } } ================================================ FILE: Mlem/App/Views/Shared/Form/FormSection.swift ================================================ // // FormSection.swift // Mlem // // Created by Sjmarf on 08/08/2024. // import SwiftUI struct FormSection: View { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { content .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/HandleThreadiverseLinksModifier.swift ================================================ // // HandleLemmyLinksModifier.swift // Mlem // // Created by Sjmarf on 25/06/2024. // import MlemMiddleware import SafariServices import SwiftUI /// Modifier that overrides the `openURL` environment variable and attempts to open threadiverse links in-app. struct HandleThreadiverseLinksModifier: ViewModifier { @Environment(NavigationLayer.self) var navigation @Environment(AppState.self) var appState @State private var showingEmailAlert = false @State private var pendingMailtoURL: URL? // If a link in the `user@example.com` format is clicked, it opens in the Mail app // immediately for these domains. For all other domains, Mlem will attempt to // resolve it as a Lemmy link first. private static let emailDomains: Set = [ "hotmail.com", "gmail.com", "yahoo.com", "icloud.com", "outlook.com", "zoho.com", "aol.com", "yandex.com", "sky.com", "bt.com", "btinternet.com" ] private static let instanceMultiplexerDomains: Set = [ "lemmyverse.link", "threadiverse.link", "vger.to" ] func body(content: Content) -> some View { content .environment(\.openURL, OpenURLAction(handler: didReceiveURL)) .onChange(of: navigation.model?.pendingOpenURL) { _, url in if let url { navigation.model?.pendingOpenURL = nil _ = didReceiveURL(url) } } .alert("Open Mail App", isPresented: $showingEmailAlert) { Button("Cancel", role: .cancel) {} Button("Open") { if let url = pendingMailtoURL { UIApplication.shared.open(url) } } } message: { Text("Would you like to open this email address in your mail app?") } } @MainActor func didReceiveURL(_ url: URL) -> OpenURLAction.Result { // TODO: Consider handling links to alternative frontends such as `old.lemmy.world` or `oldsh.itjust.works`. guard let scheme = url.scheme else { // LemmyMarkdownUI parses the `/c/comm@example.com` and `!comm@example.com` link formats into regular links, // so those don't need to be handled in this method. However, it doesn't parse links written in the format // [Some text](/c/comm@example.com), which is a format that lemmy-ui supports. Those links are handled here. // Later, it might be better to move that into LemmyMarkdownUI, but I think we'd need to modify the core // cmark code rather than just the extensions, which isn't ideal. if let newUrl = createLemmyUrlFromShortcut(parts: url.pathComponents), let page = createNavigationPage(url: newUrl) { navigation.push(page) return .handled } openLinkAsWebsite(url: url) return .handled } // `user@example.com` isn't recognised by Lemmy, and doesn't appear as a clickable link in lemmy-ui. // The *correct* syntax is `@user@example.com`, but occasionally someone doesn't know this and // types `user@example.com` instead. We handle this case by attempting to parse as a Lemmy link, and // falling back to opening the Mail app if that fails. if scheme == "mailto" { if parseEmail(url: url) { return .handled } } guard let host = url.host(), scheme.starts(with: "http") else { openLinkAsWebsite(url: url) return .handled } if Self.instanceMultiplexerDomains.contains(host), url.pathComponents.count > 3 { var components = URLComponents() components.scheme = "https" components.host = url.pathComponents[1] components.path = "/" + url.pathComponents.dropFirst(2).joined(separator: "/") if let newUrl = components.url, let page = createNavigationPage(url: newUrl) { navigation.push(page) return .handled } } // If the link is in our threadiverse domain list, push a page to the NavigationStack straight away if isThreadiverseHost(host), let page = createNavigationPage(url: url) { navigation.push(page) return .handled } let components = url.pathComponents.dropFirst() // Super-small instances may not appear in the threadiverse domain list, in which case we show a // "Loading..." toast whilst we attempt to work out if it's actually a threadiverse link if ["u", "c", "post", "comment"].contains(components.first) { // The "@" check ensures that KBin links are excluded if !host.contains("reddit.com"), components.count == 2, components[1].first != "@" { Task { await showToastAndResolve(url: url) { url in openLinkAsWebsite(url: url) } } return .handled } } // If all else fails, fallback to opening in browser openLinkAsWebsite(url: url) return .handled } // Creates https://example.com/c/comm from /c/comm@example.com or example.com/c/comm func createLemmyUrlFromShortcut(parts: [String]) -> URL? { var parts = parts var components = URLComponents() components.scheme = "https" if parts[0] != "/" { guard parts.count == 3 else { return nil } guard parts[1] == "c" || parts[1] == "u" else { return nil } components.host = parts[0] components.path = "/\(parts[1])/\(parts[2])" } else { parts.removeFirst() guard parts.count == 2 else { return nil } guard parts[0] == "c" || parts[0] == "u" else { return nil } let fullNameParts = parts[1].split(separator: "@") components.host = String(fullNameParts[1]) components.path = "/\(parts[0])/\(fullNameParts[0])" } return components.url } func createNavigationPage(url: URL) -> NavigationPage? { let components = Array(url.pathComponents.dropFirst()) if components.isEmpty, let host = url.host() { return .instanceStub(InstanceStub(api: appState.firstApi, actorId: .instance(host: host))) } switch components.first { case "u": return .personStub(PersonStub(api: appState.firstApi, url: url)) case "c": // Handle links that look like this: // https://piefed.social/c/politics/p/1385905/will-the-supreme-court-hand-government-contractors-blanket-immunity if components.count > 4, components[2] == "p" { let newUrl = url.removingPathComponents().appendingPathComponent("post/\(components[3])") return .postStub(PostStub(api: appState.firstApi, url: newUrl)) } else { return .communityStub(CommunityStub(api: appState.firstApi, url: url)) } case "post": if let fragment = url.fragment()?.trimmingPrefix("comment_") { let newUrl = url.removingPathComponents().appendingPathComponent("comment/\(fragment)") return .commentStub(CommentStub(api: appState.firstApi, url: newUrl)) } else if components.count == 2 { return .postStub(PostStub(api: appState.firstApi, url: url)) } else if components.count == 3 { let newUrl = url.removingPathComponents().appendingPathComponent("comment/\(url.lastPathComponent)") return .commentStub(CommentStub(api: appState.firstApi, url: newUrl)) } else { return nil } case "comment": return .commentStub(CommentStub(api: appState.firstApi, url: url)) default: return nil } } func parseEmail(url: URL) -> Bool { let parts = url.absoluteString.trimmingPrefix("mailto:").split(separator: "@") guard parts.count == 2 else { return false } let user = String(parts[0]) let host = String(parts[1]) // For common email domains, show an alert asking if user wants to open mail app if Self.emailDomains.contains(host) { pendingMailtoURL = url showingEmailAlert = true } else if isThreadiverseHost(host) { // If it's a Lemmy host, try to resolve as a Lemmy user Task { await showToastAndResolve(url: URL(string: "https://\(host)/u/\(user)")!) { _ in // If resolution fails, show email alert as fallback pendingMailtoURL = url showingEmailAlert = true } } } else { // If it's neither a common email domain nor a Lemmy host, show email alert pendingMailtoURL = url showingEmailAlert = true } return true } func showToastAndResolve(url: URL, fallback: @escaping (URL) -> Void) async { let toastId = ToastModel.main.add(.loading()) var output: (any Sharable)? do { output = try await appState.firstApi.resolve(url: url) } catch { output = nil handleError(error, silent: true) } if output == nil { // Retry on local instance, which is needed if there is a federation boundary output = try? await ApiClient.getApiClient( url: url.removingPathComponents(), username: nil ).resolve(url: url) } if let person = output as? Person { navigation.push(.person(person)) } else if let community = output as? Community { navigation.push(.community(community)) } else if let post = output as? Post { navigation.push(.post(post)) } else if let comment = output as? Comment { navigation.push(.comment(comment)) } else { fallback(url) } ToastModel.main.removeToast(id: toastId) } func isThreadiverseHost(_ host: String) -> Bool { MlemStats.main.hosts.contains(host) } } func openLinkAsWebsite(url: URL) { @Setting(\.links_openInBrowser) var openLinksInBrowser if let scheme = url.scheme, scheme.hasPrefix("http"), !openLinksInBrowser { Task { @MainActor in let viewController = SFSafariViewController(url: url, configuration: .default) UIApplication.shared.firstKeyWindow?.rootViewController?.topMostViewController().present(viewController, animated: true) } } else { UIApplication.shared.open(url) } } private extension SFSafariViewController.Configuration { /// The default settings used in this application static var `default`: Self { let configuration = Self() @Setting(\.links_readerMode) var openLinksInReaderMode configuration.entersReaderIfAvailable = openLinksInReaderMode return configuration } } ================================================ FILE: Mlem/App/Views/Shared/ImageUploadMenu.swift ================================================ // // ImageUploadMenu.swift // Mlem // // Created by Sjmarf on 2024-12-04. // import MlemMiddleware import SwiftUI struct ImageUploadMenu: View { @Environment(NavigationLayer.self) var navigation let imageManager: ImageUploadManager let imageUploadApi: ApiClient @ViewBuilder let label: () -> Label init(imageManager: ImageUploadManager, imageUploadApi: ApiClient, @ViewBuilder label: @escaping () -> Label) { self.imageManager = imageManager self.imageUploadApi = imageUploadApi self.label = label } var body: some View { Menu(content: { Button("Photo Library", icon: .general.chooseImage) { navigation.showPhotosPicker(for: imageManager, api: imageUploadApi) } Button("Choose File", icon: .general.chooseFile) { navigation.showFilePicker(for: imageManager, api: imageUploadApi) } Button("Paste", icon: .general.paste) { navigation.uploadImageFromClipboard(for: imageManager, api: imageUploadApi) } }, label: label) .disabled(imageManager.state != .idle) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Core/MediaView+Logic.swift ================================================ // // MediaView+Helpers.swift // Mlem // // Created by Eric Andrews on 2025-03-10. // import Foundation import Icons import SwiftUI import Theming extension MediaView { // MARK: Types enum Overlay { case controls, nsfw, error } @Observable class Overlays { private let overlays: Set init(_ overlays: Set) { self.overlays = overlays } var nsfw: Bool { overlays.contains(.nsfw) } var controls: Bool { overlays.contains(.controls) } var error: Bool { overlays.contains(.error) } } enum FallbackStyle { case standard, avatar } /// Enumeration of placeholder images to use if image loading fails enum Fallback { case personAvatar, communityAvatar, instanceAvatar, favicon, image, movie, text, link, poll, titleOnly, proxyFailure, event var icon: Icon { switch self { case .personAvatar: .lemmy.personAvatar case .communityAvatar: .lemmy.communityAvatar case .instanceAvatar: .lemmy.instanceAvatar case .favicon: .general.browser case .image: .general.missing case .movie: .general.movie case .text: .lemmy.textPost case .link: .general.website case .poll: .lemmy.pollPost case .titleOnly: .lemmy.titleOnlyPost case .proxyFailure: .lemmy.imageProxy case .event: .lemmy.event } } /// How much of the parent view this fallback should occupy var scaleFactor: CGFloat { switch self { case .personAvatar, .communityAvatar, .instanceAvatar, .favicon: 1.0 case .image, .proxyFailure: 0.375 case .link, .text, .poll: 0.45 case .titleOnly: 0.45 case .movie, .event: 0.6 } } /// Background color for the fallback view. /// - Note: this has no effect if `fallbackStyle` is `.avatar` var background: ThemedColor { switch self { case .favicon: .clear default: .themedThumbnailBackground } } var fallbackStyle: FallbackStyle { switch self { case .personAvatar, .communityAvatar, .instanceAvatar: .avatar default: .standard } } } // MARK: Functions func tapActions() { if let onTapActions { onTapActions() } if enableImageViewer, let navigation, let viewerUrl = fullSizeUrl ?? loader.url { navigation.showImageViewer(url: viewerUrl) } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift ================================================ // // MediaView+Views.swift // Mlem // // Created by Eric Andrews on 2025-01-15. // import Media import SwiftUI import Theming extension MediaView { @ViewBuilder var image: some View { CoreMediaView( media: loader.mediaType ?? .image(.blank), aspectRatio: uiImage.boundedAspectRatio(bounds: aspectRatio), contentMode: contentMode ) .overlay { if loader.mediaType == nil { fallbackImage } } } @ViewBuilder var fallbackImage: some View { if loader.loading == .loading { ProgressView() .tint(.themedSecondary) } else if !showErrorOverlay { switch fallback.fallbackStyle { case .standard: coreFallbackImage .foregroundStyle(.themedSecondary) .background(fallback.background) case .avatar: coreFallbackImage .symbolRenderingMode(.palette) .foregroundStyle(.themedContrastingLabel, palette.neutralAccent.gradient) } } } @ViewBuilder var coreFallbackImage: some View { // Use contextual fallback icons even when proxy fails. let contextualFallback: Fallback = if loader.loading == .proxyFailed { fallback.fallbackStyle == .avatar ? fallback : .proxyFailure } else { fallback } GeometryReader { geo in Image(icon: contextualFallback.icon) .resizable() .scaledToFit() .symbolVariant(contextualFallback.fallbackStyle == .avatar ? .circle.fill : .none) .frame(width: geo.size.width * contextualFallback.scaleFactor) .frame(maxWidth: .infinity, maxHeight: .infinity) } } @ViewBuilder var nsfwOverlay: some View { if loader.loading == .done, overlays.nsfw { NsfwOverlay() } } @ViewBuilder var errorOverlay: some View { if overlays.error, let loaderError = loader.error, let navigation { palette.groupedBackground.tertiary.overlay { switch loaderError { case let .proxyFailure(proxyBypass): VStack(spacing: Constants.main.standardSpacing) { Image(icon: .lemmy.imageProxy) .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 50) .padding(4) Text("Proxy Failure") .fontWeight(.semibold) Button("Load directly from \(proxyBypass.host() ?? "unknown host")") { if !bypassImageProxyShown { bypassImageProxyShown = true navigation.openSheet(.bypassImageProxyWarning { Task { await loader.load(proxyBypass) } }) } else { Task { await loader.load(proxyBypass) } } } .foregroundStyle(.themedAccent) .buttonStyle(.bordered) .padding(.horizontal, Constants.main.standardSpacing) } .foregroundStyle(.themedTertiary) case let .error(error): VStack { Image(icon: .general.missing) .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 50) .padding(4) .foregroundStyle(.themedTertiary) if let url = loader.url { Text("Image loading failed") .foregroundStyle(.themedTertiary) Button(url.host() ?? String(localized: "unknown host"), icon: .general.browser) { openURL(url) } .tint(.themedAccent) .foregroundStyle(.themedAccent) .buttonStyle(.bordered) } if developerMode { DisclosureGroup("Details") { Text(error.localizedDescription) .foregroundStyle(.themedNegative) .multilineTextAlignment(.center) .padding(.top) Button("Copy Error", icon: .general.copy) { UIPasteboard.general.string = error.localizedDescription ToastModel.main.add(.success("Copied")) } .tint(.themedNegative) .foregroundStyle(.themedNegative) .buttonStyle(.bordered) } .padding(Constants.main.standardSpacing) .background(.themedBackground, in: .rect(cornerRadius: Constants.main.doubleSpacing)) .padding(.horizontal, Constants.main.doubleSpacing) .padding(.top, Constants.main.standardSpacing) } } .frame(maxHeight: .infinity) } } } } @ViewBuilder var developerOverlay: some View { if developerMode, overlays.controls, let ext = loader.url?.proxyAwarePathExtension?.uppercased() { Text(ext) .font(.footnote) .fontWeight(.semibold) .padding(2) .padding(.horizontal, 2) .background { Capsule() .fill(.regularMaterial) } .padding(4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } } @ViewBuilder func contextMenuContent() -> some View { if let url = fullSizeUrl ?? loader.url { Button("Save", icon: .general.import) { Task { await saveMedia(url: url) } } if let navigation { Button("Share...", icon: .general.share) { Task { await shareImage(url: url, navigation: navigation) } } } } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Core/MediaView.swift ================================================ // // MediaView.swift // Mlem // // Created by Eric Andrews on 2025-01-15. // import SwiftUI import Media struct MediaView: View { @Environment(NavigationLayer.self) var navigation: NavigationLayer? @Environment(\.palette) var palette @Environment(\.openURL) var openURL @Setting(\.status_bypassImageProxyShown) var bypassImageProxyShown @Setting(\.dev_developerMode) var developerMode @State var loader: MediaLoader @Binding var controlState: MediaControlState @State var quickLookUrl: URL? let url: URL? // appearance let aspectRatio_: CoreMediaView.AspectRatioBounds? var aspectRatio: CoreMediaView.AspectRatioBounds { aspectRatio_ ?? .absolute(loader.mediaType?.image.validSize() ?? .init(width: 4, height: 3)) } let contentMode: ContentMode let cornerRadius: CGFloat let fallback: Fallback let overlays: Overlays // interaction let enableContextMenu: Bool let enableImageViewer: Bool let onTapActions: (() -> Void)? var fullSizeUrl: URL? { Mlem.fullSizeUrl(url: loader.url) } var uiImage: UIImage { loader.mediaType?.image ?? .blank } var showErrorOverlay: Bool { overlays.error && loader.error != nil && navigation != nil } var enableTap: Bool { loader.loading == .done && ((onTapActions != nil) || enableImageViewer) } /// Creates a new MediaView. This view is simple by default; if no complex behaviors are specified, it will /// return a plain image that fits the bounds of its parent frame. /// - Parameters: /// - url: url of the media to render /// - size: target size of the media /// - controlState: MediaControlState to control this media from a parent view. If not provided, assumes inline rendering mode. /// - aspectRatioBounds: specifies the maximum vertical and horizontal aspect ratio for this image /// - contentMode: content resizing mode /// - cornerRadius: corner radius to apply to the image /// - fallback: fallback to use if image loading fails or URL is not present /// - overlays: overlays to display on the image /// - enableContextMenu: true if the default context menu (save/share/quick look) should appear /// - enableImageViewer: true if tapping the image should open the image viewer /// - onTapActions: actions to perform when the image is tapped. If `enableImageViewer: true`, tapping the image will both execute /// the specified actions and open the image viewer /// - Warning: Changing the following parameters may cause unexpected view identity changes: `enableContextMenu`, `contentMode` init( url: URL?, size: CGSize? = nil, controlState: Binding? = nil, aspectRatioBounds: CoreMediaView.AspectRatioBounds? = nil, contentMode: ContentMode = .fit, cornerRadius: CGFloat = 0, fallback: Fallback = .image, overlays: Set = [], enableContextMenu: Bool = false, enableImageViewer: Bool = false, onTapActions: (() -> Void)? = nil ) { self.url = url self.overlays = .init(overlays) self.aspectRatio_ = aspectRatioBounds self.contentMode = contentMode self.cornerRadius = cornerRadius self.fallback = fallback self.enableContextMenu = enableContextMenu self.enableImageViewer = enableImageViewer self.onTapActions = onTapActions self._loader = .init(wrappedValue: .init( url: url, size: size, autoBypassImageProxy: Settings.get(\.privacy_autoBypassImageProxy) )) if let controlState { self._controlState = controlState } else { self._controlState = .constant(.init( blurred: false, animating: false, muted: Settings.get(\.behavior_muteVideos) )) } _controlState.wrappedValue.url = url } static func largeImage(url: URL, shouldBlur: Bool, onTapActions: (() -> Void)? = nil) -> MediaView { .init( url: url, controlState: .constant(.init( blurred: shouldBlur, animating: Settings.get(\.behavior_autoplayMedia), muted: Settings.get(\.behavior_muteVideos) )), aspectRatioBounds: .imageDefault, cornerRadius: Constants.main.mediumItemCornerRadius, overlays: .init(shouldBlur ? [.controls, .nsfw, .error] : [.controls, .error]), enableContextMenu: true, enableImageViewer: true, onTapActions: onTapActions ) } var body: some View { content .dynamicBlur(blurred: loader.mediaType != nil && controlState.blurred) .withAnimationControls() .overlay(nsfwOverlay) .overlay(developerOverlay) .overlay(errorOverlay) .clipShape(.rect(cornerRadius: cornerRadius)) .withContextMenu(menuContent: contextMenuContent, isEnabled: enableContextMenu && loader.error == nil) .gesture(TapGesture().onEnded(tapActions), isEnabled: enableTap) .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: url, initial: true) { Task { await loader.load(url) } } .onChange(of: loader.mediaType?.isAnimated, initial: true) { controlState.animationAvailable = loader.mediaType?.isAnimated ?? false } .environment(controlState) .environment(overlays) } @ViewBuilder var content: some View { Group { if #available(iOS 18.0, *) { image .onScrollVisibilityChange(threshold: 0.5) { isVisible in if isVisible, controlState.autoplay { controlState.animating = true } if !isVisible { controlState.animating = false } } } else { image .onDisappear { controlState.animating = false } } } } } private struct MediaViewWithContextMenu: ViewModifier { let menuContent: () -> MenuItems let isEnabled: Bool // This sort of conditional view modifier is generally considered bad form because it can cause unexpected view identity updates. // Since `enableContextMenu` is unlikely to be a dynamic value it's acceptable here; nevertheless I have put a warning // in the function doc making that behavior explicit. [ Eric 2025-01-16 ] func body(content: Content) -> some View { if isEnabled { content .contextMenu { menuContent() } } else { content } } } private extension View { /// This view modifier ensures that the context menu is only applied if enabled. If the context menu is instead always applied /// but only populated if enabled, it will disable parent context menus (e.g., in `WebsitePreviewView`). func withContextMenu(menuContent: @escaping () -> some View, isEnabled: Bool) -> some View { modifier(MediaViewWithContextMenu(menuContent: menuContent, isEnabled: isEnabled)) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift ================================================ // // AnimationControlLayer.swift // Mlem // // Created by Eric Andrews on 2024-12-06. // import Media import SwiftUI private struct AnimationControlLayer: ViewModifier { @Environment(MediaControlState.self) var controlState @Environment(MediaView.Overlays.self) var overlays // decouple controls state from blurred because the blur animation and material don't get along @State var showControls: Bool = true func body(content: Content) -> some View { if controlState.canAnimate, overlays.controls { contentWithControls(content: content) } else { content } } @ViewBuilder func contentWithControls(content: Content) -> some View { content .overlay { if controlState.animating { Color.clear.contentShape(.rect) .highPriorityGesture(TapGesture() .onEnded { controlState.animating = false } ) } else if showControls { PlayButton(postSize: .large) .highPriorityGesture(TapGesture() .onEnded { controlState.animating = true } ) } } .overlay(alignment: .bottomTrailing) { muteButton } .onChange(of: controlState.blurred, initial: true) { if overlays.nsfw, overlays.controls { if controlState.blurred { showControls = false } else { controlState.animating = true showControls = true } } } } @ViewBuilder var muteButton: some View { if controlState.audioAvailable { muteButtonContent .padding([.bottom, .trailing], 5) .padding([.top, .leading], 15) .contentShape(.rect) .highPriorityGesture(TapGesture().onEnded { controlState.muted = !controlState.muted }) .contentTransition(.symbolEffect(.replace, options: .speed(2))) } } // TODO: iOS 18 deprecation remove @ViewBuilder var muteButtonContent: some View { if #available(iOS 26, *) { muteButtonLabel .glassEffect(.clear.interactive(), in: .circle) } else { muteButtonLabel .background(.ultraThinMaterial, in: .circle) } } // TODO: iOS 18 deprecation remove var muteButtonLabel: some View { SmallOverlayButtonLabel( isOn: controlState.muted, text: (on: "Unmute", off: "Mute"), icons: (on: .general.mute, off: .general.unmute)) .symbolVariant(.fill) } } extension View { func withAnimationControls() -> some View { modifier(AnimationControlLayer()) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/NsfwOverlayView.swift ================================================ // // NsfwOverlayView.swift // Mlem // // Created by Eric Andrews on 2024-08-03. // import Foundation import SwiftUI import Media struct NsfwOverlay: View { @Environment(MediaControlState.self) var controlState @MainActor func setBlurred(_ newValue: Bool) { withAnimation(newValue ? .easeIn(duration: 0.15) : .easeOut(duration: 0.12)) { controlState.blurred = newValue } } var body: some View { if controlState.blurred { VStack(spacing: 8) { Image(icon: .general.warning) .font(.largeTitle) Text("NSFW") .fontWeight(.black) } .foregroundStyle(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(.rect) .onTapGesture { setBlurred(false) } } else { Button { setBlurred(true) } label: { blurLabel } .buttonStyle(.plain) .padding(4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } // TODO: iOS 18 deprecation remove @ViewBuilder var blurLabel: some View { if #available(iOS 26, *) { blurLabelContent .glassEffect(.clear.interactive(), in: .circle) } else { blurLabelContent .background(.thinMaterial, in: .circle) } } var blurLabelContent: some View { SmallOverlayButtonLabel(text: "Blur", icon: .general.hide) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/PlayButton.swift ================================================ // // PlayButton.swift // Mlem // // Created by Eric Andrews on 2024-09-27. // import SwiftUI struct PlayButton: View { let fontSize: CGFloat init(postSize: PostSize) { self.fontSize = switch postSize { case .compact, .headline: 10 case .tile: 20 case .large: 30 } } var body: some View { if #available(iOS 26, *) { label .glassEffect(.clear.interactive(), in: .circle) } else { label .background { Circle().fill(.ultraThinMaterial) } } } // TODO: iOS 18 deprecation remove var label: some View { Label { Text("Play") } icon: { Image(icon: .general.play) .symbolVariant(.fill) .font(.system(size: fontSize)) .foregroundStyle(.white) .padding(0.6 * fontSize) .contentShape(.rect) } .labelStyle(.iconOnly) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/SmallOverlayButtonLabel.swift ================================================ // // SmallOverlayButtonLabel.swift // Mlem // // Created by Eric Andrews on 2025-11-06. // import SwiftUI import Icons struct SmallOverlayButtonLabel: View { let isOn: Bool let text: (on: LocalizedStringResource, off: LocalizedStringResource) let icons: (on: Icon, off: Icon) init(isOn: Bool, text: (on: LocalizedStringResource, off: LocalizedStringResource), icons: (on: Icon, off: Icon)) { self.isOn = isOn self.text = text self.icons = icons } init(text: LocalizedStringResource, icon: Icon) { self.isOn = true self.text = (on: text, off: text) self.icons = (on: icon, off: icon) } var body: some View { Label { Text(isOn ? text.on : text.off) } icon: { Image(icon: isOn ? icons.on : icons.off) .resizable() .scaledToFit() .frame(width: 15, height: 15) .padding(5) .foregroundStyle(.white) .contentShape(.rect) } .labelStyle(.iconOnly) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/BridgeDragValue.swift ================================================ // // BridgeDragValue.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // import Foundation import UIKit /// Custom struct to convert UIKit drag information to SwiftUI struct BridgeDragValue { let velocity: CGSize let translation: CGSize let startLocation: CGPoint init(velocity: CGSize, translation: CGSize, startLocation: CGPoint) { self.velocity = velocity self.translation = translation self.startLocation = startLocation } init(uiPanGesture: UIPanGestureRecognizer, startLocation: CGPoint?) { assert(startLocation != nil, "startLocation was nil") let uiVelocity = uiPanGesture.velocity(in: nil) let uiTranslation = uiPanGesture.translation(in: nil) self.velocity = .init(width: uiVelocity.x, height: uiVelocity.y) self.translation = .init(width: uiTranslation.x, height: uiTranslation.y) self.startLocation = startLocation ?? .zero } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/CachedComputation.swift ================================================ // // CachedComputation.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // class CachedComputation { private var lastInput: Input? private var lastOutput: Output? private var computation: (Input) -> Output init(computation: @escaping (Input) -> Output) { self.computation = computation } func compute(_ input: Input) -> Output { if let lastInput, let lastOutput, input == lastInput { return lastOutput } lastInput = input let output = computation(input) lastOutput = output return output } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/GestureRecognizers.swift ================================================ // // Recognizers.swift // Mlem // // Created by Eric Andrews on 2025-03-30. // import SwiftUI import UIKit class MomentumResetTapGestureRecognizer: UITapGestureRecognizer { var momentumKilled: Bool = false var resetMomentum: () -> Bool init(target: Any?, action: Selector?, resetMomentum: @escaping () -> Bool) { self.resetMomentum = resetMomentum super.init(target: target, action: action) } override func touchesBegan(_ touches: Set, with event: UIEvent) { momentumKilled = resetMomentum() super.touchesBegan(touches, with: event) } } class PanningPinchRecognizer: UIPinchGestureRecognizer { @Binding var zoomScale: CGFloat var panOffset: CGSize = .zero init(target: Any?, action: Selector?, zoomScale: Binding) { _zoomScale = zoomScale super.init(target: target, action: action) } override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) guard state == .began || state == .changed else { return } let translation = translation(of: touches) panOffset += translation.scaled(by: zoomScale) } private func translation(of touches: Set) -> CGSize { var averageLocation: CGPoint = touches.reduce(into: .zero) { result, touch in result += touch.location(in: view) } averageLocation.x /= CGFloat(touches.count) averageLocation.y /= CGFloat(touches.count) var previousLocation: CGPoint = touches.reduce(into: .zero) { result, touch in result += touch.previousLocation(in: view) } previousLocation.x /= CGFloat(touches.count) previousLocation.y /= CGFloat(touches.count) return .init( width: averageLocation.x - previousLocation.x, height: averageLocation.y - previousLocation.y ) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/MomentumStatus.swift ================================================ // // MomentumStatus.swift // Mlem // // Created by Eric Andrews on 2025-03-28. // import Foundation /// Tracks the current momentum and computes position based on time class MomentumStatus { /// Time at which the current x momentum began var xt0: CFTimeInterval? /// Velocity when the current x momentum began private var xv0: CGFloat /// True if x is out of bounds, false otherwise private(set) var xOob: Bool = false /// ZoomCurve for the current x momentum private var xUnitCurve: any ZoomCurve /// Time at which the current y momentum began var yt0: CFTimeInterval? /// Velocity when the current y momentum began private var yv0: CGFloat /// True if y is out of bounds, false otherwise private(set) var yOob: Bool = false /// ZoomCurve for the current y momentum private var yUnitCurve: any ZoomCurve init(initialVelocity: CGPoint, xOob: Bool, yOob: Bool) { self.xv0 = initialVelocity.x self.xOob = xOob self.xUnitCurve = xOob ? PolynomialBoundReset.main : SinusoidalFriction.main self.yv0 = initialVelocity.y self.yOob = yOob self.yUnitCurve = yOob ? PolynomialBoundReset.main : SinusoidalFriction.main } func position(at time: CFTimeInterval) -> (CGSize, active: Bool) { guard let xt0, let yt0 else { assertionFailure("Tried to query position before setting t0s") return (position: .zero, active: false) } let (xPosition, xActive) = xUnitCurve.value(at: time - xt0) let (yPosition, yActive) = yUnitCurve.value(at: time - yt0) return ( .init(width: xPosition * xv0, height: yPosition * yv0), xActive || yActive ) } func xLeftBounds(at time: CFTimeInterval) { guard !xOob else { assertionFailure("x left bounds twice") return } guard let xt0 else { assertionFailure("x left bounds with no xt0") return } xOob = true xv0 = xUnitCurve.velocity(at: time - xt0) * xv0 self.xt0 = time xUnitCurve = PolynomialBoundBounce.main } func yLeftBounds(at time: CFTimeInterval) { guard !yOob else { assertionFailure("y left bounds twice") return } guard let yt0 else { assertionFailure("y left bounds with no yt0") return } yOob = true yv0 = yUnitCurve.velocity(at: time - yt0) * yv0 self.yt0 = time yUnitCurve = PolynomialBoundBounce.main } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/ZoomCurves.swift ================================================ // // ZoomCurves.swift // Mlem // // Created by Eric Andrews on 2025-03-28. // import Foundation /// This works like the native UnitCurve protocol ZoomCurve { func value(at progress: Double) -> (Double, active: Bool) func velocity(at progress: Double) -> Double } class SinusoidalFriction: ZoomCurve { static var main: SinusoidalFriction { .init() } func value(at progress: Double) -> (Double, active: Bool) { guard progress < 1 else { return (0.5, false) } return (((.pi * progress) + sin(.pi * progress)) / (2 * .pi), true) } func velocity(at progress: Double) -> Double { guard progress < 1 else { return 0 } return (cos(.pi * progress) + 1) / 2 } } /// ZoomCurve that starts with velocity 1, slows, then gently returns to the original position. /// The underlying curve equation is y = x^3 + x^2. /// The maximum output value is 1/3 * duration; to maintain a slope of 1 at y = 0, the curve shape is scaled by duration on both axes class PolynomialBoundBounce: ZoomCurve { static var main: PolynomialBoundBounce { PolynomialBoundBounce(duration: 0.3) } var duration: Double init(duration: Double = 1) { self.duration = duration } func value(at progress: Double) -> (Double, active: Bool) { let scaledProgress: Double = progress / duration guard scaledProgress < 1 else { return (0, false) } return ((pow(scaledProgress - 1, 3) + pow(scaledProgress - 1, 2)) * duration, true) } func velocity(at progress: Double) -> Double { assertionFailure("Not implemented") return 0 } } /// ZoomCurve matching the shape of PolynomialBoundBounce, but starting at the furthest point of the bounce (1) and gently returning to 0. /// The maximum output value is 1; this curve only scales with duration along the x axis. class PolynomialBoundReset: ZoomCurve { static var main: PolynomialBoundReset { .init(duration: 0.25) } var duration: Double init(duration: Double = 1) { self.duration = duration } func value(at progress: Double) -> (Double, active: Bool) { let scaledProgress: Double = progress / duration guard scaledProgress < 1 else { return (0, false) } let base: CGFloat = (2 / 3) * (scaledProgress - 1) return ((pow(base, 3) + pow(base, 2)) * 6.75, true) } func velocity(at progress: Double) -> Double { assertionFailure("Not implemented") return 0 } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/ZoomRecognizer.swift ================================================ // // ZoomRecognizer.swift // Mlem // // Created by Eric Andrews on 2025-03-22. // import SwiftUI // TODO: LIST // - Optimize // - Investigate CGAffineTransform instead of scaleEffect + offset struct ZoomRecognizer: UIViewRepresentable { typealias Coordinator = ZoomRecognizerCoordinator @Binding var scale: CGFloat @Binding var offset: CGSize let customDragMoved: ((BridgeDragValue) -> Void)? let customDragEnded: (() -> Void)? let customTap: (() -> Void)? init( scale: Binding, offset: Binding, customDragMoved: ((BridgeDragValue) -> Void)? = nil, customDragEnded: (() -> Void)? = nil, customTap: (() -> Void)? = nil ) { _scale = scale _offset = offset self.customDragMoved = customDragMoved self.customDragEnded = customDragEnded self.customTap = customTap } func updateUIView(_ uiView: UIView, context: Context) { // noop } func makeUIView(context: Context) -> UIView { let ret: UIView = .init() let pinchGesture = PanningPinchRecognizer( target: context.coordinator, action: #selector(Coordinator.handlePinch(gesture:)), zoomScale: $scale ) pinchGesture.delegate = context.coordinator ret.addGestureRecognizer(pinchGesture) let panGesture = UIPanGestureRecognizer( target: context.coordinator, action: #selector(Coordinator.handlePan(gesture:)) ) panGesture.delegate = context.coordinator ret.addGestureRecognizer(panGesture) let doubleTap = UITapGestureRecognizer( target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(gesture:)) ) doubleTap.numberOfTapsRequired = 2 doubleTap.delegate = context.coordinator ret.addGestureRecognizer(doubleTap) let singleTap: UITapGestureRecognizer = MomentumResetTapGestureRecognizer( target: context.coordinator, action: #selector(Coordinator.handleSingleTap(gesture:)), resetMomentum: context.coordinator.resetMomentum ) singleTap.delegate = context.coordinator ret.addGestureRecognizer(singleTap) return ret } func makeCoordinator() -> Coordinator { .init( scale: $scale, offset: $offset, customDragMoved: customDragMoved, customDragEnded: customDragEnded, customTap: customTap ) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/ZoomRecognizerCoordinator+GestureRecognition.swift ================================================ // // ZoomRecognizerCoordinator+GestureRecognition.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // import UIKit extension ZoomRecognizerCoordinator { func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { if let doubleTap = gestureRecognizer as? UITapGestureRecognizer { if doubleTap.numberOfTapsRequired == 2, otherGestureRecognizer is UIPanGestureRecognizer { // prevents quick pan gestures from triggering as double tap return false } return true } return false } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer ) -> Bool { // single tap should require double tap to fail unless it killed momentum if let momentumResetGesture = gestureRecognizer as? MomentumResetTapGestureRecognizer, !momentumResetGesture.momentumKilled, let doubleTap = otherGestureRecognizer as? UITapGestureRecognizer, doubleTap.numberOfTapsRequired == 2 { return true } return false } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPinchGestureRecognizer || gestureRecognizer is UITapGestureRecognizer { return true } else if gestureRecognizer is UIPanGestureRecognizer { let location = gestureRecognizer.location(in: nil) if gestureRecognizer.numberOfTouches == 1 { if zoomSliderLocation.leftEnabled && leftZoomSliderHitbox.contains(location) || zoomSliderLocation.rightEnabled && rightZoomSliderHitbox.contains(location) { panType = .zoom return true } else if scale > 1.0 { panType = .move return true } else { panType = .custom return true } } return false } else { return false } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/ZoomRecognizerCoordinator+Logic.swift ================================================ // // ZoomRecognizerCoordinator+Logic.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // import SwiftUI import UIKit extension ZoomRecognizerCoordinator { // MARK: - Pan handlers /// Reacts to the given pan gesture by updating offset according to the gesture's translation. When the gesture ends, /// if it in bounds on any axis and moving faster than the momentum threshold (40), the view will continue to pan /// with momentum; otherwise it will stop and, if needed, reset to bounds. func handleMovePan(gesture: UIPanGestureRecognizer) { switch gesture.state { case .possible: break case .began: initializeBounds(view: gesture.view) resetMomentum() initialOffset = offset updateOffsetForPanGesture(gesture) case .changed: updateOffsetForPanGesture(gesture) case .ended, .cancelled: panType = .none guard let view = gesture.view else { assertionFailure("Missing view or bounds") return } initialScale = scale let gestureVelocity = gesture.velocity(in: view) let maxOffsets = maxOffsets.compute(scale) let xOob = abs(offset.width) >= maxOffsets.width let yOob = abs(offset.height) >= maxOffsets.height if !(xOob && yOob), abs(gestureVelocity.x) + abs(gestureVelocity.y) > 40 { startMomentum( velocity: gestureVelocity, xOob: xOob, yOob: yOob, maxXOffset: maxOffsets.width, maxYOffset: maxOffsets.height ) } else { let translation = gesture.translation(in: view) resetToBounds(activeOffset: .init(width: translation.x, height: translation.y).scaled(by: scale)) } case .failed: panType = .none default: assertionFailure("Unknown state") } } /// Reacts to the given pan gesture by updating zoom according to the height of the pan gesture func handleZoomPan(gesture: UIPanGestureRecognizer) { switch gesture.state { case .possible: break case .began: initializeBounds(view: gesture.view) guard let bounds else { assertionFailure("No bounds") return } resetMomentum() initialScale = scale initialOffset = offset let xAnchor = (((scale * bounds.width) / 2) - offset.width) / (scale * bounds.width) let yAnchor = (((scale * bounds.height) / 2) - offset.height) / (scale * bounds.height) anchor = .init(x: xAnchor, y: yAnchor) case .changed: let newScale = (initialScale + (gesture.translation(in: nil).y / -60)).bounded(lower: 1.0, upper: 4.0) let maxOffsets = maxOffsets.compute(newScale) let offsetDeltas = computeOffsetDeltas(scaleFactor: newScale / initialScale) let newOffset = initialOffset + offsetDeltas scale = newScale offset = .init( width: newOffset.width.bounded(lower: -maxOffsets.width, upper: maxOffsets.width), height: newOffset.height.bounded(lower: -maxOffsets.height, upper: maxOffsets.height) ) case .ended, .cancelled, .failed: panType = .none default: assertionFailure("Unknown state") } } /// Reacts to the given pan gesture using user-provided drag callbacks func handleCustomPan(gesture: UIPanGestureRecognizer) { switch gesture.state { case .possible: break case .began: customPanStartLocation = gesture.location(in: nil) customDragMoved?(.init(uiPanGesture: gesture, startLocation: customPanStartLocation)) case .changed: customDragMoved?(.init(uiPanGesture: gesture, startLocation: customPanStartLocation)) case .ended, .cancelled: customPanStartLocation = nil if let customDragEnded { customDragEnded() } case .failed: customPanStartLocation = nil default: assertionFailure("Unrecognized state") } } /// Updates offset according to the translation of the given pan gesture recognizer func updateOffsetForPanGesture(_ gesture: UIPanGestureRecognizer) { guard let view = gesture.view else { assertionFailure("No view") return } let translation = gesture.translation(in: view) offset = initialOffset + .init(width: translation.x, height: translation.y).scaled(by: scale) } // MARK: - Pinch handlers /// Prepares the view to pinch on a given point func beginPinch(at location: CGPoint) { guard let bounds else { assertionFailure("No bounds") return } initialScale = scale initialOffset = offset anchor = .init(x: location.x / bounds.width, y: location.y / bounds.height) } /// Updates the view based on the given scale and pan offset such that the anchor remains centered on the pinch func updatePinch(with scale: CGFloat, panOffset: CGSize) { let targetZoomScale: CGFloat = (initialScale * scale).softBounded(softMin: 1, hardMin: 0.6, softMax: 4, hardMax: 6) let adjustedScale: CGFloat = targetZoomScale / initialScale self.scale = targetZoomScale let offsetDeltas = computeOffsetDeltas(scaleFactor: adjustedScale) offset = initialOffset + panOffset + offsetDeltas } func endPinch(gesture: PanningPinchRecognizer) { resetToBounds(activeOffset: gesture.panOffset) gesture.panOffset = .zero } // MARK: - Momentum func startMomentum(velocity: CGPoint, xOob: Bool, yOob: Bool, maxXOffset: CGFloat, maxYOffset: CGFloat) { initialScale = scale initialOffset = offset let xVelo: CGFloat if xOob { let xBound: CGFloat = offset.width < 0 ? -maxXOffset : maxXOffset initialOffset.width = xBound xVelo = offset.width - xBound } else { xVelo = velocity.x * scale } let yVelo: CGFloat if yOob { let yBound: CGFloat = offset.height < 0 ? -maxYOffset : maxYOffset initialOffset.height = yBound yVelo = offset.height - yBound } else { yVelo = velocity.y * scale } momentum = .init( initialVelocity: .init(x: xVelo, y: yVelo), xOob: xOob, yOob: yOob ) // TODO: optimize this to use the full 120fps available on ProMotion displays let link = CADisplayLink(target: self, selector: #selector(tickMomentum)) link.preferredFrameRateRange = .init(minimum: 70, maximum: 90, __preferred: 90) link.add(to: .current, forMode: .default) self.link = link } @objc func tickMomentum(displayLink: CADisplayLink) { guard let momentum else { assertionFailure("Timer fired with no momentum") return } // set up initial times if momentum.xt0 == nil { momentum.xt0 = displayLink.timestamp } if momentum.yt0 == nil { momentum.yt0 = displayLink.timestamp } // check out-of-bounds let maxOffsets = maxOffsets.compute(scale) if !momentum.xOob, abs(offset.width) >= maxOffsets.width { initialOffset.width = maxOffsets.width * (offset.width < 0 ? -1 : 1) momentum.xLeftBounds(at: displayLink.timestamp) } if !momentum.yOob, abs(offset.height) >= maxOffsets.height { initialOffset.height = maxOffsets.height * (offset.height < 0 ? -1 : 1) momentum.yLeftBounds(at: displayLink.timestamp) } // compute offset let (increment, active) = momentum.position(at: displayLink.targetTimestamp) offset = initialOffset + increment if !active { resetMomentum() } } /// Halts momentum physics /// - Returns: true if momentum was killed, false if noop (no momentum when called) @discardableResult @objc func resetMomentum() -> Bool { let ret = momentum != nil link?.invalidate() link = nil momentum = nil return ret } // MARK: - Zoom /// Computes the difference that needs to be applied to the offset to anchor the zoom effect at `anchor` for /// a given `scaleFactor`, where `scaleFactor` is the ratio of the target scale to the scale when `anchor` was set. func computeOffsetDeltas(scaleFactor: CGFloat) -> CGSize { guard let bounds else { assertionFailure("No bounds") return .zero } let scaledBounds: CGSize = .init(width: bounds.width, height: bounds.height).scaled(by: initialScale) // (scale - 1) * (0.5 - anchor) computes the offset required to center the view on the anchor while zooming, // expressed in a percentage of the zoomed view's bounds; * scaledBounds.width transforms that into an // offset in real px let xOffset: CGFloat = (scaleFactor - 1) * (0.5 - anchor.x) * scaledBounds.width let yOffset: CGFloat = (scaleFactor - 1) * (0.5 - anchor.y) * scaledBounds.height return .init(width: xOffset, height: yOffset) } // MARK: - Bounds /// If bounds are not set, initializes them using the given UIView. The view is declared as optional to make this function /// easy to call, but is expected to be defined. func initializeBounds(view: UIView?) { guard let view else { assertionFailure("No view") return } if bounds == nil, view.bounds != .zero { bounds = .init(width: view.bounds.width, height: view.bounds.height) } } func isOutOfBounds(offset: CGSize) -> Bool { guard let bounds else { assertionFailure("No bounds") return false } return abs(offset.width) > bounds.width || abs(offset.height) > bounds.height } /// Resets offset and scale to be within bounds func resetToBounds(activeOffset: CGSize) { guard let bounds else { assertionFailure("No bounds") return } let boundedScale: CGFloat = scale.bounded(lower: 1.0, upper: 4.0) let offsetDeltas = computeOffsetDeltas(scaleFactor: boundedScale / initialScale) + activeOffset let maxOffsets = maxOffsets.compute(boundedScale) let newOffset: CGSize = .init( width: (initialOffset.width + offsetDeltas.width).bounded(lower: -maxOffsets.width, upper: maxOffsets.width), height: (initialOffset.height + offsetDeltas.height).bounded(lower: -maxOffsets.height, upper: maxOffsets.height) ) withAnimation(.easeOut(duration: 0.25)) { offset = newOffset scale = boundedScale } initialOffset = newOffset anchor = .init(x: newOffset.width / bounds.width, y: newOffset.height / bounds.height) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Helpers/ZoomRecognizer/ZoomRecognizerCoordinator.swift ================================================ // // ZoomRecognizerCoordinator.swift // Mlem // // Created by Eric Andrews on 2025-03-30. // import os import SwiftUI import UIKit enum PanType { case move, zoom, custom, none } class ZoomRecognizerCoordinator: NSObject, UIGestureRecognizerDelegate { private let log: Logger = .mlemLogger() @Setting(\.a11y_zoomSliderLocation) var zoomSliderLocation @Binding var scale: CGFloat @Binding var offset: CGSize let customDragMoved: ((BridgeDragValue) -> Void)? let customDragEnded: (() -> Void)? let customTap: (() -> Void)? /// Scale when the current gesture began var initialScale: CGFloat = 1.0 /// Offset when the current gesture began var initialOffset: CGSize = .zero /// Point in the image where the zoom gesture is anchored var anchor: UnitPoint = .center var link: CADisplayLink? var momentum: MomentumStatus? /// Bounds of the view var bounds: CGSize? var panType: PanType = .none var customPanStartLocation: CGPoint? /// Computes the maximum allowed offsets for a given scale. /// - Note: to get the minimum offset, multiply the return value by -1. lazy var maxOffsets: CachedComputation = .init { input in guard let bounds = self.bounds else { assertionFailure("No bounds") return .zero } return bounds.scaled(by: (input - 1) / 2) } let leftZoomSliderHitbox: CGRect = .init( origin: .init(x: 0, y: 70), size: .init(width: 40, height: UIScreen.main.bounds.height - 140) ) let rightZoomSliderHitbox: CGRect = .init( origin: .init(x: UIScreen.main.bounds.width - 40, y: 70), size: .init(width: 40, height: UIScreen.main.bounds.height - 140) ) init( scale: Binding, offset: Binding, customDragMoved: ((BridgeDragValue) -> Void)? = nil, customDragEnded: (() -> Void)? = nil, customTap: (() -> Void)? = nil ) { _scale = scale _offset = offset self.customDragMoved = customDragMoved self.customDragEnded = customDragEnded self.customTap = customTap } @objc func handlePinch(gesture: PanningPinchRecognizer) { switch gesture.state { case .possible: break case .began: guard let view = gesture.view else { assertionFailure("No view") return } initializeBounds(view: view) resetMomentum() beginPinch(at: gesture.location(in: view)) case .changed: updatePinch(with: gesture.scale, panOffset: gesture.panOffset) case .ended, .cancelled: endPinch(gesture: gesture) case .failed: log.debug("Pinch gesture failed") default: assertionFailure("Unknown state") } } @objc func handlePan(gesture: UIPanGestureRecognizer) { switch panType { case .move: handleMovePan(gesture: gesture) case .zoom: handleZoomPan(gesture: gesture) case .custom: handleCustomPan(gesture: gesture) case .none: assertionFailure("Pan started with no valid pan type") } } @objc func handleDoubleTap(gesture: UITapGestureRecognizer) { guard let view = gesture.view else { assertionFailure("No view") return } initializeBounds(view: view) guard let bounds else { assertionFailure("No bounds") return } initialOffset = offset initialScale = scale let targetZoomScale: CGFloat let newOffset: CGSize if scale == 1 { let location = gesture.location(in: view) targetZoomScale = 3 anchor = .init(x: location.x / bounds.width, y: location.y / bounds.height) let offsetDeltas = computeOffsetDeltas(scaleFactor: targetZoomScale / initialScale) let maxOffsets = maxOffsets.compute(targetZoomScale) newOffset = .init( width: (initialOffset.width + offsetDeltas.width).bounded(lower: -maxOffsets.width, upper: maxOffsets.width), height: (initialOffset.height + offsetDeltas.height).bounded(lower: -maxOffsets.height, upper: maxOffsets.height) ) } else { targetZoomScale = 1 anchor = .center newOffset = .zero } withAnimation(.easeInOut(duration: 0.25)) { offset = newOffset scale = targetZoomScale } } @objc func handleSingleTap(gesture: MomentumResetTapGestureRecognizer) { initializeBounds(view: gesture.view) let maxOffsets = maxOffsets.compute(scale) if abs(offset.width) > maxOffsets.width || abs(offset.height) > maxOffsets.height { resetToBounds(activeOffset: offset - initialOffset) } if gesture.momentumKilled { gesture.momentumKilled = false } else if let customTap { customTap() } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Wrappers/CircleCroppedImageView.swift ================================================ // // CircleCroppedImageView.swift // Mlem // // Created by Eric Andrews on 2024-08-02. // import Foundation import MlemMiddleware import SwiftUI import Media /// Convenience struct to automatically circle-crop an image. Also applies the given `frame` parameter as a frame to the view. struct CircleCroppedImageView: View { let url: URL? let frame: CGFloat // only need one CGFloat because always 1:1 aspect ratio let fallback: MediaView.Fallback let showProgress: Bool let blurred: Bool let enableAnimation: Bool /// Creates an image from the given URL cropped into a circle /// - Parameters: /// - url: URL of the image to render /// - frame: frame to crop the image into /// - fallback: fallback image /// - showProgress: true if the progress spinner should be displayed, false otherwise. Defaults to true. /// - blurred: true if the image should be blurred, false otherwise. Defaults to false. /// - enableAnimation: true if the image should animate, false if it should not. /// If unspecified, will only animate if the animated avatars settings is `.always` init( url: URL?, frame: CGFloat, fallback: MediaView.Fallback, showProgress: Bool = true, blurred: Bool = false, enableAnimation: Bool = (Settings.get(\.media_animatedAvatars) == .always) ) { self.url = url self.frame = frame self.fallback = fallback self.showProgress = showProgress self.blurred = blurred self.enableAnimation = enableAnimation } var body: some View { MediaView( url: url, size: .init(width: frame, height: frame), controlState: .constant(.init( blurred: blurred, animating: enableAnimation, muted: Settings.get(\.behavior_muteVideos) )), aspectRatioBounds: .absoluteSquare, contentMode: .fill, fallback: fallback ) .clipShape(Circle()) .geometryGroup() .frame(width: frame, height: frame) } } // convenience initializers for avatars extension CircleCroppedImageView { init( _ model: T?, frame: CGFloat, blurred: Bool = false, showProgress: Bool = true ) { self.init( url: model?.avatar, frame: frame, fallback: T.avatarFallback, blurred: blurred ) } init( _ model: any ProfileProviding, frame: CGFloat, blurred: Bool = false, showProgress: Bool = true ) { self.init( url: model.avatar, frame: frame, fallback: Swift.type(of: model).avatarFallback, blurred: blurred ) } } ================================================ FILE: Mlem/App/Views/Shared/Images/Wrappers/SimpleAvatarView.swift ================================================ // // SimpleAvatarView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import MlemMiddleware import Nuke import Rest import SwiftUI struct SimpleAvatarView: View { @State private var uiImage: UIImage @State private var loading: Bool let url: URL? let type: MediaView.Fallback init( url: URL?, type: MediaView.Fallback ) { self.url = url self.type = type self._uiImage = .init(wrappedValue: .init()) self._loading = .init(wrappedValue: url != nil) } var defaultImage: UIImage { guard let fromIcon: UIImage = .init(icon: type.icon) else { assertionFailure("Could not create default image from \(type.icon)") return .blank } return fromIcon .applyingSymbolConfiguration(.init( font: .systemFont(ofSize: 17), scale: .large ))! .withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal) } var body: some View { Group { if url == nil { Image(uiImage: defaultImage) .symbolVariant(.circle.fill) } else { Image(uiImage: uiImage) .task { await loadImage() } } } } func loadImage() async { guard let url else { return } do { let urlRequest = mlemUrlRequest(url: url) let imageTask = ImagePipeline.shared.imageTask(with: .init(urlRequest: urlRequest)) let image = try await imageTask.image uiImage = image.circleMasked loading = false } catch { handleError(error, silent: true) } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Wrappers/ThumbnailImageView.swift ================================================ // // ThumbnailImageView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import Foundation import MlemMiddleware import QuickLook import SwiftUI import Media struct ThumbnailImageView: View { @Environment(NavigationLayer.self) var navigation @Environment(\.openURL) var openURL @Setting(\.a11y_websiteThumbnailIcon) var websiteThumbnailIcon @Setting(\.post_size) var postSize @State var mediaControlState: MediaControlState @State var quickLookUrl: URL? let post: Post let size: Size let frame: CGSize enum Size { case standard, tile } var url: URL? { switch post.type { case let .media(url), let .embedded(url, _): url case let .link(link): link.thumbnail default: nil } } var onTapActions: (() -> Void)? { switch post.type { case .media, .embedded: { post.updateRead(true) } case let .link(link): { post.updateRead(true) openURL(link.content) } default: nil } } init( post: Post, blurred: Bool, size: Size, frame: CGSize ) { self.post = post self.size = size self.frame = frame self._mediaControlState = .init(wrappedValue: .init( blurred: blurred, animating: false, enableAnimation: false, muted: Settings.get(\.behavior_muteVideos) )) } var body: some View { content .overlay { if websiteThumbnailIcon, case .link = post.type { Image(icon: .general.browser) .frame(width: 16, height: 16) .foregroundStyle(.white) .background(.ultraThinMaterial, in: .circle) .padding(4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } .frame(width: frame.width, height: frame.width) } @ViewBuilder var content: some View { MediaView( url: url, size: frame, controlState: $mediaControlState, aspectRatioBounds: .absoluteSquare, contentMode: .fill, cornerRadius: size == .tile ? 0 : Constants.main.smallItemCornerRadius, fallback: post.imageFallback, enableContextMenu: post.type.isMedia, enableImageViewer: post.type.isMedia, onTapActions: onTapActions ) .overlay { if mediaControlState.animationAvailable { PlayButton(postSize: postSize) } } } func shareImage(url: URL) async { if let fileUrl = await downloadImageToFileSystem(url: url) { navigation.model?.shareInfo = .init(url: fileUrl) } } } ================================================ FILE: Mlem/App/Views/Shared/Images/Wrappers/ZoomableImageView.swift ================================================ // // ZoomableImageView.swift // Mlem // // Created by Eric Andrews on 2025-03-31. // import SwiftUI import Media struct ZoomableImageView: View { let url: URL @Binding var controlState: MediaControlState @Binding var scale: CGFloat @Binding var offset: CGSize let customDragMoved: ((BridgeDragValue) -> Void)? let customDragEnded: (() -> Void)? let customTap: (() -> Void)? var body: some View { MediaView(url: url, controlState: $controlState, overlays: .init([.error])) .overlay { ZoomRecognizer( scale: $scale, offset: $offset, customDragMoved: customDragMoved, customDragEnded: customDragEnded, customTap: customTap ) } .scaleEffect(scale) .offset(x: offset.width, y: offset.height) } } ================================================ FILE: Mlem/App/Views/Shared/InfoStackView.swift ================================================ // // InfoStackView.swift // Mlem // // Created by Sjmarf on 16/06/2024. // import MlemMiddleware import SwiftUI struct InfoStackView: View { let readouts: [Readout] var body: some View { HStack(spacing: 12) { ForEach(readouts, id: \.viewId) { readout in ReadoutView(readout: readout) } } .geometryGroup() } } struct ReadoutView: View { @Environment(\.palette) var palette let readout: Readout var body: some View { HStack(spacing: 2) { Image(systemName: readout.icon) Group { if readout.label?.allSatisfy(\.isNumber) ?? false { Text(readout.label ?? " ") .monospacedDigit() } else { Text(readout.label ?? " ") } } .contentTransition(.numericText(value: Double(readout.label ?? "") ?? 0)) .animation(.default, value: readout.label) if let value = readout.value { Text(value) .monospacedDigit() .foregroundStyle(readout.valueColor ?? .themedSecondary) } } .foregroundStyle(readout.color ?? .themedSecondary) .font(.footnote) .lineLimit(1) } } extension InfoStackView { init(post: Post, readouts: [PostBarConfiguration.ReadoutType], coloredReadouts: Set) { self.readouts = readouts.compactMap { post.readout(type: $0, showColor: coloredReadouts.contains($0)) } } init( comment: Comment, readouts: [CommentBarConfiguration.ReadoutType], coloredReadouts: Set ) { self.readouts = readouts.compactMap { comment.readout(type: $0, showColor: coloredReadouts.contains($0)) } } } private extension Readout { var viewId: Int { id.hashValue } } ================================================ FILE: Mlem/App/Views/Shared/InteractionBar/InteractionBarActionLabelView.swift ================================================ // // InteractionBarActionView.swift // Mlem // // Created by Sjmarf on 16/08/2024. // import SwiftUI import Theming struct InteractionBarActionLabelView: View { static let unweightedSymbols: Set = [Icons.upvote, Icons.downvote] @Setting(\.a11y_showInteractionBarButtonBackground) var showInteractionBarButtonBackground let appearance: ActionAppearance init(_ appearance: ActionAppearance) { self.appearance = appearance } var body: some View { Image(systemName: appearance.barIcon) .resizable() .fontWeight(Self.unweightedSymbols.contains(appearance.barIcon) ? .regular : .medium) .symbolVariant(appearance.isOn ? .fill : .none) .opacity(appearance.isInProgress ? 0 : 1) .scaledToFit() .frame(width: Constants.main.barIconSize, height: Constants.main.barIconSize) .frame(width: Constants.main.barIconBackgroundSize, height: Constants.main.barIconBackgroundSize) .foregroundStyle(appearance.isOn ? .themedContrastingLabel : .themedPrimary) .background(appearance.isOn ? appearance.color : .clear, in: .rect(cornerRadius: Constants.main.barIconCornerRadius)) .background { if showOutline { RoundedRectangle(cornerRadius: Constants.main.barIconCornerRadius) .fill(.themedTertiaryGroupedBackground) .paletteBorder(cornerRadius: Constants.main.barIconCornerRadius) } } .frame(width: Constants.main.barIconHitbox, height: Constants.main.barIconHitbox) .contentShape(Rectangle()) .opacity(appearance.isInProgress ? 0.5 : 1) .overlay { if appearance.isInProgress { ProgressView() .tint(appearance.isOn ? .themedContrastingLabel : .themedPrimary) } } .transaction { $0.animation = nil } } var showOutline: Bool { !appearance.isOn && showInteractionBarButtonBackground } } ================================================ FILE: Mlem/App/Views/Shared/InteractionBar/InteractionBarView.swift ================================================ // // InteractionBarView.swift // Mlem // // Created by Sjmarf on 14/06/2024. // import MlemMiddleware import SwiftUI /// Renders an interaction bar. /// /// This view makes several layout assumptions: /// - There will be no padding applied to this view /// - This view will always appear at the bottom of its visual container /// - The visual container of this view will have a padding of standardSpacing struct InteractionBarView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation private let leading: [EnrichedWidget] private let trailing: [EnrichedWidget] private let readouts: [Readout] init( appState: AppState, post: Post, configuration: PostBarConfiguration, navigation: NavigationLayer, commentTreeTracker: CommentTreeTracker? = nil, communityContext: Community? = nil, reportContext: Report? = nil ) { self.leading = .init( appState: appState, navigation: navigation, post: post, items: configuration.leading, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) self.trailing = .init( appState: appState, navigation: navigation, post: post, items: configuration.trailing, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) let associatedReadouts = configuration.all.reduce(into: Set()) { result, widget in result.formUnion(widget.associatedReadouts(context: post)) } self.readouts = configuration.readouts.compactMap { readout in post.readout(type: readout, showColor: !associatedReadouts.contains(readout)) } } init( appState: AppState, navigation: NavigationLayer, comment: Comment, configuration: CommentBarConfiguration, commentTreeTracker: CommentTreeTracker? = nil, communityContext: Community? = nil, reportContext: Report? ) { self.leading = .init( appState: appState, navigation: navigation, comment: comment, items: configuration.leading, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) self.trailing = .init( appState: appState, navigation: navigation, comment: comment, items: configuration.trailing, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) let associatedReadouts = configuration.all.reduce(into: Set()) { result, widget in result.formUnion(widget.associatedReadouts(context: comment)) } self.readouts = configuration.readouts.compactMap { readout in comment.readout(type: readout, showColor: !associatedReadouts.contains(readout)) } } init( appState: AppState, navigation: NavigationLayer, comment: Comment, notification: InboxNotification, configuration: ReplyBarConfiguration ) { self.leading = .init( appState: appState, navigation: navigation, comment: comment, notification: notification, items: configuration.leading ) self.trailing = .init( appState: appState, navigation: navigation, comment: comment, notification: notification, items: configuration.trailing ) let associatedReadouts = configuration.all.reduce(into: Set()) { result, widget in result.formUnion(widget.associatedReadouts(context: comment)) } self.readouts = configuration.readouts.compactMap { readout in comment.readout(type: readout, showColor: !associatedReadouts.contains(readout)) } } var body: some View { HStack(spacing: 0) { ForEach(leading, id: \.viewId, content: widgetView) .fixedSize(horizontal: true, vertical: false) InfoStackView(readouts: readouts) .frame(maxWidth: .infinity, alignment: infoStackAlignment) .padding(.horizontal, Constants.main.standardSpacing) ForEach(trailing, id: \.viewId, content: widgetView) .fixedSize(horizontal: true, vertical: false) } .frame(height: Constants.main.barIconHitbox) .geometryGroup() } var infoStackAlignment: Alignment { switch (leading.isEmpty, trailing.isEmpty) { case (true, false): .leading case (false, true): .trailing default: .center } } @ViewBuilder private func widgetView(_ widget: EnrichedWidget) -> some View { switch widget { case let .action(action): actionView(action) case let .counter(counter): counterView(counter) } } @ViewBuilder private func counterView(_ counter: Counter) -> some View { let paddingEdges: Edge.Set = { if counter.leadingAction == nil { return .leading } if counter.trailingAction == nil { return .trailing } return [] }() HStack(spacing: 0) { if let leadingAction = counter.leadingAction { actionView(leadingAction) } Text(counter.value?.description ?? "") .monospacedDigit() .contentTransition(.numericText(value: Double(counter.value ?? 0))) .animation(.default, value: counter.value) .foregroundStyle(.themedPrimary) .padding(paddingEdges, Constants.main.standardSpacing) if let trailingAction = counter.trailingAction { actionView(trailingAction) } } } @ViewBuilder private func actionView(_ action: any Action) -> some View { Group { if let action = action as? ActionGroup { Menu { ForEach(action.children, id: \.id) { child in MenuButton(action: child) } } label: { InteractionBarActionLabelView(action.appearance) .opacity(action.disabled ? 0.5 : 1) } .onTapGesture {} } else if let action = action as? BasicAction { InteractionBarBasicButton(action: action) .popupAnchor() } } .accessibilityLabel(action.appearance.label) .accessibilityAction(.default) { (action as? BasicAction)?.callback?() } .buttonStyle(.empty) .disabled({ if let action = action as? BasicAction { return action.callback == nil } else { return false } }()) .popupAnchor() } } private struct InteractionBarBasicButton: View { @Environment(PopupAnchorModel.self) var popupModel let action: BasicAction var body: some View { Button { action.callbackWithConfirmation(popupModel: popupModel) } label: { InteractionBarActionLabelView(action.appearance) .opacity(action.disabled ? 0.5 : 1) } } } private enum EnrichedWidget { case action(any Action) case counter(Counter) var viewId: Int { var hasher = Hasher() switch self { case let .action(action): hasher.combine(1) hasher.combine(action.id) hasher.combine(action.appearance.isOn) hasher.combine(action.appearance.isInProgress) hasher.combine((action as? BasicAction)?.disabled) case let .counter(counter): // If `counter.value` is included in this, the fancy `.numericText()` transition // won't work. In theory, you *do* need to include `counter.value` if you want a // view update to happen when it changes... but one occurs anyway without doing that, // so I'm hoping it'll be fine? The inclusion of `action.isOn` above is definitely // needed. - Sjmarf 2024-06-15 hasher.combine(2) hasher.combine(counter.leadingAction?.id) hasher.combine(counter.trailingAction?.id) hasher.combine((counter.leadingAction as? BasicAction)?.disabled) hasher.combine((counter.trailingAction as? BasicAction)?.disabled) } return hasher.finalize() } } extension [EnrichedWidget] { init( appState: AppState, navigation: NavigationLayer, post: Post, items: [PostBarConfiguration.Item], commentTreeTracker: CommentTreeTracker?, communityContext: Community?, reportContext: Report? ) { self = items.compactMap { item in switch item { case let .action(action): if let action = post.action( appState: appState, navigation: navigation, type: action, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) { return .action(action) } case let .counter(counter): if let counter = post.counter(appState: appState, type: counter, commentTreeTracker: commentTreeTracker) { return .counter(counter) } } return nil } } init( appState: AppState, navigation: NavigationLayer, comment: Comment, items: [CommentBarConfiguration.Item], commentTreeTracker: CommentTreeTracker?, communityContext: Community?, reportContext: Report? ) { self = items.compactMap { item in switch item { case let .action(action): if let action = comment.action( appState: appState, type: action, navigation: navigation, commentTreeTracker: commentTreeTracker, communityContext: communityContext, reportContext: reportContext ) { return .action(action) } case let .counter(counter): if let counter = comment.counter( appState: appState, type: counter, commentTreeTracker: commentTreeTracker ) { return .counter(counter) } } return nil } } init( appState: AppState, navigation: NavigationLayer, comment: Comment, notification: InboxNotification, items: [ReplyBarConfiguration.Item] ) { self = items.compactMap { item in switch item { case let .action(action): if let action = comment.action( appState: appState, type: action, navigation: navigation, notification: notification ) { return .action(action) } case let .counter(counter): if let counter = comment.counter(appState: appState, type: counter) { return .counter(counter) } } return nil } } } ================================================ FILE: Mlem/App/Views/Shared/JumpButtonView.swift ================================================ // // JumpButton.swift // Mlem // // Created by Sjmarf on 11/08/2023. // import Haptics import Icons import SwiftUI struct JumpButtonView: View { @Environment(HapticManager.self) var hapticManager @State private var pressed: Bool = false var icon: Icon = .lemmy.jumpButton var onShortPress: () -> Void var onLongPress: (() -> Void)? var body: some View { if #available(iOS 26, *) { // using glassEffect rather than GlassButtonStyle because the button style is buggy content .tint(.primary) .glassEffect(.regular.interactive(), in: .circle) .padding(10) } else { content .buttonStyle(.empty) } } var content: some View { Button {} label: { Image(icon: icon) .fontWeight(.semibold) .foregroundStyle(.secondary) .aspectRatio(contentMode: .fit) .frame(width: 44, height: 44) .background { if !UIDevice.isIos26 { Circle() .stroke(.tertiary.opacity(0.3)) .background(.bar) .clipShape(.circle) } } .padding(UIDevice.isIos26 ? 0 : 10) .scaleEffect(pressed && !UIDevice.isIos26 ? 1.2 : 1.0) .onTapGesture { hapticManager.play(haptic: .gentleInfo, tier: .high) onShortPress() } .onLongPressGesture( perform: { hapticManager.play(haptic: .gentleInfo, tier: .high) if let onLongPress { onLongPress() } }, onPressingChanged: { pressing in withAnimation(.interactiveSpring()) { pressed = pressing } } ) } } } ================================================ FILE: Mlem/App/Views/Shared/Labels/FullyQualifiedLabelView.swift ================================================ // // FullyQualifiedLabelView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import Foundation import MlemMiddleware import SwiftUI enum FullyQualifiedLabelStyle: CaseIterable { case small case medium case large var avatarSize: CGFloat { switch self { case .small: Constants.main.smallAvatarSize case .medium: Constants.main.mediumAvatarSize case .large: Constants.main.largeAvatarSize } } var avatarResolution: Int { switch self { case .small: 32 case .medium: 64 case .large: 96 } } var instanceLocation: InstanceLocation { switch self { case .small: .trailing case .medium: .trailing case .large: .bottom } } } /// View for rendering fully qualified labels (i.e., user or community names) struct FullyQualifiedLabelView: View { typealias Entity = CommunityOrPerson & ProfileProviding @Environment(AppState.self) var appState @Environment(\.postContext) var postContext: Post? @Environment(\.commentContext) var commentContext: Comment? @Environment(\.communityContext) var communityContext: Community? @Environment(\.feedContext) var feedContext: FeedContext? @Setting(\.post_showSubscribedStatus) var showSubscribedStatus @Setting(\.person_showAvatar) var showPersonAvatar @Setting(\.community_showAvatar) var showCommunityAvatar let entity: (any Entity)? let avatarFallback: MediaView.Fallback let labelStyle: FullyQualifiedLabelStyle var showAvatar: Bool? var showInstance: Bool = true var showFlairs: Bool = true var blurred: Bool = false var shouldShowAvatar: Bool { if let showAvatar { return showAvatar } if entity is Community { return showCommunityAvatar } else { return showPersonAvatar } } var showSubscriptionIndicator: Bool { guard showSubscribedStatus, entity is Community, let userSession = appState.firstSession as? UserSession, let communityId = postContext?.communityId, let feedContextShowsIndicator = feedContext?.showSubscriptionIndicator else { return false } let subscribedToCommunity: Bool = userSession.subscriptions.communityIds.contains(communityId) return subscribedToCommunity && feedContextShowsIndicator } @ScaledMetric(relativeTo: .body) var subscriptionIndicatorSize: CGFloat = 8.0 var body: some View { HStack(spacing: 7) { if shouldShowAvatar { CircleCroppedImageView( url: entity?.avatar?.withIconSize(labelStyle.avatarResolution), frame: labelStyle.avatarSize, fallback: avatarFallback, showProgress: false, blurred: blurred ) } VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { if showSubscriptionIndicator { Image(icon: .general.circle) .symbolVariant(.fill) .font(.system(size: subscriptionIndicatorSize)) .foregroundStyle(.themedSecondary) .padding(.bottom, 2) } FullyQualifiedNameView( name: entity?.name, instance: entity?.host, instanceLocation: showInstance ? labelStyle.instanceLocation : .disabled, prependedText: flairs.textView ) .symbolVariant(.fill) } .imageScale(.small) .offset(y: 1) if let note = (entity as? Person)?.note, feedContext != .person { self.note(text: note) } } } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) } func note(text: String) -> some View { Text(text) .font(.footnote) .foregroundStyle(.secondary) } var flairs: [PersonFlair] { guard showFlairs, let person = entity as? Person else { return [] } return person.flairs( interactableContext: interactableContext, communityContext: communityContext ) } var interactableContext: (any InteractableProviding)? { guard let person = entity as? Person else { return nil } if let commentContext, let creator = commentContext.creator.value, creator.actorId == person.actorId { return commentContext } if let postContext, let creator = postContext.creator.value, creator.actorId == person.actorId { return postContext } return nil } var accessibilityLabel: String { guard let entity else { return String(localized: "Loading...") } let flairs = flairs var result = entity.fullName if !flairs.isEmpty { result += flairs.map { String(localized: $0.label) }.joined(separator: ", ") } if let note = (entity as? Person)?.note { result += "\(String(localized: "Note")): \(note)" } return result } } extension FullyQualifiedLabelView { init( _ entity: Person?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, showFlairs: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .personAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, showFlairs: showFlairs, blurred: blurred ) } init( _ entity: Community?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, showFlairs: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .communityAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, showFlairs: showFlairs, blurred: blurred ) } init( _ entity: UserAccount?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, showFlairs: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .personAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, showFlairs: showFlairs, blurred: blurred ) } } // TODO: updated mocks // #if DEBUG // #Preview("Sizes", traits: .sampleEnvironment, .sizeThatFitsLayout) { // VStack(alignment: .leading) { // ForEach(FullyQualifiedLabelStyle.allCases, id: \.self) { style in // FullyQualifiedLabelView(Person1.mock(.generic), labelStyle: style) // } // } // .padding() // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Labels/FullyQualifiedLinkView.swift ================================================ // // FullyQualifiedLinkView.swift // Mlem // // Created by Sjmarf on 01/06/2024. // import MlemMiddleware import SwiftUI struct FullyQualifiedLinkView: View { @Environment(NavigationLayer.self) private var navigation let entity: (any FullyQualifiedLabelView.Entity)? let avatarFallback: MediaView.Fallback let labelStyle: FullyQualifiedLabelStyle var showAvatar: Bool? var showInstance: Bool = true var blurred: Bool = false @State private var id = UUID() var body: some View { Button { if let person = entity as? Person { navigation.push(.person(person)) } else if let community = entity as? Community { navigation.push(.community(community)) } } label: { FullyQualifiedLabelView( entity: entity, avatarFallback: avatarFallback, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, blurred: blurred ) } .buttonStyle(.plain) .id(id) } } extension FullyQualifiedLinkView { init( _ entity: Person?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .personAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, blurred: blurred ) } init( _ entity: Community?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .communityAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, blurred: blurred ) } init( _ entity: UserAccount?, labelStyle: FullyQualifiedLabelStyle, showAvatar: Bool? = nil, showInstance: Bool = true, blurred: Bool = false ) { self.init( entity: entity, avatarFallback: .personAvatar, labelStyle: labelStyle, showAvatar: showAvatar, showInstance: showInstance, blurred: blurred ) } } ================================================ FILE: Mlem/App/Views/Shared/Labels/FullyQualifiedNameView.swift ================================================ // // FullyQualifiedNameView.swift // Mlem // // Created by Eric Andrews on 2024-05-19. // import ComponentViews import Foundation import MlemMiddleware import SwiftUI enum InstanceLocation: String, CaseIterable, Codable { case disabled case trailing case bottom var label: LocalizedStringResource { switch self { case .disabled: "Disabled" case .trailing: "Trailing" case .bottom: "Bottom" } } } struct FullyQualifiedNameView: View { // parameters let name: String? let instance: String? let instanceLocation: InstanceLocation var prependedText: Text = .init(verbatim: "") // scale placeholder capsule height and spacing according to font size @ScaledMetric(relativeTo: .footnote) var capsuleHeight: CGFloat = 13 @ScaledMetric(relativeTo: .footnote) var capsuleSpacing: CGFloat = 5 var body: some View { if let name, let instance { (prependedText + nameText(name: name) + instanceText(instance: instance)) .lineLimit(instanceLocation == .bottom ? 2 : 1) .font(.footnote) .multilineTextAlignment(.leading) .environment(\._lineHeightMultiple, 0.8) } else { placeholder } } func nameText(name: String) -> Text { Text(name) .bold() .foregroundStyle(.themedSecondary) } func instanceText(instance: String) -> Text { if instanceLocation != .disabled { // prepend a newline if location is bottom for easy concatenation Text(verbatim: "\(instanceLocation == .bottom ? "\n" : "")@\(instance)") .font(.footnote) .foregroundStyle(.themedTertiary) } else { Text(verbatim: "") // return empty Text for easy concatenation } } var placeholder: some View { VStack(alignment: .leading, spacing: capsuleSpacing) { MockTextView() .frame(width: instanceLocation == .bottom ? 100 : 160, height: capsuleHeight) if instanceLocation == .bottom { MockTextView() .frame(width: 60, height: capsuleHeight * 0.8) .padding(.vertical, capsuleHeight * 0.2) } } } } extension FullyQualifiedNameView { init(_ entity: any CommunityOrPerson, instanceLocation: InstanceLocation) { self.init(name: entity.name, instance: entity.host, instanceLocation: instanceLocation) } } ================================================ FILE: Mlem/App/Views/Shared/LinkHostView.swift ================================================ // // LinkHostView.swift // Mlem // // Created by Sjmarf on 2025-10-07. // import MlemMiddleware import SwiftUI struct LinkHostView: View { @Setting(\.post_webPreview_showIcon) var showFavicons let link: PostLink let withCapsule: Bool var body: some View { if withCapsule { content .padding(Constants.main.halfSpacing) .padding(showFavicons ? .trailing : .horizontal, 3) .background { Capsule() .fill(.regularMaterial) .overlay(Capsule().fill(.themedBackground.opacity(0.25))) } } else { content } } var content: some View { HStack(spacing: Constants.main.halfSpacing) { if showFavicons { CircleCroppedImageView(url: link.favicon, frame: Constants.main.smallAvatarSize, fallback: .favicon) } Text(link.host) .foregroundStyle(.themedSecondary) } .font(.footnote) } } ================================================ FILE: Mlem/App/Views/Shared/ListRow/AccountListRow.swift ================================================ // // AccountListRow.swift // Mlem // // Created by Sjmarf on 22/12/2023. // import Dependencies import Icons import MlemMiddleware import NukeUI import SwiftUI struct AccountListRow: View { @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState @Environment(NavigationLayer.self) private var navigation @Setting(\.accounts_keepPlace) var keepPlace @State private var showingSignOutConfirmation: Bool = false let account: any Account var unreadCount: Int? var responseTime: TimeInterval? var complications: Set = .instanceAndTime @Binding var isSwitching: Bool var body: some View { Button { if appState.firstSession.actorId != account.actorId { changeAccount(keepPlace: keepPlace) } } label: { AccountListRowBody( account: account, unreadCount: unreadCount, responseTime: responseTime, complications: complications ) } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel) .swipeActions(edge: .leading, allowsFullSwipe: true) { if appState.firstSession.actorId != account.actorId { Group { if keepPlace { Button("Reload", icon: .lemmy.switchAccountAndReload) { changeAccount(keepPlace: false) } .buttonStyle(.automatic) } else { Button("Keep Place", icon: .lemmy.switchAccountAndKeepPlace) { changeAccount(keepPlace: true) } .buttonStyle(.automatic) } } .tint(.blue) } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if (account as? GuestAccount)?.isSaved ?? true { Button(String(localized: signOutLabel)) { showingSignOutConfirmation = true } .buttonStyle(.automatic) .tint(.red) } } .contextMenu { if (account as? GuestAccount)?.isSaved ?? true { SwiftUI.Section("Switch to this account and...") { Button("Reload", icon: .lemmy.switchAccountAndReload) { changeAccount(keepPlace: false) } Button("Keep Place", icon: .lemmy.switchAccountAndKeepPlace) { changeAccount(keepPlace: true) } } .disabled(appState.firstSession.actorId == account.actorId) Divider() Button(signOutLabel, icon: .general.signOut, role: .destructive) { showingSignOutConfirmation = true } } else { Button("Keep", icon: .lemmy.addPin) { AccountsTracker.main.addAccount(account: account) } } } .labelStyle(.titleAndIcon) // Override `.conditional` label style from parent view .confirmationDialog(String(localized: signOutPrompt), isPresented: $showingSignOutConfirmation) { Button(String(localized: signOutLabel), role: .destructive) { if navigation.isInsideSheet, appState.activeSessions.contains(where: { $0.account === account }) { dismiss() } account.signOut() } } message: { Text(signOutPrompt) } } func changeAccount(keepPlace: Bool) { appState.changeAccount(to: account, keepPlace: keepPlace) if navigation.isInsideSheet { if keepPlace { dismiss() } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismiss() } } } } var signOutLabel: LocalizedStringResource { account is UserAccount ? "Sign Out" : "Remove" } var signOutPrompt: LocalizedStringResource { if account is UserAccount { "Really sign out of \(account.nickname)?" } else { "Really remove \(account.nickname)?" } } var accessibilityLabel: String { var text: String if let account = account as? UserAccount { text = account.fullName ?? "unknown" } else { text = "guest" } if appState.firstSession.actorId == account.actorId { text += ", active" } return text } } ================================================ FILE: Mlem/App/Views/Shared/ListRow/AccountListRowBody.swift ================================================ // // AccountListRowBody.swift // Mlem // // Created by Sjmarf on 24/05/2024. // import NukeUI import SwiftUI struct AccountListRowBody: View { @Environment(AppState.self) private var appState enum Complication: CaseIterable { case instance, lastUsed, responseTime, isActive, unreadCount } let account: any Account var unreadCount: Int? var responseTime: TimeInterval? var complications: Set = .instanceAndTime var body: some View { HStack(alignment: .center, spacing: 10) { CircleCroppedImageView(account, frame: 40, showProgress: false) .padding(.leading, -5) VStack(alignment: .leading) { Text(account.nickname) if let captionText { Text(captionText) .font(.footnote) .foregroundStyle(.secondary) } } .padding(.vertical, -2) Spacer() AccountListRowBodyReadoutView( isActive: appState.firstSession.actorId == account.actorId, unreadCount: unreadCount, complications: complications ) } .contentShape(.rect) .animation(.easeOut(duration: 0.1), value: animationHash) } var animationHash: Int { var hasher = Hasher() hasher.combine(unreadCount) hasher.combine(responseTime) return hasher.finalize() } var timeText: String? { switch account.activityState { case let .inactive(lastUsed): guard let lastUsed else { return nil } if abs(lastUsed.timeIntervalSinceNow) < 5 { return .init(localized: "Just Now") } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short return formatter.localizedString(for: lastUsed, relativeTo: Date.now) case .active: return .init(localized: "Now") } } var captionText: String? { var output: [String] = [] if complications.contains(.instance) { if account is GuestAccount { output.append(.init(localized: "Guest")) } else { output.append("@\(account.api.host)") } } if complications.contains(.lastUsed), let timeText { if (account as? GuestAccount)?.isSaved ?? true { output.append(timeText) } else { output.append(.init(localized: "Temporary")) } } if complications.contains(.responseTime), let responseTime { let measurement = Measurement(value: Double(Int(responseTime * 1000)), unit: UnitDuration.milliseconds) let formatter = MeasurementFormatter() formatter.unitOptions = .providedUnit formatter.unitStyle = .short output.append(formatter.string(from: measurement)) } return output.joined(separator: " • ") } } private struct AccountListRowBodyReadoutView: View { let isActive: Bool let unreadCount: Int? let complications: Set var body: some View { if complications.contains(.isActive), isActive { Image(icon: .general.circle) .symbolVariant(.fill) .foregroundStyle(.themedPositive) .font(.system(size: 10.0)) .padding(.trailing, 7) } else { Image(icon: .lemmy.notificationCount(unreadCount ?? 0)) .foregroundStyle(.themedContrastingLabel, .themedWarning) .imageScale(.large) // For some reason, the animations don't work if we use an `if` statement .opacity(unreadCount == nil ? 0 : 1) } } } extension Set { static let instanceAndTime: Self = [.instance, .lastUsed, .isActive, .unreadCount] static let instanceOnly: Self = [.instance, .isActive, .unreadCount] static let timeOnly: Self = [.lastUsed, .isActive, .unreadCount] } ================================================ FILE: Mlem/App/Views/Shared/MarkdownEditorToolbarView.swift ================================================ // // MarkdownEditorToolbarView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import MlemMiddleware import SwiftUI // These can't be passed directly to the constructor - SwiftUI doesn't pick up on // view updates, because of the way this view is nested inside the keyboard using UIKit. @Observable class MarkdownEditorToolbarModel { var imageUploadApi: ApiClient? } struct MarkdownEditorToolbarView: View { enum AvailableActions { case all, inlineOnly } @Environment(NavigationLayer.self) var navigation let actions: AvailableActions let textView: UITextView let model: MarkdownEditorToolbarModel let uploadHistory: ImageUploadHistoryManager @State var imageManager: ImageUploadManager = .init() @ScaledMetric(relativeTo: .body) var toolbarHeight: CGFloat = 32 @State var leftFade: Bool @State var rightFade: Bool init( showing actions: AvailableActions = .all, textView: UITextView, uploadHistory: ImageUploadHistoryManager = .init(), model: MarkdownEditorToolbarModel ) { self.actions = actions self.textView = textView self.uploadHistory = uploadHistory self.model = model self.leftFade = false if #available(iOS 18.0, *) { self.rightFade = true } else { self.rightFade = false } } @ViewBuilder var body: some View { Group { switch imageManager.state { case let .uploading(progress): if progress == 1 { HStack { Text("Uploading...") ProgressView() .tint(.themedSecondary) } } else { ProgressView(value: progress) .progressViewStyle(.linear) .padding(.horizontal) } default: if #available(iOS 26, *) { content .compositingGroup() .glassEffect(.regular.interactive(), in: .capsule) .padding(.horizontal, 10) .padding(.bottom, 7) } else { content } } } .frame(maxWidth: .infinity) .frame(height: toolbarHeight, alignment: .bottom) .padding(.top, UIDevice.isIos26 ? 12 : 0) .onChange(of: imageManager.state) { switch imageManager.state { case let .done(upload): if let range = textView.selectedTextRange { textView.replace(range, withText: "![](\(upload.url.absoluteString))") uploadHistory.add(upload) imageManager.clear() } default: break } } } var content: some View { ScrollView(.horizontal) { if !UIDevice.isIos26 { Spacer() } HStack(spacing: 16) { scrollContent } .imageScale(.large) .buttonStyle(.plain) .foregroundStyle(.secondary) .labelStyle(.iconOnly) .padding(.horizontal) .padding(UIDevice.isIos26 ? .vertical : .bottom, UIDevice.isIos26 ? 5 : 2) } .scrollIndicators(.hidden) .mask( HStack(spacing: 0) { LinearGradient( gradient: Gradient(colors: [Color.black.opacity(leftFade ? 0 : 1), Color.black]), startPoint: .leading, endPoint: .trailing ) .frame(width: 100) Rectangle().fill(Color.black) LinearGradient( gradient: Gradient(colors: [Color.black, Color.black.opacity(rightFade ? 0 : 1)]), startPoint: .leading, endPoint: .trailing ) .frame(width: 100) } ) .task(id: model.imageUploadApi) { do { try await model.imageUploadApi?.ensureContextPresence() } catch { handleError(error) } } } @ViewBuilder var scrollContent: some View { // iPad already shows these buttons if !UIDevice.isPad { Button("Undo", systemImage: "arrow.uturn.backward") { textView.undoManager?.undo() } .compatibilityOnScrollVisibilityChange { isVisible in withAnimation { leftFade = !isVisible } } Button("Redo", systemImage: "arrow.uturn.forward") { textView.undoManager?.redo() } SwiftUI.Divider() .padding(.top, 2) } Button("Bold", icon: .markdown.bold) { textView.wrapSelectionWithDelimiters("**") } .compatibilityOnScrollVisibilityChange { isVisible in if UIDevice.isPad { withAnimation { leftFade = !isVisible } } } Button("Italic", icon: .markdown.italic) { textView.wrapSelectionWithDelimiters("_") } Button("Strikethrough", icon: .markdown.strikethrough) { textView.wrapSelectionWithDelimiters("~~") } Button("Superscript", icon: .markdown.superscript) { textView.wrapSelectionWithDelimiters("^") } Button("Subscript", icon: .markdown.subscript) { textView.wrapSelectionWithDelimiters("~") } Button("Code", icon: .markdown.inlineCode) { textView.wrapSelectionWithDelimiters("`") } Button("Link", icon: .markdown.insertLink) { textView.wrapSelectionWithLink() } if actions == .all { SwiftUI.Divider() .padding(.top, 2) Menu("Heading", icon: .markdown.heading) { ForEach(1 ..< 7) { level in Button("Heading \(level)") { textView.toggleHeadingAtCursor(level: level) } } } Button("Quote", icon: .markdown.quote) { textView.toggleQuoteAtCursor() } if let imageUploadApi = model.imageUploadApi { ImageUploadMenu(imageManager: imageManager, imageUploadApi: imageUploadApi) { Label("Image", icon: .markdown.uploadImage) } .disabled(!imageUploadApi.contextIsFetched) } Button("Spoiler", icon: .markdown.spoiler) { textView.wrapSelectionWithSpoiler() } Button("Code Block", icon: .markdown.codeBlock) { textView.wrapSelectionWithCodeBlock() } } SwiftUI.Divider() .padding(.top, 2) Button("Community Link", icon: .lemmy.community) { navigation.openSheet(.communityPicker { community in textView.insertText(community.fullNameWithPrefix) }) } Button("User Link", icon: .lemmy.person) { navigation.openSheet(.personPicker { person in // lemmy-ui doesn't recognize the @user@example.com format, so we have to do this instead :( // See this issue https://github.com/LemmyNet/lemmy-ui/issues/2579 textView.insertText("[\(person.fullNameWithPrefix)](\(person.actorId))") }) } Button("Instance Link", icon: .lemmy.instance) { navigation.openSheet(.instancePicker { instance in textView.insertText("[\(instance.host)](https://\(instance.host))") }) } .compatibilityOnScrollVisibilityChange { isVisible in withAnimation { rightFade = !isVisible } } } } private extension View { /// If onScrollVisibilityChange is available, applies it to this view; otherwise has no effect. func compatibilityOnScrollVisibilityChange(_ action: @escaping (Bool) -> Void) -> some View { if #available(iOS 18.0, *) { return onScrollVisibilityChange(action) } else { return self } } } ================================================ FILE: Mlem/App/Views/Shared/MarkdownTextEditor.swift ================================================ // // MarkdownTextEditor.swift // Mlem // // Created by Sjmarf on 14/07/2024. // import SwiftUI struct MarkdownTextEditor: UIViewRepresentable { let content: Content let insets: UIEdgeInsets let firstResponder: Bool let prompt: String let textView: UITextView let placeholderLabel: UILabel = .init() let font: UIFont let sizingOffset: CGFloat let onChange: (String) -> Void let onBeginEditing: () -> Void init( // A binding isn't used here because it creates a view update every time // the text changes. This created a noticable lag between pressing a key // and it appearing on the screen. Instead, parent views can access the // text directly from the `textView` and/or perform logic using the below // `onChange` callback. onChange: @escaping (String) -> Void = { _ in }, onBeginEditing: @escaping () -> Void = {}, prompt: LocalizedStringResource, textView: UITextView, font: UIFont = .preferredFont(forTextStyle: .body), insets: UIEdgeInsets = .init( top: Constants.main.halfSpacing, left: Constants.main.standardSpacing, bottom: Constants.main.standardSpacing, right: Constants.main.standardSpacing ), firstResponder: Bool = true, // In forms this needs to be set to ~10 (I don't know why this is the case) sizingOffset: CGFloat = 1, @ViewBuilder content: () -> Content ) { self.prompt = String(localized: prompt) self.textView = textView self.content = content() self.font = font self.insets = insets self.firstResponder = firstResponder self.sizingOffset = sizingOffset self.onChange = onChange self.onBeginEditing = onBeginEditing } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> UITextView { textView.font = font textView.textContainerInset = insets textView.delegate = context.coordinator textView.translatesAutoresizingMaskIntoConstraints = false textView.setContentHuggingPriority(.defaultLow, for: .horizontal) textView.sizeToFit() if firstResponder { textView.becomeFirstResponder() } textView.backgroundColor = .clear let contentController = UIHostingController( // If we don't explicitly set the environment here the toolbar can't access it rootView: content.environment(context.environment[NavigationLayer.self]) ) let contentView = contentController.view! let inputView = UIInputView(frame: CGRect(x: 0, y: 0, width: 0, height: UIDevice.isIos26 ? 48 : 36), inputViewStyle: .keyboard) inputView.addSubview(contentController.view) inputView.inputViewController?.addChild(contentController) contentView.translatesAutoresizingMaskIntoConstraints = false contentView.backgroundColor = UIColor.clear contentView.rightAnchor.constraint(equalTo: inputView.rightAnchor).isActive = true contentView.leftAnchor.constraint(equalTo: inputView.leftAnchor).isActive = true textView.inputAccessoryView = inputView inputView.sizeToFit() contentController.view.sizeToFit() placeholderLabel.text = prompt placeholderLabel.font = textView.font placeholderLabel.sizeToFit() textView.addSubview(placeholderLabel) placeholderLabel.frame.origin = CGPoint( x: insets.left + 5, y: insets.top + 1 ) placeholderLabel.textColor = UIColor(context.environment.palette.label.tertiary) placeholderLabel.isHidden = !textView.text.isEmpty // Makes the text wrap instead of going off-screen textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textView.isScrollEnabled = false return textView } func updateUIView(_ textView: UITextView, context: Context) { textView.sizeToFit() } func sizeThatFits(_ proposal: ProposedViewSize, uiView textView: UITextView, context: Context) -> CGSize? { let dimensions = proposal.replacingUnspecifiedDimensions( by: .init( width: 0, height: CGFloat.greatestFiniteMagnitude ) ) textView.sizeToFit() // `textView.contentSize` varies slightly on one line depending on which characters are typed. // To avoid this we get the line height from the font and round `contentSize` to the nearest line. let lineHeight = font.lineHeight + font.leading // This value seems to be constant no matter the font size let constant: CGFloat = 15 let calculatedHeight = constant + round((textView.contentSize.height - constant) / lineHeight) * lineHeight // The "+ 1" fixes a bug in which there wouldn't be enough room to render a second line when using // certain fonts (specifically, `.title2`). This would cause lines to sometimes not render. This // is probably a result of floating point error or something like that. This bug isn't a result // of the rounding logic above; it still happens when simply using `contentSize`. return .init( width: dimensions.width, height: calculatedHeight + sizingOffset ) } class Coordinator: NSObject, UITextViewDelegate { var parent: MarkdownTextEditor init(_ textView: MarkdownTextEditor) { self.parent = textView } func textViewDidChange(_ textView: UITextView) { parent.onChange(textView.text) parent.placeholderLabel.isHidden = !textView.text.isEmpty textView.sizeToFit() } func textViewDidBeginEditing(_ textView: UITextView) { parent.placeholderLabel.isHidden = !textView.text.isEmpty parent.onBeginEditing() } } } ================================================ FILE: Mlem/App/Views/Shared/MarkdownWithLinkList.swift ================================================ // // MarkdownWithLinkList.swift // Mlem // // Created by Sjmarf on 12/10/2024. // import LemmyMarkdownUI import SwiftUI struct MarkdownWithLinkList: View { @Environment(\.palette) var palette @Environment(\.openURL) var openURL @Environment(\.scrollProxy) var scrollProxy @Setting(\.links_displayMode) var tappableLinksDisplayMode @State var linksCollapsed: Bool = true let blocks: [BlockNode] let markdownConfiguration: MarkdownConfigurationType let showLinkCaptions: Bool init( _ blocks: [BlockNode], configuration: MarkdownConfigurationType = .default, showLinkCaptions: Bool = true ) { self.blocks = blocks self.markdownConfiguration = configuration self.showLinkCaptions = showLinkCaptions } init( _ markdown: String, configuration: MarkdownConfigurationType = .default, showLinkCaptions: Bool = true ) { self.blocks = .init(markdown) self.markdownConfiguration = configuration self.showLinkCaptions = showLinkCaptions } var showSubtitle: Bool { tappableLinksDisplayMode == .large || tappableLinksDisplayMode == .contextual && showLinkCaptions } var body: some View { VStack(spacing: Constants.main.standardSpacing) { Markdown(blocks, configuration: .init(type: markdownConfiguration, palette: palette)) if tappableLinksDisplayMode != .disabled { linksView(blocks.links.filter { !$0.insideSpoiler }) } } } @ViewBuilder func linksView(_ linksData: [LinkData]) -> some View { if linksData.count > 3 { ForEach(Array(linksData[0 ..< 3].enumerated()), id: \.offset) { _, link in linkView(link) } if linksCollapsed { Button { withAnimation { linksCollapsed = false } } label: { FooterLinkView(title: String(localized: "\(linksData.count - 3) more links..."), subtitle: nil) } } if !linksCollapsed { ForEach(Array(linksData[3...].enumerated()), id: \.offset) { _, link in linkView(link) } Button { withAnimation { linksCollapsed = true scrollProxy?.scrollTo(2) } } label: { FooterLinkView(title: String(localized: "Hide links"), subtitle: nil) } } } else { ForEach(Array(linksData.enumerated()), id: \.offset) { _, link in linkView(link) } } } @ViewBuilder func linkView(_ data: LinkData) -> some View { FooterLinkView( title: data.stringTitle, subtitle: showSubtitle ? data.url.absoluteURL.description : nil ) .contextMenu { Button("Open", icon: .general.browser) { openURL(data.url) } Button("Copy", icon: .general.copy) { let pasteboard = UIPasteboard.general pasteboard.url = data.url } ShareLink(item: data.url) } preview: { WebView(url: data.url) } .onTapGesture { openURL(data.url) } } } private extension LinkData { var stringTitle: String { let literal = title.stringLiteral if literal == url.absoluteString { return url.host() ?? literal } return literal } } enum TappableLinksDisplayMode: String, Codable, CaseIterable { case disabled, large, compact, contextual } ================================================ FILE: Mlem/App/Views/Shared/MenuButton.swift ================================================ // // MenuButton.swift // Mlem // // Created by Sjmarf on 31/03/2024. // import SwiftUI struct MenuButton: View { @Environment(NavigationLayer.self) var navigation @Environment(PopupAnchorModel.self) var popupModel: PopupAnchorModel? let action: any Action init(action: any Action) { self.action = action } var body: some View { if let action = action as? BasicAction { Button( action.appearance.label, systemImage: action.appearance.menuIcon, role: action.appearance.isDestructive ? .destructive : nil, action: { if let popupModel { action.callbackWithConfirmation(popupModel: popupModel) } else { assertionFailure() } } ) .tint(action.appearance.isDestructive ? .themedNegative : nil) .disabled(action.disabled) } else if let action = action as? ActionGroup { switch action.displayMode { case .section: SwiftUI.Section { iterateActions(actions: action.children) } case .compactSection: ControlGroup { iterateActions(actions: action.children) } .controlGroupStyle(.compactMenu) case .disclosure: Menu { iterateActions(actions: action.children) } label: { Label(action.appearance.label, systemImage: action.appearance.menuIcon) } case .popup: Button( action.appearance.label, systemImage: action.appearance.menuIcon, role: action.appearance.isDestructive ? .destructive : nil, action: { if let popupModel { popupModel.showPopup(action) } else { assertionFailure() } } ) .disabled(action.disabled) } } } @ViewBuilder func iterateActions(actions: [any Action]) -> some View { ForEach(actions, id: \.id) { action in MenuButton(action: action) } } } ================================================ FILE: Mlem/App/Views/Shared/MessageView.swift ================================================ // // MessageView.swift // Mlem // // Created by Sjmarf on 05/07/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct MessageView: View { @Environment(AppState.self) private var appState @Environment(NavigationLayer.self) private var navigation @Environment(\.reportContext) private var reportContext @Setting(\.menus_modActionGrouping) var moderatorActionGrouping let message: any Message let notification: InboxNotification? let embeddedContent: EmbeddedContent init( message: any Message, notification: InboxNotification?, @ViewBuilder embeddedContent: () -> EmbeddedContent = { EmptyView() } ) { self.message = message self.notification = notification self.embeddedContent = embeddedContent() } var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { FullyQualifiedLinkView(message.creator_, labelStyle: .small) Spacer() if let notification { Image(icon: message.isOwnMessage ? .lemmy.send : .lemmy.message) .symbolVariant(notification.read ? .none : .fill) .foregroundStyle(.themedAccent) } if let notification { EllipsisMenu(size: 24, notification: notification) .frame(height: 10) } else if let reportContext { EllipsisMenu(size: 24, message: message, report: reportContext) .frame(height: 10) } } if message.deleted { Text("Message was deleted") .italic() .foregroundStyle(.themedSecondary) } else { MarkdownWithLinkList(message.content) } Group { if message.isOwnMessage { Text("Sent \(message.created.getRelativeTime())") } else { Text("Received \(message.created.getRelativeTime())") } } .font(.caption) .foregroundStyle(.themedSecondary) embeddedContent } .padding(.vertical, 2) .padding(Constants.main.standardSpacing) .clipped() .background(.themedSecondaryGroupedBackground) .contentShape(.rect) .quickSwipes(message.swipeActions(notification: notification, appState: appState)) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(notification: notification, message: message, report: reportContext) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .onTapGesture { if let otherPerson, message.api.canInteract(appState: appState) { navigation.push(.messageFeed(otherPerson)) } } } var otherPerson: Person? { message.isOwnMessage ? message.recipient_ : message.creator_ } @MainActor func editMessage() { if let otherPerson { navigation.push(.messageFeed(otherPerson, focusTextField: true, editing: message)) } } } ================================================ FILE: Mlem/App/Views/Shared/ModlogButtonView.swift ================================================ // // ModlogButtonView.swift // Mlem // // Created by Sjmarf on 2024-12-25. // import ComponentViews import MlemMiddleware import SwiftUI struct ModlogButtonView: View { let target: ModlogView.InitialTarget init(community: Community) { self.target = .community(community) } init(instance: Instance) { self.target = .instance(instance) } var body: some View { NavigationLink(.modlog(target, targetPerson: nil, moderatorPerson: nil)) { FormChevron { Label { Text("Modlog") } icon: { Image(icon: .lemmy.modlog) .foregroundStyle(.themedSecondary) } } .padding(.vertical, Constants.main.halfSpacing) .padding(.horizontal, 15) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } .buttonStyle(.empty) } } ================================================ FILE: Mlem/App/Views/Shared/MultiplatformView.swift ================================================ // // MultiplatformView.swift // Mlem // // Created by Eric Andrews on 2024-06-13. // import Foundation import SwiftUI struct MultiplatformView: View { let phone: PhoneContent? let pad: PadContent? init(@ViewBuilder phone: () -> PhoneContent, @ViewBuilder pad: () -> PadContent) { if UIDevice.isPad { self.phone = nil self.pad = pad() } else { self.phone = phone() self.pad = nil } } var body: some View { if let phone { phone } else if let pad { pad } else { Text(verbatim: "MultiplatformView: Unsupported platform") } } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/LoginPage.swift ================================================ // // LoginPage.swift // Mlem // // Created by Sjmarf on 13/05/2024. // import MlemMiddleware import SwiftUI enum LoginPage: Hashable { case pickInstance case instance(_ instance: Instance) case reauth(_ account: UserAccount) case totp(client: ApiClient, usernameOrEmail: String, password: String) @ViewBuilder func view() -> some View { switch self { case .pickInstance: LoginInstancePickerView() case let .instance(instance): LoginCredentialsView(instance: instance) case let .reauth(account): LoginCredentialsView(account: account) case let .totp(client, usernameOrEmail, password): LoginTotpView(client: client, usernameOrEmail: usernameOrEmail, password: password) } } static func == (lhs: LoginPage, rhs: LoginPage) -> Bool { switch (lhs, rhs) { case (.pickInstance, .pickInstance): true case let (.totp(url1, username1, password1), .totp(url2, username2, password2)): url1 == url2 && username1 == username2 && password1 == password2 case let (.instance(instance1), .instance(instance2)): instance1.actorId == instance2.actorId case let (.reauth(user1), .reauth(user2)): user1 == user2 default: false } } func hash(into hasher: inout Hasher) { switch self { case .pickInstance: hasher.combine("pickInstance") case .totp: hasher.combine("totp") case let .instance(instance): hasher.combine(instance.actorId) case let .reauth(user): hasher.combine(user.actorId) } } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationLayer.swift ================================================ // // NavigationLayer.swift // Mlem // // Created by Sjmarf on 28/04/2024. // import MlemMiddleware import SwiftUI import UniformTypeIdentifiers @Observable class NavigationLayer: Identifiable { var id: ObjectIdentifier { .init(self) } weak var model: NavigationModel? var index: Int var root: NavigationPage var path: [NavigationPage] var hasNavigationStack: Bool var isFullScreenCover: Bool var canDisplayToasts: Bool var rootViewPresentationDetent: PresentationDetent // Used by ActionSheet var rootChangePending: Bool = false init( root: NavigationPage, path: [NavigationPage] = [], model: NavigationModel, index: Int = -1, hasNavigationStack: Bool = true, isFullScreenCover: Bool = false, canDisplayToasts: Bool = true ) { self.model = model self.index = index self.root = root self.path = path self.hasNavigationStack = hasNavigationStack self.isFullScreenCover = isFullScreenCover self.canDisplayToasts = canDisplayToasts self.rootViewPresentationDetent = root.presentationDetentConfiguration?.default.presentationDetent() ?? .large } @MainActor func push(_ page: NavigationPage) { if hasNavigationStack { // This prevents keyboard animation glitches when navigating whilst the keyboard is open UIApplication.shared.firstKeyWindow?.endEditing(true) path.append(page) } else { openSheet(page) } } /// Replaces the current top level page with the given NavigationPage. Useful for redirecting from interim /// pages that should not appear in navigation history. @MainActor func replace(_ page: NavigationPage) { if hasNavigationStack { // This prevents keyboard animation glitches when navigating whilst the keyboard is open UIApplication.shared.firstKeyWindow?.endEditing(true) if path.isEmpty { root = page } else { path[path.count - 1] = page } } else { openSheet(page) } } @MainActor func pop() { if !path.isEmpty { path.removeLast() } if path.isEmpty, index != -1 { model?.closeSheets(aboveIndex: index) } } @MainActor func dismissSheet() { model?.closeSheets(aboveIndex: index) } var isTopSheet: Bool { isInsideSheet && index == (model?.layers.count ?? 0) - 1 } var isBottomLayer: Bool { index == -1 } var isToastDisplayer: Bool { isInsideSheet && canDisplayToasts && model?.layers.last(where: { $0.canDisplayToasts }) === self } func popToRoot() { path.removeAll() } var isAtRoot: Bool { path.isEmpty } /// Open a new sheet, optionally with navigation enabled. If `nil` is specified for `hasNavigationStack`, the value of `page.hasNavigationStack` will be used. @MainActor func openSheet(_ page: NavigationPage, hasNavigationStack: Bool? = nil) { guard let model else { assertionFailure() return } rootChangePending = true if case .actionSheet = root { withAnimation { if let detentsConfiguration = page.presentationDetentConfiguration { if detentsConfiguration.detents.contains(.large), self.rootViewPresentationDetent != .large { rootViewPresentationDetent = .large } else if detentsConfiguration.detents.contains(.medium), self.rootViewPresentationDetent != .medium { rootViewPresentationDetent = .medium } } else { rootViewPresentationDetent = .large } } completion: { withAnimation(.easeOut(duration: 0.3)) { self.root = page self.hasNavigationStack = page.hasNavigationStack } } } else { model.openSheet( page, hasNavigationStack: hasNavigationStack ?? page.hasNavigationStack ) } } /// Convenience proxy for showFullScreenCover. Opens the image viewer with the given URL and disables animations on the fullScreenCover. @MainActor func showImageViewer(url: URL) { withoutAnimation { self.showFullScreenCover(.imageViewer(url), hasNavigationStack: false) } } /// Open a new sheet, optionally with navigation enabled. If `nil` is specified for `hasNavigationStack`, the value of `page.hasNavigationStack` will be used. @MainActor func showFullScreenCover(_ page: NavigationPage, hasNavigationStack: Bool? = nil) { model?.showFullScreenCover( page, hasNavigationStack: hasNavigationStack ?? page.hasNavigationStack ) } @MainActor func showPhotosPicker(for imageUploadManager: ImageUploadManager, api: ApiClient) { model?.contentPickerTracker.photosPickerCallback = { photo in Task { do { guard let data = try await photo.loadTransferable(type: Data.self) else { throw ApiClientError.unsuccessful } guard let fileExtension = photo.supportedContentTypes.first?.preferredFilenameExtension else { throw ApiClientError.unsuccessful } if Settings.get(\.behavior_confirmImageUploads) { self.openSheet(.confirmUpload( imageData: data, fileExtension: fileExtension, imageManager: imageUploadManager, uploadApi: api )) } else { try await imageUploadManager.upload(data: data, fileExtension: fileExtension, api: api) } } catch { handleError(error) } } } } @MainActor func showFilePicker(for imageUploadManager: ImageUploadManager, api: ApiClient) { model?.contentPickerTracker.showingFilePicker = true model?.contentPickerTracker.filePickerContentTypes = [.image] model?.contentPickerTracker.filePickerCallback = { url in Task { do { guard url.startAccessingSecurityScopedResource() else { throw MlemError.cannotAccessSecurityScopedResource } let data = try Data(contentsOf: url) url.stopAccessingSecurityScopedResource() if Settings.get(\.behavior_confirmImageUploads) { self.openSheet(.confirmUpload( imageData: data, fileExtension: url.pathExtension, imageManager: imageUploadManager, uploadApi: api )) } else { try await imageUploadManager.upload(data: data, fileExtension: url.pathExtension, api: api) } } catch { url.stopAccessingSecurityScopedResource() handleError(error) } } } } @MainActor func showFilePicker(types: [UTType], callback: @escaping (Data) async -> Void) { model?.contentPickerTracker.showingFilePicker = true model?.contentPickerTracker.filePickerContentTypes = types model?.contentPickerTracker.filePickerCallback = { url in Task { do { guard url.startAccessingSecurityScopedResource() else { throw MlemError.cannotAccessSecurityScopedResource } let data = try Data(contentsOf: url) await callback(data) url.stopAccessingSecurityScopedResource() } catch { url.stopAccessingSecurityScopedResource() handleError(error) } } } } @MainActor func uploadImageFromClipboard(for imageUploadManager: ImageUploadManager, api: ApiClient) { if UIPasteboard.general.hasImages, let content = UIPasteboard.general.image { if let data = content.pngData() { if Settings.get(\.behavior_confirmImageUploads) { openSheet(.confirmUpload( imageData: data, fileExtension: "png", imageManager: imageUploadManager, uploadApi: api )) } else { Task { do { try await imageUploadManager.upload(data: data, fileExtension: "png", api: api) } catch { handleError(error) } } } } } } var isInsideSheet: Bool { index != -1 } // Can be used inside of an `.onDisappear` to determine whether the disappearance was caused by the sheet closing var isAlive: Bool { model != nil } var isImageViewer: Bool { switch root { case .imageViewer: true default: false } } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationLayerView.swift ================================================ // // NavigationLayerView.swift // Mlem // // Created by Sjmarf on 28/04/2024. // import SwiftUI import SwiftUIIntrospect import UIKit struct NavigationLayerView: View { @Setting(\.appearance_interfaceStyle) var interfaceStyle @State var layer: NavigationLayer let hasSheetModifiers: Bool var selectedDetent: Binding? private let fullWidthGestureRecognizerDelegate: FullWidthGestureRecognizerDelegate = .init() var body: some View { Group { if layer.hasNavigationStack { NavigationStack(path: $layer.path) { rootView() .environment(\.isRootView, true) .navigationDestination(for: NavigationPage.self) { $0.view() .environment(\.isRootView, false) } } .introspect(.navigationStack, on: .iOS(.v17, .v18)) { controller in // This is for the "Swipe anywhere to navigate" setting // https://stackoverflow.com/questions/20714595/extend-default-interactivepopgesturerecognizer-beyond-screen-edge guard let interactivePopGestureRecognizer = controller.interactivePopGestureRecognizer, let targets = interactivePopGestureRecognizer.value(forKey: "targets") else { return } let fullWidthBackGestureRecognizer = UIPanGestureRecognizer() fullWidthBackGestureRecognizer.setValue(targets, forKey: "targets") fullWidthGestureRecognizerDelegate.navigationController = controller fullWidthBackGestureRecognizer.delegate = fullWidthGestureRecognizerDelegate controller.view.addGestureRecognizer(fullWidthBackGestureRecognizer) } } else { rootView() .environment(\.isRootView, true) } } .overlay(alignment: .top) { ToastOverlayView( shouldDisplayNewToasts: layer.isToastDisplayer && hasSheetModifiers, location: .top ) .padding(.top, 8) .ignoresSafeArea(edges: layer.isFullScreenCover ? [] : .top) } .overlay(alignment: .bottom) { ToastOverlayView( shouldDisplayNewToasts: layer.isToastDisplayer && hasSheetModifiers, location: .bottom ) .padding(.bottom, 8) } .modifier(HandleThreadiverseLinksModifier()) .environment(layer) .preferredColorScheme(preferredColorScheme) } @ViewBuilder private func rootView() -> some View { if hasSheetModifiers { innerRootView().navigationSheetModifiers(for: layer) } else { innerRootView() } } @ViewBuilder private func innerRootView() -> some View { if let selectedDetent { layer.root.sheetView(selectedDetent: selectedDetent) } else { layer.root.view() } } private var preferredColorScheme: ColorScheme? { @Setting(\.appearance_palette) var colorPalette let newStyle: UIUserInterfaceStyle = colorPalette.supportedModes != .unspecified ? colorPalette.supportedModes : interfaceStyle // The image viewer relies on having a concrete color scheme for the status bar color. // Otherwise the status bar will "flash" when the sheet is dismissed if layer.isImageViewer, newStyle == .unspecified { return UIScreen.main.traitCollection.userInterfaceStyle == .dark ? .dark : .light } return newStyle.colorScheme } } private class FullWidthGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { var navigationController: UINavigationController? func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard Settings.get(\.navigation_swipeAnywhere), !UIDevice.isIos26 else { return false } let isSystemSwipeToBackEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled == true let isThereStackedViewControllers = (navigationController?.viewControllers.count ?? 0) > 1 return isSystemSwipeToBackEnabled && isThereStackedViewControllers } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationModel.swift ================================================ // // NavigationModel.swift // Mlem // // Created by Sjmarf on 27/04/2024. // import PhotosUI import SwiftUI @Observable class NavigationModel { static let main: NavigationModel = .init() private(set) var layers: [NavigationLayer] = .init() struct ShareInfo { let url: URL let activities: [ShareActivity] init(url: URL, activities: [ShareActivity]) { self.url = url self.activities = activities } init(url: URL, actions: [BasicAction] = []) { self.url = url self.activities = actions.compactMap { action in if let callback = action.callback { .init(appearance: action.appearance, performAction: callback) } else { nil } } } } @Observable class ContentPickerTracker { var photosPickerCallback: ((PhotosPickerItem) -> Void)? // This needs two values unlike `photosPickerCallback` because // `fileImporter` sets `isPresented` to `false` before calling // `onCompletion`, which makes it impossible to call the callback // before setting it to `nil`. var showingFilePicker: Bool = false var filePickerCallback: ((URL) -> Void)? var filePickerContentTypes: [UTType] = [] } var contentPickerTracker: ContentPickerTracker = .init() var mediaUrl: URL? var shareInfo: ShareInfo? var pendingOpenURL: URL? @MainActor private func openSheet(_ page: NavigationPage, hasNavigationStack: Bool? = nil, isFullScreenCover: Bool) { guard Thread.isMainThread else { assertionFailure() ToastModel.main.add(.failure("Failed to open sheet")) return } layers.append( .init( root: page, model: self, index: layers.count, hasNavigationStack: hasNavigationStack ?? page.hasNavigationStack, isFullScreenCover: isFullScreenCover, canDisplayToasts: page.canDisplayToasts ) ) } // Closes all sheets above and including the given index @MainActor func closeSheets(aboveIndex index: Int) { for layer in layers.dropFirst(index) { layer.model = nil } layers.removeLast(max(0, layers.count - index)) } @MainActor func clear() { layers.forEach { $0.model = nil } layers = [] } @MainActor func openSheet(_ page: NavigationPage, hasNavigationStack: Bool? = nil) { openSheet(page, hasNavigationStack: hasNavigationStack, isFullScreenCover: false) } @MainActor func showFullScreenCover(_ page: NavigationPage, hasNavigationStack: Bool? = nil) { openSheet(page, hasNavigationStack: hasNavigationStack, isFullScreenCover: true) } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationPage+PresentationDetents.swift ================================================ // // NavigationPage.swift // Mlem // // Created by Sjmarf on 27/04/2024. // import ComponentViews import SwiftUI extension NavigationPage { // Return `nil` if you want the view itself to handle detents, rather than // that it being handled by the navigation system. var presentationDetentConfiguration: NavigationDetentConfiguration? { switch self { case .selectText: .only(.medium) case .actionSheet: .init([.medium, .large], default: .medium) case .externalApiInfo: .only(.medium) case .rulesList: .init([.medium, .large], default: .medium) case .quickSwitcher: .init([.medium, .large], default: .medium) case .shareInstancePicker: .only(.fit) case .remove, .denyApplication, .purge, .report, .editCommunity, .createComment, .createPost, .ban: nil default: .only(.large) } } var fitDetentEnabled: Bool { switch self { case .shareInstancePicker: true default: false } } } struct NavigationDetentConfiguration { enum Detent { case medium, large, fit func presentationDetent() -> PresentationDetent? { switch self { case .medium: .medium case .large: .large case .fit: nil } } } let detents: Set let `default`: Detent init(_ detents: Set, default default_: Detent) { self.detents = detents self.default = default_ assert(detents.contains(default_)) } static func only(_ detent: Detent) -> Self { .init([detent], default: detent) } } private extension Set { func presentationDetents() -> Set { .init(lazy.compactMap { $0.presentationDetent() }) } } extension View { @ViewBuilder func presentationDetents( configuration: NavigationDetentConfiguration, selection: Binding ) -> some View { presentationDetentFitsContent( fitDetentEnabled: configuration.detents.contains(.fit), configuration.detents.presentationDetents(), selection: selection ) } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationPage+View.swift ================================================ // // NavigationPage+View.swift // Mlem // // Created by Sjmarf on 28/04/2024. // import ComponentViews import MlemBackend import MlemMiddleware import SwiftUI extension NavigationPage { @ViewBuilder func sheetView(selectedDetent: Binding) -> some View { if let presentationDetentConfiguration { view() .presentationDetents(configuration: presentationDetentConfiguration, selection: selectedDetent) } else { view() } } // swiftlint:disable:next cyclomatic_complexity function_body_length @ViewBuilder func view() -> some View { switch self { case .subscriptionList: SubscriptionListView() case let .selectText(string): SelectTextView(text: string) case let .shareInstancePicker(sharable): ShareInstancePickerView(entity: sharable.wrappedValue) case let .settings(page): page.view() case let .logIn(page): page.view() case let .signUp(instance): SignUpView(instance: instance) case .onboarding: OnboardingView() case let .feeds(listingType): FeedsView(listingType: listingType) case .savedFeed: VisitAgainView(filter: .saved) case .upvotedFeed: VisitAgainView(filter: .upvoted) case .topCommunities: TopCommunitiesListView() case .topPeople: TopPeopleListView() case .topInstances: TopInstancesListView() case let .communityStub(community): CommunityStubResolutionPage(stub: community) case let .community(community, visitContext): CommunityView(community: community, visitContext: visitContext) case .profile: ProfileView() case .inbox: InboxView() case .search: SearchView() case let .externalApiInfo(api: api, actorId: actorId): ExternalApiInfoView(api: api, actorId: actorId) case let .imageViewer(url): ImageViewer(url: url) case .quickSwitcher: QuickSwitcherView() case let .report(target, community): ReportEditorView(target: target.wrappedValue, community: community) case let .remove(target): ContentRemovalEditorView(target: target.wrappedValue) case let .purge(target): ContentPurgeEditorView(target: target.wrappedValue) case let .ban(person, isBannedFromCommunity: isBannedFromCommunity, shouldBan: shouldBan, community: community): PersonBanEditorView( person: person, community: community, isBannedFromCommunity: isBannedFromCommunity, shouldBan: shouldBan ) case let .post(post, scrollTargetedComment, communityContext, _): // TODO: don't embed at all? ExpandedPostView(post: post, tracker: nil, scrollTargetedComment: scrollTargetedComment) { CrossPostListView(post: post) .padding(.horizontal, Constants.main.standardSpacing) } .environment(\.communityContext, communityContext) case let .postStub(post, _): PostStubResolutionPage(stub: post) case let .comment(comment, comments, showViewPostButton, exposeRemovedContent): CommentPage( comment: comment, initialComments: comments, showViewPostButton: showViewPostButton, exposeRemovedContent: exposeRemovedContent ) case let .commentStub(comment, comments, showViewPostButton, exposeRemovedContent): CommentStubResolutionPage( stub: comment, comments: comments, showViewPostButton: showViewPostButton, exposeRemovedContent: exposeRemovedContent ) case let .person(person, visitContext): PersonView(person: person, visitContext: visitContext) case let .personStub(person, visitContext): PersonStubResolutionPage(stub: person, visitContext: visitContext) case let .createComment(context, commentTreeTracker): if let view = CommentEditorView(context: context, commentTreeTracker: commentTreeTracker) { view } else { Text(verbatim: "Error: No active UserAccount") } case let .editComment(comment, context: context): if let view = CommentEditorView(commentToEdit: comment, context: context) { view } else { Text(verbatim: "Error: No active UserAccount") } case let .editCommunity(community): CommunityDescriptionEditorView(community: community) case let .editNote(person): NoteEditorView(person: person) case let .createPost( community: community, title: title, content: content, type: type, nsfw: nsfw, feedLoader: feedLoader ): if let view = PostEditorView( community: community, title: title, content: content, type: type, nsfw: nsfw, feedLoader: feedLoader.wrappedValue ) { view } else { Text(verbatim: "Error: No active UserAccount") } case let .editPost(post): PostEditorView(postToEdit: post, community: nil) case let .communityPicker(api: api, callback: callback): SearchSheetView(api: api) { (community: Community, navigation: NavigationLayer) in Button { callback.wrappedValue(community, navigation) } label: { CommunityListRowBody(community, readout: .subscribers) .tint(.themedPrimary) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } } case let .personPicker(api: api, filter: filter, callback: callback): SearchSheetView(api: api, filter: filter) { (person: Person, navigation: NavigationLayer) in Button { callback.wrappedValue(person, navigation) } label: { PersonListRowBody(person) .tint(.themedPrimary) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } } case let .instancePicker(callback: callback, requiredFeature: requiredFeature): SearchSheetView { (instance: InstanceSummary, navigation: NavigationLayer) in Button { callback.wrappedValue(instance, navigation) } label: { InstanceListRowBody(instance) .tint(.themedPrimary) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) } .disabled(requiredFeature.map { !SiteSoftware(from: instance.software).supports($0) } ?? false) } header: { if requiredFeature != nil, requiredFeature != .signUp { Text("This feature is not available on all instances.") .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(.themedCaution.opacity(0.2), in: .rect(cornerRadius: Constants.main.standardSpacing)) .foregroundStyle(.themedCaution) .padding(.horizontal, Constants.main.standardSpacing) .padding(.bottom, Constants.main.halfSpacing) } else if let errorDetails = MlemStats.main.errorDetails { ErrorView(errorDetails) .padding(.top, 40) } } case let .languagePicker(selectedLanguages: selectedLanguages, callback: callback): LanguagePickerSheetView(selectedLanguages: selectedLanguages, callback: callback.wrappedValue) case let .instance(instance, visitContext): InstanceView(instance: instance, visitContext: visitContext) case let .instanceStub(instance, targetPage): InstanceStubResolutionPage(stub: instance, targetPage: targetPage.wrappedValue) case let .instanceOpinionList(instance: instance, opinionType: opinionType, data: data): FediseerOpinionListView(instance: instance, opinionType: opinionType, fediseerData: data) case .fediseerInfo: FediseerInfoView() case let .instanceUptime(instance, uptimeData): InstanceUptimeView(instance: instance, uptimeData: uptimeData) case let .deleteAccount(account): DeleteAccountView(account: account) case let .bypassImageProxy(callback): BypassProxyWarningSheet(callback: callback.wrappedValue) case let .confirmUpload(imageData: imageData, fileExtension: fileExtension, imageManager: imageManager, uploadApi: uploadApi): UploadConfirmationView( imageData: imageData, fileExtension: fileExtension, imageManager: imageManager, uploadApi: uploadApi ) case let .rulesList(model, callback): RulesPickerView(model: model.wrappedValue, callback: callback.wrappedValue) case .blockList: BlockListView() case let .advancedSorting(sort): AdvancedSortView(selectedSort: sort.wrappedValue) case let .votesList(target): VotesListView(target: target) case let .messageFeed(person, messageContent: messageContent, focusTextField: focusTextField, editing: editing): MessageFeedView( person: person, messageContent: messageContent, focusTextField: focusTextField, editing: editing?.wrappedValue ) case let .modlog(target, targetPerson, moderatorPerson): ModlogView(initialTarget: target, targetPerson: targetPerson, moderatorPerson: moderatorPerson) case let .denyApplication(application): RegistrationApplicationDenialEditorView(application: application) case let .exportPostImage(post): ExportablePostEditorView(post: post) case let .exportCommentImage(comment, tracker): ExportableCommentEditorView(comment: comment, commentTreeTracker: tracker) case let .actionSheet(sections, environment, configuration): ActionSheet( sections: sections.wrappedValue, environment: environment.wrappedValue, configuration: configuration ) } } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationPage.swift ================================================ // // NavigationPage.swift // Mlem // // Created by Sjmarf on 27/04/2024. // import Actions import MlemBackend import MlemMiddleware import SwiftUI // swiftlint:disable file_length // swiftlint:disable:next type_body_length enum NavigationPage: Hashable { case settings(_ page: SettingsPage = .root) case logIn(_ page: LoginPage = .pickInstance) case signUp(_ instance: Instance) case onboarding case feeds(_ selection: ListingType? = nil) case savedFeed case upvotedFeed case topCommunities, topPeople, topInstances case profile, inbox, search case quickSwitcher case post( _ post: Post, scrollTargetedComment: Comment? = nil, communityContext: Community? = nil, navigationNamespace: Namespace.ID? = nil ) case postStub(_ post: PostStub, navigationNamespace: Namespace.ID? = nil) case comment( _ comment: Comment, comments: [Comment]? = nil, showViewPostButton: Bool = true, exposeRemovedContent: Bool = false ) case commentStub( _ comment: CommentStub, comments: [Comment]? = nil, showViewPostButton: Bool = true, exposeRemovedContent: Bool = false ) case communityStub( _ community: CommunityStub ) case community(_ community: Community, visitContext: VisitHistory.VisitContext = .other) case person(_ person: Person, visitContext: VisitHistory.VisitContext = .other) case personStub(_ personStub: PersonStub, visitContext: VisitHistory.VisitContext = .other) case instance(_ instance: Instance, visitContext: VisitHistory.VisitContext = .other) case instanceStub(_ instanceStub: InstanceStub, targetPage: HashWrapper<(Instance) -> NavigationPage>) case instanceOpinionList(instance: Instance, opinionType: FediseerOpinionType, data: FediseerData) case messageFeed(_ person: Person, messageContent: String, focusTextField: Bool, editing: MessageHashWrapper?) case fediseerInfo case instanceUptime(_ instance: Instance, uptimeData: UptimeData) case externalApiInfo(api: ApiClient, actorId: ActorIdentifier) case imageViewer(_ url: URL) case communityPicker(api: ApiClient?, callback: HashWrapper<(Community, NavigationLayer) -> Void>) case personPicker(api: ApiClient?, filter: ListingType, callback: HashWrapper<(Person, NavigationLayer) -> Void>) case instancePicker(callback: HashWrapper<(InstanceSummary, NavigationLayer) -> Void>, requiredFeature: Feature? = nil) case languagePicker(selectedLanguages: Set, callback: HashWrapper<(Locale.Language) -> Void>) case selectText(_ string: String) case shareInstancePicker(_ sharable: SharableHashWrapper) case subscriptionList case createComment(_ context: CommentEditorView.Context, commentTreeTracker: CommentTreeTracker? = nil) case editComment(_ comment: Comment, context: CommentEditorView.Context?) case editCommunity(_ community: Community) case editNote(_ person: Person) case report(_ interactable: ReportableHashWrapper, community: Community? = nil) case remove(_ removable: RemovableHashWrapper) case purge(_ purgable: PurgableHashWrapper) case ban(_ person: Person, isBannedFromCommunity: Bool, shouldBan: Bool, community: Community?) case createPost( community: Community?, title: String, content: String?, type: PostType?, nsfw: Bool, feedLoader: HashWrapper<(any FeedLoading)?> ) case editPost(_ post: Post) case deleteAccount(_ account: UserAccount) case bypassImageProxy(callback: HashWrapper<() -> Void>) case confirmUpload(imageData: Data, fileExtension: String, imageManager: ImageUploadManager, uploadApi: ApiClient) case rulesList(_ model: Profile2HashWrapper, callback: HashWrapper<(String) -> Void>) case blockList case advancedSorting(_ sort: HashWrapper>) case votesList(_ target: VotesListView.Target) case modlog(ModlogView.InitialTarget, targetPerson: Person?, moderatorPerson: Person?) case denyApplication(RegistrationApplication) case exportPostImage(_ post: Post) case exportCommentImage(_ comment: Comment, tracker: CommentTreeTracker?) // If `configuration` is specified, show a "customise" button in the sheet for editing that configuration. // Otherwise, no "customise" button is shown. case actionSheet( _ actions: HashWrapper<[ActionSheetSection]>, environment: HashWrapper, configuration: ContextMenuSettingsPage? ) static func shareInstancePicker(_ sharable: any Sharable) -> NavigationPage { shareInstancePicker(.init(wrappedValue: sharable)) } static func modlog( community: Community, targetPerson: Person? = nil, moderatorPerson: Person? = nil ) -> NavigationPage { modlog(.community(community), targetPerson: targetPerson, moderatorPerson: moderatorPerson) } static func modlog( instance: Instance, targetPerson: Person? = nil, moderatorPerson: Person? = nil ) -> NavigationPage { modlog(.instance(instance), targetPerson: targetPerson, moderatorPerson: moderatorPerson) } static func modlog( targetPerson: Person? = nil, moderatorPerson: Person? = nil ) -> NavigationPage { modlog(.currentInstance, targetPerson: targetPerson, moderatorPerson: moderatorPerson) } static func messageFeed( _ person: Person, messageContent: String = "", focusTextField: Bool = false, editing: (any Message1Providing)? = nil ) -> NavigationPage { var editingWrapper: MessageHashWrapper? if let editing { editingWrapper = .init(wrappedValue: editing) } return messageFeed( person, messageContent: messageContent, focusTextField: focusTextField, editing: editingWrapper ) } static func instanceStub(_ stub: InstanceStub, visitContext: VisitHistory.VisitContext = .other) -> NavigationPage { .instanceStub(stub, targetPage: .init(wrappedValue: { .instance($0, visitContext: visitContext) })) } static func instanceStub(_ stub: InstanceStub, targetPage: @escaping (Instance) -> NavigationPage) -> NavigationPage { .instanceStub(stub, targetPage: .init(wrappedValue: targetPage)) } static func hostInstance( of entity: any ActorIdentifiable, visitContext: VisitHistory.VisitContext = .other ) -> NavigationPage { if let entity = entity as? Person, let instance = entity.instance.value_ { return .instance(instance, visitContext: visitContext) } if let entity = entity as? Community, let instance = entity.instance.value_ as? Instance { return .instance(instance, visitContext: visitContext) } return .instanceStub(.init(api: AppState.main.firstApi, actorId: .instance(host: entity.actorId.host))) } static func communityPicker( api: ApiClient? = nil, callback: @escaping (Community, NavigationLayer) -> Void ) -> NavigationPage { communityPicker(api: api, callback: .init(wrappedValue: callback)) } static func personPicker( api: ApiClient? = nil, filter: ListingType = .all, callback: @escaping (Person, NavigationLayer) -> Void ) -> NavigationPage { personPicker(api: api, filter: filter, callback: .init(wrappedValue: callback)) } static func instancePicker( callback: @escaping (InstanceSummary, NavigationLayer) -> Void, requiredFeature: Feature? = nil ) -> NavigationPage { instancePicker(callback: .init(wrappedValue: callback), requiredFeature: requiredFeature) } static func languagePicker( selectedLanguages: Set, callback: @escaping (Locale.Language) -> Void ) -> NavigationPage { languagePicker(selectedLanguages: selectedLanguages, callback: .init(wrappedValue: callback)) } static func signUp() -> NavigationPage { .instancePicker(callback: { instance, navigation in Task { @MainActor in navigation.push(.signUp(instance.instanceStub)) } }, requiredFeature: .signUp) } static func signUp(_ stub: InstanceStub) -> NavigationPage { .instanceStub(stub, targetPage: .init(wrappedValue: { .signUp($0) })) } static func communityPicker( api: ApiClient? = nil, callback: @escaping (Community) -> Void ) -> NavigationPage { communityPicker(api: api, callback: .init(wrappedValue: { value, navigation in Task { @MainActor in navigation.dismissSheet() callback(value) } })) } static func personPicker( api: ApiClient? = nil, filter: ListingType = .all, callback: @escaping (Person) -> Void ) -> NavigationPage { personPicker(api: api, filter: filter, callback: .init(wrappedValue: { value, navigation in Task { @MainActor in navigation.dismissSheet() callback(value) } })) } static func instancePicker( callback: @escaping (InstanceSummary) -> Void, requiredFeature: Feature? = nil ) -> NavigationPage { instancePicker(callback: .init(wrappedValue: { value, navigation in Task { @MainActor in navigation.dismissSheet() callback(value) } }), requiredFeature: requiredFeature) } static func createPost( community: Community?, title: String = "", content: String? = nil, type: PostType?, nsfw: Bool = false, feedLoader: (any FeedLoading)? ) -> NavigationPage { return createPost( community: community, title: title, content: content, type: type, nsfw: nsfw, feedLoader: .init(wrappedValue: feedLoader) ) } static func report(_ interactable: any ReportableProviding, community: Community?) -> NavigationPage { return report(.init(wrappedValue: interactable), community: community) } static func remove(_ interactable: any RemovableProviding) -> NavigationPage { remove(.init(wrappedValue: interactable)) } static func purge(_ purgable: any PurgableProviding) -> NavigationPage { purge(.init(wrappedValue: purgable)) } static func bypassImageProxyWarning(callback: @escaping () -> Void) -> NavigationPage { bypassImageProxy(callback: .init(wrappedValue: callback)) } static func rulesList(_ model: any ProfileProviding, callback: @escaping (String) -> Void) -> NavigationPage { rulesList(.init(wrappedValue: model), callback: .init(wrappedValue: callback)) } static func advancedSorting(_ sort: Binding) -> NavigationPage { advancedSorting(.init(wrappedValue: sort)) } static func actionSheet( _ actions: [ActionSheetSection], environment: EnvironmentValues, configuration: ReferenceWritableKeyPath? = nil ) -> NavigationPage { actionSheet( .init(wrappedValue: actions), environment: .init(wrappedValue: environment), configuration: configuration.map(ContextMenuSettingsPage.init) ) } var hasNavigationStack: Bool { switch self { case .quickSwitcher, .report, .externalApiInfo, .selectText, .createComment, .editComment, .createPost, .editPost, .denyApplication, .actionSheet: false default: true } } var canDisplayToasts: Bool { switch self { case .quickSwitcher, .externalApiInfo, .selectText, .advancedSorting: false default: true } } } struct HashWrapper: Hashable, Identifiable { let wrappedValue: Value let id = UUID() func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: HashWrapper, rhs: HashWrapper) -> Bool { lhs.id == rhs.id } } struct ReportableHashWrapper: Hashable { var wrappedValue: any ReportableProviding func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.hashValue) } static func == (lhs: ReportableHashWrapper, rhs: ReportableHashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } struct SharableHashWrapper: Hashable { var wrappedValue: any Sharable func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.hashValue) } static func == (lhs: SharableHashWrapper, rhs: SharableHashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } struct RemovableHashWrapper: Hashable { var wrappedValue: any RemovableProviding func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.hashValue) } static func == (lhs: RemovableHashWrapper, rhs: RemovableHashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } struct PurgableHashWrapper: Hashable { var wrappedValue: any PurgableProviding func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.hashValue) } static func == (lhs: PurgableHashWrapper, rhs: PurgableHashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } struct Profile2HashWrapper: Hashable { var wrappedValue: any ProfileProviding func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.actorId) } static func == (lhs: Profile2HashWrapper, rhs: Profile2HashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } struct MessageHashWrapper: Hashable { var wrappedValue: any Message1Providing func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue.actorId) } static func == (lhs: MessageHashWrapper, rhs: MessageHashWrapper) -> Bool { lhs.hashValue == rhs.hashValue } } // swiftlint:enable file_length ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationRootView.swift ================================================ // // NavigationRoot.swift // Mlem // // Created by Sjmarf on 27/04/2024. // import SwiftUI struct NavigationSplitRootView: View { @State var layer: NavigationLayer let sidebar: NavigationPage @State var columnVisibility: NavigationSplitViewVisibility = .all init(sidebar: NavigationPage, root: NavigationPage) { self._layer = .init(wrappedValue: .init( root: UIDevice.isPad ? root : sidebar, path: UIDevice.isPad ? [] : [root], model: .main )) self.sidebar = sidebar self._columnVisibility = .init(wrappedValue: Settings.get(\.navigation_sidebarVisibleByDefault) ? .all : .detailOnly) } var body: some View { MultiplatformView( phone: { NavigationLayerView(layer: layer, hasSheetModifiers: false) }, pad: { NavigationSplitView( columnVisibility: $columnVisibility, sidebar: { sidebar.view() }, detail: { NavigationLayerView(layer: layer, hasSheetModifiers: false) .id(layer.root) } ) .modifier(HandleThreadiverseLinksModifier()) } ) .environment(layer) } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/NavigationSearchType.swift ================================================ // // NavigationSearchType.swift // Mlem // // Created by Sjmarf on 27/06/2024. // import MlemMiddleware import SwiftUI enum NavigationSearchType { case community } ================================================ FILE: Mlem/App/Views/Shared/Navigation/SettingsPage.swift ================================================ // // SettingsPage.swift // Mlem // // Created by Sjmarf on 07/05/2024. // import LemmyMarkdownUI import SwiftUI // swiftlint:disable:next type_body_length enum SettingsPage: Hashable { enum ContentActionType: Hashable { case post, comment, inboxNotification, postReport, commentReport } enum SwipeActionSettingType: Hashable { case post, comment, inboxNotification, postReport, commentReport, community } case root case accounts, account case profile, accountContent, accountAdvanced, accountSignIn, accountChangeEmail, accountLocal, accountChangePassword, accountLanguages case general, privacy, safety, accessibility, sorting, filters case zoomSlider case defaultFeed, haptics, accountAgeVisibility case privacyBypassImageProxy case safetyBlurNsfw, safetyWarnings case links, embedding case imageViewer, imageViewerControls, imageViewerDismissSensitivity case animatedAvatars case externalLinks, sharingLinks, tappableLinks case importExportSettings case theme, icon case post, comment, inbox, community, subscriptionList case tabBar, longPressAction case postThumbnail, postSubscriptionIndicator, postReadIndicator case commentMaximumDepth, commentJumpButton case inboxBadge case about, advanced, developer, errorLog case interactionBar(ContentActionType) case swipeActions(SwipeActionSettingType) case contextMenu(ContextMenuSettingsPage) case postBarWidgetPicker(HashWrapper>) case commentBarWidgetPicker(HashWrapper>) case replyBarWidgetPicker(HashWrapper>) case postReportBarWidgetPicker(HashWrapper>) case commentReportBarWidgetPicker(HashWrapper>) case moderation case modMailInteractionBar case separateModeratorActions case licences, document(Document) static func contextMenu(_ keyPath: ReferenceWritableKeyPath) -> Self { .contextMenu(.init(keyPath)) } @ViewBuilder // swiftlint:disable:next cyclomatic_complexity function_body_length func view() -> some View { switch self { case .root: SettingsView() case .account: AccountSettingsView() case .profile: if let person = AppState.main.firstPerson { ProfileSettingsView(person: person) } else { Text(verbatim: "Error: No active user account") } case .accountContent: AccountContentSettingsView() case .accountSignIn: AccountSignInSettingsView() case .accountAdvanced: AccountAdvancedSettingsView() case .accountChangeEmail: AccountEmailSettingsView() case .accountChangePassword: ChangePasswordView() case .accountLanguages: DiscussionLanguageSettingsView() case .accountLocal: AccountLocalSettingsView() case .accounts: AccountListSettingsView() case .general: GeneralSettingsView() case .defaultFeed: DefaultFeedSettingsView() case .haptics: HapticSettingsView() case .accountAgeVisibility: AccountAgeVisibilitySettingsView() case .privacy: PrivacySettingsView() case .privacyBypassImageProxy: PrivacyBypassImageProxySettingsView() case .safety: SafetySettingsView() case .safetyBlurNsfw: SafetyBlurNsfwSettingsView() case .safetyWarnings: SafetyWarningsSettingsView() case .accessibility: AccessibilitySettingsView() case .zoomSlider: ZoomSliderSettingsView() case .importExportSettings: ImportExportSettingsView() case .advanced: AdvancedSettingsView() case .developer: DeveloperSettingsView() case .errorLog: ErrorLogView() case .about: AboutMlemView() case .theme: ThemeSettingsView() case .icon: IconSettingsView() case .post: PostSettingsView() case .community: CommunitySettingsView() case .postThumbnail: PostThumbnailSettingsView() case .postSubscriptionIndicator: PostSubscriptionIndicatorSettingsView() case .postReadIndicator: PostReadIndicatorSettingsView() case .comment: CommentSettingsView() case .commentMaximumDepth: CommentMaximumDepthSettingsView() case .commentJumpButton: CommentJumpButtonSettingsView() case .inbox: InboxSettingsView() case .links: LinkSettingsView() case .externalLinks: ExternalLinkSettingsView() case .sharingLinks: SharingLinksSettingsView() case .tappableLinks: TappableLinksSettingsView() case .embedding: EmbeddingSettingsView() case .animatedAvatars: AnimatedAvatarSettingsView() case .sorting: SortingSettingsView() case .filters: FiltersSettingsView() case .moderation: ModeratorSettingsView() case .modMailInteractionBar: ModMailInteractionBarSettingsView() case .separateModeratorActions: ModeratorActionSeparationSettingsView() case .subscriptionList: SubscriptionListSettingsView() case .tabBar: TabBarSettingsView() case .longPressAction: LongPressActionSettingsView() case .inboxBadge: InboxBadgeSettingsView() case .imageViewer: ImageViewerSettingsView() case .imageViewerControls: ImageViewerShowControlsSettingsView() case .imageViewerDismissSensitivity: ImageViewerDismissSettingsView() case let .swipeActions(type): switch type { case .post: SwipeActionEditorView(\.interactionBar_post, onApplyToAll: { configuration in Settings.mutate(\.interactionBar_comment) { $0.applying(other: configuration, types: [.swipe]) } Settings.mutate(\.interactionBar_reply) { $0.applying(other: configuration, types: [.swipe]) } }) case .comment: SwipeActionEditorView(\.interactionBar_comment, onApplyToAll: { configuration in Settings.mutate(\.interactionBar_post) { $0.applying(other: configuration, types: [.swipe]) } Settings.mutate(\.interactionBar_reply) { $0.applying(other: configuration, types: [.swipe]) } }) case .inboxNotification: SwipeActionEditorView(\.interactionBar_reply, onApplyToAll: { configuration in Settings.mutate(\.interactionBar_post) { $0.applying(other: configuration, types: [.swipe]) } Settings.mutate(\.interactionBar_comment) { $0.applying(other: configuration, types: [.swipe]) } }) case .postReport: SwipeActionEditorView(\.interactionBar_postReport, onApplyToAll: { configuration in Settings.mutate(\.interactionBar_commentReport) { $0.applying(other: configuration, types: [.swipe]) } }) case .commentReport: SwipeActionEditorView(\.interactionBar_commentReport, onApplyToAll: { configuration in Settings.mutate(\.interactionBar_postReport) { $0.applying(other: configuration, types: [.swipe]) } }) case .community: SwipeActionEditorView(\.interactionBar_community) } case let .contextMenu(page): page.view case let .interactionBar(type): switch type { case .post: InteractionBarEditorView(setting: \.interactionBar_post, isReport: false) case .comment: InteractionBarEditorView(setting: \.interactionBar_comment, isReport: false) case .inboxNotification: InteractionBarEditorView(setting: \.interactionBar_reply, isReport: false) case .postReport: InteractionBarEditorView(setting: \.interactionBar_postReport, isReport: true) case .commentReport: InteractionBarEditorView(setting: \.interactionBar_commentReport, isReport: true) } case let .postBarWidgetPicker(configuration): InteractionBarWidgetPickerView(configuration: configuration.wrappedValue) case let .commentBarWidgetPicker(configuration): InteractionBarWidgetPickerView(configuration: configuration.wrappedValue) case let .replyBarWidgetPicker(configuration): InteractionBarWidgetPickerView(configuration: configuration.wrappedValue) case let .postReportBarWidgetPicker(configuration): InteractionBarWidgetPickerView(configuration: configuration.wrappedValue) case let .commentReportBarWidgetPicker(configuration): InteractionBarWidgetPickerView(configuration: configuration.wrappedValue) case let .document(doc): SimpleMarkdownPage(doc: doc) case .licences: Form { ForEach(Document.allLicenses) { doc in NavigationLink(doc.title, destination: .settings(.document(doc))) } } } } static func postBarWidgetPicker(_ configuration: Binding) -> SettingsPage { .postBarWidgetPicker(.init(wrappedValue: configuration)) } static func commentBarWidgetPicker(_ configuration: Binding) -> SettingsPage { .commentBarWidgetPicker(.init(wrappedValue: configuration)) } static func replyBarWidgetPicker(_ configuration: Binding) -> SettingsPage { .replyBarWidgetPicker(.init(wrappedValue: configuration)) } static func postReportBarWidgetPicker(_ configuration: Binding) -> SettingsPage { .postReportBarWidgetPicker(.init(wrappedValue: configuration)) } static func commentReportBarWidgetPicker(_ configuration: Binding) -> SettingsPage { .commentReportBarWidgetPicker(.init(wrappedValue: configuration)) } } private struct SimpleMarkdownPage: View { @Environment(\.palette) var palette let doc: Document var body: some View { ScrollView { Markdown(doc.body, configuration: .default(palette: palette)) .padding(Constants.main.standardSpacing) } .background(.themedBackground) } } struct ContextMenuSettingsPage: Hashable { let view: AnyView let hash: Int init(_ keyPath: ReferenceWritableKeyPath) { self.hash = keyPath.hashValue self.view = AnyView(ContextMenuSettingsView(keyPath)) } func hash(into hasher: inout Hasher) { hasher.combine(hash) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.hashValue == rhs.hashValue } } ================================================ FILE: Mlem/App/Views/Shared/Navigation/View+NavigationSheetModifiers.swift ================================================ // // NavigationPage+View.swift // Mlem // // Created by Sjmarf on 28/04/2024. // import SwiftUI import Theming private struct NavigationSheetModifier: ViewModifier { @Setting(\.appearance_palette) var colorPalette let nextLayer: NavigationLayer? let contentPickerTracker_: () -> NavigationModel.ContentPickerTracker? let isTopSheet: Bool @Binding var shareInfo: NavigationModel.ShareInfo? init( nextLayer: NavigationLayer?, isTopSheet: Bool, shareInfo: Binding, // This tomfoolery exists to prevent this view being subject to NavigationModel view updates, which caused #1492 contentPickerTracker: @escaping () -> NavigationModel.ContentPickerTracker? ) { self.nextLayer = nextLayer self.isTopSheet = isTopSheet self._shareInfo = shareInfo self.contentPickerTracker_ = contentPickerTracker } // DO NOT access this in the view body; see #1492 var contentPickerTracker: NavigationModel.ContentPickerTracker? { contentPickerTracker_() } // swiftlint:disable:next function_body_length func body(content: Content) -> some View { content // https://stackoverflow.com/questions/69693871/how-to-open-share-sheet-from-presented-sheet .background(SharingViewController( isPresenting: Binding(get: { shareInfo != nil && isTopSheet }, set: { if !$0 { shareInfo = nil }}) ) { activityViewController } ) .sheet(item: Binding( get: { if let nextLayer, !nextLayer.isFullScreenCover { nextLayer } else { nil } }, set: { if $0 == nil { closeSheet() } } )) { layer in NavigationLayerView( layer: layer, hasSheetModifiers: true, selectedDetent: Binding( get: { layer.rootViewPresentationDetent }, set: { layer.rootViewPresentationDetent = $0 } ) ) } .fullScreenCover(item: Binding( get: { if let nextLayer, nextLayer.isFullScreenCover { nextLayer } else { nil } }, set: { if $0 == nil { closeSheet() } } )) { layer in NavigationLayerView(layer: layer, hasSheetModifiers: true) } .photosPicker( isPresented: .init( get: { nextLayer == nil && contentPickerTracker?.photosPickerCallback != nil }, set: { contentPickerTracker?.photosPickerCallback = $0 ? contentPickerTracker?.photosPickerCallback : nil } ), selection: .init(get: { nil }, set: { photo in if let photo { contentPickerTracker?.photosPickerCallback?(photo) contentPickerTracker?.photosPickerCallback = nil } }), matching: .images ) .fileImporter( isPresented: .init( get: { nextLayer == nil && (contentPickerTracker?.showingFilePicker ?? false) }, set: { contentPickerTracker?.showingFilePicker = $0 } ), allowedContentTypes: contentPickerTracker?.filePickerContentTypes ?? [], onCompletion: { result in do { try contentPickerTracker?.filePickerCallback?(result.get()) } catch { handleError(error) } } ) .accentColor(ThemedColor.themedAccent.resolve(with: colorPalette.palette)) // deprecated, but .tint colors menu buttons } var activityViewController: UIActivityViewController { let activityView = UIActivityViewController( activityItems: [shareInfo?.url ?? URL(string: "www.apple.com")!], applicationActivities: shareInfo?.activities ) if UIDevice.isPad { activityView.popoverPresentationController?.sourceView = UIView() } activityView.completionWithItemsHandler = { _, _, _, _ in shareInfo = nil } return activityView } func closeSheet() { if let nextLayer, let model = nextLayer.model { model.closeSheets(aboveIndex: nextLayer.index) } } } private struct ComputeNextLayerModifier: ViewModifier { let layer: NavigationLayer // This exists to prevent the view from being subject to NavigationModel state updates, which caused #1492 @State var nextLayer: NavigationLayer? func body(content: Content) -> some View { Group { content.navigationSheetModifiers( nextLayer: nextLayer, isTopSheet: layer.isTopSheet, shareInfo: .init(get: { layer.model?.shareInfo }, set: { layer.model?.shareInfo = $0 }), contentPickerTracker: layer.model?.contentPickerTracker ) }.onChange(of: computeNextLayer()?.id, initial: true) { nextLayer = computeNextLayer() } } func computeNextLayer() -> NavigationLayer? { if let model = layer.model { (layer.index < model.layers.count - 1) ? model.layers[layer.index + 1] : nil } else { nil } } } extension View { @ViewBuilder func navigationSheetModifiers(for layer: NavigationLayer) -> some View { modifier(ComputeNextLayerModifier(layer: layer)) } @ViewBuilder func navigationSheetModifiers( nextLayer: NavigationLayer?, isTopSheet: Bool, shareInfo: Binding, contentPickerTracker: @autoclosure @escaping () -> NavigationModel.ContentPickerTracker? ) -> some View { modifier(NavigationSheetModifier( nextLayer: nextLayer, isTopSheet: isTopSheet, shareInfo: shareInfo, contentPickerTracker: contentPickerTracker )) } } private struct SharingViewController: UIViewControllerRepresentable { @Binding var isPresenting: Bool var content: () -> UIViewController func makeUIViewController(context: Context) -> UIViewController { UIViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { if isPresenting { uiViewController.present(content(), animated: true, completion: { isPresenting = false }) } } } ================================================ FILE: Mlem/App/Views/Shared/Palette Components/Button.swift ================================================ // // Button.swift // Mlem // // Created by Eric Andrews on 2024-09-06. // import Foundation import SwiftUI struct PaletteButton: ButtonStyle { @Environment(\.isEnabled) var isEnabled func makeBody(configuration: Configuration) -> some View { Group { if isEnabled { configuration.label .foregroundStyle(.tint) } else { configuration.label .foregroundStyle(.themedSecondary) } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } } ================================================ FILE: Mlem/App/Views/Shared/Palette Components/Divider.swift ================================================ // // Divider.swift // Mlem // // Created by Eric Andrews on 2024-08-30. // import Foundation import SwiftUI /// Divider() that colors itself appropriately to the palette. /// DO NOT use in UIKit environments! struct Divider: View { var body: some View { SwiftUI.Divider() .hidden() .overlay(.themedDivider) } } ================================================ FILE: Mlem/App/Views/Shared/Palette Components/Form.swift ================================================ // // Form.swift // Mlem // // Created by Eric Andrews on 2024-08-30. // import Foundation import SwiftUI import Theming /// Identical to Form, but respects Palette struct Form: View { @Environment(\.palette) var palette @ViewBuilder let content: () -> Content let tint: ThemedColor init(tint: ThemedColor = .themedAccent, @ViewBuilder content: @escaping () -> Content) { self.tint = tint self.content = content } var body: some View { SwiftUI.Form { content() .foregroundStyle(.themedPrimary) .listRowBackground(palette.groupedBackground.secondary) .buttonStyle(PaletteButton()) } .tint(tint) .listStyle(.insetGrouped) .scrollContentBackground(.hidden) .background(.themedGroupedBackground) .themedGroupedBackground() .shadow(color: palette.label.primary.opacity(palette.bordered ? 0.4 : 0.0), radius: 1) } } ================================================ FILE: Mlem/App/Views/Shared/Palette Components/Section.swift ================================================ // // Section.swift // Mlem // // Created by Eric Andrews on 2024-08-30. // import Foundation import SwiftUI struct Section: View { @ViewBuilder let header: () -> Parent @ViewBuilder let content: () -> Content @ViewBuilder let footer: () -> Footer init( @ViewBuilder content: @escaping () -> Content, @ViewBuilder header: @escaping () -> Parent = { EmptyView() }, @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } ) { self.header = header self.content = content self.footer = footer } init( _ header: LocalizedStringResource, @ViewBuilder content: @escaping () -> Content, @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } ) where Parent == Text { self.header = { Text(header) } self.content = content self.footer = footer } @_disfavoredOverload init( _ header: String, @ViewBuilder content: @escaping () -> Content, @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } ) where Parent == Text { self.header = { Text(header) } self.content = content self.footer = footer } var body: some View { SwiftUI.Section( content: content, header: { header().foregroundStyle(.themedSecondary) }, footer: { footer().foregroundStyle(.themedSecondary) } ) } } ================================================ FILE: Mlem/App/Views/Shared/PersonContentGridView+FeedLoaderType.swift ================================================ // // PersonContentGridView+FeedLoaderType.swift // Mlem // // Created by Sjmarf on 2025-10-29. // import Foundation import MlemMiddleware extension PersonContentGridView { enum FeedLoaderType { case standard(StandardFeedLoader, contentType: PersonContentType) case singleSourceMixed(SingleSourceMixedFeedLoader, contentType: PersonContentType) var items: [PersonContent] { switch self { case let .standard(feedLoader, _): feedLoader.items case let .singleSourceMixed(feedLoader, contentType): feedLoader.itemsForType(contentType) } } var loadingState: FeedLoadingState { switch self { case let .singleSourceMixed(feedLoader, contentType): feedLoader.loadingStateForType(contentType) default: feedLoading.loadingState } } var feedLoading: any FeedLoading { switch self { case let .standard(feedLoader, _): feedLoader case let .singleSourceMixed(feedLoader, _): feedLoader } } func loadIfThreshold(_ item: PersonContent) throws { switch self { case let .standard(feedLoader, _): try feedLoader.loadIfThreshold(item) case let .singleSourceMixed(feedLoader, contentType): try feedLoader.loadIfThreshold(item, asChild: contentType != .all) } } var type: PersonContentType { switch self { case let .standard(_, type): type case let .singleSourceMixed(_, type): type } } } } ================================================ FILE: Mlem/App/Views/Shared/PersonContentGridView.swift ================================================ // // PersonContentGridView.swift // Mlem // // Created by Eric Andrews on 2024-07-18. // import Foundation import MlemMiddleware import SwiftUI enum PersonContentType: CaseIterable, Identifiable { case all, posts, comments var id: Self { self } var label: LocalizedStringResource { switch self { case .all: "All" case .posts: "Posts" case .comments: "Comments" } } } struct PersonContentGridView: View { @Environment(AppState.self) var appState @Setting(\.post_size) var postSize @Setting(\.behavior_infiniteScroll) var infiniteScroll @State var columns: [GridItem] = [GridItem(.flexible())] @State var frameWidth: CGFloat = .zero @State var errorDetails: ErrorDetails? var feedLoader: FeedLoaderType var body: some View { content .loadFeed(feedLoader.feedLoading, errorDetails: $errorDetails) .widthReader(width: $frameWidth) .environment(\.parentFrameWidth, frameWidth) .onChange(of: postSize, initial: true) { _, newValue in if newValue.tiled { // leading/trailing alignment makes them want to stick to each other, allowing the Constants.main.halfSpacing padding applied below // to push them apart by a sum of Constants.main.standardSpacing columns = [ GridItem(.flexible(), spacing: 0, alignment: .trailing), GridItem(.flexible(), spacing: 0, alignment: .leading) ] } else if columns.count > 1 { // Only trigger if not already 1 column to avoid causing unnecessary view update columns = [GridItem(.flexible())] } } .toolbar { FeedToolbarOptions() } } @ViewBuilder var content: some View { let items = feedLoader.items VStack(spacing: 0) { LazyVGrid(columns: columns, spacing: spacing) { ForEach(items, id: \.hashValue) { item in if !item.shouldHideInFeed { personContentItem(item) .buttonStyle(.empty) .padding(.horizontal, postSize.tiled ? Constants.main.halfSpacing : 10) .onAppear { do { try feedLoader.loadIfThreshold(item) } catch { // TODO: is postFeedLoader.loadIfThreshold throws 400, this line is not executed handleError(error) } } } } } .quickSwipeCornerRadius(postSize.cornerRadius) .quickSwipeIconSize(postSize.quickSwipeIconSize) .quickSwipeThresholds(postSize.quickSwipeThresholds) .animation(.easeOut(duration: 0.1), value: items.isEmpty) if let errorDetails { ErrorView(errorDetails) } else { EndOfFeedView(loadingState: feedLoader.loadingState, viewType: .hobbit) } } } var spacing: CGFloat { switch feedLoader.type { case .all, .comments: postSize.sectionSpacing case .posts: postSize.sectionSpacing } } @ViewBuilder func personContentItem(_ personContent: PersonContent) -> some View { switch personContent.wrappedValue { case let .post(post): NavigationLink(.post(post)) { FeedPostView(post: post) } case let .comment(comment): NavigationLink(.comment(comment)) { FeedCommentView(comment: comment) } } } } ================================================ FILE: Mlem/App/Views/Shared/PostEllipsisMenus.swift ================================================ // // PostEllipsisMenus.swift // Mlem // // Created by Sjmarf on 01/10/2024. // import MlemMiddleware import SwiftUI /// Ellipsis menu for a post appearing in a larger view context. Posts appearing on their own page (i.e., ExpandedPostView) should /// place their ellipsis menu in the toolbar. struct PostEllipsisMenus: View { @Environment(AppState.self) private var appState @Environment(CommentTreeTracker.self) private var commentTreeTracker: CommentTreeTracker? @Environment(NavigationLayer.self) private var navigation @Environment(\.reportContext) private var reportContext: Report? @Setting(\.interactionBar_post) var postInteractionBar @Setting(\.menus_modActionGrouping) var moderatorActionGrouping // This @State is necessary! @State var post: Post var size: CGFloat = 24 var body: some View { HStack { if moderatorActionGrouping == .separateMenu { if post.canModerate { EllipsisMenu( icon: .lemmy.moderation, size: size, post: post, type: [.moderator] ) } EllipsisMenu(size: size, post: post, type: [.basic]) } else { EllipsisMenu(size: size, post: post, type: [.basic, .moderator]) } } .environment(\.communityContext, post.community.value) } } ================================================ FILE: Mlem/App/Views/Shared/PostGridView.swift ================================================ // // PostFeedView.swift // Mlem // // Created by Eric Andrews on 2024-06-29. // import Foundation import MlemMiddleware import SwiftUI /// Renders the content of a given StandardPostFeedLoader and adds a toolbar menu with the standard post feed controls. Additional toolbar actions /// should be handled by using ToolbarItemGroup(placement: .secondaryAction) on the parent view. /// This view handles: /// - Post layout /// - Loading /// - Default toolbar menu actions (show/hide read, post size) /// Scrolling, handling feed type changes, header, footer, etc. should be handled by the parent view struct PostGridView: View { @Setting(\.post_size) var postSize @Setting(\.feed_showRead) var showRead @Setting(\.behavior_infiniteScroll) var infiniteScroll @Setting(\.post_allowMultipleColumns) var allowMultipleColumns @Environment(FiltersTracker.self) var filtersTracker @Environment(\.communityContext) var communityContext @State var frameWidth: CGFloat = .zero @State var bottomAppearedPostIndex: Int = -1 @State var isWideEnoughForTwoColumns: Bool = false @Namespace var navigationNamespace let postFeedLoader: CorePostFeedLoader let alwaysShowRead: Bool init(postFeedLoader: CorePostFeedLoader, alwaysShowRead: Bool = false) { self.postFeedLoader = postFeedLoader self.alwaysShowRead = alwaysShowRead } var body: some View { content .widthReader(width: $frameWidth) .environment(\.parentFrameWidth, frameWidth) .loadFeed(postFeedLoader) .task(id: showRead) { do { if showRead || alwaysShowRead { try await postFeedLoader.deactivateFilter(.read) } else { try await postFeedLoader.activateFilter(.read) } } catch { handleError(error) } } .onDisappear { if let api = postFeedLoader.items.first?.api { Task { do { try await api.flushPostReadQueue() } catch { handleError(error) } } } } .toolbar { FeedToolbarOptions() } } var content: some View { VStack(spacing: 0) { GeometryReader { geometry in Spacer() .onChange(of: geometry.size.width, initial: true) { let newVal = geometry.size.width > 700 if isWideEnoughForTwoColumns != newVal { // Avoid unnecessary view update isWideEnoughForTwoColumns = newVal } } } .frame(height: 0) let columns = columns LazyVGrid(columns: columns, spacing: postSize.sectionSpacing) { ForEach(Array(postFeedLoader.items.enumerated()), id: \.element.hashValue) { index, post in if !post.shouldHideInFeed { NavigationLink(.post(post, communityContext: communityContext, navigationNamespace: navigationNamespace)) { FeedPostView(post: post, requireConsistentHeight: columns.count != 1) .matchedTransitionSource_(id: "post\(post.actorId)", in: navigationNamespace) } .buttonStyle(.empty) .padding(.horizontal, postInnerPadding) .markReadOnScroll( index: index, post: post, postFeedLoader: postFeedLoader, bottomAppearedItemIndex: $bottomAppearedPostIndex ) .onAppear { if infiniteScroll { do { try postFeedLoader.loadIfThreshold(post) } catch { // TODO: if postFeedLoader.loadIfThreshold throws 400, this line is not executed handleError(error) } } } } } } .quickSwipeCornerRadius(postSize.cornerRadius) .quickSwipeIconSize(postSize.quickSwipeIconSize) .quickSwipeThresholds(postSize.quickSwipeThresholds) .padding(.horizontal, postSize.tiled || columns.count == 1 ? 0 : Constants.main.halfSpacing) .animation(.easeOut(duration: 0.1), value: postFeedLoader.items.isEmpty) EndOfFeedView(feedLoader: postFeedLoader, viewType: .hobbit) } } var postInnerPadding: CGFloat { if columns.count == 1 { Constants.main.standardSpacing } else { Constants.main.standardSpacing / (postSize == .compact ? 4 : 2) } } var columns: [GridItem] { if postSize.tiled || (postSize != .large && isWideEnoughForTwoColumns), allowMultipleColumns { // leading/trailing alignment makes them want to stick to each other, allowing the Constants.main.halfSpacing padding applied below // to push them apart by a sum of Constants.main.standardSpacing // Avoid causing unnecessary view update return [ GridItem(.flexible(), spacing: 0, alignment: .trailing), GridItem(.flexible(), spacing: 0, alignment: .leading) ] } else { // Only trigger if not already 1 column to avoid causing unnecessary view update return [GridItem(.flexible())] } } } ================================================ FILE: Mlem/App/Views/Shared/ProfileDateView.swift ================================================ // // ProfileDateView.swift // Mlem // // Created by Sjmarf on 31/05/2024. // import Icons import MlemMiddleware import SwiftUI import Theming struct ProfileDateView: View { var profilable: any ProfileProviding @ViewBuilder var body: some View { if let created = profilable.profileCreated { Label(format(created), icon: icon) .symbolVariant(profilable.createdRecently || profilable.isCakeDay ? .fill : .none) .foregroundStyle(color) .font(.footnote) } } var color: ThemedColor { if profilable.createdRecently { .themedColorfulAccent(3) } else if profilable.isCakeDay { .themedColorfulAccent(1) } else { .themedSecondary } } var icon: Icon { profilable.createdRecently ? .lemmy.newAccountFlair : .lemmy.cakeDay } /// Returns a `String` with the cake date and a custom message depending to if today is cake day or not. /// If the current day is not the cake day, forges a `String` with relative time between the cake date and today. /// In that case the result string is in *full* unit style so as to improve vocalization of the date with *Voice Over¨. /// - Parameter date: the date to process, here the profile creation day /// - Returns String: The enriched string to add in the profile func format(_ date: Date) -> String { if profilable.isCakeDay { // It's possible for it to be a user's cake day without their account age quite being 365 days. // To account for this we subtrat 1 day from the start date, to push it over the 1 year mark. let startDate = date.addingTimeInterval(-60 * 60 * 24) let components = Calendar.current.dateComponents([.year], from: startDate, to: .now) return "\(date.dateString), " + String(localized: "\(components.year ?? 0) years ago today!") } return "\(date.dateString), \(date.getRelativeTime(unitsStyle: .full))" } } ================================================ FILE: Mlem/App/Views/Shared/ReadCheck.swift ================================================ // // ReadCheck.swift // Mlem // // Created by Eric Andrews on 2024-12-26. // import Icons import SwiftUI import Theming import MlemMiddleware struct ReadCheck: View { let dimension: CGFloat let read: ExpectedValue init(read: ExpectedValue, tiled: Bool = false) { self.read = read self.dimension = tiled ? 10 : 12 } var body: some View { ExpectedView(read) { read in content(read: read) } placeholder: { ProgressView() } } @ViewBuilder func content(read: Bool) -> some View { if read { Image(icon: .general.success) .resizable() .scaledToFit() .frame(width: dimension, height: dimension) .foregroundStyle(.themedSecondary) } } } ================================================ FILE: Mlem/App/Views/Shared/ReasonShortcutView.swift ================================================ // // ReasonPickerView.swift // Mlem // // Created by Sjmarf on 09/10/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct ReasonShortcutView: View { @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss @Binding var reason: String let rulesTarget: (any ProfileProviding)? init(reason: Binding, rulesTarget: (any ProfileProviding)? = nil) { self._reason = reason self.rulesTarget = rulesTarget } var body: some View { HStack(spacing: 12) { ForEach([ LocalizedStringResource("Spam"), LocalizedStringResource("Troll"), LocalizedStringResource("Abuse") ], id: \.key) { item in Text(item) .padding(.vertical, 8) .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 10)) .contentShape(.rect) .onTapGesture { var item = item // TODO: Set this to instance/community language? item.locale = .init(languageCode: .english) reason = String(localized: item) } } if let rulesTarget, ![BlockNode](rulesTarget.description ?? "").rules().isEmpty { Label("\(rulesTarget.name) rules...", systemImage: "book.pages") .labelStyle(.iconOnly) .foregroundStyle(.themedAccent) .padding(.vertical, 8) .padding(.horizontal, 12) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 10)) .onTapGesture { navigation.openSheet(.rulesList(rulesTarget, callback: { reason = $0 })) } } } .listRowBackground(Color.clear) .listRowInsets(.init()) } } ================================================ FILE: Mlem/App/Views/Shared/RefreshPopupView.swift ================================================ // // RefreshPopupView.swift // Mlem // // Created by Sjmarf on 12/07/2024. // import Haptics import SwiftUI struct RefreshPopupView: View { @Environment(HapticManager.self) var hapticManager let title: LocalizedStringResource @Binding var isPresented: Bool let callback: () -> Void init(_ title: LocalizedStringResource, isPresented: Binding, callback: @escaping () -> Void) { self.title = title self._isPresented = isPresented self.callback = callback } var body: some View { Group { if isPresented { Button { isPresented = false hapticManager.play(haptic: .lightSuccess, tier: .high) Task { @MainActor in callback() } } label: { HStack(spacing: 0) { Text(title) .padding(.horizontal, 10) Label("Refresh", icon: .general.refresh) .foregroundStyle(.themedContrastingLabel) .fontWeight(.semibold) .padding(.vertical, 4) .padding(.horizontal, 10) .background(.themedAccent, in: .capsule) } } .buttonStyle(.empty) .padding(4) .background(.themedSecondaryBackground, in: .capsule) .shadow(color: .black.opacity(0.1), radius: 5) .shadow(color: .black.opacity(0.1), radius: 1) .padding() .transition(.move(edge: .bottom).combined(with: .opacity)) } } .animation(.bouncy, value: isPresented) } } ================================================ FILE: Mlem/App/Views/Shared/RegistrationApplicationView.swift ================================================ // // RegistrationApplicationView.swift // Mlem // // Created by Sjmarf on 2025-01-13. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI import Theming struct RegistrationApplicationView: View { @Environment(\.palette) var palette let application: RegistrationApplication var body: some View { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { FullyQualifiedLinkView(application.creator, labelStyle: .medium) Spacer() EllipsisMenu(size: 24) { application.menuActions() } } Markdown(application.questionResponse, configuration: .default(palette: palette)) switch application.resolution { case .unresolved: resolutionButtonsView case .approved, .denied: resolutionInfoView } } .padding(Constants.main.standardSpacing) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .paletteBorder(cornerRadius: Constants.main.standardSpacing) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu { application.menuActions() } } @ViewBuilder var resolutionInfoView: some View { if let resolver = application.resolver { let color: ThemedColor = application.resolution == .approved ? .themedPositive : .themedNegative let resolverLabel = resolver.nameTextView( showFlairs: false, showInstance: true, font: .footnote, palette: palette, nameColor: color, instanceColor: color.opacity(0.5) ) Group { if case let .denied(reason) = application.resolution { if let reason { Label("Denied by \(resolverLabel): \"\(reason)\"", icon: .general.failure) } else { Label("Denied by \(resolverLabel)", icon: .general.failure) .lineLimit(1) } } else { Label("Approved by \(resolverLabel)", icon: .general.success) .lineLimit(1) } } .symbolVariant(.circle.fill) .foregroundStyle(color) .font(.footnote) } } @ViewBuilder var resolutionButtonsView: some View { HStack(spacing: Constants.main.standardSpacing) { Button { application.showDenialSheet() } label: { Image(icon: .general.failure) .frame(maxWidth: .infinity) .padding(.vertical, Constants.main.standardSpacing) } .background(.themedTertiaryGroupedBackground) .foregroundStyle(.themedNegative) .clipShape(.capsule) Button { application.approve() } label: { Image(icon: .general.success) .frame(maxWidth: .infinity) .padding(.vertical, Constants.main.standardSpacing) } .background(.themedTertiaryGroupedBackground) .foregroundStyle(.themedAccent) .clipShape(.capsule) } .font(.subheadline) .fontWeight(.semibold) } } ================================================ FILE: Mlem/App/Views/Shared/ReplyView.swift ================================================ // // ReplyView.swift // Mlem // // Created by Sjmarf on 04/07/2024. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct ReplyView: View { @Setting(\.interactionBar_reply) var replyInteractionBar @Environment(AppState.self) private var appState @Environment(NavigationLayer.self) private var navigation let notification: InboxNotification let comment: Comment var body: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: Constants.main.standardSpacing) { HStack { ExpectedView(comment.creator) { creator in FullyQualifiedLinkView(creator, labelStyle: .small) } placeholder: { Text(verbatim: .personPlaceholder).redacted(reason: .placeholder) } Spacer() Image(icon: (notification.content.type == .mention) ? .lemmy.mention : .lemmy.reply) .symbolVariant(notification.read ? .none : .fill) .foregroundStyle(.themedAccent) EllipsisMenu(size: 24, notification: notification) .frame(height: 10) } ExpectedView(comment.post) { post in FooterLinkView(title: post.title, subtitle: nil) } MarkdownWithLinkList(comment.content) } .padding([.top, .horizontal], Constants.main.standardSpacing) InteractionBarView( appState: appState, navigation: navigation, comment: comment, notification: notification, configuration: replyInteractionBar ) } .clipped() .background(.themedSecondaryGroupedBackground) .contentShape(.rect) .onTapGesture { navigation.push(.comment(comment)) } .quickSwipes(notification: notification, configuration: replyInteractionBar) .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(notification: notification) .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/ReportView.swift ================================================ // // ReportView.swift // Mlem // // Created by Sjmarf on 2024-12-16. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct ReportView: View { @Environment(\.palette) var palette let report: Report var body: some View { targetView .buttonStyle(.empty) .environment(\.reportContext, report) } @ViewBuilder var targetView: some View { switch report.target { case let .post(post): NavigationLink(.post(post)) { FeedPostView(post: post, overridePostSize: .headline, favoredLink: .creator) { reportDetailsView resolutionInfoView } } case let .comment(comment): NavigationLink(.comment(comment)) { FeedCommentView(comment: comment, overriddenSize: .large) { reportDetailsView resolutionInfoView } } case let .message(message): MessageView(message: message, notification: nil) { reportDetailsView resolveButton } } } @ViewBuilder var reportDetailsView: some View { VStack(alignment: .leading) { let reporterLabel = report.creator.nameTextView( showFlairs: false, showInstance: true, font: .footnote, palette: palette, nameColor: .themedWarning.opacity(0.5), instanceColor: .themedWarning.opacity(0.3) ) Text("Reported \(report.created.getRelativeTime()) by \(reporterLabel)") .foregroundStyle(.secondary) // No palette! .font(.footnote) .lineLimit(1) Text(report.reason) } .foregroundStyle(.themedWarning) .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.main.standardSpacing) .background( .themedWarning.opacity(0.1), in: .rect(cornerRadius: Constants.main.standardSpacing) ) } @ViewBuilder var resolutionInfoView: some View { if report.resolved, let resolver = report.resolver { let resolverLabel = resolver.nameTextView( showFlairs: false, showInstance: true, font: .footnote, palette: palette, nameColor: .themedPositive, instanceColor: .themedPositive.opacity(0.5) ) Label("Resolved by \(resolverLabel)", icon: .general.success) .foregroundStyle(.themedPositive) .symbolVariant(.circle.fill) .font(.footnote) .padding(.horizontal, Constants.main.halfSpacing) .lineLimit(1) } } @ViewBuilder var resolveButton: some View { HStack { Button( report.resolved ? "Resolved" : "Resolve", systemImage: Icons.success ) { report.toggleResolved(feedback: [.haptic]) } .foregroundStyle(report.resolved ? .themedContrastingLabel : .themedPrimary) .padding(.vertical, 3) .padding(.horizontal, 8) .imageScale(.small) .background( report.resolved ? .themedPositive : .themedTertiaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing) ) if report.resolved, let resolver = report.resolver { Text("by \(resolver.fullName)") .foregroundStyle(.themedPositive) } } .font(.footnote) } } ================================================ FILE: Mlem/App/Views/Shared/RulesListView.swift ================================================ // // RulesListView.swift // Mlem // // Created by Sjmarf on 2024-11-11. // import LemmyMarkdownUI import MlemMiddleware import SwiftUI struct RulesListView: View { @Environment(\.palette) var palette let model: any ProfileProviding @Binding var reason: String var body: some View { let rules = [BlockNode](model.description ?? "").rules() if !rules.isEmpty { Section { ForEach(Array(rules.enumerated()), id: \.offset) { index, blocks in HStack(spacing: 12) { Image(systemName: "\(index + 1).circle.fill") .foregroundStyle(.themedSecondary) .fontWeight(.semibold) Markdown(blocks, configuration: .default(palette: palette)) .frame(maxWidth: .infinity) } .contentShape(.rect) .onTapGesture { switch blocks.first { case let .paragraph(inlines: inlines), .heading(level: _, inlines: let inlines): let text = inlines.stringLiteral if text.count < 100 { reason = "\(model.name) rule #\(index + 1): \"\(text)\"" return } default: break } reason = "\(model.name) rule #\(index + 1)" } } } header: { HStack { CircleCroppedImageView(model, frame: 22) Text("\(model.name) rules:") .foregroundStyle(.themedSecondary) .textCase(nil) } } } } } ================================================ FILE: Mlem/App/Views/Shared/RulesPickerView.swift ================================================ // // RulesPickerView.swift // Mlem // // Created by Sjmarf on 2024-11-11. // import MlemMiddleware import SwiftUI struct RulesPickerView: View { @Environment(\.dismiss) var dismiss let model: any ProfileProviding let callback: (String) -> Void var body: some View { Form { RulesListView(model: model, reason: .init(get: { "" }, set: { callback($0) dismiss() })) } } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/EventRowView.swift ================================================ // // EventRowView.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import FediverseEvents import SwiftUI struct EventRowView: View { @Environment(\.openURL) var openURL let event: Event var body: some View { Button { if let url = event.navigationUrl { openURL(url) } } label: { HStack(spacing: 15) { CircleCroppedImageView( url: event.logos.first?.url, frame: SearchHomeLabelStyle.iconSize, fallback: .event ) Text(event.name) Spacer() dateView .padding(.trailing, 15) } } .buttonStyle(.chevron) } @ViewBuilder var dateView: some View { Group { if event.start < .now { Text("Ends \(event.end, format: .relative(presentation: .numeric, unitsStyle: .wide))") } else { Text("Starts \(event.start, format: .relative(presentation: .numeric, unitsStyle: .wide))") } } .font(.footnote) .foregroundStyle(.secondary) } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/SearchHomeCategoryLabelStyle.swift ================================================ // // SearchHomeCategoryLabelStyle.swift // Mlem // // Created by Sjmarf on 2026-04-24. // import SwiftUI import Theming struct SearchHomeCategoryLabelStyle: LabelStyle { @Environment(\.palette) var palette @Environment(\.tint) var tint static let iconSize: CGFloat = 80 private static var innerIconSize: CGFloat { iconSize - 40 } func makeBody(configuration: Configuration) -> some View { VStack { configuration.icon .font(.system(size: Self.innerIconSize)) .frame(width: Self.innerIconSize, height: Self.innerIconSize) .foregroundStyle(.white) .symbolVariant(.fill) .padding(20) .background(tint.gradient(palette: palette), in: .circle) configuration.title .fontWeight(.semibold) .font(.subheadline) } } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/SearchHomeLabelStyle.swift ================================================ // // SearchHomeLabelStyle.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import SwiftUI import Theming struct SearchHomeLabelStyle: LabelStyle { @Environment(\.palette) var palette @Environment(\.tint) var tint static let iconSize: CGFloat = 35 func makeBody(configuration: Configuration) -> some View { HStack(spacing: 15) { configuration.icon .symbolVariant(.fill.circle) .foregroundStyle(.white, tint.gradient(palette: palette)) .scaledToFit() .font(.system(size: Self.iconSize)) .frame(width: Self.iconSize, height: Self.iconSize) configuration.title } } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/SearchHomeListView.swift ================================================ // // SearchHomeListView.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import SwiftUI struct SearchHomeListView: View { var content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { VStack { Group(subviews: content) { subviews in ForEach(Array(subviews.enumerated()), id: \.element.id) { index, subview in subview if index != subviews.count - 1 { Divider() .padding(.leading, 50) } } } } .padding(10) .padding(.trailing, 5) .labelStyle(SearchHomeLabelStyle()) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 25)) .paletteBorder(cornerRadius: 25) } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/SearchHomeView.swift ================================================ // // SearchHomeView.swift // Mlem // // Created by Sjmarf on 2025-08-14. // import ComponentViews import FediverseEvents import Icons import SwiftUI import Theming struct SearchHomeView: View { @Environment(\.navigation) var navigation @Environment(\.palette) var palette @Environment(AppState.self) var appState @Environment(EventsTracker.self) var eventsTracker @Setting(\.events_showEvents) var showEvents var body: some View { VStack(spacing: 20) { if appState.firstAccount.accountType != .guest { subheadingView("Visit Again") topRow } subheadingView("Browse") browseList .padding(.top, 15) if let events = eventsTracker.events, !events.isEmpty, showEvents { eventsView(events) } } .padding(.horizontal, 16) .padding(.top, 20) } @ViewBuilder func subheadingView(_ text: LocalizedStringResource) -> some View { Text(text) .font(.title) .fontWeight(.semibold) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, -4) } @ViewBuilder var topRow: some View { SearchHomeListView { NavigationLink("Saved", icon: .lemmy.savedFeed, destination: .savedFeed) .tint(.themedSavedFeed) NavigationLink("Upvoted", icon: .lemmy.upvoted, destination: .upvotedFeed) .tint(.themedUpvote) } .buttonStyle(.chevron) } @ViewBuilder func eventsView(_ events: [Event]) -> some View { HStack { subheadingView("Events") Spacer() eventsMenuView } .padding(.top, 15) SearchHomeListView { ForEach(events) { EventRowView(event: $0) } } } @ViewBuilder var eventsMenuView: some View { Menu("More", icon: .general.menu) { Button("Turn Off Events", icon: .general.hide, role: .destructive) { showEvents = false } } .foregroundStyle(.secondary) .font(.title) .labelStyle(.iconOnly) .padding(.trailing, 10) .padding(.bottom, -6) } @ViewBuilder var browseList: some View { HStack(alignment: .center, spacing: UIDevice.isPad ? 30 : 0) { NavigationLink("Communities", icon: .lemmy.community, destination: .topCommunities) .tint(.themedCommunityAccent) if !UIDevice.isPad { Spacer() } NavigationLink("Users", icon: .lemmy.person, destination: .topPeople) .tint(.themedPersonAccent) if !UIDevice.isPad { Spacer() } NavigationLink("Instances", icon: .lemmy.instance, destination: .topInstances) .tint(.themedColorfulAccent(1)) } .labelStyle(SearchHomeCategoryLabelStyle()) .buttonStyle(.empty) .padding(.horizontal, 20) } @ViewBuilder var browseGrid: some View { LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 16) { GridButton(title: "Top Communities", color: .themedCommunityAccent) GridButton(title: "Trending Communities", color: .themedColorfulAccent(0)) GridButton(title: "Users", color: .themedPersonAccent) GridButton(title: "Instances", color: .themedColorfulAccent(1)) } .frame(maxWidth: .infinity) .padding(.horizontal, -4) } } private struct GridButton: View { @Environment(\.palette) var palette let title: LocalizedStringResource let color: ThemedColor var body: some View { ZStack { Text(title) .foregroundStyle(.themedContrastingLabel) .fontWeight(.bold) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .padding(.horizontal, 15) .padding(.vertical, 10) } .aspectRatio(5 / 3, contentMode: .fit) .frame(maxWidth: .infinity) .background(color.resolve(with: palette).gradient) .clipShape(.rect(cornerRadius: 16)) .padding(.horizontal, 4) .onTapGesture {} } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/TopCommunitiesListView.swift ================================================ // // TopCommunitiesListView.swift // Mlem // // Created by Sjmarf on 2025-08-15. // import MlemMiddleware import SwiftUI struct TopCommunitiesListView: View { @Environment(AppState.self) var appState @State var communityLoader: CommunityFeedLoader? var body: some View { FancyScrollView { LazyVStack(spacing: 0) { if let communityLoader { SearchResultsView(results: communityLoader.items) { community in CommunityListRow( community, readout: .subscribers, visitContext: .other ) .onAppear { do { try communityLoader.loadIfThreshold(community) } catch { handleError(error) } } } EndOfFeedView(feedLoader: communityLoader, viewType: .hobbit) } } .animation(.easeOut(duration: 0.1), value: communityLoader?.items.isEmpty) .task { do { if communityLoader == nil { communityLoader = .init(api: appState.firstApi) try await communityLoader?.refresh(listing: .all) } } catch { handleError(error) } } } .background(.themedGroupedBackground) .navigationTitle("Communities") } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/TopInstancesListView.swift ================================================ // // TopInstancesListView.swift // Mlem // // Created by Sjmarf on 2025-08-15. // import MlemMiddleware import SwiftUI struct TopInstancesListView: View { @Environment(AppState.self) var appState var body: some View { FancyScrollView { if let errorDetails = MlemStats.main.errorDetails { ErrorView(errorDetails) .frame(maxWidth: .infinity) .padding(.top, 40) } else { content } } .background(.themedGroupedBackground) .navigationTitle("Instances") } var content: some View { LazyVStack(spacing: 0) { SearchResultsView(results: MlemStats.main.instances ?? []) { instance in InstanceListRow( instance, readout: .users, visitContext: .other ) } EndOfFeedView(loadingState: .done, viewType: .hobbit) } .animation(.easeOut(duration: 0.1), value: MlemStats.main.instances?.isEmpty) } } ================================================ FILE: Mlem/App/Views/Shared/Search/Home/TopPeopleListView.swift ================================================ // // TopPeopleListView.swift // Mlem // // Created by Sjmarf on 2025-08-15. // import MlemMiddleware import SwiftUI struct TopPeopleListView: View { @Environment(AppState.self) var appState @State var personLoader: PersonFeedLoader? var body: some View { FancyScrollView { LazyVStack(spacing: 0) { if let personLoader { SearchResultsView(results: personLoader.items) { person in PersonListRow( person, readout: .postsAndComments, visitContext: .other ) .onAppear { do { try personLoader.loadIfThreshold(person) } catch { handleError(error) } } } EndOfFeedView(feedLoader: personLoader, viewType: .hobbit) } } .animation(.easeOut(duration: 0.1), value: personLoader?.items.isEmpty) .task { do { if personLoader == nil { personLoader = .init(api: appState.firstApi) try await personLoader?.refresh(listing: .all) } } catch { handleError(error) } } } .background(.themedGroupedBackground) .navigationTitle("Users") } } ================================================ FILE: Mlem/App/Views/Shared/Search/PasteLinkButtonView.swift ================================================ // // PasteLinkButtonView.swift // Mlem // // Created by Sjmarf on 20/06/2024. // import Dependencies import SwiftUI struct PasteLinkButtonView: View { @Environment(\.openURL) private var openURL var body: some View { Button("Open URL from Clipboard", icon: .general.paste) { if let url = UIPasteboard.general.url { openURL(url) } else if let string = UIPasteboard.general.string, let url = urlFromString(string), UIApplication.shared.canOpenURL(url) { openURL(url) } else { ToastModel.main.add(.failure("Couldn't read URL")) } } } func urlFromString(_ string: String) -> URL? { if let url = URL(string: string), UIApplication.shared.canOpenURL(url) { return url } return webfingersToUrl(string) } func webfingersToUrl(_ webfingers: String) -> URL? { do { guard let match = try /[!|@](?[\w_-]+)@(?[\w_-]+\.[\w_\-\.]+)+/.wholeMatch(in: webfingers) else { return nil } let name = match.output.name let host = match.output.host let prefix = webfingers.starts(with: "@") ? "u" : "c" return URL(string: "https://\(host)/\(prefix)/\(name)") } catch { return nil } } } ================================================ FILE: Mlem/App/Views/Shared/Search/Results/CommunityListRow.swift ================================================ // // CommunityListRow.swift // Mlem // // Created by Sjmarf on 06/07/2024. // import ComponentViews import MlemMiddleware import SwiftUI struct CommunityListRow: View { typealias Content = CommunityListRowBody @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Setting(\.interactionBar_community) var communityActionConfiguration let community: Community let content: Content let visitContext: VisitHistory.VisitContext init( _ community: Community, complications: [Content.Complication] = [.instance], showBlockStatus: Bool = true, visitContext: VisitHistory.VisitContext = .other, @ViewBuilder content: @escaping () -> Content2 ) { self.community = community self.content = .init(community, complications: complications, showBlockStatus: showBlockStatus, content: content) self.visitContext = visitContext } init( _ community: Community, complications: [Content.Complication] = [.instance], showBlockStatus: Bool = true, readout: Content.Readout? = nil, visitContext: VisitHistory.VisitContext = .other ) where Content2 == EmptyView { self.community = community self.content = .init(community, complications: complications, showBlockStatus: showBlockStatus, readout: readout) self.visitContext = visitContext } var body: some View { Button { navigation.push(.community(community, visitContext: visitContext)) } label: { FormChevron { content } .padding(.trailing) } .buttonStyle(.empty) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(community: community) .quickSwipes(community: community, configuration: communityActionConfiguration) .popupAnchor() .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment) { // ScrollView { // ForEach(CommunityMockType.Realistic.allCases) { type in // CommunityListRow( // Community2.mock(.realistic(type)), // complications: [.instance], // readout: .subscribers // ) // } // } // .contentMargins(.horizontal, Constants.main.standardSpacing) // .background(.themedGroupedBackground) // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Search/Results/CommunityListRowBody.swift ================================================ // // CommunityListRowBody.swift // Mlem // // Created by Eric Andrews on 2024-03-07. // import Icons import MlemMiddleware import SwiftUI import Theming struct CommunityListRowBody: View { enum Complication { case instance, subscriberCount } enum Readout { case subscribers } @Environment(\.isEnabled) var isEnabled @Setting(\.safety_blurNsfw) var blurNsfw let community: Community let showBlockStatus: Bool let complications: [Complication] let readout: Readout? @ViewBuilder let content: () -> Content init( _ community: Community, complications: [Complication] = [.instance], showBlockStatus: Bool = true, @ViewBuilder content: @escaping () -> Content ) { self.community = community self.showBlockStatus = showBlockStatus self.readout = nil self.content = content self.complications = complications } init( _ community: Community, complications: [Complication] = [.instance], showBlockStatus: Bool = true, readout: Readout? = nil ) where Content == EmptyView { self.community = community self.showBlockStatus = showBlockStatus self.readout = readout self.content = { EmptyView() } self.complications = complications } var title: String { var title = community.name if community.blocked_.realizedValue, showBlockStatus { title = title + " ∙ " + String(localized: "Blocked") } if community.nsfw { title = title + " ∙ " + String(localized: "NSFW") } return title } var body: some View { HStack(spacing: Constants.main.standardSpacing) { if community.blocked_.realizedValue, showBlockStatus { Image(icon: .general.hide) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) .padding(9) } else { CircleCroppedImageView( url: community.avatar?.withIconSize(128), frame: Constants.main.listRowAvatarSize, fallback: .communityAvatar, blurred: community.nsfw && (blurNsfw != .never) ) } VStack(alignment: .leading, spacing: 2) { Text(title) .lineLimit(1) .foregroundStyle(titleColor) caption .font(.footnote) .foregroundStyle(.themedSecondary) .lineLimit(1) } Spacer() switch readout { case .subscribers: subscriberCountReadout case nil: content() } } .padding(.horizontal) } @ViewBuilder var caption: some View { HStack(spacing: 2) { ForEach(Array(complications.enumerated()), id: \.element) { index, complication in if index != 0 { Text(verbatim: "∙") } Group { switch complication { case .instance: Text(verbatim: "@\(community.host)") case .subscriberCount: ExpectedView(community.subscription) { subscription in HStack(spacing: 2) { Image(icon: .lemmy.person) Text(subscription.total.abbreviated) } } } } } } } var titleColor: ThemedColor { if community.nsfw { .themedWarning } else { isEnabled ? .themedPrimary : .themedSecondary } } var subscriberCountReadout: some View { let icon: Icon let color: ThemedColor switch community.subscriptionTier { case .favorited: color = .themedFavorite icon = .lemmy.favorite case .subscribed: color = .themedPositive icon = .lemmy.subscribed case .unsubscribed: color = .themedSecondary icon = .lemmy.person } return HStack { Text((community.subscription.value?.total ?? 0).abbreviated) Image(icon: icon) .fontWeight(.semibold) } .monospacedDigit() .foregroundStyle(color) .symbolVariant(.fill) .symbolRenderingMode(.hierarchical) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // CommunityListRowBody( // Community2.mock(.generic), // complications: [.instance], // readout: .subscribers // ) // .padding(.vertical, Constants.main.standardSpacing) // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Search/Results/InstanceListRow.swift ================================================ // // InstanceListRow.swift // Mlem // // Created by Sjmarf on 06/07/2024. // import ComponentViews import MlemBackend import MlemMiddleware import SwiftUI struct InstanceListRow: View { typealias Content = InstanceListRowBody @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation let instance: any InstanceActionProviding let content: Content let visitContext: VisitHistory.VisitContext init( _ instance: Instance, @ViewBuilder content: @escaping () -> Content2 = { EmptyView() }, showBlockStatus: Bool = true, readout: Content.Readout? = nil, visitContext: VisitHistory.VisitContext = .other ) { self.instance = instance self.content = .init(instance, content: content, showBlockStatus: showBlockStatus, readout: readout) self.visitContext = visitContext } init( _ summary: InstanceSummary, @ViewBuilder content: @escaping () -> Content2 = { EmptyView() }, showBlockStatus: Bool = true, readout: Content.Readout? = nil, visitContext: VisitHistory.VisitContext = .other ) where Content2 == EmptyView { self.instance = summary self.content = .init(summary, content: content, showBlockStatus: showBlockStatus, readout: readout) self.visitContext = visitContext } var body: some View { Button { if let instance = instance as? Instance { navigation.push(.instance(instance, visitContext: visitContext)) } else { navigation.push(.instanceStub(instance.instanceStub, visitContext: visitContext)) } } label: { FormChevron { content } .padding(.trailing) } .buttonStyle(.empty) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(instance: instance) .popupAnchor() .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } ================================================ FILE: Mlem/App/Views/Shared/Search/Results/InstanceListRowBody.swift ================================================ // // InstanceListRowBody.swift // Mlem // // Created by Eric Andrews on 2024-03-07. // import Icons import MlemBackend import MlemMiddleware import SwiftUI import Theming struct InstanceListRowBody: View { enum Readout { case users } @Setting(\.safety_blurNsfw) var blurNsfw @Environment(\.isEnabled) var isEnabled let instance: Instance? let summary: InstanceSummary? let readout: Readout? let showBlockStatus: Bool @ViewBuilder let content: () -> Content init( _ instance: Instance, @ViewBuilder content: @escaping () -> Content = { EmptyView() }, showBlockStatus: Bool = true, readout: Readout? = nil ) { self.instance = instance self.summary = nil self.showBlockStatus = showBlockStatus self.content = content self.readout = readout } init( _ summary: InstanceSummary, @ViewBuilder content: @escaping () -> Content = { EmptyView() }, showBlockStatus: Bool = true, readout: Readout? = nil ) { self.summary = summary self.instance = nil self.showBlockStatus = showBlockStatus self.content = content self.readout = readout } var isBlocked: Bool { guard showBlockStatus else { return false } if let instance { return instance.blocked_.realizedValue } if let summary, let session = AppState.main.firstSession as? UserSession, let blocks = session.blocks { let actorId = ActorIdentifier.instance(host: summary.host) return blocks.contains(instanceActorId: actorId) } return false } var title: String { let hostText = instance?.host ?? summary?.host ?? "" if isBlocked { return hostText + " ∙ " + String(localized: "Blocked") } return hostText } var avatar: URL? { instance?.avatar ?? summary?.avatar } var software: SiteSoftware? { instance?.software.value ?? summary.map { .init(from: $0.software) } } var body: some View { HStack(spacing: Constants.main.standardSpacing) { if isBlocked { Image(icon: .general.hide) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) .padding(9) } else { CircleCroppedImageView( url: avatar?.withIconSize(128), frame: Constants.main.listRowAvatarSize, fallback: .instanceAvatar ) } VStack(alignment: .leading, spacing: 2) { Text(title) .foregroundStyle(isEnabled ? .themedPrimary : .themedSecondary) .lineLimit(1) if let software { Text(software.label) .font(.footnote) .foregroundStyle(.themedSecondary) .lineLimit(1) } } Spacer() switch readout { case .users: userCountReadout case nil: content() } } .padding(.horizontal) .contentShape(.rect) } var userCountReadout: some View { HStack { Text((instance?.userCount.value ?? summary?.totalUsers ?? 0).abbreviated) Image(icon: .lemmy.person) .symbolVariant(.fill) .fontWeight(.semibold) } .monospacedDigit() .foregroundStyle(.themedSecondary) .symbolRenderingMode(.hierarchical) } } ================================================ FILE: Mlem/App/Views/Shared/Search/Results/PersonListRow.swift ================================================ // // PersonListRow.swift // Mlem // // Created by Sjmarf on 06/07/2024. // import ComponentViews import MlemMiddleware import SwiftUI struct PersonListRow: View { typealias Content = PersonListRowBody @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Environment(\.communityContext) var communityContext let person: Person let content: Content let visitContext: VisitHistory.VisitContext init( _ person: Person, complications: [Content.Complication] = [.instance], showBlockStatus: Bool = true, visitContext: VisitHistory.VisitContext = .other, @ViewBuilder content: @escaping () -> Content2 ) { self.person = person self.content = .init(person, complications: complications, showBlockStatus: showBlockStatus, content: content) self.visitContext = visitContext } init( _ person: Person, complications: [Content.Complication] = [.instance], showBlockStatus: Bool = true, readout: Content.Readout? = nil, visitContext: VisitHistory.VisitContext = .other ) where Content2 == EmptyView { self.person = person self.content = .init(person, complications: complications, showBlockStatus: showBlockStatus, readout: readout) self.visitContext = visitContext } var body: some View { Button { navigation.push(.person(person, visitContext: visitContext)) } label: { FormChevron { content } .padding(.trailing) } .buttonStyle(.empty) .padding(.vertical, 6) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu(person: person) .popupAnchor() .paletteBorder(cornerRadius: Constants.main.standardSpacing) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment) { // ScrollView { // ForEach(PersonMockType.Realistic.allCases) { type in // PersonListRow( // Person2.mock(.realistic(type)), // complications: [.instance, .date], // readout: .postsAndComments // ) // } // } // .contentMargins(.horizontal, Constants.main.standardSpacing) // .background(.themedGroupedBackground) // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Search/Results/PersonListRowBody.swift ================================================ // // PersonListRowBody.swift // Mlem // // Created by Sjmarf on 28/06/2024. // import MlemMiddleware import SwiftUI struct PersonListRowBody: View { enum Complication { case instance, date } enum Readout { case postsAndComments } @Environment(\.communityContext) var communityContext @Environment(\.isEnabled) var isEnabled let person: Person var showBlockStatus: Bool = true let complications: [Complication] let readout: Readout? @ViewBuilder let content: () -> Content init( _ person: Person, complications: [Complication] = [.instance], showBlockStatus: Bool = true, @ViewBuilder content: @escaping () -> Content ) { self.person = person self.showBlockStatus = showBlockStatus self.readout = nil self.content = content self.complications = complications } init( _ person: Person, complications: [Complication] = [.instance], showBlockStatus: Bool = true, readout: Readout? = nil ) where Content == EmptyView { self.person = person self.showBlockStatus = showBlockStatus self.readout = readout self.content = { EmptyView() } self.complications = complications } var title: String { if person.blocked_.realizedValue, showBlockStatus { return person.displayName + " ∙ " + String(localized: "Blocked") } else { return person.displayName } } var body: some View { HStack(spacing: Constants.main.standardSpacing) { if person.blocked_.realizedValue, showBlockStatus { Image(icon: .general.hide) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) .padding(9) } else { CircleCroppedImageView( url: person.avatar?.withIconSize(128), frame: Constants.main.listRowAvatarSize, fallback: .personAvatar ) } VStack(alignment: .leading, spacing: 4) { (flairs.textView + Text(title)) .foregroundStyle(isEnabled ? .themedPrimary : .themedSecondary) .lineLimit(1) .imageScale(.small) .symbolVariant(.fill) caption .font(.footnote) .foregroundStyle(.themedSecondary) .lineLimit(1) } Spacer() switch readout { case .postsAndComments: postsAndCommentsReadout case nil: content() } } .padding(.horizontal) .padding(.vertical, -5) .contentShape(.rect) .padding(.vertical, 5) } var dateFormatter: DateFormatter { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy" return dateFormatter } @ViewBuilder var caption: some View { HStack(spacing: 2) { ForEach(Array(complications.enumerated()), id: \.element) { index, complication in if index != 0 { Text(verbatim: "∙") } Group { switch complication { case .instance: Text(verbatim: "@\(person.host)") case .date: Text(dateFormatter.string(from: person.created)) } } } } } @ViewBuilder var postsAndCommentsReadout: some View { HStack(spacing: 5) { VStack(alignment: .trailing, spacing: 6) { Text((person.postCount.value ?? 0).abbreviated) Text((person.commentCount.value ?? 0).abbreviated) } .foregroundStyle(.secondary) .font(.subheadline) .monospacedDigit() VStack(spacing: 10) { Image(icon: .lemmy.post) Image(icon: .lemmy.comment) } .imageScale(.small) } .foregroundStyle(.themedSecondary) } var flairs: [PersonFlair] { person.flairs(communityContext: communityContext) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment, .sizeThatFitsLayout) { // PersonListRowBody( // Person2.mock(.generic), // complications: [.instance, .date], // readout: .postsAndComments // ) // .padding(.vertical, Constants.main.standardSpacing) // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/DefaultTextInputType.swift ================================================ // This code taken from the open-source SwiftUIX library https://github.com/SwiftUIX/SwiftUIX/blob/cf729fcab44196ed7361293bcad493a0e928fb24/Sources/Intermodular/Helpers/SwiftUI/DefaultTextInputType.swift#L10 // Copyright (c) Vatsal Manot // import Combine import Swift import SwiftUI // MARK: - Extensions public extension SearchBar { init( _ title: LocalizedStringResource, text: Binding, isEditing: Binding, onCommit: @escaping () -> Void = {} ) { self.init( title, text: text, onEditingChanged: { isEditing.wrappedValue = $0 }, onCommit: onCommit ) } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/SearchBar+NavigationView.swift ================================================ // // This code taken from the open-source SwiftUIX library. https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intramodular/Search%20Bar/SearchBar%2BNavigationView.swift // // Copyright (c) Vatsal Manot // import Swift import SwiftUI #if (os(iOS) && canImport(CoreTelephony)) || targetEnvironment(macCatalyst) @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) private struct _NavigationSearchBarConfigurator: UIViewControllerRepresentable { let searchBar: SearchBar let searchResultsContent: () -> SearchResultsContent @Environment(\._hidesNavigationSearchBarWhenScrolling) var hidesSearchBarWhenScrolling: Bool? var automaticallyShowSearchBar: Bool? = true var hideNavigationBarDuringPresentation: Bool? var obscuresBackgroundDuringPresentation: Bool? func makeUIViewController(context: Context) -> UIViewControllerType { UIViewControllerType(coordinator: context.coordinator) } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { context.coordinator.base = self context.coordinator.searchBarCoordinator.base = searchBar searchBar._updateUISearchBar(context.coordinator.searchController.searchBar, environment: context.environment) } func makeCoordinator() -> Coordinator { Coordinator(base: self, searchBarCoordinator: .init(base: searchBar)) } } @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) extension _NavigationSearchBarConfigurator { fileprivate class SearchController: UISearchController { private var customSearchBar: UISearchBar? override var searchBar: UISearchBar { if let customSearchBar { return customSearchBar } else { customSearchBar = UISearchBar(frame: .zero) return customSearchBar! } } override init( searchResultsController: UIViewController? ) { super.init(searchResultsController: searchResultsController) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } class Coordinator: NSObject, UISearchBarDelegate, UISearchControllerDelegate, UISearchResultsUpdating { fileprivate var base: _NavigationSearchBarConfigurator fileprivate var searchBarCoordinator: SearchBar.Coordinator fileprivate var searchController: SearchController! fileprivate weak var uiViewController: UIViewController? { didSet { if uiViewController == nil || uiViewController != oldValue { if oldValue?.searchController != nil { oldValue?.searchController = nil } } updateSearchController() } } fileprivate init( base: _NavigationSearchBarConfigurator, searchBarCoordinator: SearchBar.Coordinator ) { self.base = base self.searchBarCoordinator = searchBarCoordinator super.init() initializeSearchController() updateSearchController() } private func initializeSearchController() { let searchResultsController: UIViewController? let searchResultsContent = base.searchResultsContent() if searchResultsContent is EmptyView { searchResultsController = nil } else { searchResultsController = UIHostingController(rootView: base.searchResultsContent()) } searchController = SearchController( searchResultsController: searchResultsController ) searchController.definesPresentationContext = true searchController.obscuresBackgroundDuringPresentation = false searchController.searchBar.delegate = self searchController.searchResultsUpdater = self } private func updateSearchController() { guard let uiViewController else { return } if uiViewController.searchController !== searchController { uiViewController.searchController = searchController } if let obscuresBackgroundDuringPresentation = base.obscuresBackgroundDuringPresentation { searchController.obscuresBackgroundDuringPresentation = obscuresBackgroundDuringPresentation } else { searchController.obscuresBackgroundDuringPresentation = false } if let hideNavigationBarDuringPresentation = base.hideNavigationBarDuringPresentation { searchController.hidesNavigationBarDuringPresentation = hideNavigationBarDuringPresentation } ( searchController.searchResultsController as? UIHostingController )?.rootView = base.searchResultsContent() if let hidesSearchBarWhenScrolling = base.hidesSearchBarWhenScrolling { uiViewController.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling } if let automaticallyShowSearchBar = base.automaticallyShowSearchBar, automaticallyShowSearchBar { uiViewController.sizeToFitSearchBar() } } // MARK: - UISearchBarDelegate public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { searchBarCoordinator.searchBarTextDidBeginEditing(searchBar) } public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { searchBarCoordinator.searchBar(searchBar, textDidChange: searchText) } public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { searchBarCoordinator.searchBarTextDidEndEditing(searchBar) } public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchController.isActive = false searchBarCoordinator.searchBarCancelButtonClicked(searchBar) } public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBarCoordinator.searchBarSearchButtonClicked(searchBar) } // MARK: UISearchControllerDelegate func willPresentSearchController(_ searchController: UISearchController) {} func didPresentSearchController(_ searchController: UISearchController) {} func willDismissSearchController(_ searchController: UISearchController) {} func didDismissSearchController(_ searchController: UISearchController) {} // MARK: UISearchResultsUpdating func updateSearchResults(for searchController: UISearchController) {} } class UIViewControllerType: UIViewController { weak var coordinator: Coordinator? init(coordinator: Coordinator?) { self.coordinator = coordinator super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func willMove(toParent parent: UIViewController?) { super.willMove(toParent: parent) coordinator?.uiViewController = navigationController?.viewControllers.first } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) coordinator?.uiViewController = navigationController?.viewControllers.first } } } // MARK: - API public extension View { /// Sets the navigation search bar for this view. @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) func navigationSearchBar(_ searchBar: () -> SearchBar) -> some View { background(_NavigationSearchBarConfigurator(searchBar: searchBar(), searchResultsContent: { EmptyView() })) } /// Hides the integrated search bar when scrolling any underlying content. func navigationSearchBarHiddenWhenScrolling(_ hidesSearchBarWhenScrolling: Bool) -> some View { environment(\._hidesNavigationSearchBarWhenScrolling, hidesSearchBarWhenScrolling) } } private class DefaultEnvironmentKey: EnvironmentKey { public static var defaultValue: Value? { nil } } // MARK: - Auxiliary extension EnvironmentValues { private class _HidesNavigationSearchBarWhenScrolling: DefaultEnvironmentKey {} var _hidesNavigationSearchBarWhenScrolling: Bool? { get { self[_HidesNavigationSearchBarWhenScrolling.self] } set { self[_HidesNavigationSearchBarWhenScrolling.self] = newValue } } } // MARK: - Helpers private extension UIViewController { var searchController: UISearchController? { get { navigationItem.searchController } set { navigationItem.searchController = newValue } } var hidesSearchBarWhenScrolling: Bool { get { navigationItem.hidesSearchBarWhenScrolling } set { navigationItem.hidesSearchBarWhenScrolling = newValue } } func sizeToFitSearchBar() { navigationController?.navigationBar.sizeToFit() } } #endif ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/SearchBar.swift ================================================ // // Modified version of code taken from the open-source SwiftUIX library. https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intramodular/Search%20Bar/SearchBar.swift // // Copyright (c) Vatsal Manot // import Swift import SwiftUI #if (os(iOS) && canImport(CoreTelephony)) || os(macOS) || targetEnvironment(macCatalyst) /// A specialized view for receiving search-related information from the user. public struct SearchBar { @Binding fileprivate var text: String // var customAppKitOrUIKitClass: AppKitOrUIKitSearchBar.Type? // UISearchBar private let onEditingChanged: (Bool) -> Void private let onCommit: () -> Void private var isInitialFirstResponder: Bool? private var isFocused: Binding? private var placeholder: String? #if os(iOS) || targetEnvironment(macCatalyst) private var iconImageConfiguration: [UISearchBar.Icon: UIImage] = [:] #endif private var showsCancelButton: Bool? private var onCancel: () -> Void = {} #if os(iOS) || targetEnvironment(macCatalyst) private var returnKeyType: UIReturnKeyType? private var enablesReturnKeyAutomatically: Bool? private var isSecureTextEntry: Bool = false private var textContentType: UITextContentType? private var keyboardType: UIKeyboardType? #endif public init( _ title: LocalizedStringResource, text: Binding, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {} ) { self.placeholder = .init(localized: title) self._text = text self.onCommit = onCommit self.onEditingChanged = onEditingChanged } public init( text: Binding, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {} ) { self._text = text self.onCommit = onCommit self.onEditingChanged = onEditingChanged } } @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) extension SearchBar: UIViewRepresentable { public typealias UIViewType = UISearchBar public func makeUIView(context: Context) -> UIViewType { let uiView = _UISearchBar() uiView.delegate = context.coordinator if context.environment.isEnabled { DispatchQueue.main.async { if (isInitialFirstResponder ?? isFocused?.wrappedValue) ?? false { uiView.becomeFirstResponder() } } } return uiView } public func updateUIView(_ uiView: UIViewType, context: Context) { if #available(iOS 26, *) { uiView.backgroundColor = .clear uiView.barTintColor = .clear uiView.setBackgroundImage(UIImage(), for: .any, barMetrics: .default) uiView.isTranslucent = true } context.coordinator.base = self _updateUISearchBar(uiView, environment: context.environment) } func _updateUISearchBar( _ uiView: UIViewType, environment: EnvironmentValues ) { uiView.isUserInteractionEnabled = environment.isEnabled do { uiView.searchTextField.autocorrectionType = environment.disableAutocorrection.map { $0 ? .no : .yes } ?? .default if let placeholder { uiView.placeholder = placeholder } for (icon, image) in iconImageConfiguration where uiView.image( for: icon, state: .normal ) == nil { // FIXME: This is a performance hack. uiView.setImage(image, for: icon, state: .normal) } if let showsCancelButton { if uiView.showsCancelButton != showsCancelButton { uiView.setShowsCancelButton(showsCancelButton, animated: true) } } } do { _assignIfNotEqual(returnKeyType ?? .default, to: &uiView.returnKeyType) _assignIfNotEqual(keyboardType ?? .default, to: &uiView.keyboardType) _assignIfNotEqual(enablesReturnKeyAutomatically ?? false, to: &uiView.enablesReturnKeyAutomatically) } do { if uiView.text != text { uiView.text = text } if !uiView.searchTextField.tokens.isEmpty { uiView.searchTextField.tokens = [] } } (uiView as? _UISearchBar)?.isFirstResponderBinding = isFocused do { // version of below with no responder binding. it's not a pretty hack but it does work // note that switching tabs with search selected will result in search still displaying "search for communities and users," // but since the keyboard hides the tab bar that probably won't come up for 99% of users if let isFocused, environment.isEnabled { if isFocused.wrappedValue, !uiView.isFirstResponder { DispatchQueue.main.async { uiView.becomeFirstResponder() } } else if !isFocused.wrappedValue, uiView.isFirstResponder { DispatchQueue.main.async { uiView.resignFirstResponder() } } } // if let uiView = uiView as? _UISearchBar, environment.isEnabled { // DispatchQueue.main.async { // if let isFocused, uiView.window != nil { // uiView.isFirstResponderBinding = isFocused // // if isFocused.wrappedValue, !uiView.isFirstResponder { // uiView.becomeFirstResponder() // } else if !isFocused.wrappedValue, uiView.isFirstResponder { // uiView.resignFirstResponder() // } // } // } // } } } public class Coordinator: NSObject, UISearchBarDelegate { var base: SearchBar init(base: SearchBar) { self.base = base } public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { base.isFocused?.removeDuplicates().wrappedValue = true base.onEditingChanged(true) } public func searchBar(_ searchBar: UIViewType, textDidChange searchText: String) { base.text = searchText } public func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { true } public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { base.isFocused?.removeDuplicates().wrappedValue = false base.onEditingChanged(false) } public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.endEditing(true) base.isFocused?.removeDuplicates().wrappedValue = false base.onCancel() } public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBar.endEditing(true) // base.isFocused?.removeDuplicates().wrappedValue = false base.onCommit() } } public func makeCoordinator() -> Coordinator { Coordinator(base: self) } } // MARK: - API public extension SearchBar { @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) func isInitialFirstResponder(_ isInitialFirstResponder: Bool) -> Self { then { $0.isInitialFirstResponder = isInitialFirstResponder } } @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) func focused(_ isFocused: Binding) -> Self { then { $0.isFocused = isFocused } } } @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) public extension SearchBar { #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) func placeholder(_ placeholder: String?) -> Self { then { $0.placeholder = placeholder } } #endif func showsCancelButton(_ showsCancelButton: Bool) -> Self { then { $0.showsCancelButton = showsCancelButton } } func onCancel(perform action: @escaping () -> Void) -> Self { then { $0.onCancel = action } } func returnKeyType(_ returnKeyType: UIReturnKeyType) -> Self { then { $0.returnKeyType = returnKeyType } } func enablesReturnKeyAutomatically(_ enablesReturnKeyAutomatically: Bool) -> Self { then { $0.enablesReturnKeyAutomatically = enablesReturnKeyAutomatically } } func textContentType(_ textContentType: UITextContentType?) -> Self { then { $0.textContentType = textContentType } } func keyboardType(_ keyboardType: UIKeyboardType) -> Self { then { $0.keyboardType = keyboardType } } } // MARK: - Auxiliary #if os(iOS) || targetEnvironment(macCatalyst) private final class _UISearchBar: UISearchBar { var isFirstResponderBinding: Binding? override init(frame: CGRect) { super.init(frame: frame) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @discardableResult override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() isFirstResponderBinding?.wrappedValue = result return result } @discardableResult override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() isFirstResponderBinding?.wrappedValue = !result return result } } #endif // MARK: - Development Preview - #if (os(iOS) && canImport(CoreTelephony)) || targetEnvironment(macCatalyst) @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) struct SearchBar_Previews: PreviewProvider { static var previews: some View { SearchBar("Search...", text: .constant("")) } } #endif #endif ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/SearchBarExtensions.swift ================================================ // // Modified version of code taken from the open-source SwiftUIX library. // // Copyright (c) Vatsal Manot // import SwiftUI public extension View { @inlinable func then(_ body: (inout Self) -> Void) -> Self { var result = self body(&result) return result } } public extension Binding { func removeDuplicates() -> Self where Value: Equatable { .init( get: { self.wrappedValue }, set: { newValue in let oldValue = self.wrappedValue guard newValue != oldValue else { return } self.wrappedValue = newValue } ) } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/View+WithSheetSearch.swift ================================================ // // View+withSheetSearch.swift // Mlem // // Created by Sjmarf on 2025-08-22. // import ComponentViews import SwiftUI private struct SearchSheetViewModifier: ViewModifier { @Environment(NavigationLayer.self) var navigation @Binding var query: String @FocusState var focused: Bool func body(content: Content) -> some View { Group { if #available(iOS 26, *) { ios26Body(content: content) } else { ios18Body(content: content) } } .toolbar { CloseButtonToolbarItem(ios18Label: .cancel) { navigation.dismissSheet() } } .onAppear { focused = true } } func ios18Body(content: Content) -> some View { content .toolbar { ToolbarItem(placement: .principal) { HStack(spacing: 0) { SearchBar("Search", text: $query, isEditing: .constant(true)) .isInitialFirstResponder(true) .focused($focused) .autocorrectionDisabled() } } } } func ios26Body(content: Content) -> some View { content .toolbar { ToolbarItem(placement: .bottomBar) { HStack(spacing: 0) { SearchBar("Search", text: $query, isEditing: .constant(true)) .isInitialFirstResponder(true) .focused($focused) .autocorrectionDisabled() } .padding(-10) } } } } extension View { func withSheetSearch(query: Binding) -> some View { modifier(SearchSheetViewModifier(query: query)) } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchBar/_assignIfNotEqual.swift ================================================ // // This code taken from the open-source SwiftUIX library. https://github.com/SwiftUIX/SwiftUIX/blob/cf729fcab44196ed7361293bcad493a0e928fb24/Sources/Intramodular/Miscellaneous/_assignIfNotEqual.swift#L58 // // Copyright (c) Vatsal Manot // import Swift import SwiftUI @_spi(Internal) @_transparent public func _assignIfNotEqual( _ value: Value, to destination: inout Value ) { if value != destination { destination = value } } public extension NSObjectProtocol { @_spi(Internal) @_transparent func _assignIfNotEqual( _ newValue: Value, to keyPath: ReferenceWritableKeyPath ) { if self[keyPath: keyPath] != newValue { self[keyPath: keyPath] = newValue } } @_spi(Internal) @_transparent func _assignIfNotEqual( _ newValue: Value, to keyPath: ReferenceWritableKeyPath ) { if self[keyPath: keyPath] != newValue { self[keyPath: keyPath] = newValue } } } @_spi(Internal) @_disfavoredOverload @_transparent public func _assignIfNotEqual( _ value: Value, to destination: inout Value ) { if value !== destination { destination = value } } @_spi(Internal) @_disfavoredOverload @_transparent public func _assignIfNotEqual( _ value: Value, to destination: inout Value? ) { if value !== destination { destination = value } } @_spi(Internal) @_transparent public func _assignIfNotEqual( _ value: Value, to destination: inout Any ) { if let _destination = destination as? Value { if value != _destination { destination = value } } else { destination = value } } @_spi(Internal) @_transparent public func _assignIfNotEqual( _ value: Value, to destination: inout Any? ) { if let _destination = destination as? Value { if value != _destination { destination = value } } else { destination = value } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchResultsView.swift ================================================ // // SearchResultsView.swift // Mlem // // Created by Sjmarf on 27/06/2024. // import MlemMiddleware import SwiftUI struct SearchResultsView: View { @ViewBuilder let content: (Item) -> Content let results: [Item] init( results: [Item], @ViewBuilder content: @escaping (Item) -> Content ) { self.results = results self.content = content } var body: some View { ForEach(results) { item in content(item) .padding(.horizontal, Constants.main.standardSpacing) .padding(.bottom, Constants.main.halfSpacing) } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchSheetView.swift ================================================ // // SearchSheetView.swift // Mlem // // Created by Sjmarf on 27/06/2024. // import Combine import MlemMiddleware import SwiftUI struct SearchSheetView: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @ViewBuilder let content: ([Item], NavigationLayer) -> Content let api: ApiClient let filter: ListingType @State var query: String = "" @State var results: [Item] = [] /// If `api` is `nil`, the active ApiClient will be used. init( api: ApiClient? = nil, filter: ListingType? = nil, @ViewBuilder content: @escaping ([Item], NavigationLayer) -> Content ) { self.api = api ?? AppState.main.firstApi self.filter = filter ?? .all self.content = content } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { content(results, navigation) } } .background(.themedGroupedBackground) .presentationBackground(.themedGroupedBackground) .navigationBarTitleDisplayMode(.inline) .withSheetSearch(query: $query) .task(id: query, priority: .userInitiated) { do { if !query.isEmpty { try await Task.sleep(for: .seconds(0.2)) } let response = try await Item.search( api: api, query: query, page: 1, limit: 20, filter: filter, hostApi: appState.firstApi ) Task { @MainActor in results = response } } catch { handleError(error) } } } } extension SearchSheetView { init( api: ApiClient? = nil, filter: ListingType? = nil, @ViewBuilder content: @escaping (Item, NavigationLayer) -> RowContent ) where Content == SearchResultsView { self.api = api ?? AppState.main.firstApi self.filter = filter ?? .all self.content = { (results: [Item], navigation: NavigationLayer) in SearchResultsView(results: results) { item in content(item, navigation) } } } init( api: ApiClient? = nil, filter: ListingType? = nil, @ViewBuilder content: @escaping (Item, NavigationLayer) -> RowContent, @ViewBuilder header: @escaping () -> HeaderContent ) where Content == VStack)>> { self.api = api ?? AppState.main.firstApi self.filter = filter ?? .all self.content = { (results: [Item], dismiss: NavigationLayer) in VStack(alignment: .leading, spacing: 0) { header() SearchResultsView(results: results) { item in content(item, dismiss) } } } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+CreatorPicker.swift ================================================ // // SearchView+CreatorPicker.swift // Mlem // // Created by Sjmarf on 2025-01-19. // import MlemMiddleware import SwiftUI extension SearchView { struct CreatorPicker: View { @Environment(NavigationLayer.self) var navigation let api: ApiClient @Binding var creator: Person? var body: some View { Button(creator?.name ?? .init(localized: "Anyone"), icon: .lemmy.person) { if creator == nil { navigation.openSheet(.personPicker( api: api, callback: { person in creator = person } )) } else { creator = nil } } .buttonStyle(FeedFilterButtonStyle( isOn: creator != nil, icon: creator == nil ? .general.dropDown : .general.close )) } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+FilterModels.swift ================================================ // // SearchView+FilterModels.swift // Mlem // // Created by Sjmarf on 04/10/2024. // import Icons import MlemBackend import MlemMiddleware import SwiftUI extension SearchView { enum InstanceFilter: Hashable { case any, local, other(InstanceSummary) var label: String { switch self { case .any: .init(localized: "Any Instance") case .local: AppState.main.firstApi.host case let .other(instance): instance.host } } var isOther: Bool { switch self { case .other: true default: false } } } enum LocationFilter: Hashable { case any, subscribed, moderated, localInstance, instance(InstanceSummary), community(Community) var label: String { switch self { case .any: .init(localized: "Anywhere") case .subscribed: .init(localized: "Subscribed") case .moderated: .init(localized: "Moderated") case .localInstance: AppState.main.firstApi.host case let .instance(instance): instance.host case let .community(community): community.name } } var icon: Icon { switch self { case .any: .general.website case .subscribed: .lemmy.subscribedFeed case .moderated: .lemmy.moderation case .localInstance, .instance: .lemmy.instance case .community: .lemmy.community } } var isInstance: Bool { switch self { case .instance: true default: false } } var instanceStub: InstanceStub? { if case let .instance(instance) = self { return instance.instanceStub.asLocal() } return nil } var isCommunity: Bool { switch self { case .community: true default: false } } } @Observable class CommunityFilters { var sort: SearchSortType var instance: InstanceFilter = .any init(software: SiteSoftware) { if software.supports(.searchSortType(.top(.allTime))) { self.sort = .top(.allTime) } else { self.sort = .top(.limited(.month)) } } } @Observable class PersonFilters { var sort: SearchSortType var instance: InstanceFilter = .any init(software: SiteSoftware) { if software.supports(.searchSortType(.top(.allTime))) { self.sort = .top(.allTime) } else { self.sort = .top(.limited(.month)) } } } @Observable class InstanceFilters { var sort: InstanceSort = .score } @Observable class PostFilters { var sort: PostSortType var creator: Person? var location: LocationFilter = .any init(software: SiteSoftware) { if software.supports(.searchSortType(.top(.allTime))) { self.sort = .top(.allTime) } else { self.sort = .top(.limited(.month)) } } } @Observable class CommentFilters { var sort: CommentSortType = .top(.allTime) var creator: Person? var location: LocationFilter = .any } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+FiltersView.swift ================================================ // // SearchView+FiltersView.swift // Mlem // // Created by Sjmarf on 08/09/2024. // import MlemMiddleware import SwiftUI extension SearchView { @ViewBuilder var filtersView: some View { VStack(alignment: .leading, spacing: 0) { ScrollView(.horizontal) { HStack { switch selectedTab { case .communities: communityFiltersView case .people: personFiltersView case .instances: instanceFiltersView case .posts: postFiltersView case .comments: commentFiltersView } } .padding(.bottom, 12) .padding(.horizontal, Constants.main.standardSpacing) } .scrollIndicators(.hidden) } .animation(.easeOut(duration: 0.1), value: filterAnimationHashValue) } @ViewBuilder private var communityFiltersView: some View { if let communityFilters { CommunitySearchSortPicker(sort: Binding( get: { communityFilters.sort }, set: { self.communityFilters?.sort = $0 } )) .buttonStyle(.feedFilter(isOn: communityFilters.sort != .top(.allTime))) InstancePicker( filter: Binding(get: { communityFilters.instance }, set: { self.communityFilters?.instance = $0 }), requiredFeature: .searchLocalCommunities ) .buttonStyle(.feedFilter(isOn: communityFilters.instance != .any)) } } @ViewBuilder private var personFiltersView: some View { if let personFilters { Menu(personFilters.sort.label(timeRangeFormat: .topOnly), icon: personFilters.sort.icon) { Picker("Sort", selection: Binding( get: { personFilters.sort }, set: { self.personFilters?.sort = $0 } )) { ForEach(SearchSortType.legacyPersonCases, id: \.self) { item in Label(item.label(timeRangeFormat: .topOnly), icon: item.icon) } } } .buttonStyle(.feedFilter(isOn: personFilters.sort != .top(.allTime))) InstancePicker( filter: Binding(get: { personFilters.instance }, set: { self.personFilters?.instance = $0 }), requiredFeature: .searchLocalPeople ) .buttonStyle(.feedFilter(isOn: personFilters.instance != .any)) } } @ViewBuilder private var postFiltersView: some View { if let postFilters { FeedSortPicker(sort: Binding(get: { postFilters.sort }, set: { self.postFilters?.sort = $0 })) .buttonStyle(.feedFilter(isOn: postFilters.sort != .top(.allTime))) LocationPicker(filter: Binding(get: { postFilters.location }, set: { self.postFilters?.location = $0 })) .buttonStyle(.feedFilter(isOn: postFilters.location != .any)) CreatorPicker( api: postFilters.location.instanceStub?.api ?? appState.firstApi, creator: Binding(get: { postFilters.creator }, set: { self.postFilters?.creator = $0 }) ) } } @ViewBuilder private var commentFiltersView: some View { Menu(commentFilters.sort.label(timeRangeFormat: .topOnly), icon: commentFilters.sort.icon) { Picker("Sort", selection: $commentFilters.sort) { ForEach(CommentSortType.legacyCases, id: \.self) { item in Label(item.label(timeRangeFormat: .topOnly), icon: item.icon) } } } .buttonStyle(.feedFilter(isOn: commentFilters.sort != .top(.allTime))) LocationPicker(filter: $commentFilters.location, requiredFeature: .searchLocalComments) .buttonStyle(.feedFilter(isOn: commentFilters.location != .any)) CreatorPicker( api: commentFilters.location.instanceStub?.api ?? appState.firstApi, creator: $commentFilters.creator ) } @ViewBuilder private var instanceFiltersView: some View { Menu( instanceFilters.sort.label, icon: instanceFilters.sort.icon ) { Picker("Sort", selection: $instanceFilters.sort) { ForEach(InstanceSort.allCases, id: \.self) { sort in Label(sort.label.key, icon: sort.icon) } } .pickerStyle(.inline) } .buttonStyle(.feedFilter(isOn: instanceFilters.sort != .score)) } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+InstancePicker.swift ================================================ // // SearchView+InstancePicker.swift // Mlem // // Created by Sjmarf on 04/10/2024. // import MlemMiddleware import SwiftUI extension SearchView { struct InstancePicker: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Binding var filter: InstanceFilter var requiredFeature: Feature? @State var instanceSupportsRequiredFeature: Bool? var allowActiveAccountLocalInstanceSearch: Bool { if requiredFeature != nil { instanceSupportsRequiredFeature ?? false } else { true } } var body: some View { Menu(filter.label, icon: .lemmy.instance) { Toggle( "Any Instance", icon: .lemmy.federation, isOn: .init(get: { filter == .any }, set: { _ in filter = .any }) ) if allowActiveAccountLocalInstanceSearch { Toggle(isOn: .init(get: { filter == .local }, set: { _ in filter = .local })) { Label { Text(AppState.main.firstApi.host) } icon: { SimpleAvatarView(url: AppState.main.firstSession.instance?.avatar, type: .instanceAvatar) } } } switch filter { case let .other(instance): if instance.host != AppState.main.firstApi.host { Toggle(isOn: .constant(true)) { Label { Text(instance.host) } icon: { SimpleAvatarView(url: instance.avatar, type: .instanceAvatar) .id(instance.avatar) } } } else { EmptyView() } default: EmptyView() } Button("Choose Instance...", icon: .lemmy.instance) { navigation.openSheet(.instancePicker(callback: { instance in filter = .other(instance) }, requiredFeature: requiredFeature)) } } .task(id: appState.firstApi) { if let requiredFeature { do { instanceSupportsRequiredFeature = try await appState.firstApi.supports(requiredFeature) } catch { handleError(error) } } } } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+LocationPicker.swift ================================================ // // SearchView+CommunityPicker.swift // Mlem // // Created by Sjmarf on 04/10/2024. // import MlemMiddleware import SwiftUI extension SearchView { struct LocationPicker: View { @Environment(AppState.self) var appState @Environment(NavigationLayer.self) var navigation @Binding var filter: LocationFilter var requiredFeature: Feature? var allowActiveAccountLocalInstanceSearch: Bool { if let requiredFeature { appState.firstApi.supports(requiredFeature, defaultValue: false) } else { true } } var body: some View { Menu(filter.label, icon: filter.icon) { Section { Toggle( "Anywhere", systemImage: "globe", isOn: .init(get: { filter == .any }, set: { _ in filter = .any }) ) } Section { switch filter { case let .community(community): Toggle(isOn: .constant(true)) { Label { Text(community.name) } icon: { SimpleAvatarView(url: community.avatar, type: .communityAvatar) .id(community.avatar) } } default: EmptyView() } Button("Choose Community...", icon: .lemmy.community) { navigation.openSheet(.communityPicker(callback: { community in filter = .community(community) })) } } Section { if !((AppState.main.firstSession as? UserSession)?.subscriptions.communities.isEmpty ?? true) { Toggle( "Subscribed", icon: .lemmy.subscribedFeed, isOn: .init(get: { filter == .subscribed }, set: { _ in filter = .subscribed }) ) } if !((AppState.main.firstSession as? UserSession)?.person?.moderatedCommunities.value?.isEmpty ?? true) { Toggle( "Moderated", icon: .lemmy.moderation, isOn: .init(get: { filter == .moderated }, set: { _ in filter = .moderated }) ) } } Section { if allowActiveAccountLocalInstanceSearch { Toggle(isOn: .init(get: { filter == .localInstance }, set: { _ in filter = .localInstance })) { Label { Text(AppState.main.firstApi.host) } icon: { SimpleAvatarView(url: AppState.main.firstSession.instance?.avatar, type: .instanceAvatar) } } } switch filter { case let .instance(instance): Toggle(isOn: .constant(true)) { Label { Text(instance.host) } icon: { SimpleAvatarView(url: instance.avatar, type: .instanceAvatar) .id(instance.avatar) } } default: EmptyView() } Button("Choose Instance...", icon: .lemmy.instance) { navigation.openSheet(.instancePicker(callback: { instance in filter = .instance(instance) }, requiredFeature: requiredFeature)) } } } } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+Logic.swift ================================================ // // SearchView+Logic.swift // Mlem // // Created by Sjmarf on 08/09/2024. // import MlemBackend import MlemMiddleware import SwiftUI extension SearchView { var availableTabs: [Tab] { var ret: [Tab] = [.communities, .people, .instances, .posts] if appState.firstApi.supports(.commentSearch, defaultValue: false) || selectedTab == .comments { ret.append(.comments) } return ret } func contentChangeTriggerDebouncedRefresh() { let stashedQuery = query DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { if query == stashedQuery { contentChangeTriggerRefresh() } } } func contentChangeTriggerRefresh() { editingRecentSearches = false Task { await refresh(clearBeforeRefresh: false) } } func onFilterRefreshHashValueChange() { Task { await refresh(clearBeforeRefresh: selectedTab == .posts || selectedTab == .comments) } } func returnToHome() { if selectedTab == .posts || selectedTab == .comments { selectedTab = .communities } page = .home if !query.isEmpty { query = "" Task { await refresh(clearBeforeRefresh: true) } } resultsScrollToTopTrigger.toggle() } func setupFilters() async { guard communityFilters == nil else { return } do { let software = try await appState.firstApi.software communityFilters = .init(software: software) personFilters = .init(software: software) postFilters = .init(software: software) } catch { handleError(error) } } func refresh(clearBeforeRefresh: Bool) async { do { if clearBeforeRefresh { setInstances(.init()) } switch selectedTab { case .communities: try await refreshCommunities(clearBeforeRefresh: clearBeforeRefresh) case .people: try await refreshPeople(clearBeforeRefresh: clearBeforeRefresh) case .instances: try await setInstances(MlemStats.main.searchInstances( query: query, sort: filtersActive ? instanceFilters.sort : .score )) case .posts: try await refreshPosts(clearBeforeRefresh: clearBeforeRefresh) case .comments: try await refreshComments(clearBeforeRefresh: clearBeforeRefresh) } if lastExecutedQuery[selectedTab] != query { lastExecutedQuery[selectedTab] = query } } catch { handleError(error) } } private func refreshCommunities(clearBeforeRefresh: Bool) async throws { guard let communityFilters else { return } let refreshApi = getRefreshApi(for: communityFilters.instance) await communityLoader.changeApi( to: refreshApi, context: filtersTracker.filterContext, hostApi: refreshApi == appState.firstApi ? nil : appState.firstApi ) let defaultSort: SearchSortType if try await refreshApi.supports(.searchSortType(.top(.allTime))) { defaultSort = .top(.allTime) } else { defaultSort = .top(.limited(.month)) } try await communityLoader.refresh( query: query, listing: (!filtersActive || communityFilters.instance == .any) ? .all : .local, sort: filtersActive ? communityFilters.sort : defaultSort, clearBeforeRefresh: clearBeforeRefresh ) } private func refreshPeople(clearBeforeRefresh: Bool) async throws { guard let personFilters else { return } let refreshApi = getRefreshApi(for: personFilters.instance) await personLoader.changeApi( to: refreshApi, context: filtersTracker.filterContext ) let defaultSort: SearchSortType if try await refreshApi.supports(.searchSortType(.top(.allTime))) { defaultSort = .top(.allTime) } else { defaultSort = .top(.limited(.month)) } try await personLoader.refresh( query: query, listing: (!filtersActive || personFilters.instance == .any) ? .all : .local, sort: filtersActive ? personFilters.sort : defaultSort, clearBeforeRefresh: clearBeforeRefresh ) } private func refreshPosts(clearBeforeRefresh: Bool) async throws { guard let postFilters else { return } guard !query.isEmpty else { return } let refreshApi = getRefreshApi(for: postFilters.location) await postLoader.searchPostFetcher.changeApi( to: refreshApi, context: filtersTracker.filterContext ) let defaultSort: PostSortType if try await refreshApi.supports(.searchSortType(.top(.allTime))) { defaultSort = .top(.allTime) } else { defaultSort = .top(.limited(.month)) } postLoader.searchPostFetcher.setSortType(.v3(filtersActive ? postFilters.sort : defaultSort)) postLoader.searchPostFetcher.query = query postLoader.searchPostFetcher.creatorId = filtersActive ? postFilters.creator?.id : nil postLoader.searchPostFetcher.communityId = nil postLoader.searchPostFetcher.listing = .all if filtersActive { switch postFilters.location { case .subscribed: postLoader.searchPostFetcher.listing = .subscribed case .moderated: postLoader.searchPostFetcher.listing = .moderated case .localInstance, .instance: postLoader.searchPostFetcher.listing = .local case let .community(community): postLoader.searchPostFetcher.communityId = community.id default: break } } try await postLoader.refresh(clearBeforeRefresh: clearBeforeRefresh) } public func refreshComments(clearBeforeRefresh: Bool) async throws { guard !query.isEmpty else { return } await commentLoader.searchCommentFetcher.changeApi( to: getRefreshApi(for: commentFilters.location) ) var listing: ListingType = .all commentLoader.searchCommentFetcher.communityId = nil commentLoader.searchCommentFetcher.creatorId = filtersActive ? commentFilters.creator?.id : nil if filtersActive { switch commentFilters.location { case .subscribed: listing = .subscribed case .moderated: listing = .moderated case .localInstance, .instance: listing = .local case let .community(community): commentLoader.searchCommentFetcher.communityId = community.id default: break } } try await commentLoader.refresh( query: query, listing: listing, sort: .v3(filtersActive ? commentFilters.sort : .top(.allTime)), clearBeforeRefresh: clearBeforeRefresh ) } private func getRefreshApi(for filter: InstanceFilter) -> ApiClient { if !filtersActive { appState.firstApi } else { switch filter { case let .other(instance): instance.instanceStub.asLocal().api default: appState.firstApi } } } private func getRefreshApi(for filter: LocationFilter) -> ApiClient { if !filtersActive { appState.firstApi } else { switch filter { case let .instance(instance): instance.instanceStub.asLocal().api default: appState.firstApi } } } func resolvePostFilterCreator() { guard let postFilters else { return } let api = postFilters.location.instanceStub?.api ?? appState.firstApi if let creator = postFilters.creator, api !== creator.api { Task { let stub = PersonStub(api: api, url: creator.actorId.url) do { postFilters.creator = try await (stub.getPerson()) } catch { handleError(error) } } } } @MainActor func setInstances(_ newValue: [InstanceSummary]) { instances = newValue } var filterAnimationHashValue: Int { var hasher = Hasher() hasher.combine(filtersActive) hasher.combine(communityFilters?.instance.isOther) hasher.combine(selectedTab) return hasher.finalize() } var filterRefreshHashValue: Int { var hasher = Hasher() hasher.combine(filtersActive) hasher.combine(communityFilters?.sort) hasher.combine(communityFilters?.instance) hasher.combine(personFilters?.sort) hasher.combine(personFilters?.instance) hasher.combine(instanceFilters.sort) hasher.combine(postFilters?.sort) hasher.combine(postFilters?.creator?.actorId) hasher.combine(postFilters?.location) hasher.combine(commentFilters.sort) hasher.combine(commentFilters.creator?.actorId) hasher.combine(commentFilters.location) return hasher.finalize() } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView+Views.swift ================================================ // // SearchView+Views.swift // Mlem // // Created by Sjmarf on 2025-01-01. // import Haptics import SwiftUI extension SearchView { @ViewBuilder var tabView: some View { HStack { BubblePicker( availableTabs, selected: $selectedTab, label: { $0.label } ) .overlay(alignment: .trailing) { LinearGradient( colors: [Color.clear, palette.groupedBackground.primary], startPoint: .leading, endPoint: .trailing ) .frame(width: 10) } if page != .home { Button { hapticManager.play(haptic: .gentleInfo, tier: .low) filtersActive.toggle() } label: { Label("Filters", icon: .general.filter) .symbolVariant(filtersActive ? .fill : .none) .transaction { $0.animation = nil } } .labelStyle(.iconOnly) .padding(.trailing) .imageScale(.large) } } .animation(.easeOut(duration: 0.1), value: page) } @ViewBuilder var resultsListView: some View { switch selectedTab { case .communities: Group { if query != lastExecutedQuery[.communities] { ProgressView().padding(.top, 25) } else { LazyVStack(spacing: 0) { SearchResultsView(results: communityLoader.items) { community in CommunityListRow( community, readout: .subscribers, visitContext: page == .home ? .other : .search ) .onAppear { do { try communityLoader.loadIfThreshold(community) } catch { handleError(error) } } } EndOfFeedView(feedLoader: communityLoader, viewType: .hobbit) } } } .animation(.easeOut(duration: 0.1), value: communityLoader.items.isEmpty) case .people: Group { if query != lastExecutedQuery[.people] { ProgressView().padding(.top, 25) } else { LazyVStack(spacing: 0) { SearchResultsView(results: personLoader.items) { person in PersonListRow( person, complications: [.instance, .date], readout: .postsAndComments, visitContext: page == .home ? .other : .search ) .onAppear { do { try personLoader.loadIfThreshold(person) } catch { handleError(error) } } } EndOfFeedView(feedLoader: personLoader, viewType: .hobbit) } } } .animation(.easeOut(duration: 0.1), value: personLoader.items.isEmpty) case .instances: Group { if query != lastExecutedQuery[.instances] { ProgressView().padding(.top, 25) } else { LazyVStack(spacing: 0) { SearchResultsView(results: instances) { instance in InstanceListRow( instance, readout: .users, visitContext: page == .home ? .other : .search ) } EndOfFeedView(loadingState: .done, viewType: .hobbit) } } } case .posts: Group { if postLoader.loadingState == .idle, postLoader.items.isEmpty { searchPlaceholder } else if query != lastExecutedQuery[.posts] { ProgressView().padding(.top, 25) } else { PostGridView(postFeedLoader: postLoader, alwaysShowRead: true) } } .animation(.easeOut(duration: 0.1), value: personLoader.items.isEmpty) case .comments: if commentLoader.loadingState == .idle, commentLoader.items.isEmpty { searchPlaceholder } else if query != lastExecutedQuery[.comments] { ProgressView().padding(.top, 25) } else { VStack(spacing: 0) { LazyVStack(spacing: compactComments ? Constants.main.halfSpacing : Constants.main.standardSpacing) { ForEach(commentLoader.items, id: \.actorId) { comment in NavigationLink(.comment(comment)) { FeedCommentView(comment: comment) } .buttonStyle(.empty) .onAppear { do { try commentLoader.loadIfThreshold(comment) } catch { handleError(error) } } } } .animation(.easeOut(duration: 0.1), value: commentLoader.items.isEmpty) .padding(.horizontal, Constants.main.standardSpacing) EndOfFeedView(feedLoader: commentLoader, viewType: .hobbit) } } } } @ViewBuilder var recentSearchesListView: some View { if let session = appState.firstSession as? UserSession, let visitHistory = session.visitHistory { switch selectedTab { case .communities: let items = visitHistory.communities(withContext: .search) if !items.isEmpty { recentSearchesHeader SearchResultsView(results: items) { community in HStack { CommunityListRow(community, readout: .subscribers) .disabled(editingRecentSearches) deleteRecentSearchButton(session: session) { visitHistory.removeCommunity(community, context: .search) } } } } else { searchPlaceholder } case .people: let items = visitHistory.people(withContext: .search) if !items.isEmpty { recentSearchesHeader SearchResultsView(results: items) { person in HStack { PersonListRow(person, readout: .postsAndComments) .disabled(editingRecentSearches) deleteRecentSearchButton(session: session) { visitHistory.removePerson(person, context: .search) } } } } else { searchPlaceholder } case .instances: let items = visitHistory.instances(withContext: .search) if !items.isEmpty { recentSearchesHeader SearchResultsView(results: items) { instance in HStack { InstanceListRow(instance, readout: .users) .disabled(editingRecentSearches) deleteRecentSearchButton(session: session) { visitHistory.removeInstance(instance, context: .search) } } } } else { searchPlaceholder } default: searchPlaceholder } } else { searchPlaceholder } } @ViewBuilder var recentSearchesHeader: some View { HStack { if editingRecentSearches { ClearRecentSearchesButton() } else { Text("Recently Searched") .foregroundStyle(.themedPrimary) } Spacer() if editingRecentSearches { Button("Done") { withAnimation { editingRecentSearches = false } } } else { Button("Edit") { withAnimation { editingRecentSearches = true } } } } .font(.callout) .bold() .padding(.horizontal, 15) .padding(.bottom, Constants.main.standardSpacing) .padding(.top, Constants.main.standardSpacing) } @ViewBuilder var searchPlaceholder: some View { VStack(spacing: 20) { Image(icon: .general.search) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 120) .fontWeight(.thin) .foregroundStyle(.themedTertiary) Text(searchPlaceholderTitle) .font(.title2) .fontWeight(.semibold) .foregroundStyle(.themedSecondary) .multilineTextAlignment(.center) .padding(.horizontal, 20) } .padding(.top, 30) } var searchPlaceholderTitle: LocalizedStringResource { switch selectedTab { case .communities: "Search for communities" case .instances: "Search for Lemmy instances" case .people: "Search for users" case .posts: "Search for posts" case .comments: "Search for comments" } } struct ClearRecentSearchesButton: View { @Environment(AppState.self) var appState @State var showingConfirmation: Bool = false var body: some View { Button("Clear") { showingConfirmation = true } .confirmationDialog( "Clear search history?", isPresented: $showingConfirmation, titleVisibility: .visible ) { Button("Clear", role: .destructive) { if let session = appState.firstSession as? UserSession, let visitHistory = session.visitHistory { visitHistory.clear() Task { do { try await session.saveVisitHistory() } catch { handleError(error) } } } } Button("Turn Off Search History", role: .destructive) { if let session = appState.firstSession as? UserSession { Task { @MainActor in do { try await session.setVisitHistoryEnabled(false) } catch { handleError(error) } } } } Button("Cancel", role: .cancel) {} } message: { Text("You can also turn off search history completely for this account.") } } } @ViewBuilder func deleteRecentSearchButton(session: UserSession, callback: @escaping (() -> Void)) -> some View { if editingRecentSearches { Button("Remove Recent Search", icon: .general.delete) { withAnimation { callback() } Task(priority: .background) { try await session.saveVisitHistory() } } .labelStyle(.iconOnly) .foregroundStyle(palette.negative) .padding(.horizontal, Constants.main.halfSpacing) } } } ================================================ FILE: Mlem/App/Views/Shared/Search/SearchView.swift ================================================ // // SearchView.swift // Mlem // // Created by Sjmarf on 06/07/2024. // import Haptics import MlemMiddleware import MlemBackend import SwiftUI import Theming struct SearchView: View { enum Page { case home, recents, results } enum Tab: CaseIterable, Identifiable { case communities, people, instances, posts, comments var id: Self { self } var label: LocalizedStringResource { switch self { case .communities: "Communities" case .people: "Users" case .instances: "Instances" case .posts: "Posts" case .comments: "Comments" } } var shouldAutocorrect: Bool { switch self { case .comments, .posts: true case .communities, .people, .instances: false } } } @Environment(AppState.self) var appState @Environment(HapticManager.self) var hapticManager @Environment(NavigationLayer.self) var navigation @Environment(FiltersTracker.self) var filtersTracker @Environment(\.palette) var palette @Setting(\.comment_compact) var compactComments @State var searchBarFocused: Bool = false @State var isSearching: Bool = false @State var query: String = "" @State var hasAppeared: Bool = false @State var page: Page = .home @State var filtersActive: Bool = false @State var communityFilters: CommunityFilters? @State var personFilters: PersonFilters? @State var instanceFilters: InstanceFilters = .init() @State var postFilters: PostFilters? @State var commentFilters: CommentFilters = .init() @State var selectedTab: Tab = .communities @State var resultsScrollToTopTrigger: Bool = false @State var communityLoader: CommunityFeedLoader @State var personLoader: PersonFeedLoader @State var instances: [InstanceSummary] = [] @State var postLoader: SearchPostFeedLoader @State var commentLoader: SearchCommentFeedLoader @State var editingRecentSearches: Bool = false @State var lastExecutedQuery: [Tab: String] = .init() init(appState: AppState = .main) { self._communityLoader = .init(wrappedValue: .init(api: appState.firstApi)) self._personLoader = .init(wrappedValue: .init(api: appState.firstApi)) self._postLoader = .init( wrappedValue: .init( api: appState.firstApi, sortType: .v3(.top(.allTime)), prefetchingConfiguration: .forPostSize(Settings.get(\.post_size)), urlCache: Constants.main.urlCache ) ) self._commentLoader = .init(wrappedValue: .init(api: appState.firstApi)) } var body: some View { content .background(ThemedColor.themedGroupedBackground) .themedGroupedBackground() .navigationTitle("Search") .navigationBarTitleDisplayMode(.large) .navigationSearchBar(searchBar) .autocorrectionDisabled(!selectedTab.shouldAutocorrect) .navigationSearchBarHiddenWhenScrolling(false) .toolbar { PasteLinkButtonView() } .scrollDismissesKeyboard(.interactively) .onChange(of: query) { _, newValue in switch newValue { case let str where str.hasPrefix("@"), let str where str.hasPrefix("!"): selectedTab = str.hasPrefix("@") ? .people : .communities query = "" page = .recents searchBarFocused = true contentChangeTriggerDebouncedRefresh() default: if page != .home { page = query.isEmpty ? .recents : .results } } lastExecutedQuery[selectedTab] = query } .onChange(of: isSearching) { if isSearching, query.isEmpty { page = .recents } } // Don't use `.task` here, because it triggers when navigating back .onChange(of: query, initial: true) { oldValue, newValue in if oldValue != newValue || selectedTab == .communities && communityLoader.items.isEmpty && !isSearching { contentChangeTriggerDebouncedRefresh() } } .onChange(of: selectedTab) { contentChangeTriggerRefresh() } .onChange(of: filterRefreshHashValue, onFilterRefreshHashValueChange) .onChange(of: postFilters?.location.instanceStub) { resolvePostFilterCreator() } .onDisappear { editingRecentSearches = false } .environment(\.feedContext, .search) .onChange(of: appState.firstApi) { communityFilters = nil personFilters = nil postFilters = nil } .task(id: appState.firstApi) { await setupFilters() } } @ViewBuilder var content: some View { FancyScrollView(scrollToTopTrigger: $resultsScrollToTopTrigger) { searchBarFocused = true } content: { VStack(alignment: .leading, spacing: 0) { if page != .home { tabView if filtersActive { filtersView } } } .padding(.top, -8) switch page { case .recents: recentSearchesListView case .home: SearchHomeView() .frame(maxWidth: .infinity) case .results: resultsListView } } .animation(.easeOut(duration: 0.1), value: filtersActive) .animation(.easeOut(duration: 0.2), value: page) } func searchBar() -> SearchBar { SearchBar( "Search...", text: $query, isEditing: $isSearching, onCommit: { if selectedTab == .posts || selectedTab == .comments { Task { @MainActor in await refresh(clearBeforeRefresh: true) } } } ) .returnKeyType(.search) .showsCancelButton(page != .home) .onCancel(perform: returnToHome) .focused($searchBarFocused) } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment(api: .realistic)) { // @Previewable @Environment(AppState.self) var appState // NavigationStack { // SearchView(appState: appState) // } // } // #endif ================================================ FILE: Mlem/App/Views/Shared/Search/Searchable.swift ================================================ // // Searchable.swift // Mlem // // Created by Sjmarf on 28/06/2024. // import MlemBackend import MlemMiddleware // swiftlint:disable function_parameter_count protocol Searchable: Identifiable { static func search( api: ApiClient, query: String, page: Int, limit: Int, filter: ListingType, hostApi: ApiClient? ) async throws -> [Self] } extension Community: Searchable { static func search( api: ApiClient, query: String, page: Int, limit: Int, filter: ListingType, hostApi: ApiClient? ) async throws -> [Community] { try await api.searchCommunities(query: query, page: page, limit: limit, filter: filter, hostApi: hostApi) } } extension Person: Searchable { static func search( api: ApiClient, query: String, page: Int, limit: Int, filter: ListingType, hostApi: ApiClient? = nil ) async throws -> [Person] { try await api.searchPeople(query: query, page: page, limit: limit, filter: filter) } } extension InstanceSummary: Searchable { static func search( api _: ApiClient, query: String, page _: Int, limit _: Int, filter _: ListingType, hostApi: ApiClient? = nil ) async throws -> [InstanceSummary] { try await MlemStats.main.searchInstances(query: query) } } // swiftlint:enable function_parameter_count ================================================ FILE: Mlem/App/Views/Shared/Search/VisitHistory+CodedData.swift ================================================ // // VisitHistory+CodedData.swift // Mlem // // Created by Sjmarf on 2025-01-01. // import Foundation import MlemBackend import MlemMiddleware extension VisitHistory { struct CodedData: Codable { var communities: [VisitContext: [CodedVisitRecord]] = [:] var people: [VisitContext: [CodedVisitRecord]] = [:] var instances: [VisitContext: [CodedVisitRecord]] = [:] } struct CodedVisitRecord: Codable { let value: T let date: Date } convenience init(data: CodedData, api: ApiClient) async throws { let communityRecords = try await data.communities.mapValueArraysAsync { item in try await VisitRecord(value: api.decodeCommunity(item.value), date: item.date) } let personRecords = try await data.people.mapValueArraysAsync { item in try await VisitRecord(value: api.decodePerson(item.value), date: item.date) } self.init( communityRecords: communityRecords, personRecords: personRecords, instanceRecords: data.instances.mapValues { $0.map { .init(value: $0.value, date: $0.date) } } ) } func codedData() async throws -> CodedData { let communities = try await communityRecords.mapValueArraysAsync { item in try await CodedVisitRecord(value: item.value.codedData(), date: item.date) } let people = try await personRecords.mapValueArraysAsync { item in try await CodedVisitRecord(value: item.value.codedData(), date: item.date) } return .init( communities: communities, people: people, instances: instanceRecords.mapValues { $0.map { .init(value: $0.value, date: $0.date) } } ) } } private func decodeDictionary( _ input: [VisitHistory.VisitContext: [InputValue]], _ transform: (InputValue) async throws -> OutputValue ) async throws -> [VisitHistory.VisitContext: [OutputValue]] { var output: [VisitHistory.VisitContext: [OutputValue]] = [:] for (context, items) in input { var outputValues: [OutputValue] = [] for item in items { try await outputValues.append(transform(item)) } output[context] = outputValues } return output } private extension Dictionary where Value: Collection, Key == VisitHistory.VisitContext { func mapValueArraysAsync( _ transform: (Value.Element) async throws -> OutputValue ) async throws -> [Key: [OutputValue]] { var output: [Key: [OutputValue]] = [:] for (context, items) in self { var outputValues: [OutputValue] = [] for item in items { try await outputValues.append(transform(item)) } output[context] = outputValues } return output } } ================================================ FILE: Mlem/App/Views/Shared/Search/VisitHistory.swift ================================================ // // VisitHistory.swift // Mlem // // Created by Sjmarf on 2025-01-01. // import Foundation import MlemBackend import MlemMiddleware @Observable class VisitHistory { enum VisitContext: Codable { case search, other var maximumHistorySize: Int { switch self { case .search: 15 case .other: 5 } } } struct VisitRecord { let value: T let date: Date } private(set) var communityRecords: [VisitContext: [VisitRecord]] private(set) var personRecords: [VisitContext: [VisitRecord]] // Using `InstanceSummary` here rather than an `Instance` model because otherwise we'd need // to store full `Instance3` models in order to have access to the site `version`, which means // storing a lot of other unnecessary data. private(set) var instanceRecords: [VisitContext: [VisitRecord]] init( communityRecords: [VisitContext: [VisitRecord]] = [:], personRecords: [VisitContext: [VisitRecord]] = [:], instanceRecords: [VisitContext: [VisitRecord]] = [:] ) { self.communityRecords = communityRecords self.personRecords = personRecords self.instanceRecords = instanceRecords } var isEmpty: Bool { communityRecords.isEmpty && personRecords.isEmpty && instanceRecords.isEmpty } func communities(withContext context: VisitContext) -> [Community] { communityRecords[context]?.map(\.value) ?? [] } func communities(withContexts contexts: Set) -> [Community] { contexts .reduce(into: []) { result, context in result += communityRecords[context] ?? [] } .sorted { $0.date > $1.date } .map(\.value) .uniqued() } func people(withContext context: VisitContext) -> [Person] { personRecords[context]?.map(\.value) ?? [] } func instances(withContext context: VisitContext) -> [InstanceSummary] { instanceRecords[context]?.map(\.value) ?? [] } @MainActor func addCommunity(_ community: Community, context: VisitContext) { addValue(community, to: &communityRecords, context: context) } @MainActor func removeCommunity(_ community: Community, context: VisitContext) { removeValue(community, from: &communityRecords, context: context) } @MainActor func addPerson(_ person: Person, context: VisitContext) { addValue(person, to: &personRecords, context: context) } @MainActor func removePerson(_ person: Person, context: VisitContext) { removeValue(person, from: &personRecords, context: context) } @MainActor func addInstance(_ instance: InstanceSummary, context: VisitContext) { addValue(instance, to: &instanceRecords, context: context) } @MainActor func removeInstance(_ instance: InstanceSummary, context: VisitContext) { removeValue(instance, from: &instanceRecords, context: context) } private func addValue( _ value: T, to dict: inout [VisitContext: [VisitRecord]], context: VisitContext ) { removeValue(value, from: &dict, context: context) if !dict.keys.contains(context) { dict[context] = [] } dict[context]?.prepend(.init(value: value, date: .now)) if dict[context, default: []].count > context.maximumHistorySize { dict[context, default: []].removeLast() } } private func removeValue( _ value: T, from dict: inout [VisitContext: [VisitRecord]], context: VisitContext ) { if let index = dict[context, default: []].firstIndex(where: { $0.value == value }) { dict[context, default: []].remove(at: index) } } func clear() { communityRecords = [:] personRecords = [:] instanceRecords = [:] } } extension Set { static var all: Set { [.other, .search] } } ================================================ FILE: Mlem/App/Views/Shared/SelectTextView.swift ================================================ // // SelectTextView.swift // Mlem // // Created by Sjmarf on 03/03/2024. // import ComponentViews import Dependencies import Haptics import SwiftUI import SwiftUIIntrospect struct SelectTextView: View { @Environment(HapticManager.self) var hapticManager @Environment(\.dismiss) var dismiss @Environment(\.palette) var palette let text: String var body: some View { Group { if #available(iOS 26, *) { ios26Body } else { ios18Body } } .presentationBackgroundInteraction(.enabled) } @ViewBuilder var ios18Body: some View { VStack(spacing: 10) { HStack { Spacer() copyButton .foregroundStyle(.white) .frame(height: 30) .padding(.horizontal, 12) .background(Capsule().fill(.themedAccent)) CloseButtonView() } .padding(.horizontal, 10) textEditor(withBackground: true) } .padding(.top, 10) .presentationCornerRadius(20) .background(.themedBackground) } @available(iOS 26, *) @ViewBuilder var ios26Body: some View { NavigationStack { textEditor(withBackground: false) .padding(.horizontal, 20) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButtonView() } ToolbarItem(placement: .topBarTrailing) { copyButton } } } } @ViewBuilder func textEditor(withBackground: Bool) -> some View { TextEditor(text: .constant(text)) .scrollContentBackground(.hidden) .introspect(.textEditor, on: .iOS(.v17, .v18, .v26)) { textEditor in textEditor.isEditable = false textEditor.textContainerInset = .init(top: 0, left: 10, bottom: 10, right: 10) if withBackground { textEditor.backgroundColor = UIColor(palette.background.primary) } else { textEditor.backgroundColor = .clear } } } @ViewBuilder var copyButton: some View { Button { let pasteboard = UIPasteboard.general pasteboard.string = text hapticManager.play(haptic: .lightSuccess, tier: .high) dismiss() } label: { Label("Copy All", icon: .general.copy) .symbolVariant(.fill) .font(.footnote) .fontWeight(.semibold) } } } ================================================ FILE: Mlem/App/Views/Shared/ShareInstancePickerView.swift ================================================ // // ShareInstancePickerView.swift // Mlem // // Created by Sjmarf on 2025-03-09. // import ComponentViews import MlemMiddleware import SwiftUI struct ShareInstancePickerView: View { @Environment(NavigationLayer.self) var navigation @Environment(\.dismiss) var dismiss let entity: any Sharable var body: some View { VStack(spacing: 16) { HStack { Text("Share using...") .fontWeight(.bold) .foregroundStyle(.themedSecondary) .padding(.leading, 8) Spacer() closeButton } VStack(spacing: 0) { instanceTargetRow(entity.host, label: "My Instance", url: entity.url()) Divider() instanceTargetRow(entity.actorId.host, label: "Original Instance", url: entity.actorId.url) if let lemmyverseUrl = entity.lemmyverseUrl { Divider() instanceTargetRow("lemmyverse.link", label: "Universal", url: lemmyverseUrl) } } .frame(maxWidth: .infinity) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 16)) chooseButtonView } .padding(16) .presentationBackground(.themedGroupedBackground) } @ViewBuilder var closeButton: some View { if #available(iOS 26, *) { Button { dismiss() } label: { Label("Close", icon: .general.close) .padding(10) .background(.themedSecondaryGroupedBackground, in: .circle) .foregroundStyle(.themedSecondary) } .labelStyle(.iconOnly) .font(.title) .buttonStyle(.plain) } else { CloseButtonView() } } @ViewBuilder func instanceTargetRow(_ host: String, label: LocalizedStringResource, url: URL) -> some View { Button { navigation.dismissSheet() DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NavigationModel.main.shareInfo = .init(url: url, actions: entity.shareSheetActions()) } } label: { HStack(spacing: 16) { if host == "lemmyverse.link" { Image(systemName: "globe") .foregroundStyle(.themedAccent) .frame(width: 42, height: 42) .background(.themedAccent.opacity(0.2), in: .circle) } else { CircleCroppedImageView(url: faviconUrl(for: url), frame: 42, fallback: .instanceAvatar) } VStack(alignment: .leading, spacing: 5) { Text(host) .foregroundStyle(.themedPrimary) Text(label) .font(.footnote) .foregroundStyle(.themedSecondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 12) } } func faviconUrl(for instanceUrl: URL) -> URL? { guard let host = instanceUrl.host() else { return nil } let summary = MlemStats.main.instances?.first(where: { $0.host == host }) return summary?.avatar?.withIconSize(128) } @ViewBuilder var chooseButtonView: some View { Button { let model = navigation.model navigation.dismissSheet() guard let model else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { model.openSheet(.instancePicker(callback: { instance in DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { Task { await resolveEntity(url: instance.instanceStub.actorId.url, model: model) } } })) } } label: { HStack(spacing: 16) { Image(icon: .general.search) .frame(width: 42) Text("Choose Another Instance...") } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(.themedSecondaryGroupedBackground, in: .rect(cornerRadius: 16)) } } func resolveEntity(url: URL, model: NavigationModel) async { let toastId = ToastModel.main.add(.loading("Resolving..."), location: .bottom) do { let client = ApiClient.getApiClient(url: url, username: nil) let resolvedEntity = try await client.resolve(url: entity.actorId.url) NavigationModel.main.shareInfo = .init( url: resolvedEntity.url(), actions: entity.shareSheetActions() ) ToastModel.main.removeToast(id: toastId) } catch { ToastModel.main.removeToast(id: toastId) handleError(error) } } } // TODO: updated mocks // #if DEBUG // #Preview(traits: .sampleEnvironment) { // ScrollView { // VStack(spacing: Constants.main.standardSpacing) { // LargePostView(post: Post2.mock(.realistic(.yorkshireDales))) // LargePostView(post: Post2.mock(.realistic(.meguroRiver))) // } // .padding(.horizontal, Constants.main.standardSpacing) // } // .background(.themedGroupedBackground) // .sheet(isPresented: .constant(true)) { // ShareInstancePickerView(entity: Community2.mock(.realistic(.pics))) // } // } // #endif ================================================ FILE: Mlem/App/Views/Shared/ShieldsBadgeView/ShieldsBadgeView+Logic.swift ================================================ // // ShieldsBadgeView+Logic.swift // Mlem // // Created by Sjmarf on 2025-01-28. // import Foundation extension ShieldsBadgeView { enum LogoType { case bundle(String), system(String) } mutating func decodeBadgeType(_ path: [String]) { switch path[1] { case "mastodon": label = .init(localized: "Follow on Mastodon") logo = .bundle("mastodon.logo") case "discord": label = .init(localized: "Join Discord Server") logo = .bundle("discord.logo") case "matrix": label = .init(localized: "Join Matrix Room") logo = .bundle("matrix.logo") case "github": label = "GitHub" logo = .bundle("github.logo") case "opencollective": label = "OpenCollective" case "liberapay": label = "LiberaPay" case "mozilla-observatory": label = .init(localized: "Mozilla Observatory") case "lemmy": label = path[2] default: break } } mutating func decodeLabel(_ text: String) { let parts = text.replacingOccurrences(of: "_", with: " ").split(separator: "-") if parts.count == 3 { label = String(parts[0]) message = String(parts[1]) } else if parts.count == 2 { label = String(parts[0]) } } mutating func decodeLogo(name: String) { switch name { case "github": logo = .bundle("github.logo") case "matrix": logo = .bundle("matrix.logo") case "mastodon": logo = .bundle("mastodon.logo") case "discord": logo = .bundle("discord.logo") case "lemmy": logo = .bundle("lemmy.logo") default: break } } } ================================================ FILE: Mlem/App/Views/Shared/ShieldsBadgeView/ShieldsBadgeView.swift ================================================ // // ShieldsBadgeView.swift // Mlem // // Created by Sjmarf on 2025-01-28. // import SwiftUI // https://shields.io/badges struct ShieldsBadgeView: View { @Environment(\.palette) var palette @Environment(\.openURL) var openURL var label: String var message: String? var link: URL? var logo: LogoType? init(shieldsUrl: URL, link: URL?) { self.link = link self.label = .init(localized: "Unsupported Badge") if let host = shieldsUrl.host(), host == "img.shields.io" { let path = shieldsUrl.pathComponents if path.count >= 3 { decodeBadgeType(path) decodeLabel(path[2]) if let components = URLComponents(url: shieldsUrl, resolvingAgainstBaseURL: false) { if let parameters = components.queryItems { for parameter in parameters { switch parameter.name { case "logo": if let value = parameter.value { decodeLogo(name: value) } case "label": if let value = parameter.value { self.label = value } default: break } } } } } } } init(label: String, message: String?, link: URL?) { self.label = label self.message = message self.link = link } var body: some View { HStack(spacing: 7) { Group { switch logo { case let .bundle(name): Image(name) case let .system(systemName): Image(systemName: systemName) case nil: EmptyView() } Text(label) .padding(.vertical, 3) } .foregroundStyle(message != nil ? .themedPrimary : .themedContrastingLabel) if let message { Text(message) .padding(.vertical, 3) .padding(.horizontal, 7) .foregroundStyle(.themedContrastingLabel) .background(.themedAccent) } } .padding(.leading, 7) .padding(.trailing, message == nil ? 7 : 0) .background(message == nil ? palette.accent : .clear) .clipShape(RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius)) .overlay { RoundedRectangle(cornerRadius: Constants.main.smallItemCornerRadius) .stroke(.themedAccent, lineWidth: 1) } .onTapGesture { if let link { openURL(link) } } } } ================================================ FILE: Mlem/App/Views/Shared/Toast/Toast.swift ================================================ // // Toast.swift // Mlem // // Created by Sjmarf on 15/05/2024. // import Foundation class Toast: Identifiable, Hashable { let type: ToastType let location: ToastLocation let important: Bool let id: UUID private var killTask: Task? var killTaskStarted: Bool { killTask != nil } var shouldTimeout: Bool = true { didSet { if oldValue != shouldTimeout { if shouldTimeout { startKillTask() } else { killTask?.cancel() killTask = nil } } } } init(type: ToastType, location: ToastLocation, important: Bool = false) { self.type = type self.location = location self.important = important self.id = .init() } func kill() { ToastModel.main.removeToast(id: id) killTask?.cancel() killTask = nil } func startKillTask() { if shouldTimeout { killTask?.cancel() killTask = Task { try await Task.sleep( nanoseconds: UInt64(1_000_000_000 * type.duration) ) Task { @MainActor in self.kill() } } } } func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(type) hasher.combine(location) hasher.combine(important) hasher.combine(shouldTimeout) } static func == (lhs: Toast, rhs: Toast) -> Bool { lhs.hashValue == rhs.hashValue } } ================================================ FILE: Mlem/App/Views/Shared/Toast/ToastLocation.swift ================================================ // // ToastLocation.swift // Mlem // // Created by Sjmarf on 19/05/2024. // import SwiftUI enum ToastLocation { case top, bottom var edge: Edge { switch self { case .top: .top case .bottom: .bottom } } } ================================================ FILE: Mlem/App/Views/Shared/Toast/ToastModel.swift ================================================ // // ToastModel.swift // Mlem // // Created by Sjmarf on 16/05/2024. // import os import SwiftUI @Observable class ToastModel { private let log: Logger = .mlemLogger() private var toasts: [Toast] = .init() static let main: ToastModel = .init() func activeToasts(location: ToastLocation) -> [Toast] { Array(toasts.filter { $0.location == location }.prefix(3)) } @discardableResult func add(_ type: ToastType, location: ToastLocation? = nil, important: Bool? = nil) -> UUID { let newToast: Toast = .init( type: type, location: location ?? type.location, important: important ?? type.important ) Task { @MainActor in if !newToast.important, let index = toasts.firstIndex( where: { !$0.important && $0.location == newToast.location } ) { toasts.remove(at: index) } toasts.append(newToast) } return newToast.id } func removeToast(id: UUID) { Task { @MainActor in if let index = toasts.firstIndex(where: { $0.id == id }) { toasts.remove(at: index) } else { log.info("No Toast Index") } } } } ================================================ FILE: Mlem/App/Views/Shared/Toast/ToastOverlayView.swift ================================================ // // ToastOverlayView.swift // Mlem // // Created by Sjmarf on 17/05/2024. // import SwiftUI struct ToastOverlayView: View { let shouldDisplayNewToasts: Bool let location: ToastLocation @State var activeToasts: [Toast] = [] var toastModel: ToastModel { .main } var body: some View { VStack { ForEach(location == .top ? activeToasts : activeToasts.reversed(), id: \.id) { toast in ToastView(toast: toast) .transition( activeToasts.count <= 1 ? .move(edge: location.edge).combined(with: .opacity) : .opacity ) .onAppear { toast.startKillTask() } } } .animation(.snappy(duration: 0.3, extraBounce: 0.2), value: activeToasts) .onChange(of: onChangeHash) { let toasts = toastModel.activeToasts(location: location) if shouldDisplayNewToasts || toasts.isEmpty { activeToasts = toasts } } .onChange(of: shouldDisplayNewToasts) { _, newValue in if !newValue { activeToasts.forEach { $0.kill() } activeToasts = [] } else { Task { try await Task.sleep(nanoseconds: UInt64(100_000_000)) Task { @MainActor in addNewToasts(toastModel.activeToasts(location: location), startTimersAgain: true) } } } } .onDisappear { activeToasts.forEach { $0.kill() } } .task { if shouldDisplayNewToasts, activeToasts.isEmpty { do { try await Task.sleep(nanoseconds: UInt64(500_000_000)) addNewToasts(toastModel.activeToasts(location: location), startTimersAgain: true) } catch {} } } } func addNewToasts(_ toasts: [Toast], startTimersAgain: Bool = true) { for toast in toasts where startTimersAgain || !toast.killTaskStarted { toast.startKillTask() } } var onChangeHash: Int { var hasher = Hasher() hasher.combine(toastModel.activeToasts(location: location).map(\.id)) hasher.combine(shouldDisplayNewToasts) return hasher.finalize() } var taskHash: Int { var hasher = Hasher() hasher.combine(activeToasts.map(\.id)) hasher.combine(activeToasts.map(\.shouldTimeout)) return hasher.finalize() } } #Preview { VStack { Button(String("Test")) { ToastModel.main.add(.success()) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { ToastOverlayView(shouldDisplayNewToasts: true, location: .top) } .overlay(alignment: .bottom) { ToastOverlayView(shouldDisplayNewToasts: true, location: .bottom) } } ================================================ FILE: Mlem/App/Views/Shared/Toast/ToastType.swift ================================================ // // Toast.swift // Mlem // // Created by Sjmarf on 15/05/2024. // import Icons import MlemMiddleware import SwiftUI import Theming enum ToastType: Hashable { // Don't initialize this directly - use one of the static methods instead case basic( title: String, subtitle: String?, icon: Icon?, color: ThemedColor, duration: Double ) static func basic( _ title: LocalizedStringResource, subtitle: LocalizedStringResource? = nil, icon: Icon? = nil, color: ThemedColor? = nil, duration: Double = 1.5 ) -> ToastType { let subtitleString: String? if let subtitle { subtitleString = String(localized: subtitle) } else { subtitleString = nil } return .basic( title: String(localized: title), subtitle: subtitleString, icon: icon, color: color ?? .themedAccent, duration: duration ) } @_disfavoredOverload static func basic( _ title: some StringProtocol, subtitle: String? = nil, icon: Icon? = nil, color: ThemedColor? = nil, duration: Double = 1.5 ) -> ToastType { .basic( title: String(title), subtitle: subtitle, icon: icon, color: color ?? .themedAccent, duration: duration ) } // Don't initialize this directly - use one of the static methods instead case undoable( title: String?, icon: Icon?, successIcon: Icon?, callback: () -> Void, color: ThemedColor ) static func undoable( _ title: LocalizedStringResource? = nil, icon: Icon? = nil, successIcon: Icon? = nil, callback: @escaping () -> Void, color: ThemedColor = .themedAccent ) -> ToastType { let string: String? if let title { string = .init(localized: title) } else { string = nil } return .undoable( title: string, icon: icon, successIcon: successIcon, callback: callback, color: color ) } @_disfavoredOverload static func undoable( _ title: String? = nil, icon: Icon? = nil, successIcon: Icon? = nil, callback: @escaping () -> Void, color: ThemedColor = .themedAccent ) -> ToastType { .undoable( title: title, icon: icon, successIcon: successIcon, callback: callback, color: color ) } case loading(title: String) static func loading(_ title: LocalizedStringResource = "Loading...") -> ToastType { .loading(title: String(localized: title)) } @_disfavoredOverload static func loading(_ title: String) -> ToastType { .loading(title: title) } static var urlCopyError: ToastType { basic( "No URL Copied", subtitle: "Copy a URL to the clipboard, then try again.", icon: nil, color: .themedAccent, duration: 2 ) } case error(_ details: ErrorDetails) case account(any Account) var duration: Double { switch self { case let .basic(_, _, _, _, duration): duration case .undoable: 2.5 case .account: 1.0 case .error: Settings.get(\.dev_errorTimeout) case .loading: 10 } } var location: ToastLocation { switch self { case .undoable: .bottom default: .top } } var important: Bool { switch self { case .error: true default: false } } static func success(_ message: LocalizedStringResource? = nil) -> Self { if let message { return success(String(localized: message)) } else { return success(nil as String?) } } @_disfavoredOverload static func success(_ message: String? = nil) -> Self { .basic( title: message ?? "Success", subtitle: nil, icon: .general.success, color: .themedPositive, duration: 1 ) } static func failure(_ message: LocalizedStringResource? = nil) -> Self { if let message { return failure(String(localized: message)) } else { return failure(nil as String?) } } @_disfavoredOverload static func failure(_ message: String? = nil) -> Self { .basic( title: message ?? "Failed", subtitle: nil, icon: .general.failure, color: .themedNegative, duration: 1 ) } func hash(into hasher: inout Hasher) { switch self { case let .basic(title, subtitle, systemImage, color, duration): hasher.combine("basic") hasher.combine(title) hasher.combine(subtitle) hasher.combine(systemImage) hasher.combine(color) hasher.combine(duration) case let .undoable( title: title, icon: icon, successIcon: successIcon, callback: _, color: color ): hasher.combine("undoable") hasher.combine(title) hasher.combine(icon) hasher.combine(successIcon) hasher.combine(color) case let .error(details): hasher.combine("error") hasher.combine(details) case let .account(account): hasher.combine("account") hasher.combine(account) case .loading: hasher.combine("loading") } } static func == (lhs: ToastType, rhs: ToastType) -> Bool { lhs.hashValue == rhs.hashValue } } ================================================ FILE: Mlem/App/Views/Shared/Toast/ToastView.swift ================================================ // // ToastView.swift // Mlem // // Created by Sjmarf on 17/05/2024. // import ComponentViews import Icons import SwiftUI import Theming struct ToastView: View { @Environment(\.colorScheme) var colorScheme let toast: Toast @State private var isExpanded: Bool = false @State private var didUndo: Bool = false // These symbols only have a single hierarchical layer, so we render it as `.secondary` static let dimmedSymbols: Set = [.lemmy.block] // These symbols need `.symbolVariant(.circle.fill)` applied to render properly static let circledSymbols: Set = [.general.success, .general.error, .general.failure, .general.undo] var body: some View { HStack { switch toast.type { case let .basic( title: title, subtitle: subtitle, icon: icon, color: color, duration: _ ): regularView( title: title, subtitle: subtitle, icon: icon, imageColor: color ) case let .undoable( title: title, icon: icon, successIcon: successIcon, callback: callback, color: color ): Button { if !didUndo { didUndo = true callback() DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { toast.kill() } } } label: { let icon = didUndo ? (successIcon ?? .general.success) : (icon ?? .general.undo) regularView( title: title ?? (didUndo ? .init(localized: "Undone!") : .init(localized: "Undo")), subtitle: title == nil ? nil : (didUndo ? .init(localized: "Undone!") : .init(localized: "Tap to Undo")), icon: icon, imageColor: color, subtitleColor: .themedAccent ) .symbolVariant(ToastView.circledSymbols.contains(icon) ? .circle.fill : .none) .contentShape(.rect) } .buttonStyle(.empty) case let .error(details): errorView(details) case let .loading(title): loadingView(title) case let .account(account): accountView(account) } } .multilineTextAlignment(.center) .frame(maxHeight: isExpanded ? 230 : nil) .background((colorScheme == .dark ? ThemedColor.themedSecondaryBackground : ThemedColor.themedBackground).opacity(0.5)) .background(.regularMaterial) .clipShape(.rect(cornerRadius: 25)) .shadow(color: .black.opacity(0.1), radius: 5) .shadow(color: .black.opacity(0.1), radius: 1) .padding(.horizontal) } @ViewBuilder func regularView( title: String, subtitle: String?, icon: Icon?, imageColor: ThemedColor, subtitleColor: ThemedColor = .themedSecondary ) -> some View { HStack(spacing: Constants.main.doubleSpacing) { if let icon { image(icon, color: imageColor) .symbolVariant(ToastView.circledSymbols.contains(icon) ? .circle.fill : .none) .contentTransition(.symbolEffect(.replace, options: .speed(4))) } Group { if let subtitle { VStack(spacing: 1) { Text(title) .font(.caption) .fontWeight(.semibold) .contentTransition(.opacity) Text(subtitle) .font(.caption) .fontWeight(.semibold) .foregroundStyle(subtitleColor) .contentTransition(.opacity) } .frame(minWidth: 80) } else { Text(title) .lineLimit(1) .frame(minWidth: 80) } } .padding(icon == nil ? .horizontal : .trailing, Constants.main.doubleSpacing) } .frame(minWidth: 157) .padding(icon == nil ? .vertical : [], Constants.main.standardSpacing) } @ViewBuilder func accountView(_ account: any Account) -> some View { HStack(spacing: Constants.main.doubleSpacing) { CircleCroppedImageView(account, frame: 27, showProgress: false) .padding([.vertical, .leading], Constants.main.standardSpacing) Text(account.nickname) .lineLimit(1) .frame(minWidth: 80) .padding(.trailing, Constants.main.doubleSpacing) } .frame(minWidth: 157) } @ViewBuilder // swiftlint:disable:next function_body_length func errorView(_ details: ErrorDetails) -> some View { Button { if details.error != nil { withAnimation(.bouncy(duration: 0.2)) { isExpanded = true toast.shouldTimeout = false } } } label: { let icon = details.icon ?? .general.error VStack(spacing: 0) { HStack { image(icon, color: .themedNegative) .symbolVariant(ToastView.circledSymbols.contains(icon) ? .circle.fill : .none) Text(details.title ?? .init(localized: "Error")) .frame(minWidth: 100) .padding(isExpanded ? [] : [.trailing]) .frame(maxWidth: isExpanded ? .infinity : nil) if isExpanded { Button("Close", icon: .general.close) { toast.kill() } .labelStyle(.iconOnly) .symbolVariant(.circle.fill) .symbolRenderingMode(.hierarchical) .foregroundStyle(.themedSecondary) .foregroundStyle(.secondary) .font(.title) .padding(.trailing, 10) } } .contentShape(.rect) VStack(alignment: .leading, spacing: 0) { if isExpanded { ScrollView { Text(details.errorText()) .foregroundStyle(.red) .padding(8) .multilineTextAlignment(.leading) } .frame(maxWidth: .infinity) Button("Copy", icon: .general.copy) { UIPasteboard.general.string = details.errorText() } .font(.caption) .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) .tint(.themedNegative) .padding(Constants.main.standardSpacing) } } .frame(maxHeight: isExpanded ? .infinity : 0, alignment: .leading) .background(.themedNegative.opacity(isExpanded ? 0.15 : 0)) } } .buttonStyle(.empty) } @ViewBuilder func loadingView(_ title: String) -> some View { HStack(spacing: Constants.main.doubleSpacing) { ProgressView() .tint(.themedSecondary) .frame(width: 22, height: 22) .padding([.vertical, .leading], Constants.main.standardSpacing) Text(title) .frame(minWidth: 80) .padding(.trailing, Constants.main.doubleSpacing) } .frame(minWidth: 152) } @ViewBuilder func image(_ icon: Icon, color: ThemedColor) -> some View { Image(icon: icon) .resizable() .aspectRatio(contentMode: .fit) .fontWeight(.semibold) .symbolVariant(.fill) .symbolRenderingMode(.hierarchical) // Don't use palette here! - Sjmarf .foregroundStyle(ToastView.dimmedSymbols.contains(icon) ? .secondary : .primary) .foregroundStyle(color) .frame(width: 27) .padding([.vertical, .leading], Constants.main.standardSpacing) } } extension ToastView { init(_ type: ToastType) { self.init(toast: .init(type: type, location: .top)) } } #Preview { VStack { ToastView(.success()) ToastView(.failure()) ToastView(.undoable(callback: {})) ToastView(.error(.init())) ToastView(.success(String("Really super long text"))) } .background { VStack(spacing: 0) { Color.clear HStack(spacing: 0) { Color.red Color.blue } } } } ================================================ FILE: Mlem/App/Views/Shared/ToolbarEllipsisMenu.swift ================================================ // // ToolbarEllipsisMenu.swift // Mlem // // Created by Sjmarf on 16/03/2024. // import SwiftUI struct ToolbarEllipsisMenu: View { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } init(_ actions: [any Action]) where Content == ForEach<[any Action], String, MenuButton> { self.init(content: { ForEach(actions, id: \.id) { action in MenuButton(action: action) } }) } var body: some View { Menu { content } label: { Label("More", icon: .general.toolbarMenu) .frame(height: Constants.main.barIconHitbox) .contentShape(Rectangle()) } .popupAnchor() } } ================================================ FILE: Mlem/App/Views/Shared/WarningOverlayView.swift ================================================ // // WarningOverlayView.swift // Mlem // // Created by Sjmarf on 2024-12-30. // import SwiftUI struct WarningOverlayView: View { @Environment(NavigationLayer.self) private var navigation let text: LocalizedStringResource @Binding var isPresented: Bool @Binding var showWarningAgain: Bool var body: some View { VStack(spacing: Constants.main.doubleSpacing) { WarningView( icon: .general.warning, text: text, inList: false ) Group { HStack(spacing: Constants.main.doubleSpacing) { Button { navigation.pop() } label: { Text("Go Back").frame(maxWidth: .infinity) } .buttonStyle(.bordered) Button { isPresented = false } label: { Text("Continue").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } Toggle(isOn: $showWarningAgain.invert(), label: { Text("Don't show this again") }) } .padding(.horizontal, 30) } .padding(Constants.main.doubleSpacing) .background { RoundedRectangle(cornerRadius: Constants.main.largeItemCornerRadius) .fill(.themedBackground.opacity(0.8)) } .padding(Constants.main.doubleSpacing) .presentationBackground(.ultraThinMaterial) } } ================================================ FILE: Mlem/App/Views/Shared/WarningView.swift ================================================ // // WarningView.swift // Mlem // // Created by Eric Andrews on 2024-08-19. // import Foundation import Icons import SwiftUI import Theming struct WarningView: View { let icon: Icon let text: String let inList: Bool let overrideColor: ThemedColor? init(icon: Icon, text: LocalizedStringResource, inList: Bool, overrideColor: ThemedColor? = nil) { self.icon = icon self.text = .init(localized: text) self.inList = inList self.overrideColor = overrideColor } @_disfavoredOverload init(icon: Icon, text: some StringProtocol, inList: Bool, overrideColor: ThemedColor? = nil) { self.icon = icon self.text = String(text) self.inList = inList self.overrideColor = overrideColor } var color: ThemedColor { overrideColor ?? .themedWarning } var body: some View { VStack(alignment: .center, spacing: 12) { Image(icon: icon) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(color) .frame(width: 50) Text(text) .font(.headline) .fontWeight(.medium) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity) .padding(.vertical, 5) .padding(inList ? 0 : Constants.main.doubleSpacing) .listRowBackground(listBackground()) .background(background()) } @ViewBuilder func listBackground() -> some View { if inList { backgroundRect } } @ViewBuilder func background() -> some View { if !inList { backgroundRect } } var backgroundRect: some View { RoundedRectangle(cornerRadius: 26) .stroke(color, lineWidth: 3) .background(color.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 26)) } } ================================================ FILE: Mlem/App/Views/Shared/WebView.swift ================================================ // // WebView.swift // Mlem // // Created by Sjmarf on 05/02/2024. // import SwiftUI import WebKit struct WebView: UIViewRepresentable { let url: URL func makeUIView(context: Context) -> WKWebView { let wkwebView = WKWebView() let request = URLRequest(url: url) wkwebView.load(request) return wkwebView } func updateUIView(_ uiView: WKWebView, context: Context) {} } ================================================ FILE: Mlem/App/Views/Shared/WebsitePreviewView.swift ================================================ // // WebsitePreviewView.swift // Mlem // // Created by Eric Andrews on 2024-06-16. // import Foundation import MlemMiddleware import SwiftUI struct WebsitePreviewView: View { @Environment(\.openURL) private var openURL @Setting(\.post_webPreview_showIcon) var showFavicons @Setting(\.behavior_muteVideos) var muteVideos let shouldBlur: Bool let link: PostLink var onTapActions: (() -> Void)? init(link: PostLink, shouldBlur: Bool, onTapActions: (() -> Void)? = nil) { self.link = link self.onTapActions = onTapActions self.shouldBlur = shouldBlur } var body: some View { content .contentShape(.rect) .onTapGesture { if let onTapActions { onTapActions() } openURL(link.content) } .contextMenu { Button("Open", icon: .general.browser) { openURL(link.content) } Button("Copy", icon: .general.copy) { let pasteboard = UIPasteboard.general pasteboard.url = link.content } ShareLink(item: link.content) } preview: { WebView(url: link.content) } } var content: some View { complex .frame(maxWidth: .infinity, alignment: .leading) .background(.themedTertiaryGroupedBackground) .clipShape(RoundedRectangle(cornerRadius: Constants.main.mediumItemCornerRadius)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.mediumItemCornerRadius)) .paletteBorder(cornerRadius: Constants.main.mediumItemCornerRadius) .contentShape(.rect) } var complex: some View { VStack(alignment: .leading, spacing: 0) { if let thumbnailUrl = link.effectiveThumbnail { MediaView( url: thumbnailUrl, controlState: .constant(.init( blurred: shouldBlur, animating: false, muted: muteVideos )), aspectRatioBounds: .bounded(vertical: .init(width: 1, height: 1), horizontal: nil), contentMode: .fill, overlays: shouldBlur ? [.controls, .nsfw, .error] : [.controls, .error] ) .overlay(alignment: .bottomLeading) { LinkHostView(link: link, withCapsule: true) .padding(Constants.main.halfSpacing) } } else { LinkHostView(link: link, withCapsule: false) .padding([.horizontal, .top], Constants.main.standardSpacing) } Text(link.label) .font(.subheadline) .fontWeight(.semibold) .padding(Constants.main.standardSpacing) .foregroundStyle(.themedPrimary) .fixedSize(horizontal: false, vertical: true) } } } ================================================ FILE: Mlem/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Default Community.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Lemmy.pdf", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.eric.lemmy.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Classic Lemmy.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.alien.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "logo_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.green.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "green_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.ocean.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "logo_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.orange.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "orange_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.pink.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "pink_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.pride.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "light_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icon Previews/icon.sjmarf.silver.preview.imageset/Contents.json ================================================ { "images" : [ { "filename" : "logo_small.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.eric.lemmy.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "Classic Lemmy.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.alien.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.green.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.orange.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.pink.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.pride.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Icons/icon.sjmarf.silver.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "aaa.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Symbols/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/Symbols/discord.logo.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "discord-symbol.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Assets.xcassets/Symbols/github.logo.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "logo.github.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Assets.xcassets/Symbols/lemmy.logo.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "lemmy-symbol.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Assets.xcassets/Symbols/mastodon.logo.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "mastodon-symbol.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Assets.xcassets/Symbols/matrix.logo.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "matrix-favicon-symbol.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Assets.xcassets/background.earth.imageset/Contents.json ================================================ { "images" : [ { "filename" : "earth.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/background.trees.imageset/Contents.json ================================================ { "images" : [ { "filename" : "magbis-amin-vzoCiBC7nK8-unsplash.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/logo.imageset/Contents.json ================================================ { "images" : [ { "filename" : "logo.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Assets.xcassets/nsfw.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "nsfw.svg", "idiom" : "universal" } ] } ================================================ FILE: Mlem/Info.plist ================================================ CADisableMinimumFrameDurationOnPhone CFBundleURLTypes CFBundleTypeRole Viewer CFBundleURLIconFile AppIcon CFBundleURLName $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleURLSchemes mlem NSAppTransportSecurity NSAllowsArbitraryLoads ================================================ FILE: Mlem/Localizable.xcstrings ================================================ { "sourceLanguage" : "en", "strings" : { "**%@** and **%@** chose to defederate from one another." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "**%1$@** and **%2$@** chose to defederate from one another." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "**%1$@** et **%2$@** ont choisi de se défédérer l'une de l'autre." } } } }, "**%@** chose to defederate from your instance, **%@**." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "**%1$@** chose to defederate from your instance, **%2$@**." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "**%1$@** a choisi de se défédérer de votre instance, **%2$@**." } } } }, "**%@** hasn't chosen to federate with your instance, **%@**." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "**%1$@** hasn't chosen to federate with your instance, **%2$@**." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "**%1$@** n'a pas choisi de se fédérer avec votre instance, **%2$@**." } } } }, "%@ Administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ Administrateur" } } } }, "%@ appointed a moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a nommé un modérateur" } } } }, "%@ appointed an administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a nommé un administrateur" } } } }, "%@ banned a user" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a banni un utilisateur" } } } }, "%@ disallows %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ disallows %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ interdit %2$@" } } } }, "%@ has been unresponsive recently." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ n'a pas répondu récemment." } } } }, "%@ hid a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a caché une communauté" } } } }, "%@ is {{online}}" : { "comment" : "The word(s) within the curly brackets will be colored green.", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ est {{en ligne}}" } } } }, "%@ is {{unhealthy}}" : { "comment" : "The word(s) within the curly brackets will be colored red.", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ n’est {{pas sain}}" } } } }, "%@ is banned from %@ until %@." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ is banned from %2$@ until %3$@." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ est banni de %2$@ jusqu'à %3$@." } } } }, "%@ is guaranteed by %@." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ is guaranteed by %2$@." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ est garanti par %2$@." } } } }, "%@ is permanently banned from %@." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ is permanently banned from %2$@." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ est définitivement banni par %2$@." } } } }, "%@ locked a post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a verrouillé une publication" } } } }, "%@ pinned a post to %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ pinned a post to %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ a épinglé une publication sur %2$@" } } } }, "%@ purged a comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a purgé un commentaire" } } } }, "%@ purged a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a purgé une communauté" } } } }, "%@ purged a post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a purgé une publication" } } } }, "%@ purged a user" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a purgé un utilisateur" } } } }, "%@ removed a comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a supprimé un commentaire" } } } }, "%@ removed a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a supprimé une communauté" } } } }, "%@ removed a moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a supprimé un modérateur" } } } }, "%@ removed a post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a supprimé une publication" } } } }, "%@ removed an administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a supprimé un administrateur" } } } }, "%@ restored a comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a restauré un commentaire" } } } }, "%@ restored a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a restauré une communauté" } } } }, "%@ restored a post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a restauré une publication" } } } }, "%@ rules:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ règles:" } } } }, "%@ rules..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ règles…" } } } }, "%@ transferred ownership of a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a transféré la propriété d'une communauté" } } } }, "%@ unbanned a user" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a débanni un utilisateur" } } } }, "%@ unhid a community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a rendu visible une communauté" } } } }, "%@ unlocked a post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%@ a déverrouillé une publication" } } } }, "%@ unpinned a post from %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$@ unpinned a post from %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ a désépinglé une publication de %2$@" } } } }, "%lld Active" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "%lld actifs" } } } }, "%lld Crossposts..." : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld Crosspost..." } }, "other" : { "stringUnit" : { "state" : "new", "value" : "%lld Crossposts..." } } } } }, "en-GB" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld Crosspost..." } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld Crossposts..." } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld publication croisée…" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld publications croisées…" } } } } } } }, "%lld more links..." : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld more link..." } }, "other" : { "stringUnit" : { "state" : "new", "value" : "%lld more links..." } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld liens en plus…" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld liens en plus…" } } } } } } }, "%lld votes" : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld vote" } }, "other" : { "stringUnit" : { "state" : "new", "value" : "%lld votes" } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld vote" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld votes" } } } } } } }, "%lld years ago today!" : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld year ago today!" } }, "other" : { "stringUnit" : { "state" : "new", "value" : "%lld years ago today!" } } } } }, "en-GB" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld year ago today!" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld years ago today!" } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "Il y a %lld an aujourd'hui !" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "Il y a %lld ans aujourd'hui !" } } } } } } }, "A censure signifies that an instance disapproves of another instance. Like an endorsement, it is completely subjective and a reason does not have to be given." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Une censure signifie qu'une instance désapprouve une autre instance. Comme une approbation, elle est entièrement subjective et il n'est pas nécessaire de donner de raison." } } } }, "A hesitation signifies that an instance mistrusts another instance. It is a milder version of a censure." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Une hésitation signifie qu'une instance se méfie d'une autre instance. C'est une version atténuée d'une censure." } } } }, "A selection of communities curated by %@ admins" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communautés sélectionnées par les administrateurs de %@\n" } } } }, "About" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "À propos" } } } }, "About Mlem" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "À propos de Mlem" } } } }, "Abuse" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Abus" } } } }, "Accessibility" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Accessibilité" } } } }, "Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compte" } } } }, "Account Created %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compte crée %@" } } } }, "Accounts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Comptes" } } } }, "Action Type" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Type d’action" } } } }, "Actions" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Actions" } } } }, "Active" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Actif" } } } }, "Active Users" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Utilisateurs actifs" } } } }, "Add" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter" } } } }, "Add Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter un compte" } } } }, "Add Administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter un administrateur" } } } }, "Add an image..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter une image..." } } } }, "Add Comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter un commentaire" } } } }, "Add description" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter une description" } } } }, "Add Guest" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter un invité" } } } }, "Add Image" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter image" } } } }, "Add Language..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter langue" } } } }, "Add Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter lien" } } } }, "Add Moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter un modérateur" } } } }, "Add Note" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter une note" } } } }, "Add NSFW Tag" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter le tag NSFW" } } } }, "Additional Read Indicator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur supplémentaire de lecture" } } } }, "Administration" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Administration" } } } }, "Administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Administrateur" } } } }, "Administrator was appointed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L'administrateur a été nommé" } } } }, "Administrator was removed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L'administrateur a été supprimé" } } } }, "Advanced" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avancé" } } } }, "Alien" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Alien" } } } }, "Alignment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Alignement" } } } }, "All" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tout" } } } }, "All Languages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Toutes les langues" } } } }, "All Time" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tout les temps" } } } }, "Alphabetical" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Alphabétiquement" } } } }, "Already voted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déjà voté" } } } }, "Always" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Toujours" } } } }, "Always Allow Direct Loading" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Toujours autoriser le chargement direct" } } } }, "Always Show Usernames" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Toujours afficher les noms d'utilisateur" } } } }, "An endorsement signifies that an instance approves of another instance. It is completely subjective, and a reason does not have to be given." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Une approbation signifie qu'une instance approuve une autre instance. Elle est entièrement subjective et il n'est pas nécessaire de donner de raison." } } } }, "Animate Avatars..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Animer les avatars..." } } } }, "Animated Avatars" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avatars animés" } } } }, "Anonymous" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Anonyme" } } } }, "Answer..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réponse…" } } } }, "Any" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "N’importe" } } } }, "Any Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "N’importe quelle communauté" } } } }, "Any Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "N’importe quelle instance" } } } }, "Any User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tout utilisateur" } } } }, "Anyone" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "N’importe qui" } } } }, "Anywhere" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "N’importe où" } } } }, "App Icon" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Icône de l’application" } } } }, "Application submitted!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Inscription envoyée !" } } } }, "Applications" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Inscriptions" } } } }, "Applications Email Admins" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Administrateurs de messages d’inscription" } } } }, "Applications Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Inscriptions uniquement" } } } }, "Apply to All" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Appliquer à tout" } } } }, "Appoint Administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nommer administrateur" } } } }, "Appoint Moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nommer modérateur" } } } }, "Appointed: %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nommé : %@" } } } }, "Appointed: %@\nTo: %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Appointed: %1$@\nTo: %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nommé : %1$@\nEn tant que : %2$@" } } } }, "Approve" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Approuver" } } } }, "Approved by %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Approuvé par %@" } } } }, "Are you sure you want to delete all community favorites for this account? This cannot be undone." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Are you sure you want to delete all community favourites for this account? This cannot be undone." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Etes-vous sûr de vouloir supprimer tous les favoris de la communauté pour ce compte ? Cette opération est irréversible." } } } }, "Ask Every Time" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Demander à chaque fois" } } } }, "Ask First" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Demander d'abord" } } } }, "Ask to confirm every time" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Demander de confirmer à chaque fois" } } } }, "Authenticating..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Authentification" } } } }, "Authentication code is incorrect." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le code d’authentification est incorrect." } } } }, "Automatic" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Automatique" } } } }, "Automatically" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Automatiquement" } } } }, "Automatically enable Reader for supported webpages. You can only enable this when using the in-app browser." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Activer automatiquement le « mode lecture » pour les pages Web prises en charge. Vous ne pouvez activer cette option que lorsque vous utilisez le navigateur intégré à l'application." } } } }, "Automatically refreshes every %lld seconds." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "S'actualise automatiquement toutes les %lld secondes." } } } }, "Autoplay" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lecture automatique" } } } }, "Available" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Disponible" } } } }, "Avatar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avatar" } } } }, "Average: %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moyenne : %@" } } } }, "Back" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Retour" } } } }, "Ban" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir" } } } }, "Ban %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir %@" } } } }, "Ban Duration" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Durée de bannissement" } } } }, "Ban from Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir de la communauté" } } } }, "Ban from Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir de l’instance" } } } }, "Ban from..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir de…" } } } }, "Ban Target" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cible de bannisement" } } } }, "Ban User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir l’utilisateur" } } } }, "Ban..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannir…" } } } }, "Banned from Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Banni de la communauté" } } } }, "Banned from Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Banni de l’Instance" } } } }, "Banned: %@\nFrom: %@\nExpires: %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Banned: %1$@\nFrom: %2$@\nExpires: %3$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Banni : %1$@\nDepuis : %2$@\nExpire : %3$@" } } } }, "Banner" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannière" } } } }, "Banning from..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bannissement de..." } } } }, "Biography" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Biographie" } } } }, "Block" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer" } } } }, "Block Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer la communauté" } } } }, "Block community or user?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer la communauté ou l’utilisateur ?" } } } }, "Block Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer l'instance" } } } }, "Block List" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Liste de blocage" } } } }, "Block User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer l’utilisateur" } } } }, "Block..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloquer…" } } } }, "Blocked" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloqué" } } } }, "Blocked by Cloudflare" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloqué par Cloudflare" } } } }, "Blocking..." : { "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Blocage…" } } } }, "Blur" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Flou" } } } }, "Blur NSFW" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Flouter NSFW" } } } }, "Blur NSFW Content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer le contenu NSFW" } } } }, "Bold" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Gras" } } } }, "Bot Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compte robot" } } } }, "Bot accounts are unable to vote." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les comptes robots ne peuvent pas voter." } } } }, "Bottom" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bas" } } } }, "Browse" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Parcourir" } } } }, "by %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "par %@" } } } }, "Bypass Image Proxy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contourner le proxy d’image" } } } }, "Bypass Image Proxy?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contourner le proxy d’image ?" } } } }, "Bypass Image Proxy..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contournement du proxy d’image…" } } } }, "Cache" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cache" } } } }, "Cache Cleared" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cache nettoyé" } } } }, "Cake Day" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Jour du gâteau" } } } }, "Cancel" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annuler" } } } }, "Captcha" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Captcha" } } } }, "Captcha Difficulty Yes" : { "comment" : "Used to indicate Captcha difficulty. E.g. \"Yes (Hard)\".", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Yes (%@)" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Oui (%@)" } } } }, "Censored" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Censuré" } } } }, "Censured" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Censurés" } } } }, "Censures" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Censure" } } } }, "Center" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Centre" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Centré" } } } }, "Change Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer le mot de passe" } } } }, "Change Thumbnail" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer la vignette" } } } }, "Checkmark" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Coche" } } } }, "Choose a community..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une communauté..." } } } }, "Choose a Username" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir un nom d’utilisateur" } } } }, "Choose Account Type" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir un type de compte" } } } }, "Choose an action..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une action…" } } } }, "Choose another instance..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une autre instance" } } } }, "Choose Another Instance..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une autre instance…" } } } }, "Choose Community..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sélection de la communauté..." } } } }, "Choose File" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir un fichier" } } } }, "Choose how far you have to drag to dismiss the image viewer." : { }, "Choose Instance..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sélection d’une Instance…" } } } }, "Choose Language" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir une langue" } } } }, "Choose target..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir la cible…" } } } }, "Choose the default sort mode for posts and comments." : { }, "Choose when Not Safe For Work content should be blurred." : { }, "Choose when the image viewer controls should appear." : { }, "Choose whether to show a user's account age next to their username." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir d'afficher ou non l'âge du compte d'un utilisateur à côté de son nom d'utilisateur." } } } }, "Choose whether to show a warning when opening a page that is likely to contain sensitive content." : { }, "Choose whether to show community avatars on posts." : { }, "Choose whether to use alternate interaction bar and swipe action layouts for post and comment reports in Mod Mail." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir d'utiliser ou non des dispositions de barre d'interaction et d'action de swipe alternatives pour les rapports de publication et de commentaire dans Mod Mail." } } } }, "Choose which action to perform when you tap and hold the profile icon." : { }, "Choose which feed is shown when the app opens." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir le flux à afficher à l'ouverture de l'application." } } } }, "Choose which languages appear in your feed. Posts and comments in other languages will be hidden." : { }, "Choose which widgets to display in your palette." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisir les widgets à afficher dans votre palette." } } } }, "Choose wisely - you cannot change this later." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Choisissez judicieusement : vous ne pourrez pas changer cela plus tard." } } } }, "Clear" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nettoyer" } } } }, "Clear Cache" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nettoyer le cache" } } } }, "Clear Search History" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nettoyer l’historique de recherche" } } } }, "Clear search history?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nettoyer l’historique de recherche ?" } } } }, "Click on the link in the email to continue." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cliquez sur le lien dans l'e-mail pour continuer." } } } }, "Close" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fermer" } } } }, "Close Button" : { }, "Closed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fermé" } } } }, "Code" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Code" } } } }, "Code Block" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bloc de Code" } } } }, "Collapse" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplier" } } } }, "Collapse Parent" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplier le parent" } } } }, "Collapse Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réduire la publication" } } } }, "Collapse to Top" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplier jusqu’en haut" } } } }, "Color Scheme" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Thème de couleurs" } } } }, "Comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Commentaire" } } } }, "Comment Downvotes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votes négatifs sur les commentaires" } } } }, "Comment Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de commentaires" } } } }, "Comment Reports Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de commentaires uniquement" } } } }, "Comment Upvotes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votes positifs sur les commentaires" } } } }, "Comment was deleted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le commentaire a été supprimé" } } } }, "Comment was purged" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le commentaire a été purgé" } } } }, "Comment was removed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le commentaire a été retiré" } } } }, "Comment was restored" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le commentaire a été restauré" } } } }, "Comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Commentaires" } } } }, "Communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communautés" } } } }, "Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communauté" } } } }, "Community Avatar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avatar de le communauté" } } } }, "Community Creation" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Création de la communauté" } } } }, "Community Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lien de la communauté" } } } }, "Community ownership was transferred" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La propriété de la communauté a été transférée" } } } }, "Community was hidden" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La communauté a été cachée" } } } }, "Community was purged" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La communauté a été purgée" } } } }, "Community was removed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La communauté a été retirée" } } } }, "Community was restored" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La communauté a été restaurée" } } } }, "Community was unhidden" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La communauté a été rendue visible" } } } }, "Community: %@\nNew Owner: %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Community: %1$@\nNew Owner: %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communauté : %1$@\nNouveau propriétaire : %2$@" } } } }, "Compact" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compacte" } } } }, "Configure which types of notification should be included in the notification badge." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Configurez les types de notifications à inclure dans le badge de notification." } } } }, "Confirm" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Confirmer" } } } }, "Confirm Image Uploads" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Confirmer le téléversement d’image" } } } }, "Confirm New Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Confirmer le nouveau mot de passe" } } } }, "Confirm Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Conformer le mot de passe" } } } }, "Connecting..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connexion…" } } } }, "Content & Notifications" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contenu et notifications" } } } }, "Content Warnings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avertissements de contenu" } } } }, "Continue" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Continuer" } } } }, "Contrast" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contraste" } } } }, "Controls" : { }, "Controversial" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Controversé" } } } }, "Copied" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copié" } } } }, "Copy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier" } } } }, "Copy a URL to the clipboard, then try again." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier une URL dans le presse-papiers, et essayer à nouveau." } } } }, "Copy All" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tout copier" } } } }, "Copy Error" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier l'erreur" } } } }, "Copy Name" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier le nom" } } } }, "Copy Username" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier le nom d'utilisateur" } } } }, "Could not find settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de trouver les réglages" } } } }, "Couldn't read URL" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de lire l’URL" } } } }, "Counters" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compteurs" } } } }, "Create Image" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Créer une image" } } } }, "Creator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Créateur" } } } }, "Crosspost" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publication croisée" } } } }, "Crossposted from %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publication croisée depuis %@" } } } }, "Current Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mot de passe actuel" } } } }, "Current password is incorrect" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le mot de passe actuel est incorrect" } } } }, "Custom" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Personnalisé" } } } }, "Custom Order" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Personnaliser l’ordre" } } } }, "Custom Thumbnail" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vignette personnalisée" } } } }, "Customize" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise" } } } }, "Customize Context Menu" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise Context Menu" } } } }, "Customize how content is displayed in your feed. Choose which types of content are blurred, and apply filters to hide posts from the feed altogether." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise how content is displayed in your feed. Choose which types of content are blurred, and apply filters to hide posts from the feed altogether." } } } }, "Customize how moderator actions are separated from regular actions in context menus." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise how moderator actions are separated from regular actions in context menus." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Personnalisez la manière dont les actions du modérateur sont séparées des actions normales dans les menus contextuels." } } } }, "Customize how often Mlem plays haptic feedback." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise how often Mlem plays haptic feedback." } } } }, "Customize how your subscription list is sorted." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise how your subscription list is sorted." } } } }, "Customize Mlem to work best for you. Some features are tied to system-wide accessibility settings." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise Mlem to work best for you. Some features are tied to system-wide accessibility settings." } } } }, "Customize the appearance of communities." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise the appearance of communities." } } } }, "Customize the appearance of the tab bar." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise the appearance of the tab bar." } } } }, "Customize the image viewer's buttons and gestures." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise the image viewer's buttons and gestures." } } } }, "Customize the interaction bar for inbox items, and choose which types of notification are included in the tab bar badge." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Customise the interaction bar for inbox items, and choose which types of notification are included in the tab bar badge." } } } }, "Data not available" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Donnée non disponible" } } } }, "Days:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Jours :" } } } }, "Debug" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débogage" } } } }, "Default" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Défaut" } } } }, "Default Feed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Flux par défaut" } } } }, "Default Feed Type (Desktop)" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Type de flux par défaut (bureau)" } } } }, "Delete" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer" } } } }, "Delete Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer un compte" } } } }, "Delete Community Favorites" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Delete Community Favourites" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer les favoris de la communauté" } } } }, "Delete posts and comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer les publications et les commentaires" } } } }, "Deleted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimé" } } } }, "Denied by %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Refusé par %@" } } } }, "Denied by %@: \"%@\"" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Denied by %1$@: \"%2$@\"" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Refusé par %1$@ : « %2$@ »" } } } }, "Deny" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Refuser" } } } }, "Deny Application" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Refuser la Demande" } } } }, "Deprecated Format" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Format obsolète" } } } }, "Description" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Description" } } } }, "Details" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Détails" } } } }, "Developer" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Développeur" } } } }, "Diagnosing..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Analyse…" } } } }, "Differentiate Without Color" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Différencier sans couleur" } } } }, "Disabled" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désactivé" } } } }, "Discard" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Abandonner" } } } }, "Disclosure Group" : { "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Groupe de divulgation" } } } }, "Discussion Languages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Langues de discussion" } } } }, "Disk Usage" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Utilisation du disque" } } } }, "Dismiss" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rejeter" } } } }, "Dismiss Sensitivity" : { }, "Display linked media from supported hosts in-app rather than as a link." : { }, "Display Name" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nom d'affichage" } } } }, "Distinguish Interaction Bar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Distinguer la barre d'interaction" } } } }, "Divider" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Séparateur" } } } }, "Domain" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Domaine" } } } }, "Don't show this again" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ne plus montrer ceci à nouveau" } } } }, "Done" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Terminé" } } } }, "Downvote" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vote négatif" } } } }, "Downvote Counter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compteur de vote négatif" } } } }, "Downvoted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voté négativement" } } } }, "Dracula" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dracula" } } } }, "Easy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Facile" } } } }, "Edit" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Éditer" } } } }, "Edit link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modifier le lien" } } } }, "Edit Note" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modifier la note" } } } }, "Edited" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Édité" } } } }, "Either" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Soit" } } } }, "Email" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Email" } } } }, "Email Address" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Adresse email" } } } }, "Email Verification" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vérification de l'e-mail" } } } }, "Embedded Content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contenu embarqué" } } } }, "Enable" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Activer" } } } }, "end.of.feed.icon.1" : { "comment" : "This is the key for an icon that appears next to the \"I think I've found the bottom!\" text. It is localized so that you can change the icon to fit better with your translation of the text.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "figure.climbing" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "figure.climbing" } } } }, "end.of.feed.icon.2" : { "comment" : "This is the key for an icon that appears next to the \"That's all, folks!\" text. It is localized so that you can change the icon to fit better with your translation of the text.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "figure.wave" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "figure.wave" } } } }, "end.of.feed.icon.3" : { "comment" : "This is the key for an icon that appears next to the \"It's turtles all the way down\" text. It is localized so that you can change the icon to fit better with your translation of the text.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "tortoise" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "tortoise" } } } }, "Ended %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Terminé %@" } } } }, "Endorsements" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Approbations" } } } }, "Ends %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se termine %@" } } } }, "Enter your instance's domain name below." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Saisissez le nom de domaine de votre instance ci-dessous." } } } }, "Error" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Erreur" } } } }, "EULA" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "EULA" } } } }, "Events" : { }, "Every time I share a link, show a popup asking which instance to use." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "À chaque fois que je partage un lien, une fenêtre contextuelle s'affiche demandant quelle instance utiliser." } } } }, "example.com" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "example.com" } } } }, "Except Applications" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les inscriptions" } } } }, "Except Comment Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les rapports de commentaires" } } } }, "Except Mentions" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les mentions" } } } }, "Except Message Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les rapports de messages" } } } }, "Except Messages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les messages" } } } }, "Except Post Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les rapports de publications" } } } }, "Except Replies" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sauf les réponses" } } } }, "Expand" : { }, "Expand Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplier la publication" } } } }, "Expires:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Expire :" } } } }, "Export Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres d'exportation" } } } }, "Export..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Exporter…" } } } }, "External Links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Liens externes" } } } }, "Failed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec" } } } }, "Failed to block!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec du blocage !" } } } }, "Failed to connect to %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de se connecter à %@" } } } }, "Failed to delete post!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de supprimer la publication !" } } } }, "Failed to delete!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec de la suppression !" } } } }, "Failed to import settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible d’importer les réglages" } } } }, "Failed to lock post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de verrouiller la publication" } } } }, "Failed to open sheet" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible d’ouvrir la page" } } } }, "Failed to pin post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible d’épingler la publication" } } } }, "Failed to remove content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de supprimer le contenu" } } } }, "Failed to resolve post. Try another account." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de résoudre la publication. Essayez un autre compte." } } } }, "Failed to restore!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec de la restauration !" } } } }, "Failed to save media" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de sauvegarder le média" } } } }, "Failed to set NSFW status" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec de la modification du statut NSFW" } } } }, "Failed to unblock!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec du déblocage !" } } } }, "Failed to unpin post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Impossible de désépingler la publication" } } } }, "Fallback" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Replis" } } } }, "Fast" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapide" } } } }, "Favorite" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Favourite" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Favoris" } } } }, "Favorited" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Favourited" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mis en favoris" } } } }, "Favorites" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Favourites" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Favoris" } } } }, "Federates" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fédères" } } } }, "federation.explanation" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Lemmy instances talk to each other so that content can be shared across sites. This is called \"federation\". Instance administrators can choose which other instances they would like their instance to federate with. Some instances federate with all but a curated \"block-list\" of other instances; other instances might only federate with instances on an \"allow-list\"." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les instances Lemmy communiquent entre elles afin que le contenu puisse être partagé entre les sites. C'est ce qu'on appelle la « fédération ». Les administrateurs d'instances peuvent choisir avec quelles autres instances ils souhaitent que leur instance se fédère. Certaines instances se fédèrent avec toutes les autres instances, à l'exception d'une « liste de blocage » organisée ; d'autres instances peuvent se fédérer uniquement avec des instances figurant sur une « liste d'autorisation »." } } } }, "Fediseer GUI" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Interface Fediseer" } } } }, "Feed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Flux" } } } }, "Feed is outdated" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le flux est périmé" } } } }, "Feeds" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Flux" } } } }, "Files" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fichiers" } } } }, "Filter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Filtre" } } } }, "Filter as..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Filtrer en tant que…" } } } }, "Filter violation" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Violation de filtre" } } } }, "Filters" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Filtres" } } } }, "Follow on Mastodon" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Suivre sur Mastodon" } } } }, "For New Accounts Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pour les nouveaux comptes seulement" } } } }, "General" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Général" } } } }, "Gestures" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Gestes" } } } }, "GitHub Repository" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dépôt GitHub" } } } }, "Go Back" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Retour" } } } }, "Go to Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aller à l'instance" } } } }, "Green" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vert" } } } }, "Grouped" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Groupé" } } } }, "Guaranteed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Garantis" } } } }, "Guarantees" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Garanties" } } } }, "Guest" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Invité" } } } }, "Haptic Level" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Niveau d’Haptique" } } } }, "Haptics" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Haptiques" } } } }, "Hard" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dur" } } } }, "Heading" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Titre" } } } }, "Heading %lld" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Titre %lld" } } } }, "Headline" : { "extractionState" : "manual", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "En-tête" } } } }, "Hesitations" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Hésitations" } } } }, "Hidden" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masqué" } } } }, "Hidden by filters" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masqué par les filtres" } } } }, "Hide" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer" } } } }, "Hide Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer la communauté" } } } }, "Hide Details" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les détails" } } } }, "Hide links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cacher le sliens" } } } }, "Hide Older Incidents" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les incidents plus vieux" } } } }, "Hide posts containing certain words, phrases, or character sequences from your feed." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer de votre fil les publications contenant certains mots, expressions ou séquences de caractères." } } } }, "Hide posts with titles containing containing these precise character sequences." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les publications dont les titres contiennent ces séquences exactes de caractères." } } } }, "Hide posts with titles containing these whole words or phrases. Ignores case and punctuation (e.g., the keyword \"john\" will also filter \"John's\")." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les publications dont les titres contiennent ces mots ou expressions entiers. Ignore la casse et la ponctuation (par ex., le mot-clé « john » filtrera aussi « John's »)." } } } }, "Hide Read" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les lus" } } } }, "Hide Results" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les résultats" } } } }, "Hide Website Icons" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Masquer les icônes du site web" } } } }, "Hiding Read" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lus masqués" } } } }, "High" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Élevé" } } } }, "Highest" : { }, "Hot" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Chaud" } } } }, "I think I've found the bottom!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Je crois que je suis arrivé en bas !" } } } }, "If an instance is \"guaranteed\", it is known as definitely not spam. Unguaranteed instances are not necessarily spam; rather, it is unknown whether a non-guaranteed instance is spam or not.\n\nAn instance can be guaranteed by any other guaranteed instance. This forms a chain of guaranteed instances known as the \"Chain of Trust\". The Chain of Trust starts at the Fediseer itself, which guarantees several of the largest instances.\n\nA guarantee can be revoked by the guarantor at any time. If an instance's guarantee is revoked, it returns to a \"not guaranteed\" state along with any instances it guarantees.\n\nOnce an instance has been guaranteed, it is able to express its approval or disapproval of other instances using endorsements, hesitations and censures." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Si une instance est « garantie », elle n'est pas considérée comme du spam. Les instances non garanties ne sont pas nécessairement du spam. En revanche, on ne sait pas si une instance non garantie est du spam ou non.\n\nUne instance peut être garantie par n'importe quelle autre instance garantie. Cela forme une chaîne d'instances garanties appelée « chaîne de confiance ». La chaîne de confiance commence au niveau du Fediseer lui-même, qui garantit plusieurs des plus grandes instances.\n\nUne garantie peut être révoquée par le garant à tout moment. Si la garantie d'une instance est révoquée, elle revient à l'état « non garantie » avec toutes les instances qu'elle garantit.\n\nUne fois qu'une instance a été garantie, elle est en mesure d'exprimer son approbation ou sa désapprobation d'autres instances en utilisant des approbations, des hésitations et des censures." } } } }, "If set to \"Automatic\", the full URL will be hidden in compact comments." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Si défini sur « Automatique », l'URL complète sera masquée dans les commentaires compacts." } } } }, "If you are a moderator or administrator of a filtered post, it will appear in your feed but require you to tap to view its content." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Si vous êtes modérateur ou administrateur d'une publication filtrée, elle apparaîtra dans votre fil mais vous devrez appuyer pour en afficher le contenu." } } } }, "Image" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Image" } } } }, "Image loading failed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec du chargement de l'image" } } } }, "Image Saved" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Image enregistrée" } } } }, "Image Viewer" : { }, "Images are cached on your device for fast reuse. The maximum cache size is around %@." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les images sont mises en cache sur votre appareil pour une réutilisation rapide. La taille maximale du cache est d'environ %@." } } } }, "Immediately" : { }, "Import Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres d’importation" } } } }, "Import..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Importer..." } } } }, "Import/Export Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres d’importation / exportation" } } } }, "Imported Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres importés" } } } }, "In Browser" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dans le navigateur" } } } }, "In Default Browser" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dans le navigateur par défaut" } } } }, "In Mlem" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dans Mlem" } } } }, "In Reader" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dans le « mode lecture »" } } } }, "In the Fediverse, many different links can point to the same piece of content. Choose which site to use when sharing content." : { }, "Inbox" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Notifications" } } } }, "Inbox is outdated" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La boîte de réception est périmée" } } } }, "Incidents" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Incidents" } } } }, "Indicate link thumbnails with an icon." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indiquer les miniatures des liens avec une icône." } } } }, "Infinite Scroll" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Défilement infini" } } } }, "Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Instance" } } } }, "Instance is private" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L'instance est privée" } } } }, "Instance Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lien de l'instance" } } } }, "Instances" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Instances" } } } }, "Interaction Bar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Barre d’intéraction" } } } }, "It's turtles all the way down" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce sont des tortues tout le long" } } } }, "Italic" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Italique" } } } }, "john_doe" : { "comment" : "Translate this into a similar placeholder name in your language.", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "jean_dupont" } } } }, "john_doe@example.com" : { "comment" : "Translate \"john_doe\" into the equivalent placeholder name in your language, and \"example.com\" into a suitable example domain for your locale.", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "jean_dupont@exemple.com" } } } }, "john@example.com" : { "comment" : "Translate \"john\" into the equivalent placeholder name in your language, and \"example.com\" into a suitable example domain for your locale. The placeholder name should be as short as possible, as this string is displayed in contexts where there may not be much space horizontally.", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "jean@exemple.com" } } } }, "Join %@ active users on Lemmy.world" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rejoindre %@ utilisateurs actifs sur Lemmy.world" } } } }, "Join Discord Server" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rejoindre le serveur Discord" } } } }, "Join Matrix Room" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rejoindre l’espace Matrix" } } } }, "Jump Button" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bouton de saut" } } } }, "Just Now" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Maintenant" } } } }, "Keep" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Conserver" } } } }, "Keep Place" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Conserver l’Emplacement" } } } }, "Key" : { "extractionState" : "manual", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Clé" } } } }, "Keywords" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mots-clés" } } } }, "Large" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Large" } } } }, "Last %lld days" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ces %lld derniers jours" } } } }, "Learn more..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "En savoir davantage…" } } } }, "Left" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Gauche" } } } }, "Lemmy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lemmy" } } } }, "Lemmy Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communauté Lemmy" } } } }, "Let's Go" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "C’est parti" } } } }, "Licenses" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Licences" } } } }, "Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lien" } } } }, "Literals" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Littéraux" } } } }, "Load directly from %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Charger directement depuis %@" } } } }, "Load More" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Charger davantage" } } } }, "Load This Image Directly" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Charger cette image directement" } } } }, "Loading instance details" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Chargement des détails de l’instance" } } } }, "Loading..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Chargement…" } } } }, "Local" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Local" } } } }, "Local Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Local uniquement" } } } }, "Local Options" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Options locales" } } } }, "local.subscriber.count.text" : { "comment" : "Used in the \"Details\" tab of a community page to indicate how many local subscribers use the instance. E.g. \"56 on lemmy.world\".", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "%1$lld on %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld dans %2$@" } } } }, "Location" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Localisation" } } } }, "Lock" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Verrouillage" } } } }, "Lock Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Verrouiller la publication" } } } }, "Log In" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se connecter" } } } }, "Log in or sign up to view your inbox." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connectez-vous ou inscrivez-vous pour voir votre boîte de réception." } } } }, "Log in or sign up to view your subscriptions." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connectez-vous ou inscrivez-vous pour consulter vos abonnements." } } } }, "Long Press Action" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Action d’appui long" } } } }, "Looking for something? Read posts are hidden." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous cherchez quelque chose ? Les publications lues sont masquées." } } } }, "Low" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bas" } } } }, "Lowest" : { }, "Manage how Mlem handles links and control how images and videos are displayed." : { }, "Manage how Mlem interacts with Lemmy instances and other websites." : { }, "Manage settings related to content moderation." : { }, "Manage your overall setup for Mlem." : { }, "Mark Read" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Marquer comme lu" } } } }, "Mark Read on Scroll" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Marquer comme lu au défilement" } } } }, "Mark Unread" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Marquer comme non lu" } } } }, "Matrix Room" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Espace Matrix" } } } }, "Maximum Comment Depth" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Profondeur maximale des commentaires" } } } }, "Maximum Depth" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Profondeur maximale" } } } }, "Media & Links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Médias et liens" } } } }, "Medium" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moyen" } } } }, "Mentions" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mentions" } } } }, "Mentions Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Uniquement les mentions" } } } }, "Message Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de messages" } } } }, "Message Reports Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de messages uniquement" } } } }, "Message was deleted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le message a été supprimé" } } } }, "Messages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Messages" } } } }, "Messages Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Messages uniquement" } } } }, "Mlem Developer" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Développeur de Mlem" } } } }, "Mlem Privacy Policy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Politique de confidentialité de Mlem" } } } }, "Mlem uses a Google API to fetch website icon URLs. If you'd prefer not to use this, you can choose to hide website icons." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mlem utilise une API Google pour récupérer les URL des icônes de sites web. Si vous préférez ne pas l'utiliser, vous pouvez choisir de masquer les icônes de sites web." } } } }, "Mlem will always try to load from the proxy first." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mlem essaiera toujours de charger à partir du proxy en premier." } } } }, "Mod Mail" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Messages de modération" } } } }, "Mod Mail Action Layouts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Disposition des actions Mod Mail" } } } }, "Mod Mail Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Uniquement les messages de modération" } } } }, "Moderated" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modéré" } } } }, "Moderation" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modération" } } } }, "Moderation..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modération..." } } } }, "Moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Modérateur" } } } }, "Moderator Actions" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Actions du modérateur" } } } }, "Moderator was appointed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le modérateur a été nommé" } } } }, "Moderator was removed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le Modérateur a été retiré" } } } }, "Modlog" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Log de modération" } } } }, "Modlogs" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Logs de modération" } } } }, "Monochrome" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Monochrome" } } } }, "More" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus" } } } }, "More Info" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus d'info" } } } }, "More Replies" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus de réponses" } } } }, "More Widgets..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus de widgets..." } } } }, "More..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus..." } } } }, "Most Comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus de commentaires" } } } }, "Most Recent" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le plus récent" } } } }, "Mozilla Observatory" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mozilla Observatory" } } } }, "Multiple Columns" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Colonnes multiples" } } } }, "Mute" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mettre en sourdine" } } } }, "Mute Videos" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vidéos en sourdine" } } } }, "My Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mon instance" } } } }, "My Profile" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mon profil" } } } }, "Name" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nom" } } } }, "Never" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Jamais" } } } }, "New" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouveau" } } } }, "New Comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouveau commentaire" } } } }, "New Keyword..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouveau mot clé..." } } } }, "New Literal..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouveau littéral…" } } } }, "New Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouveau mot de passe" } } } }, "New password is invalid" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nouveau mot de passe n’est pas valide" } } } }, "New password must be between %lld and %lld characters long." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "New password must be between %1$lld and %2$lld characters long." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nouveau mot de passe doit comporter entre %1$lld et %2$lld caractères." } } } }, "New Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nouvelle publication" } } } }, "news@example.com" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "news@example.com" } } } }, "Next" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Suivant" } } } }, "Nickname" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Surnom" } } } }, "No" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Non" } } } }, "No comments found" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun commentaire trouvé" } } } }, "No image" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucune image" } } } }, "No reason given" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas de motif donné" } } } }, "No URL Copied" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas d’URL copiée" } } } }, "Non-Text Indicators" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur non textuel" } } } }, "None" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun" } } } }, "Not Guaranteed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Non garanti" } } } }, "Note" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Note" } } } }, "Notification Badge" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Badge de notification" } } } }, "Notifications will be sent to %@." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les notifications seront envoyées vers %@." } } } }, "Now" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Maintenant" } } } }, "NSFW" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "NSFW" } } } }, "NSFW Communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communautés NSFW" } } } }, "NSFW Content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contenu NSFW" } } } }, "NSFW Tag" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Étiquette NSFW" } } } }, "Ocean" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Océan" } } } }, "Off" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Off" } } } }, "Old" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vieux" } } } }, "Older" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Plus vieux" } } } }, "OLED" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "OLED" } } } }, "On" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "On" } } } }, "Once approved, you'll be able to log in to your account from the Settings tab." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Une fois approuvé, vous pourrez vous connecter à votre compte depuis l'onglet des paramètres." } } } }, "One of more of your posts failed to send." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Un ou plusieurs de vos messages n'ont pas pu être envoyés." } } } }, "Only in Profile" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Uniquement dans le profil" } } } }, "Open" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir" } } } }, "Open Account Switcher" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir le sélectionneur de comptes" } } } }, "Open Authenticator App..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir l’app d’authentification..." } } } }, "Open External Links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir les liens externes" } } } }, "Open in Browser" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir dans le navigateur" } } } }, "Open in Reader" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir en « mode lecture »" } } } }, "Open Mail App" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir l’application de mails" } } } }, "Open URL from Clipboard" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Copier l’URL dans le Presse-papiers" } } } }, "Optional Description" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Description optionnelle" } } } }, "Orange" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Orange" } } } }, "Original Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Instance originale" } } } }, "Original Poster" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publieur originel" } } } }, "Other" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Autre" } } } }, "Outline" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Contour" } } } }, "Outline Thickness" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épaisseur de contour" } } } }, "Outside NSFW Communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "En dehors de Communautés NSFW" } } } }, "Overview" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aperçu" } } } }, "Parent Comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Commentaires parents" } } } }, "Password" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mot de passe" } } } }, "Password must be %lld characters or more." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le mot de passe doit contenir %lld caractères ou plus." } } } }, "Passwords don't match." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les mots de passe en correspondent pas." } } } }, "Paste" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Coller" } } } }, "Pause" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pause" } } } }, "Permanent" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Définitivement" } } } }, "Permanently delete %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer définitivement %@" } } } }, "Personal Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Perspnnel Seulement" } } } }, "Photo Library" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Gallerie de photos" } } } }, "Photos" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Photos" } } } }, "PieFed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "PieFed" } } } }, "Pin" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler" } } } }, "Pin Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler la Publication" } } } }, "Pin to community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler dans la communauté" } } } }, "Pin to Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler la Communauté" } } } }, "Pin to Community or Instance?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler dans la communauté ou l’instance ?" } } } }, "Pin to instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler sur l'instance" } } } }, "Pin to Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler l’Instance" } } } }, "Pin..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Épingler…" } } } }, "Pink" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rose" } } } }, "Play" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lecture" } } } }, "Poll has ended" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le sondage est terminé" } } } }, "Popular" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Populaire" } } } }, "Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publication" } } } }, "Post Downvotes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votes négatifs sur les publications" } } } }, "Post failed to send." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication n’a pas pu être envoyée." } } } }, "Post Read Indicator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur de lecture de publication" } } } }, "Post Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de publication" } } } }, "Post Reports Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rapports de publication seulement" } } } }, "Post Size" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Taille de la publication" } } } }, "Post Upvotes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votes positifs sur les publications" } } } }, "Post was locked" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été verrouillée" } } } }, "Post was pinned to %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été épinglée sur %@" } } } }, "Post was purged" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été purgée" } } } }, "Post was removed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été retirée" } } } }, "Post was restored" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été restaurée" } } } }, "Post was unlocked" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été déverrouillée" } } } }, "Post was unpinned from %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "La publication a été désépinglée depuis %@" } } } }, "Posts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications" } } } }, "Posts from %@ communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications des communautés %@" } } } }, "Posts from all federated instances" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications de toutes les instances fédérées" } } } }, "Posts from communities you moderate" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications depuis les communautés que vous modérez" } } } }, "Posts from communities you subscribe to" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications depuis les communautés suivies" } } } }, "Posts from popular communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Publications des communautés populaires" } } } }, "Pride" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fierté" } } } }, "Privacy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vie privée" } } } }, "Privacy Policy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Politique de Confidentialité" } } } }, "Private" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Privé" } } } }, "Profile" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Profil" } } } }, "Profile Tab Label" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Étiquette de l'onglet de profil" } } } }, "Proxy Failure" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Échec du proxy" } } } }, "Purge" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger" } } } }, "Purge Comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger un commentaire" } } } }, "Purge Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger une communauté" } } } }, "Purge Person" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger une personne" } } } }, "Purge Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger une publication" } } } }, "Purge User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Purger un utilisateur" } } } }, "Purged content is erased from the database and cannot be restored." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le contenu purgé est effacé de la base de données et ne peut pas être restauré." } } } }, "Quick Look" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aperçu rapide" } } } }, "Quote" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Citation" } } } }, "Ranks posts based on the post score and creation time." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Ranks posts based on the post score and creation time." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Classe les publications en fonction du score de publication et du temps de création." } } } }, "Ranks posts based on the post score and the time since the last comment was created." : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Ranks posts based on the post score and the time since the last comment was created." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Classe les publications en se basant sur son score et la durée écoulée depuis la création du dernier commentaire." } } } }, "Re-Export" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réexporter" } } } }, "Read %lld posts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lu %lld publications" } } } }, "Read Indicator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur de lecture" } } } }, "Read posts are shown with dimmed title text. If you like, you can choose an additional way of indicating read status." : { }, "Really add NSFW tag?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment ajouter le tag NSFW ?" } } } }, "Really apply this configuration to all interaction bars?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Faut-il vraiment appliquer cette configuration à toutes les barres d’interaction ?" } } } }, "Really apply this configuration to all other content types?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Faut-il vraiment appliquer cette configuration à tous les autres types de contenu ?" } } } }, "Really appoint %@ as a moderator of %@?" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Really appoint %1$@ as a moderator of %2$@?" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment nommer %1$@ comme modérateur de %2$@ ?" } } } }, "Really appoint %@ as an administrator of %@?" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Really appoint %1$@ as an administrator of %2$@?" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment nommer %1$@ comme administrateur de %2$@ ?" } } } }, "Really appoint this user as a moderator of %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment nommer cet utilisateur comme modérateur de %@ ?" } } } }, "Really appoint this user as an administrator of %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment nommer cet utilisateur comme administrateur de %@ ?" } } } }, "Really block this community?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment bloquer cette communauté ?" } } } }, "Really block this instance?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment bloquer cette instance ?" } } } }, "Really block this user?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment bloquer cet utilisateur ?" } } } }, "Really delete %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment supprimer %@ ?" } } } }, "Really delete?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment supprimer ?" } } } }, "Really lock this post?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment verrouiller cette publication ?" } } } }, "Really pin this post to %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment épingler ce message sur %@ ?" } } } }, "Really pin this post to the community?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment épingler ce message dans la communauté ?" } } } }, "Really pin this post?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment épingler cette publication ?" } } } }, "Really remove %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment supprimer %@ ?" } } } }, "Really remove administrator %@ from %@?" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Really remove administrator %1$@ from %2$@?" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment supprimer l’administrateur %1$@ de %2$@ ?" } } } }, "Really remove moderator %@ from %@?" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Really remove moderator %1$@ from %2$@?" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment supprimer le modérateur %1$@ de %2$@ ?" } } } }, "Really remove NSFW tag?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment retirer le tag NSFW ?" } } } }, "Really sign out of %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment vous déconnecter de %@ ?" } } } }, "Really unlock this post?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment débloquer ce post ?" } } } }, "Really unpin this post from %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment retirer ce message de %@ ?" } } } }, "Really unpin this post from the community?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voulez-vous vraiment retirer ce message de la communauté ?" } } } }, "Really unpin this post?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vraiment désépingler cette publication ?" } } } }, "Reason" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Motif" } } } }, "Reason (Optional)" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Motif (optionnel)" } } } }, "Reason:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Motif :" } } } }, "Received %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Reçu %@" } } } }, "Recent Checks" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vérifications Récentes" } } } }, "Recently Searched" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Recherches Récentes" } } } }, "Redo" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Refaire" } } } }, "Reduce Motion" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réduire les animations" } } } }, "Refresh" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rafraîchir" } } } }, "Refresh Token" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Token de rafraîchissement" } } } }, "Registration" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Inscription" } } } }, "Registration Applications" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Demandes d’inscription" } } } }, "Registrations are closed on this instance." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Les inscriptions sont closes sur cette instance." } } } }, "Reload" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechargement" } } } }, "Reload on Switch" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Recharger au changement" } } } }, "Remember Search History" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se souvenir de l’historique de recherche" } } } }, "Remove" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer" } } } }, "Remove Administrator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer un administrateur" } } } }, "Remove Comment" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer un commentaire" } } } }, "Remove Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer une communauté" } } } }, "Remove Content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer un contenu" } } } }, "Remove Moderator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer un modérateur" } } } }, "Remove NSFW Tag" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Retirer le tag NSFW" } } } }, "Remove Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer une publication" } } } }, "Remove Recent Search" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimer une recherche récente" } } } }, "Removed: %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimé : %@" } } } }, "Removed: %@\nFrom: %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Removed: %1$@\nFrom: %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Supprimé : %1$@\nDe : %2$@" } } } }, "Replies" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réponses" } } } }, "Replies Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réponses uniquement" } } } }, "Replies, mentions and messages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réponses, mentions et messages" } } } }, "Reply" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réponse" } } } }, "Reply Counter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compteur de réponse" } } } }, "Report" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signaler" } } } }, "Reported %@ by %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Reported %1$@ by %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalé %1$@ par %2$@" } } } }, "Reports" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalements" } } } }, "Reports and Registration Applications" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalements et demandes d’inscriptions" } } } }, "Reports Email Admins" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalements d’emails d’admins" } } } }, "Reports from communities you moderate" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalements depuis des communautés que vous modérez" } } } }, "Reports Only" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Signalements uniquement" } } } }, "Requires Application" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nécessite une inscription" } } } }, "Reset" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Réinitialsier" } } } }, "Resolve" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Résoudre" } } } }, "Resolved" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Résolu" } } } }, "Resolved by %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Résolu par %@" } } } }, "Resolving..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Résolution…" } } } }, "Response Time" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Temps de réponse" } } } }, "Restore" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Restaurer" } } } }, "Restore Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres de restauration" } } } }, "Restored" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Restauré" } } } }, "Restored Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres restaurés" } } } }, "Right" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Droite" } } } }, "Row Size" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Taille de ligne" } } } }, "Safety & Filtering" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sécurité et filtrage" } } } }, "Save" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrer" } } } }, "Save and Restore" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrer et restaurer" } } } }, "Save Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrer les réglages" } } } }, "Save the current settings and restore them later." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistrez les réglages actuels et restaurez-les ultérieurement." } } } }, "Saved" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enregistré" } } } }, "Saved Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres enregistrés" } } } }, "Scaled" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mis à l’échelle" } } } }, "Score" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Score" } } } }, "Score Counter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compteur de score" } } } }, "Search" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Recherche" } } } }, "Search for comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des commentaires" } } } }, "Search for communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des communautés" } } } }, "Search for Lemmy instances" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des instances Lemmy" } } } }, "Search for posts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des publications" } } } }, "Search for users" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des utilisateurs" } } } }, "Search..." : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Search…" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Recherche…" } } } }, "See All" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voir tout" } } } }, "Select Text" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sélectionner le texte" } } } }, "Send" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer" } } } }, "Send a Message..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer un message..." } } } }, "Send Message" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer un message" } } } }, "Send Notifications to Email" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer des notifications par e-mail" } } } }, "Send to Lemmy User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer à un utilisateur Lemmy" } } } }, "Sent %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyé %@" } } } }, "Separate Actions Using" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Séparer les actions à l'aide de" } } } }, "Separate Menu" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Menu séparé" } } } }, "Settings" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres" } } } }, "Settings Icons" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Paramètres des icônes" } } } }, "Share" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager" } } } }, "Share Links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager les liens" } } } }, "Share links using %@. When someone opens the link, they can choose which instance to use." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager des liens avec %@. Lorsqu'un utilisateur ouvre le lien, il peut choisir l'instance à utiliser." } } } }, "Share links using the instance that the content originated from." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager des liens en utilisant l’instance d’où provient le contenu." } } } }, "Share links using the instance you are currently connected to." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager des liens en utilisant l'instance à laquelle vous êtes actuellement connectée." } } } }, "Share using..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager via…" } } } }, "Share..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Partager..." } } } }, "Show" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher" } } } }, "Show Account Age" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher l’âge du compte" } } } }, "Show All Actions in Feed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher toutes les actions dans le flux" } } } }, "Show Avatar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher l’avatar" } } } }, "Show Bot Accounts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les comptes robots" } } } }, "Show content flagged as Not Safe For Work." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les contenus étiquetés comme « Not Safe For Work »." } } } }, "Show Controls" : { }, "Show Details" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les détails" } } } }, "Show Downvotes Separately" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher séparément les votes négatifs" } } } }, "Show Events" : { }, "Show Full URL" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher toute l’URL" } } } }, "Show Mod Names in Modlog" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les noms de modérateurs dans le journal de modération" } } } }, "Show NSFW Content" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher le contenu NSFW" } } } }, "Show Older Incidents" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les incidents plus vieux" } } } }, "Show Parent" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher le parent" } } } }, "Show Post" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher la publication" } } } }, "Show Read" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les lus" } } } }, "Show Response Times" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les temps de réponse" } } } }, "Show Results" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les résultats" } } } }, "Show Sidebar on App Launch" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher la barre latérale au lancement de l'application" } } } }, "Show warnings when opening..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher les avertissements lors de l'ouverture..." } } } }, "Showing Read" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lus affichés" } } } }, "Shown" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Affiché" } } } }, "Sign In" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se connecter" } } } }, "Sign In to Lemmy" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se connecter à Lemmy" } } } }, "Sign Out" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sign Out" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se déconnecter" } } } }, "Sign Up" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "S’inscrire" } } } }, "Sign-In & Security" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connexion et sécurité" } } } }, "Silver" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Argent" } } } }, "Similar to Hot, but ranks posts from smaller communities higher." : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Similar to \\\"Hot\\\", but ranks posts from smaller communities higher." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Similaire à « Chaud », mais met davantage en avant les publications des plus petites communautés." } } } }, "Size" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Taille" } } } }, "Slide to Zoom" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Glisser pour zoomer" } } } }, "Slide to Zoom Images" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Glisser pour zoomer sur les images" } } } }, "Slow" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lent" } } } }, "Slur Filter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Filtre de liaison" } } } }, "Solarized" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Solarised" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Solarisé" } } } }, "Some" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quelques" } } } }, "Some instances proxy images to protect your privacy. In certain cases, this causes image loading to fail. You can bypass the image proxy and load directly, but this will expose your IP address to the image host." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Certaines instances utilisent des proxy d’images pour protéger votre vie privée. Dans certains cas, cela entraîne l'échec du chargement de l'image. Vous pouvez contourner le proxy d'image et charger directement, mais cela exposera votre adresse IP à l'hôte de l'image." } } } }, "Some users set animated media as their avatar. Control whether these avatars should play their animations." : { }, "Sort" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Trier" } } } }, "Sort by: %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Trier par : %@" } } } }, "Sort by..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Trier par..." } } } }, "Sorting" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Triage" } } } }, "Spam" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Spam" } } } }, "Spoiler" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Divulgâcheur" } } } }, "Start writing..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Commencer à écrire…" } } } }, "Starts %@" : { }, "Stats" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Statistiques" } } } }, "Strikethrough" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Barré" } } } }, "Style" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Style" } } } }, "Subject" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sujet" } } } }, "Submit" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Soumettre" } } } }, "Submit Application" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Soumettre l’inscription" } } } }, "Submitted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Soumis" } } } }, "Submitting..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoi..." } } } }, "Subscribe" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "S'abonner" } } } }, "Subscribed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Abonné" } } } }, "Subscribers" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Abonnés" } } } }, "Subscript" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Subscript" } } } }, "Subscription Indicator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur d’abonnement" } } } }, "Subscription List" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Liste d’abonnement" } } } }, "Suggested" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Suggérées" } } } }, "Suggested Languages" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Langues suggérées" } } } }, "Superscript" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Superscript" } } } }, "Swipe Actions" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Actions de glissement" } } } }, "Swipe Anywhere to Navigate" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Glisser n’importe où pour naviguer" } } } }, "Swiping up on the tab bar will always open the account switcher." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Glisser sur la bar d’onglets va toujours ouvrir le sélectionneur de comptes." } } } }, "Switch Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer de Compte" } } } }, "Switch account to view your inbox." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer de compte pour voir votre boîte de réception." } } } }, "Switch to Most Recent Account" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer vers le compte le plus récent" } } } }, "Switch to this account and..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Changer de compte et…" } } } }, "Tab Bar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Barre d'onglets" } } } }, "Table" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tableau" } } } }, "Tap and hold items to add, remove, or rearrange them." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Appuyez longuement sur les éléments pour les ajouter, les supprimer ou les réorganiser." } } } }, "Tap on the Jump Button whilst viewing a comment thread to scroll to the next comment." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Appuyez sur le bouton de saut lorsque vous consultez un fil de commentaires pour faire défiler jusqu'au commentaire suivant." } } } }, "Tap to Collapse" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Appuyez pour réduire" } } } }, "Tap to show slur filter regex." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Appuyez pour afficher l'expression régulière du filtre de liaison." } } } }, "Tap to Undo" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tapper pour annuler" } } } }, "Tappable Links" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Liens clickables" } } } }, "Temporary" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Temporaire" } } } }, "TestFlight updated!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "TestFlight mis à jour !" } } } }, "That's all, folks!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "C'est tout, les amis !" } } } }, "The \"%@\" sort mode is only available on some instances. On unsupported instances, the \"Fallback\" sort mode will be used instead." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le mode de tri « %@ » n’est disponible que dans certaines instances. Sur les instances non supportées, le tri « Fallback » sera appliqué à la place." } } } }, "The %@ theme only supports %@ mode." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "The %1$@ theme only supports %2$@ mode." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le thème %1$@ ne prend en charge que le mode %2$@." } } } }, "The Fediseer" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le Fediseer" } } } }, "The Fediseer is a service that instance administrators use to identify spam instances and express their approval or disapproval of other instances." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fediseer est un service que les administrateurs d'instance utilisent pour identifier les instances de spam et exprimer leur approbation ou leur désapprobation d'autres instances." } } } }, "The modlog may contain disturbing or adult material." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le modlog peut contenir du contenu dérangeant ou réservé aux adultes." } } } }, "The most recent outage was %@, and lasted for %@." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "The most recent outage was %1$@, and lasted for %2$@." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "La panne la plus récente était %1$@ et a duré %2$@." } } } }, "The name shown in the account switcher and tab bar." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom affiché dans le sélecteur de compte et la barre d'onglets." } } } }, "The name shown in the account switcher." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom affiché dans le sélecteur de compte." } } } }, "The name that is displayed on your profile. This is not the same as your username, which cannot be changed." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom qui apparaît sur votre profil. Il ne correspond pas à votre nom d'utilisateur, qui ne peut pas être modifié." } } } }, "The number of child comments that can appear in a chain before the \"More Replies\" button is shown." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nombre de commentaires enfants qui peuvent apparaître dans une chaîne avant que le bouton « Plus de réponses » ne soit affiché." } } } }, "Theme" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Thèmes" } } } }, "There were %lld recorded incidents today." : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "There was %lld recorded incidents today." } }, "other" : { "stringUnit" : { "state" : "new", "value" : "There were %lld recorded incidents today." } } } } }, "en-GB" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "There was %lld recorded incidents today." } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "There were %lld recorded incidents today." } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "Il y a eu %lld incident enregistré aujourd'hui." } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "Il y a eu %lld incidents enregistrés aujourd'hui." } } } } } } }, "There were no recorded incidents today." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aucun incident n'a été enregistré aujourd'hui." } } } }, "These filters were saved by an older version of Mlem, and will not be compatible with future versions. To preserve compatibility, re-export your filters." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ces filtres ont été enregistrés par une ancienne version de Mlem et ne seront pas compatibles avec les versions futures. Pour préserver la compatibilité, réexportez vos filtres." } } } }, "These options are stored locally in Mlem and not on your Lemmy account." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ces options sont stockées localement dans Mlem et non sur votre compte Lemmy." } } } }, "This account has %lld favorite communities." : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "This account has %lld favorite community." } }, "other" : { "stringUnit" : { "state" : "new", "value" : "This account has %lld favorite communities." } } } } }, "en-GB" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "This account has %lld favourite community." } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "This account has %lld favourite communities." } } } } }, "fr" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "Ce compte a %lld communauté favorite." } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "Ce compte a %lld communautés favorites." } } } } } } }, "This account has no favorite communities." : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "This account has no favourite communities." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce compte n'a pas de communautés favorites." } } } }, "This action cannot be undone." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette action ne peut pas être annulée." } } } }, "This cannot be changed later." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ceci ne pourra pas être changé plus tard." } } } }, "This community has been removed." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette communauté a été supprimée." } } } }, "This community likely contains graphic or explicit content." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette communauté contient probablement du contenu graphique ou explicite." } } } }, "This content will be loaded from **%@** instead." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce contenu sera chargé depuis **%@** à la place." } } } }, "This feature is not available on all instances." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette fonctionnalité n’est pas disponible sur toutes les instances." } } } }, "This field is optional." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce champ est facultatif." } } } }, "This instance is not part of the Fediseer Chain of Trust." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette instance ne fait pas partie de la chaîne de confiance Fediseer." } } } }, "This instance is viewed very negatively by one or more trusted instances." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette instance est perçue très négativement par une ou plusieurs instances de confiance." } } } }, "This is turned on by default because Differentiate Without Color is enabled in System Settings." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette option est activée par défaut car l'option « Différencier sans couleur » est activée dans les paramètres système." } } } }, "This page can't be displayed because Cloudflare blocked the request." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cette page ne peut pas être affichée car Cloudflare a bloqué la requête." } } } }, "This poll is no longer accepting votes." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce sondage n'accepte plus de votes." } } } }, "This probably contains foul language." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cela contient probablement un langage grossier." } } } }, "This username is taken." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ce nom d'utilisateur est pris." } } } }, "This will clear your recent searches, which cannot be undone." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cela effacera vos recherches récentes, ce qui ne peut pas être annulé." } } } }, "This will permanently remove it from %@, not just Mlem!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Cela le supprimera définitivement de %@, pas seulement de Mlem !" } } } }, "Thumbnail" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vignette" } } } }, "Thumbnail Location" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Emplacement de la vignette" } } } }, "Tiled" : { "extractionState" : "manual", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tuile" } } } }, "Time" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Temps" } } } }, "Title" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Titre" } } } }, "To confirm, please enter your password:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pour confirmer, entrez svp votre mot de passe :" } } } }, "To join this instance, you need to create an application and wait to be accepted." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pour rejoindre cette instance, vous devez faire une demande et attendre d'être accepté." } } } }, "Today" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aujourd’hui" } } } }, "Toggle Developer Tools" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Basculer les outils développeur" } } } }, "Too many items" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Trop d’éléments" } } } }, "Top" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Top" } } } }, "Top Communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Top Communautés" } } } }, "Top of..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Top…" } } } }, "Top:" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Top :" } } } }, "Top..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Top…" } } } }, "Trailing" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "À la fin" } } } }, "Transfer Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Transférer la communauté" } } } }, "Transfer Ownership" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Transférer la propriété" } } } }, "Trending Communities" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Communautés Tendances" } } } }, "Troll" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Troll" } } } }, "Trust & Safety" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Confiance et sécurité" } } } }, "Try a different Captcha..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Essayer un autre captcha…" } } } }, "Turn Off" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désactiver" } } } }, "Turn Off Events" : { }, "Turn Off Search History" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désactiver l’historique de recherche" } } } }, "Turn off search history?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désactiver l’historique de recherche ?" } } } }, "Two-Factor Authentication" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Authentification à Double-Facteur (2FA)" } } } }, "Unavailable" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indisponible" } } } }, "Unban" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannier" } } } }, "Unban %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannir %@" } } } }, "Unban from Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannir de la communauté" } } } }, "Unban from Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannir de l'instance" } } } }, "Unban from..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannir de..." } } } }, "Unban User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débannir l’utilisateur" } } } }, "Unbanned: %@\nFrom: %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Unbanned: %1$@\nFrom: %2$@" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débanni : %1$@\nDe : %2$@" } } } }, "Unbanning from..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débanissement de…" } } } }, "Unblock" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer" } } } }, "Unblock Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer la communauté" } } } }, "Unblock Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer l'instance" } } } }, "Unblock User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer l'utilisateur" } } } }, "Unblock..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer…" } } } }, "Unblocked" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloqué" } } } }, "Unblocking..." : { "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déblocage…" } } } }, "Undo" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annuler" } } } }, "Undo Downvote" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annuler vote négatif" } } } }, "Undo Upvote" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annuler vote positif" } } } }, "Undone!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annulé !" } } } }, "Unfavorite" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourite" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enlever des favoris" } } } }, "Unfavorited" : { "localizations" : { "en-GB" : { "stringUnit" : { "state" : "translated", "value" : "Unfavourited" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Enlevé des favoris" } } } }, "Unhealthy for %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mauvais pour %@" } } } }, "Universal" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Universel" } } } }, "Universal Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lien universel" } } } }, "Unknown" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Inconnu" } } } }, "unknown host" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "hôte inconnu" } } } }, "Unlock" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Débloquer" } } } }, "Unmute" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Rétablir le son" } } } }, "Unpin" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désépingler" } } } }, "Unpin from community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désépingler de la communauté" } } } }, "Unpin From Community" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désépingler de la communauté" } } } }, "Unpin from instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désépingler de l'instance" } } } }, "Unpin From Instance" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désépingler de l’instance" } } } }, "Unresolve" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ne plus résoudre" } } } }, "Unsave" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ne plus enregistrer" } } } }, "Unsubscribe" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désinscrire" } } } }, "Unsubscribed" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Désinscrit" } } } }, "Unsupported Badge" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Badge non supporté" } } } }, "Upload" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Téléverser" } } } }, "Upload this image to %@?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Téléverser cette image sur %@ ?" } } } }, "Uploading..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Téléversement…" } } } }, "Uptime" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Temps de disponibilité" } } } }, "Uptime data fetched from %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Données de disponibilité récupérées à partir de %@" } } } }, "Upvote" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vote positif" } } } }, "Upvote Counter" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Compteur de votes positifs" } } } }, "Upvote on Save" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vote positif si enregistrement" } } } }, "Upvoted" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votées positivement" } } } }, "Use Alternate Layouts" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Utiliser des dispositions alternatives" } } } }, "User" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Utilisateur" } } } }, "User Avatar" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avatar de l'utilisateur" } } } }, "User Link" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lien de l’utilisateur" } } } }, "User Modlog" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Journal de modération de l'utilisateur" } } } }, "User or community?" : { }, "User was banned" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L’utilisateur a été banni" } } } }, "User was purged" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L’utilisateur a été purgé" } } } }, "User was unbanned" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "L’utilisateur a été débanni" } } } }, "Username" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nom d’utilisateur" } } } }, "Username can only contain lowercase letters, numbers and underscores." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d'utilisateur ne peut contenir que des lettres minuscules, des chiffres et des traits de soulignement." } } } }, "Username cannot be longer than %lld characters." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d’utilisateur ne peut pas être plus long que %lld caractères." } } } }, "Username cannot contain %@." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d’utilisateur ne peut pas contenir %@." } } } }, "Username is invalid." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d’utilisateur n’est pas valide." } } } }, "Username is taken." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d’utilisateur est déjà pris." } } } }, "Username must be 3 or more characters." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d'utilisateur doit comporter 3 caractères ou plus." } } } }, "Username must be at least %lld characters long." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le nom d’utilisateur doit être d’au moins %lld caractères." } } } }, "Users" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Utilisateurs" } } } }, "Version" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Version" } } } }, "Video Saved" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vidéo enregistrée" } } } }, "View All" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tout voir" } } } }, "View on %@" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voir sur %@" } } } }, "View on original host" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voir sur la publication originale" } } } }, "View Votes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Voir les votes" } } } }, "Viewing %@ as guest" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lecture %@ en tant qu’invité" } } } }, "Visit" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Visiter" } } } }, "Visit Again" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Revisiter" } } } }, "Votes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votes" } } } }, "We sent an email to %@ to verify your email address and activate your account." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nous avons envoyé un e-mail à %@ pour vérifier votre adresse e-mail et activer votre compte." } } } }, "Website" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Site web" } } } }, "Website Icon" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Icône du site web" } } } }, "Website Thumbnail Indicator" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Indicateur de miniature de site Web" } } } }, "Welcome %@" : { "comment" : "Example: \"Welcome John\"", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bievenue %@" } } } }, "Welcome to %@" : { "comment" : "Example: \"Welcome to lemmy.world\"", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bienvenue sur %@" } } } }, "Welcome to Lemmy!" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bienvenue sur Lemmy !" } } } }, "What is Federation?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "C’est quoi la fédération ?" } } } }, "What's New?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quoi de neuf ?" } } } }, "When disabled, some moderator actions will only be accessible from the post page." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lorsque cette option est désactivée, certaines actions du modérateur ne seront accessibles qu'à partir de la page de publication." } } } }, "When enabled, Mlem will ask you to confirm your choice before uploading an image to your instance." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lorsqu'il est activé, Mlem vous demandera de confirmer votre choix avant de télécharger une image sur votre instance." } } } }, "When I Tap" : { }, "Would you like to open this email address in your mail app?" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Souhaitez-vous ouvrir cette adresse e-mail dans votre application de messagerie?" } } } }, "Wrap Code Block Lines" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envelopper les lignes du bloc de code" } } } }, "Write a bit about yourself..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Écrivez un peu sur vous-même..." } } } }, "Yes" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Oui" } } } }, "You are browsing %@ as a guest. If you'd like to vote or reply, you'll need to log in or sign up." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous parcourez %@ en tant qu'invité. Si vous souhaitez voter ou répondre, vous devez vous connecter ou vous inscrire." } } } }, "You are required to provide an email on this instance." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous devez fournir une adresse e-mail sur cette instance." } } } }, "You can also turn off search history completely for this account." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous pouvez également désactiver complètement l’historique de recherche pour ce compte." } } } }, "You cannot change your vote." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous ne pouvez pas modifier votre vote." } } } }, "You cannot use a temporary email address." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous ne pouvez pas utiliser une adresse email temporaire." } } } }, "You cannot view the communities of a private instance." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous ne pouvez pas voir les communautés d'une instance privée." } } } }, "You don't have an email attached to this account." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous n'avez pas d'e-mail associé à ce compte." } } } }, "You don't have any accounts." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous n'avez aucun compte." } } } }, "You may need to allow Mlem to access your Photo Library in System Settings." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous devrez peut-être autoriser Mlem à accéder à votre galerie de photos dans les paramètres système." } } } }, "You'll receive an email once your application has been approved. Once approved, you can log in to your account from the Settings tab." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vous recevrez un e-mail une fois votre demande approuvée. Une fois approuvée, vous pourrez vous connecter à votre compte depuis l'onglet des paramètres." } } } }, "Your Answer..." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre réponse..." } } } }, "Your instance and **%@** federate, but the content could not be loaded. It may not have federated yet, or your instance may have purged it." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre instance et **%@** se fédèrent, mais le contenu n'a pas pu être chargé. Il se peut qu'il n'ait pas encore été fédéré ou que votre instance l'ait purgé." } } } }, "Your instance, **%@**, chose to defederate from **%@**." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Your instance, **%1$@**, chose to defederate from **%2$@**." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre instance, **%1$@**, a choisi de se défédérer de **%2$@**." } } } }, "Your instance, **%@**, hasn't chosen to federate with **%@**." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Your instance, **%1$@**, hasn't chosen to federate with **%2$@**." } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre instance, **%1$@**, n'a pas choisi de se fédérer avec **%2$@**." } } } }, "Your saved posts and comments" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vos publications et commentaires enregistrés" } } } }, "Your session has expired. Enter your password to authenticate a new session." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre session a expiré. Saisissez votre mot de passe pour authentifier une nouvelle session." } } } }, "Your subscriptions live here." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vos abonnements sont ici." } } } }, "Zoom Indicator" : { }, "Zoom the image viewer with a slide gesture on the selected side." : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Zoomez la visionneuse d'images avec un geste de glissement sur le côté sélectionné." } } } } }, "version" : "1.0" } ================================================ FILE: Mlem/Mlem.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.network.client com.apple.security.network.server keychain-access-groups $(AppIdentifierPrefix)com.hanners.Mlem ================================================ FILE: Mlem/Mlem.xcdatamodeld/.xccurrentversion ================================================ ================================================ FILE: Mlem/Mlem.xcdatamodeld/Mlem.xcdatamodel/contents ================================================ ================================================ FILE: Mlem/Packages/Actions/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Actions", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Actions", targets: ["Actions"] ) ], dependencies: [ .package(path: "../Icons"), .package(path: "../Theming") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Actions", dependencies: [ .byName(name: "Icons"), .byName(name: "Theming") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/Action.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-13. // import SwiftUI public protocol Action { func createLabel(environment: EnvironmentValues) -> ActionLabel @MainActor func execute(environment: EnvironmentValues) } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/ActionLabel.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-13. // import Foundation import Icons import Theming public struct ActionLabel { public var title: String public var icon: Icon public var color: ThemedColor public var isDestructive: Bool public var visibility: ActionVisiblity public init( _ title: LocalizedStringResource, icon: Icon, color: ThemedColor = .themedAccent, isDestructive: Bool = false, visibility: ActionVisiblity = .enabled ) { self.title = .init(localized: title) self.icon = icon self.color = color self.isDestructive = isDestructive self.visibility = visibility } @_disfavoredOverload public init( _ title: some StringProtocol, icon: Icon, color: ThemedColor = .themedAccent, isDestructive: Bool = false, visibility: ActionVisiblity = .enabled ) { self.title = String(title) self.icon = icon self.color = color self.isDestructive = isDestructive self.visibility = visibility } public func withVisibility(_ visibility: ActionVisiblity) -> ActionLabel { var new = self new.visibility = visibility return new } public func withTitle(_ title: LocalizedStringResource) -> ActionLabel { var new = self new.title = .init(localized: title) return new } @_disfavoredOverload public func withTitle(_ title: some StringProtocol) -> ActionLabel { var new = self new.title = String(title) return new } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/ActionSeed.swift ================================================ // // ActionSeed.swift // Mlem // // Created by Sjmarf on 2025-10-16. // import Foundation public final class ActionSeed: Hashable, Encodable { public let key: String private let actionType: any Action.Type public let label: ActionLabel public let createAction: (Any) -> (any Action)? public init( _ key: String, label: ActionLabel, createAction: @escaping (Any) -> T? ) { self.key = key self.label = label self.createAction = createAction self.actionType = T.self } public convenience init( _ key: String, createAction: @escaping (Any) -> T? ) { self.init(key, label: T.label, createAction: createAction) } public static func == (lhs: ActionSeed, rhs: ActionSeed) -> Bool { lhs.key == rhs.key } public func hash(into hasher: inout Hasher) { hasher.combine(key) } public func encode(to encoder: any Encoder) throws { try self.key.encode(to: encoder) } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/ActionVisiblity.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-14. // import Foundation public enum ActionVisiblity { case enabled, disabled, hidden } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/BasicAction.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-13. // import Icons import SwiftUI /// A basic implementation of `Action` designed for use in non-customizable contexts, such as the options within an alert. /// /// ```swift /// BasicAction("Confirm", icon: .general.success) { /// post.upvote() /// } /// ``` /// public struct BasicAction: Action { private let label: ActionLabel private let callback: () -> Void public init( _ title: LocalizedStringResource, icon: Icon, callback: @escaping () -> Void ) { self.label = .init(title, icon: icon) self.callback = callback } @_disfavoredOverload public init( _ title: String, icon: Icon, callback: @escaping () -> Void ) { self.label = .init(title, icon: icon) self.callback = callback } public func createLabel(environment: EnvironmentValues) -> ActionLabel { label } public func execute(environment: EnvironmentValues) { callback() } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/SimpleLabelAction.swift ================================================ // // SimpleLabelAction.swift // Actions // // Created by Sjmarf on 2025-10-13. // import SwiftUI public protocol SimpleLabelAction: Action { static var label: ActionLabel { get } } public extension SimpleLabelAction { func createLabel(environment: EnvironmentValues) -> ActionLabel { Self.label } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/Views/ActionButtonWithVisibilityControl.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-13. // import SwiftUI public struct ActionButtonWithVisibilityControl: View { @Environment(\.self) private var environment private let action: any Action public init(_ action: any Action) { self.action = action } public var body: some View { let label = action.createLabel(environment: environment) if label.visibility != .hidden { Button(label) { action.execute(environment: environment) } .disabled(label.visibility == .disabled) // Without this, destructive items appear black in the // subscription list due to a shim we've got in there #2374. // Intentionally unthemed. .tint(label.isDestructive ? .red : .primary) } } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/Views/ActionButtons.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-14. // import SwiftUI // This type can be used inside of a `.contextMenu()` rather than using the `ForEach` directly. // This avoids instantiating the actions until the menu is actually opened. public struct ActionButtons: View { @Environment(\.self) var environment let actions: (EnvironmentValues) -> [any Actions.Action] public init(_ actions: @escaping (EnvironmentValues) -> [any Actions.Action]) { self.actions = actions } public var body: some View { ForEach(Array(actions(environment).enumerated()), id: \.offset) { _, action in ActionButtonWithVisibilityControl(action) } } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/Views/Button+Extensions.swift ================================================ // // Button+Extensions.swift // Actions // // Created by Sjmarf on 2025-11-12. // import SwiftUI public extension Button { // Remeber to handle ActionLabel visibility when you use this init( _ label: ActionLabel, callback: @escaping () -> Void ) where Label == SwiftUI.Label { self.init(role: label.isDestructive ? .destructive : nil, action: callback) { Label(label) } } } ================================================ FILE: Mlem/Packages/Actions/Sources/Actions/Views/Label+Extensions.swift ================================================ // // File.swift // Actions // // Created by Sjmarf on 2025-10-13. // import Icons import SwiftUI public extension Label where Title == Text, Icon == Image { @inlinable init(_ actionLabel: ActionLabel) { self.init(actionLabel.title, icon: actionLabel.icon) } } ================================================ FILE: Mlem/Packages/ComponentViews/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "ComponentViews", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "ComponentViews", targets: ["ComponentViews"] ) ], dependencies: [.package(path: "../Theming")], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "ComponentViews", dependencies: [.byName(name: "Theming")], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/ButtonStyle/CapsuleButtonStyle.swift ================================================ // // CapsuleButtonStyle.swift // Mlem // // Created by Sjmarf on 28/09/2024. // import SwiftUI public struct CapsuleButtonStyle: ButtonStyle { @Environment(\.palette) var palette public func makeBody(configuration: Self.Configuration) -> some View { configuration.label .fontWeight(.semibold) .foregroundStyle(.themedAccent) .padding(.vertical, 10) .frame(maxWidth: .infinity) .background { Capsule() .fill(.themedSecondaryGroupedBackground) .stroke(palette.bordered ? .themedDivider : .clear, lineWidth: 0.5) } .opacity(configuration.isPressed ? 0.8 : 1) .animation(.easeOut(duration: 0.1), value: configuration.isPressed) } } public extension ButtonStyle where Self == CapsuleButtonStyle { @MainActor static var capsule: CapsuleButtonStyle { .init() } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/ButtonStyle/ChevronButtonStyle.swift ================================================ // // ChevronButtonStyle.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import Foundation import SwiftUI public struct ChevronButtonStyle: ButtonStyle { public func makeBody(configuration: Self.Configuration) -> some View { FormChevron { configuration.label } } } public extension ButtonStyle where Self == ChevronButtonStyle { @MainActor static var chevron: ChevronButtonStyle { .init() } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/ButtonStyle/EmptyButtonStyle.swift ================================================ // // Empty Button Style.swift // Mlem // // Created by Eric Andrews on 2023-06-10. // import Foundation import SwiftUI /// Style to disable navigation highlighting public struct EmptyButtonStyle: ButtonStyle { public func makeBody(configuration: Self.Configuration) -> some View { configuration.label } } public extension ButtonStyle where Self == EmptyButtonStyle { @MainActor static var empty: EmptyButtonStyle { .init() } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/Checkbox.swift ================================================ // // Checkbox.swift // Mlem // // Created by Sjmarf on 2025-01-05. // import SwiftUI import Theming public struct Checkbox: View { public let isOn: Bool public init(isOn: Bool) { self.isOn = isOn } public var body: some View { VStack { if isOn { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.themedContrastingLabel, .tint) .imageScale(.large) } else { Image(systemName: "circle") .foregroundStyle(.themedTertiary) .imageScale(.large) } } } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/CloseButtonToolbarItem.swift ================================================ // // File.swift // ComponentViews // // Created by Sjmarf on 2025-09-11. // import SwiftUI public struct CloseButtonToolbarItem: ToolbarContent { var ios18Label: CloseButtonView.LabelType var callback: (() -> Void)? public init( ios18Label: CloseButtonView.LabelType = .xmark, callback: (() -> Void)? = nil ) { self.ios18Label = ios18Label self.callback = callback } public var body: some ToolbarContent { ToolbarItem(placement: placement) { CloseButtonView(ios18Label: ios18Label, callback: callback) } } var placement: ToolbarItemPlacement { if #available(iOS 26, *) { return .topBarLeading } else { return .topBarTrailing } } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/CloseButtonView.swift ================================================ // // CloseButtonView.swift // Mlem // // Created by Sjmarf on 09/03/2024. // import Foundation import SwiftUI import Theming public struct CloseButtonView: View { @Environment(\.dismiss) var dismiss public enum LabelType { case cancel, xmark } var ios18Label: LabelType var requiresConfirmation: Bool var callback: (() -> Void)? @State var showingConfirmation: Bool = false public init( ios18Label: LabelType = .xmark, requiresConfirmation: Bool = false, callback: (() -> Void)? = nil ) { self.ios18Label = ios18Label self.requiresConfirmation = requiresConfirmation self.callback = callback } public var body: some View { Group { if #available(iOS 26, *) { Button("Dismiss", systemImage: "xmark", action: submit) .confirmationDialog("Really close?", isPresented: $showingConfirmation) { Button("Yes", role: .destructive, action: submit) } message: { Text("Really close?") } } else { ios18Body .alert("Really close?", isPresented: $showingConfirmation) { Button("Yes", role: .destructive, action: submit) Button("Cancel", role: .cancel) {} } } } } @ViewBuilder private var ios18Body: some View { switch ios18Label { case .cancel: Button("Cancel", action: submit) case .xmark: Button(action: submit) { Image(systemName: "xmark.circle.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 30) .symbolRenderingMode(.palette) .foregroundStyle(.themedSecondary, .themedSecondary.opacity(0.2)) } .buttonStyle(.plain) .accessibilityLabel("Dismiss") } } func submit() { if requiresConfirmation, !showingConfirmation { showingConfirmation = true } else if let callback { callback() } else { dismiss() } } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/CollapsibleSheetView.swift ================================================ // // CollapsibleSheetView.swift // Mlem // // Created by Sjmarf on 15/07/2024. // import SwiftUI public struct CollapsibleSheetView: View { let content: Content let canDismiss: Bool @Binding var presentationSelection: PresentationDetent public init( presentationSelection: Binding, canDismiss: Bool, @ViewBuilder content: () -> Content ) { self.canDismiss = canDismiss self._presentationSelection = presentationSelection self.content = content() } public var body: some View { content .opacity(presentationSelection == .large ? 1 : 0) .overlay(alignment: .top) { Button { presentationSelection = .large } label: { Image(systemName: "chevron.compact.up") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 16) .foregroundStyle(.themedSecondary) .frame(maxWidth: .infinity, maxHeight: 62) .contentShape(.rect) } .opacity(presentationSelection == .large ? 0 : 1) } .animation(.easeOut(duration: 0.2), value: presentationSelection) .presentationDetents(canDismiss ? [.large] : [.height(62), .large], selection: $presentationSelection) .interactiveDismissDisabled(!canDismiss) .presentationCornerRadius(presentationSelection == .large ? nil : 16) .presentationBackgroundInteraction(.enabled) .presentationDragIndicator(.hidden) .background(.themedBackground) } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/FitContentPresentationDetentViewModifier.swift ================================================ // // File.swift // ComponentViews // // Created by Sjmarf on 2025-06-15. // import SwiftUI private struct FitContentPresentationDetentViewModifier: ViewModifier { let otherDetents: Set var selection: Binding? @State private var sheetContentHeight: CGFloat = SheetHeightKey.defaultValue func body(content: Content) -> some View { if let selection, !otherDetents.isEmpty { innerBody(content: content) .presentationDetents( otherDetents.union([.height(sheetContentHeight)]), selection: selection ) } else { innerBody(content: content) .presentationDetents(otherDetents.union([.height(sheetContentHeight)])) } } func innerBody(content: Content) -> some View { content .overlay { GeometryReader { proxy in Color.clear.preference( key: SheetHeightKey.self, value: proxy.size.height ) } } .onPreferenceChange(SheetHeightKey.self) { sheetContentHeight = $0 } } } public extension View { @ViewBuilder func presentationDetentFitsContent( fitDetentEnabled: Bool = true, _ otherDetents: Set = [] ) -> some View { if fitDetentEnabled { modifier(FitContentPresentationDetentViewModifier(otherDetents: otherDetents)) } else { presentationDetents(otherDetents) } } @ViewBuilder func presentationDetentFitsContent( fitDetentEnabled: Bool = true, _ otherDetents: Set = [], selection: Binding ) -> some View { if fitDetentEnabled { modifier(FitContentPresentationDetentViewModifier(otherDetents: otherDetents, selection: selection)) } else { presentationDetents(otherDetents, selection: selection) } } } private struct SheetHeightKey: PreferenceKey { static var defaultValue: CGFloat = 500 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/FormChevron.swift ================================================ // // FormChevron.swift // Mlem // // Created by Sjmarf on 25/08/2024. // import SwiftUI public struct FormChevron: View { let content: Content public init(@ViewBuilder content: () -> Content) { self.content = content() } public var body: some View { HStack(spacing: 0) { content .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "chevron.forward") .imageScale(.small) .foregroundStyle(.themedTertiary) .fontWeight(.semibold) } .contentShape(.rect) } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/KeyboardAwarePadding.swift ================================================ // // KeyboardAwarePadding.swift // ComponentViews // // Created by Sjmarf on 2025-05-27. // import Combine import SwiftUI // https://stackoverflow.com/a/59098816/17629371 struct KeyboardAwareModifier: ViewModifier { let removePaddingOnDismiss: Bool @State private var keyboardHeight: CGFloat = 0 private var keyboardHeightPublisher: AnyPublisher { Publishers.Merge( NotificationCenter.default .publisher(for: UIResponder.keyboardWillShowNotification) .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue } .map(\.cgRectValue.height), NotificationCenter.default .publisher(for: UIResponder.keyboardWillHideNotification) .map { _ in CGFloat(0) } ).eraseToAnyPublisher() } func body(content: Content) -> some View { content .padding(.bottom, keyboardHeight) .onReceive(keyboardHeightPublisher) { if removePaddingOnDismiss || $0 > 0 { keyboardHeight = $0 } } } } public extension View { func keyboardAwarePadding(removePaddingOnDismiss: Bool = true) -> some View { ModifiedContent(content: self, modifier: KeyboardAwareModifier(removePaddingOnDismiss: removePaddingOnDismiss)) } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/Line.swift ================================================ // // Line.swift // Mlem // // Created by Sjmarf on 09/06/2024. // import SwiftUI public struct Line: Shape { public init() {} public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: rect.width, y: 0)) return path } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/MockTextView.swift ================================================ // // MockTextView.swift // Mlem // // Created by Eric Andrews on 2025-01-27. // import SwiftUI import Theming public struct MockTextView: View { @Environment(\.palette) var palette let beginOpacity: CGFloat let endOpacity: CGFloat public init(beginOpacity: CGFloat? = nil, endOpacity: CGFloat? = nil) { self.beginOpacity = beginOpacity ?? 0.55 self.endOpacity = endOpacity ?? 0.45 } public var body: some View { Capsule() .fill(LinearGradient( colors: [ palette.label.secondary.opacity(beginOpacity), palette.label.secondary.opacity(endOpacity) ], startPoint: .leading, endPoint: .trailing )) } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/OptimalHeightLayout.swift ================================================ // // OptimalHeightLayout.swift // Mlem // // Created by Sjmarf on 2024-12-23. // import SwiftUI // https://stackoverflow.com/a/77631512/17629371 public struct OptimalHeightLayout: Layout { public init() {} public func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { let result: CGSize if let firstSubview = subviews.first { let containerWidth = proposal.width ?? .infinity let size = firstSubview.sizeThatFits(.init(width: containerWidth, height: nil)) result = CGSize(width: containerWidth, height: size.height) } else { result = .zero } return result } public func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { if let firstSubview = subviews.first { firstSubview.place( at: CGPoint(x: bounds.minX, y: bounds.minY), proposal: .init(width: bounds.width, height: bounds.height) ) } } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/View+GlassProminentButtonStyle.swift ================================================ // // File.swift // ComponentViews // // Created by Sjmarf on 2025-09-11. // import SwiftUI public extension View { @ViewBuilder func glassProminentButtonStyle() -> some View { if #available(iOS 26, *) { buttonStyle(.glassProminent) } else { self } } } ================================================ FILE: Mlem/Packages/ComponentViews/Sources/ComponentViews/View+VersionAwareDialog.swift ================================================ // // View+VersionAwareDialog.swift // ComponentViews // // Created by Sjmarf on 2025-08-23. // import SwiftUI // Acts as a `.alert()` on iOS 26 and above and a `.confirmationDialog()` otherwise public extension View { @ViewBuilder func versionAwareDialog( _ title: LocalizedStringResource, isPresented: Binding, @ViewBuilder actions: @escaping () -> some View ) -> some View { versionAwareDialog(String(localized: title), isPresented: isPresented, actions: actions) {} } @_disfavoredOverload @ViewBuilder func versionAwareDialog( _ title: String, isPresented: Binding, @ViewBuilder actions: @escaping () -> some View ) -> some View { if #available(iOS 26, *) { alert(title, isPresented: isPresented, actions: actions) } else { confirmationDialog(title, isPresented: isPresented, actions: actions) { Text(title) } } } @ViewBuilder func versionAwareDialog( _ title: LocalizedStringResource, isPresented: Binding, @ViewBuilder actions: @escaping () -> some View, @ViewBuilder message: @escaping () -> some View ) -> some View { versionAwareDialog(String(localized: title), isPresented: isPresented, actions: actions, message: message) } @_disfavoredOverload @ViewBuilder func versionAwareDialog( _ title: String, isPresented: Binding, @ViewBuilder actions: @escaping () -> some View, @ViewBuilder message: @escaping () -> some View ) -> some View { if #available(iOS 26, *) { alert(title, isPresented: isPresented, actions: actions, message: message) } else { confirmationDialog(title, isPresented: isPresented, actions: actions, message: message) } } } ================================================ FILE: Mlem/Packages/FediverseEvents/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/FediverseEvents/Package.resolved ================================================ { "originHash" : "433eb46e437fba071b47444bcf712c57872e1973de4812d146c7db18e50834e2", "pins" : [ { "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", "version" : "1.5.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke.git", "state" : { "revision" : "0ead44350d2737db384908569c012fe67c421e4d", "version" : "12.8.0" } }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", "version" : "5.21.0" } }, { "identity" : "sdwebimagewebpcoder", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", "state" : { "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", "version" : "0.14.6" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/FediverseEvents/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "FediverseEvents", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "FediverseEvents", targets: ["FediverseEvents"] ) ], dependencies: [ .package(path: "../MlemLogger"), .package(path: "../Rest") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "FediverseEvents", dependencies: [ .byName(name: "Rest"), .byName(name: "MlemLogger") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("FullTypedThrows"), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/FediverseEvents/Sources/FediverseEvents/Event.swift ================================================ // // Event.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import Foundation public struct Event: Codable, Identifiable { public let id: String public let name: String public let start: Date public let end: Date public let endpoints: EventEndpoints public let logos: [EventLogo] public let social: [EventSocial] } public struct EventEndpoints: Codable { public let open: URL? public let auth_open: URL? public let more_info: URL? } public struct EventLogo: Codable { public let type: String // Mime type, e.g. "image/png" public let url: URL public let size: String // E.g. "512x512" } public struct EventSocial: Codable { public let icon: EventSocialIcon public let label: String public let url: URL } public enum EventSocialIcon: String, Codable { case mastodon, lemmy, matrix, discord, other } ================================================ FILE: Mlem/Packages/FediverseEvents/Sources/FediverseEvents/EventsClient+Requests.swift ================================================ // // EventsClient+Requests.swift // Mlem // // Created by Sjmarf on 2026-04-23. // extension EventsClient { public func listEvents() async throws -> [Event] { let response = try await perform(ListEventsRequest()) return response.events } } ================================================ FILE: Mlem/Packages/FediverseEvents/Sources/FediverseEvents/EventsClient.swift ================================================ // // File.swift // FediverseEvents // // Created by Sjmarf on 2026-04-21. // import Foundation import Observation import Rest public enum EventsEnvironment { case qualityControl, production var address: URL { switch self { case .production: .init(string: "https://api.fediverse.events")! case .qualityControl: .init(string: "https://test-api.fediverse.events")! } } } @Observable public final class EventsClient { internal let restClient = RestClient(convertParamsToSnakeCase: false, decoder: .defaultDecoder) public internal(set) var environment: EventsEnvironment = .production internal var baseUrl: URL { environment.address } public init() {} public func changeEnvironment(to environment: EventsEnvironment) { self.environment = environment } @discardableResult internal func perform(_ request: Request) async throws -> Request.Response { return try await restClient.perform(baseUrl: baseUrl, request, token: nil) } } ================================================ FILE: Mlem/Packages/FediverseEvents/Sources/FediverseEvents/Requests/ListEventsRequest.swift ================================================ // // ListEventsRequest.swift // Mlem // // Created by Sjmarf on 2026-04-23. // import Rest internal struct ListEventsRequest: GetRequest { typealias Parameters = Never let path: String = "v1/events" let parameters: Parameters? = nil struct Response: Codable { let events: [Event] } } ================================================ FILE: Mlem/Packages/Haptics/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Haptics", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Haptics", targets: ["Haptics"] ) ], dependencies: [ .package(path: "../MlemLogger") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Haptics", dependencies: [ .byName(name: "MlemLogger") ], resources: [.process("Resources")], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Haptic.swift ================================================ // // Haptic.swift // Mlem // // Created by Eric Andrews on 2023-08-02. // import Foundation /// Enumerates all custom defined haptics used in the app. The raw value of a case corresponds to the name of the file it is stored in. public enum Haptic: String, CaseIterable { /// Very gentle tap. Used for subtle feedback--things like crossing a swipe boundary case gentleInfo = "Gentle Info" /// Slightly firmer tap. Used for less subtle feedback--crossing a second swipe boundary, dropping a widget case firmInfo = "Firm Info" /// Mushy, gentle tap. Used for extremely subtle feedback case mushyInfo = "Mushy Info" /// Rigid tap. Used for subtle feedback on "clickier" things case rigidInfo = "Rigid Info" /// Success notification for extremely common, low-priority successes--dropping a widget, upvoting a post case lightSuccess = "Light Success" /// Standard success notification /// NOTE: this is a gentleInfo and a firmerInfo played in quick succession case success = "Success" /// Success notification for destructive events like unsubscribing or deleting case destructiveSuccess = "Destructive Success" /// Success notification for events like blocking a user, sending a report case violentSuccess = "Violent Success" /// Failure notification case failure = "Failure" } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/HapticError.swift ================================================ // // File.swift // Haptics // // Created by Sjmarf on 2025-05-28. // import Foundation public enum HapticError: Error, CustomStringConvertible { case failedToStartEngine(Error) case failedToStartPlayer(Error) case failedToMakePlayer(Error) case noPlayer(Haptic) public var description: String { switch self { case let .failedToStartEngine(error): "HapticManager engine failed to start. Underlying error: \(String(describing: error))" case let .failedToStartPlayer(error): "HapticManager player failed to start. Underlying error: \(String(describing: error))" case let .failedToMakePlayer(error): "HapticManager failed to make player. Underlying error: \(String(describing: error))" case let .noPlayer(haptic): "No player available for \(haptic.rawValue)" } } } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/HapticLevel.swift ================================================ // // HapticPriority.swift // Mlem // // Created by Eric Andrews on 2024-06-10. // import Foundation public enum HapticTier: String, CaseIterable, Comparable, Codable { case high case low var intValue: Int { switch self { case .high: return 2 case .low: return 1 } } public static func < (lhs: HapticTier, rhs: HapticTier) -> Bool { lhs.intValue < rhs.intValue } } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/HapticManager.swift ================================================ // // HapticManager.swift // Mlem // // Created by Eric Andrews on 2023-07-17. // import AVFAudio import CoreHaptics import Foundation import MlemLogger import os import SwiftUI @Observable public class HapticManager { private let log: Logger = .mlemLogger() static let mainInternal: HapticManager = .init() @available(*, deprecated, message: "Access the HapticManager from the environment instead.") public static var main: HapticManager { mainInternal } // Config var errorHandler: (HapticError) -> Void = { Logger.universal.error("Haptic error: \($0.description)") } var maximumHapticTier: HapticTier? // generators/engines private let rigidImpactGenerator: UIImpactFeedbackGenerator = .init(style: .rigid) private let notificationGenerator: UINotificationFeedbackGenerator = .init() private var hapticEngine: CHHapticEngine? private var players: [Haptic: CHHapticPatternPlayer] = .init() private init() { // create and start the engine if this device supports haptics self.hapticEngine = initEngine() // load all the haptic files into players to avoid lag on first play caused by slow disk read loadPlayers() // if the engine stops, tell us why hapticEngine?.stoppedHandler = { reason in self.log.warning("The engine stopped: \(String(describing: reason))") } // if the engine fails, attempt to restart hapticEngine?.resetHandler = { [weak self] in (self?.log ?? Logger.universal).warning("The haptic engine reset") self?.handleFailure() } log.info("Initialized haptic engine") } func startEngine() { if let engine = hapticEngine { do { try engine.start() } catch { // silently log error, re-create the engine, and retry errorHandler(.failedToStartEngine(error)) hapticEngine = initEngine() loadPlayers() } } } /// Plays a haptic if the given priority is equal to or lower than the current haptic level public func play(haptic: Haptic, tier: HapticTier) { Task(priority: .userInitiated) { if hapticEngine == nil { log.warning("\(haptic.rawValue) not played (no engine)") return } if tier.intValue <= (maximumHapticTier?.intValue ?? 0) { do { guard let player = players[haptic] else { throw HapticError.noPlayer(haptic) } try player.start(atTime: .zero) } catch { errorHandler(.failedToStartPlayer(error)) handleFailure(with: haptic, error: error as? HapticError) } } else { log.debug("\(haptic.rawValue) not played (priority \(tier.intValue) > \(self.maximumHapticTier?.intValue ?? 0))") } } } /// If this device supports haptics, creates and returns a CHHaptic engine; otherwise returns nil private func initEngine() -> CHHapticEngine? { if CHHapticEngine.capabilitiesForHardware().supportsHaptics { do { let ret = try CHHapticEngine(audioSession: AVAudioSession.sharedInstance()) try ret.start() return ret } catch { errorHandler(.failedToStartEngine(error)) } } return nil } /// Restarts the engine if it is present, creates it if not, starts the engine, and plays the given haptic private func handleFailure(with haptic: Haptic? = nil, error: HapticError? = nil) { if hapticEngine == nil { hapticEngine = initEngine() } if let error, case let .noPlayer(failedHaptic) = error { assertionFailure("No player for \(failedHaptic)") loadPlayers() } if hapticEngine != nil { startEngine() // attempt to play the pattern that failed, but don't do anything on failure here if let haptic { guard let player = players[haptic] else { assertionFailure("No player for \(haptic) in failure handler") errorHandler(.noPlayer(haptic)) return } do { try player.start(atTime: .zero) } catch { errorHandler(.failedToStartPlayer(error)) } } } } private func loadPlayers() { // load all the haptic files into players to avoid lag on first play caused by slow disk read for haptic in Haptic.allCases { do { guard let path = Bundle.module.path(forResource: haptic.rawValue, ofType: "ahap") else { assertionFailure("No haptic file found for \(haptic.rawValue)") continue } let file = URL(filePath: path) players[haptic] = try hapticEngine?.makePlayer(with: .init(contentsOf: file)) } catch { assertionFailure("Failed to initialize haptic player") errorHandler(.failedToMakePlayer(error)) } } } } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Failure/Failure.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.4 }, { "ParameterID": "HapticSharpness", "ParameterValue": 1.0 } ] } }, { "Event": { "Time": 0.1, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.8 } ] } }, { "Event": { "Time": 0.2, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.8 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.6 } ] } }, { "Event": { "Time": 0.3, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.4 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Info/Firm Info.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.75 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Info/Gentle Info.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.45 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.55 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Info/Mushy Info.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.45 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.2 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Info/Rigid Info.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 1 }, { "ParameterID": "HapticSharpness", "ParameterValue": 1 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Success/Destructive Success.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.7 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.8 } ] } }, { "Event": { "Time": 0.2, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.9 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.6 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Success/Light Success.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.75 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.9 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Success/Success.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.45 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.55 } ] } }, { "Event": { "Time": 0.1, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.75 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.9 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/Resources/Success/Violent Success.ahap ================================================ { "Version": 1, "Pattern": [ { "Event": { "Time": 0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.8 }, { "ParameterID": "HapticSharpness", "ParameterValue": 1.0 } ] } }, { "Event": { "Time": 0.55, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.4 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.5 } ] } }, { "Event": { "Time": 0.75, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.4 } ] } }, { "Event": { "Time": 0.76, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.6 } ] } } ] } ================================================ FILE: Mlem/Packages/Haptics/Sources/Haptics/View+Haptic.swift ================================================ // // File.swift // Haptics // // Created by Sjmarf on 2025-05-28. // import SwiftUI private struct HapticConfigurationViewModifier: ViewModifier { @Environment(\.scenePhase) var scenePhase func body(content: Content) -> some View { content .environment(HapticManager.mainInternal) .onChange(of: scenePhase, initial: false) { if scenePhase == .active { // When the app moves into the background, the haptic engine stops. // This ensures the engine is started before a haptic is played to avoid a short lag while the engine starts HapticManager.mainInternal.startEngine() } } } } public extension View { func hapticConfiguration( maximumHapticTier: HapticTier?, errorHandler: @escaping (HapticError) -> Void ) -> some View { HapticManager.mainInternal.errorHandler = errorHandler HapticManager.mainInternal.maximumHapticTier = maximumHapticTier return modifier(HapticConfigurationViewModifier()) } } ================================================ FILE: Mlem/Packages/Icons/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Icons", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Icons", targets: ["Icons"] ) ], dependencies: [], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Icons", dependencies: [], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+Fediseer.swift ================================================ // // Icon+Fediseer.swift // Icons // // Created by Sjmarf on 2025-04-08. // import Foundation public extension Icon { struct FediseerIcons { public let fediseer: Icon = .init("shield.checkered") public let guarantee: Icon = .init("checkmark.seal") public let unguarantee: Icon = .init("xmark.seal") public let endorsement: Icon = .init("signature") public let hesitation: Icon = .init("exclamationmark.triangle") public let censure: Icon = .init("exclamationmark.octagon") } static let fediseer: FediseerIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+General.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-06. // import Foundation public extension Icon { struct GeneralIcons { public let circle: Icon = .init("circle") public let success: Icon = .applyCircle("checkmark") public let failure: Icon = .applyCircle("xmark") public let warning: Icon = .init("exclamationmark.triangle") public let error: Icon = .init("exclamationmark.circle") public let hide: Icon = .init("eye.slash") public let show: Icon = .init("eye") public let time: Icon = .init("clock") public let updateTime: Icon = .init("clock.arrow.2.circlepath") public let close: Icon = .applyCircle("multiply") public let add: Icon = .applyCircle("plus") public let remove: Icon = .applyCircle("minus") public let website: Icon = .init("globe") public let undo: Icon = .applyCircle("arrow.uturn.backward") public let redo: Icon = .applyCircle("arrow.uturn.forward") public let share: Icon = .init("square.and.arrow.up") public let search: Icon = .custom { variant in switch variant { case .active: "text.magnifyingglass" default: "magnifyingglass" } } public let settings: Icon = .init("gear") public let filter: Icon = .init("line.3.horizontal.decrease.circle") public let filterMenu: Icon = .init(.custom { _ in if #available(iOS 26, *) { "line.3.horizontal.decrease" } else { "line.3.horizontal.decrease.circle" } }) public let menu: Icon = .init("ellipsis") public let toolbarMenu: Icon = .init(.custom { _ in if #available(iOS 26, *) { "ellipsis" } else { "ellipsis.circle" } }) public let configure: Icon = .init("slider.horizontal.3") public let `import`: Icon = .init("square.and.arrow.down") public let export: Icon = .init("square.and.arrow.up") public let edit: Icon = .applyCircle("pencil") public let delete: Icon = .init("trash") public let undelete: Icon = .init("trash.slash") public let copy: Icon = .init("doc.on.doc") public let paste: Icon = .init("doc.on.clipboard") public let signOut: Icon = .init("minus.circle") public let attachment: Icon = .init("paperclip") public let refresh: Icon = .init("arrow.clockwise") public let select: Icon = .init("selection.pin.in.out") public let chooseFile: Icon = .init("folder") public let chooseImage: Icon = .init("photo") public let image: Icon = .init("photo") public let photoLibary: Icon = .init("photo.on.rectangle.angled") public let createImage: Icon = .init("scanner") public let play: Icon = .init("play") public let playCircle: Icon = .applyCircle("play.circle") public let pause: Icon = .init("pause") @inlinable public var muted: Icon { mute } public let mute: Icon = .init("speaker.slash") public let unmute: Icon = .init("speaker.wave.2") public let collapse: Icon = .custom { variant in switch variant { case .none: "arrow.down.and.line.horizontal.and.arrow.up" case .active: "minus.square.fill" case .inactive: "minus.square" } } public let expand: Icon = .custom { variant in switch variant { case .none: "arrow.up.and.line.horizontal.and.arrow.down" case .active: "plus.square.fill" case .inactive: "plus.square" } } public let embedding: Icon = .init("app.connected.to.app.below.fill") public let movie: Icon = .init("film") public let email: Icon = .init("envelope") public let action: Icon = .init("diamond") public let missing: Icon = .init("questionmark.square.dashed") public let connection: Icon = .init("antenna.radiowaves.left.and.right") public let haptics: Icon = .init("circle.dotted.and.circle") public let noWifi: Icon = .init("wifi.slash") public let browser: Icon = .init("safari") public let dropDown: Icon = .applyCircle("chevron.down") public let noFile: Icon = .init("questionmark.folder") public let forward: Icon = .init("chevron.forward") public let backward: Icon = .init("chevron.backward") public let security: Icon = .init("key") public let link: Icon = .init("link") public let info: Icon = .init("info.circle") public let cloudflare: Icon = .init("cloud.bolt") } static let general: GeneralIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+Lemmy.swift ================================================ // // Icons+StaticValues.swift // Icons // // Created by Sjmarf on 2025-04-06. // import Foundation public extension Icon { struct LemmyIcons { // MARK: - Votes @inlinable public var upvoted: Icon { addUpvote } @inlinable public var downvoted: Icon { addDownvote } public let addUpvote: Icon = .applySquare("arrow.up") public let addDownvote: Icon = .applySquare("arrow.down") public let removeUpvote: Icon = .custom { variant in switch variant { case .active: "minus.square.fill" case .inactive: "minus.square" default: "arrow.up.slash" } } public let removeDownvote: Icon = .custom { variant in switch variant { case .active: "minus.square.fill" case .inactive: "minus.square" default: "arrow.down.slash" } } public let votes: Icon = .applySquare("arrow.up.arrow.down") public let scoreCounter: Icon = .init("arrow.up.arrow.down.circle") public let upvoteCounter: Icon = .init("arrow.up.circle") public let downvoteCounter: Icon = .init("arrow.down.circle") // MARK: - Reply/Send public let reply: Icon = .init("arrowshape.turn.up.left") public let replyCounter: Icon = .init("arrowshape.turn.up.left.circle") public let send: Icon = .init("paperplane") public let sendMessage: Icon = .init("arrow.up") // MARK: - Save @inlinable public var saved: Icon { addSave } public let addSave: Icon = .init("bookmark") public let removeSave: Icon = .init("bookmark.slash") // MARK: - Mark Read public let markRead: Icon = .init("envelope.open") public let markUnread: Icon = .init("envelope") // MARK: - Block public let block: Icon = .init("hand.raised") public let unblock: Icon = .init("hand.raised.slash") // MARK: - Pin @inlinable public var pinned: Icon { addPin } public let addPin: Icon = .init("pin") public let removePin: Icon = .init("pin.slash") // MARK: - Lock @inlinable public var locked: Icon { addLock } public let addLock: Icon = .init("lock") public let removeLock: Icon = .init("lock.open") // MARK: - Remove @inlinable public var removed: Icon { remove } public let remove: Icon = .init("xmark.bin") public let restore: Icon = .init("arrow.up.bin") // MARK: - Purge @inlinable public var purged: Icon { purge } public let purge: Icon = .init("burn") // MARK: - Ban @inlinable public var bannedFromInstance: Icon { banFromInstance } public let banFromInstance: Icon = .init("xmark.square") public let unbanFromInstance: Icon = .init("checkmark.square") @inlinable public var bannedFromCommunity: Icon { banFromCommunity } public let banFromCommunity: Icon = .init("xmark.shield") public let unbanFromCommunity: Icon = .init("checkmark.shield") // MARK: - Subscribe public let subscribed: Icon = .init("checkmark.circle") public let subscribe: Icon = .init("plus.circle") public let unsubscribe: Icon = .init("multiply.circle") public let didUnsubscribe: Icon = .init("person.slash") // MARK: - Subscribe @inlinable public var favorited: Icon { favorite } public let favorite: Icon = .init("star") public let unfavorite: Icon = .init("star.slash") // MARK: - Collapse public let collapseParent: Icon = .applySquare("chevron.up") public let collapseToTop: Icon = .applySquare("arrow.up.to.line") // MARK: - Moderation public let moderation: Icon = .init("shield") public let targetedPerson: Icon = .init("scope") public let administration: Icon = .init("crown") @inlinable public var addModerator: Icon { moderation } public let removeModerator: Icon = .init("shield.slash") public let report: Icon = .init("flag") public let registrationApplication: Icon = .init("list.clipboard") public let modlog: Icon = .init("book.pages") public let transferCommunity: Icon = .init("arrow.right") @inlinable public var addAdministrator: Icon { administration } public let removeAdministrator: Icon = .init("arrowshape.down") // MARK: - Inbox public let mention: Icon = .init("quote.bubble") public let message: Icon = .init("envelope") // MARK: - Misc Post public let post: Icon = .init("doc.plaintext") public let comment: Icon = .init("bubble.left") public let crosspost: Icon = .applyCircle("shuffle") @inlinable public var replies: Icon { comment } public let unreadReplies: Icon = .init("text.bubble") public let textPost: Icon = .init("text.book.closed") public let titleOnlyPost: Icon = .init("character.bubble") public let pollPost: Icon = .init("chart.bar.xaxis") // MARK: - Feeds public let feed: Icon = .init("scroll") public let federatedFeed: Icon = .init("circle.hexagongrid") public let localFeed: Icon = .init("building.2") public let subscribedFeed: Icon = .init("newspaper") public let popularFeed: Icon = .init("chart.line.uptrend.xyaxis") public let suggestedFeed: Icon = .init("lightbulb") @inlinable public var savedFeed: Icon { saved } @inlinable public var moderatedFeed: Icon { moderation } // MARK: - Sort Types public let activeSort: Icon = .init("popcorn") public let hotSort: Icon = .init("flame") public let scaledSort: Icon = .init("arrow.up.left.and.down.right.and.arrow.up.right.and.down.left") public let newSort: Icon = .init("hare") public let oldSort: Icon = .init("tortoise") public let newCommentsSort: Icon = .init("exclamationmark.bubble") public let mostCommentsSort: Icon = .init("bubble.left.and.bubble.right") public let controversialSort: Icon = .init("bolt") public let topSort: Icon = .init("trophy") public let alphabeticalSort: Icon = .init("textformat") public let scoreSort: Icon = .init("star") public let usersSort: Icon = .init("person.2") public let versionSort: Icon = .init("server.rack") // MARK: - Flairs public let developerFlair: Icon = .init("hammer") public let botFlair: Icon = .init("terminal") public let opFlair: Icon = .init("person") public let newAccountFlair: Icon = .init("leaf") public let cakeDay: Icon = .init("birthday.cake") // MARK: - General Concepts public let federation: Icon = .init("point.3.filled.connected.trianglepath.dotted") public let `private`: Icon = .init("lock") public let captcha: Icon = .init("photo") public let instance: Icon = .init("building.2") public let community: Icon = .init("house") public let person: Icon = .init("person") public let inbox: Icon = .init("mail.stack") public let imageProxy: Icon = .init("firewall") public let subscriptionList: Icon = .init("list.bullet") public let tag: Icon = .init("tag") public let event: Icon = .init("crown") @inlinable public var communityAvatar: Icon { community } public let instanceAvatar: Icon = .init("building.2.crop.circle") public let personAvatar: Icon = .init("person.crop.circle") // MARK: - Other public let noContent: Icon = .init("binoculars") public let note: Icon = .init("note.text") public let editNote: Icon = .init("square.and.pencil") public let openAccountSwitcher: Icon = .init("person.crop.rectangle.stack.fill") public let groupAccountSort: Icon = .init("square.stack.3d.up.fill") public let switchAccount: Icon = .init("arrow.left.arrow.right") public let switchAccountAndReload: Icon = .init("arrow.2.circlepath") public let switchAccountAndKeepPlace: Icon = .init("checkmark.diamond") public let visitInstance: Icon = .init("arrow.right") public let logIn: Icon = .init("person.text.rectangle") public let signUp: Icon = .init("pencil.and.list.clipboard") public let jumpButton: Icon = .init("chevron.down") public let jumpToLastPositionButton: Icon = .init("chevron.down.2") public let nsfwTag: Icon = .init("nsfw", source: .custom) public func notificationCount(_ count: Int) -> Icon { .init(count <= 50 ? "\(count).circle.fill" : "exclamationmark.circle.fill") } } static let lemmy: LemmyIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+Markdown.swift ================================================ // // Icon+Markdown.swift // Icons // // Created by Sjmarf on 2025-04-07. // import Foundation public extension Icon { struct MarkdownIcons { public let bold: Icon = .init("bold") public let italic: Icon = .init("italic") public let strikethrough: Icon = .init("strikethrough") public let superscript: Icon = .init("textformat.superscript") public let `subscript`: Icon = .init("textformat.subscript") // Potentially "chevron.left.chevron.right" is better, it's iOS 18+ though public let inlineCode: Icon = .init("chevron.left.forwardslash.chevron.right") public let quote: Icon = .init("quote.opening") public let heading: Icon = .init("textformat.size") public let spoiler: Icon = .init("eye") public let codeBlock: Icon = .init("text.viewfinder") @inlinable public var insertLink: Icon { .general.link } @inlinable public var uploadImage: Icon { .general.chooseImage } } static let markdown: MarkdownIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+Settings.swift ================================================ // // Icon+Settings.swift // Icons // // Created by Sjmarf on 2025-04-06. // import Foundation public extension Icon { struct SettingsIcons { public let hideRead: Icon = .init("book") @inlinable public var showRead: Icon { hideRead } public let postSize: Icon = .init("rectangle.expand.vertical") public let postSizeCompact: Icon = .init("rectangle.grid.1x2") public let postSizeTiled: Icon = .init("rectangle.grid.2x2") public let postSizeHeadline: Icon = .init("rectangle") public let postSizeLarge: Icon = .init("text.below.photo") public let blurNsfw: Icon = .init("eye.trianglebadge.exclamationmark") public let upvoteOnSave: Icon = .init("arrow.up.heart") public let readIndicatorSetting: Icon = .init("book") public let readIndicatorBarSetting: Icon = .init("rectangle.leftthird.inset.filled") public let profileTabSettings: Icon = .init("person.text.rectangle") public let nicknameField: Icon = .init("rectangle.and.pencil.and.ellipsis") public let label: Icon = .init("tag") public let unreadBadge: Icon = .init("envelope.badge") public let showAvatar: Icon = .init("person.fill.questionmark") public let thumbnail: Icon = .init("photo") public let author: Icon = .init("signature") public let leftRight: Icon = .init("arrow.left.arrow.right") public let developerMode: Icon = .init("wrench.adjustable.fill") public let limitImageHeightSetting: Icon = .init("rectangle.compress.vertical") public let appLockSettings: Icon = .init("lock.app.dashed") public let sidebar: Icon = .init("sidebar.left") public let infiniteScroll: Icon = .init("infinity") public let markReadOnScroll: Icon = .init("book") public let confirmImageUploads: Icon = .init("photo.badge.checkmark") public let swipeActions: Icon = .init("inset.filled.leadinghalf.rectangle") public let swipeAnywhere: Icon = .init("arrow.left") public let importSettings: Icon = .init("folder.badge.gearshape") public let inApp: Icon = .init("house") public let reader: Icon = .init("text.page") public let keywordFilter: Icon = .init("rectangle.and.text.magnifyingglass") public let saveSettings: Icon = .init("document.badge.gearshape") public let restoreSettings: Icon = .init("gearshape.arrow.trianglehead.2.clockwise.rotate.90") public let menuItems: Icon = .init("filemenu.and.selection") public let systemMode: Icon = .init("circle.lefthalf.filled") public let lightMode: Icon = .init("sun.max") public let darkMode: Icon = .init("moon") public let compactComments: Icon = .init("rectangle.compress.vertical") public let interactionBar: Icon = .init("square.and.line.vertical.and.square.fill") public let commentDepth: Icon = .init("text.append") public let qualifiedLabel: Icon = .init("at") public let right: Icon = .init("arrow.right") public let left: Icon = .init("arrow.left") public let center: Icon = .init("dot") public let zoomSlider: Icon = .init("arrow.up.and.down.and.sparkles") public let language: Icon = .init("globe") public let settingsIcons: Icon = .init("fleuron") public let privacy: Icon = .init("hand.raised") public let openExternalLinks: Icon = .init("arrow.up.right") public let tappableLinks: Icon = .init("hand.tap") public let general: Icon = .init("gear") public let safety: Icon = .init("shield.lefthalf.filled") public let accessibility: Icon = .init("hand.point.up.braille.fill") public let sorting: Icon = .init("arrow.up.and.down.text.horizontal") public let tabBar: Icon = .init("platter.filled.bottom.iphone") public let advanced: Icon = .init("gearshape.2.fill") public let localAccountOptions: Icon = .init("iphone") public let eula: Icon = .init("doc.plaintext") public let licence: Icon = .init("doc") public let ask: Icon = .init("questionmark.circle") public let jumpButton: Icon = .init("chevron.down.circle") public let longPress: Icon = .init("hand.point.up.left.fill") public let imageViewer: Icon = .init("rectangle.portrait.center.inset.filled") public let imageViewerControls: Icon = .init("ellipsis") public let imageViewerDismissSensitivity: Icon = .applySquare("arrow.down.to.line") } static let settings: SettingsIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon+Uptime.swift ================================================ // // Icon+Uptime.swift // Icons // // Created by Sjmarf on 2025-04-08. // import Foundation public extension Icon { struct UptimeIcons { public let offline: Icon = .init("xmark.circle") public let online: Icon = .init("checkmark.circle") public let outage: Icon = .init("exclamationmark.circle") } static let uptime: UptimeIcons = .init() } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/Icon.swift ================================================ // // Icon.swift // Mlem // // Created by Sjmarf on 2024-12-23. // import SwiftUI public struct Icon: Hashable { public enum Source { case system, custom } public enum Variant: Hashable { case active, inactive } public enum VariantApplicationStrategy { case baseOnly(name: String) case applySquare(name: String) case applyCircle(name: String) case custom((Variant?) -> String) // swiftlint:disable:next cyclomatic_complexity func computeImageName(variant: Variant?) -> String { switch self { case let .baseOnly(name): switch variant { case .active: "\(name).fill" default: name } case let .applySquare(name): switch variant { case .inactive: "\(name).square" case .active: "\(name).square.fill" case nil: name } case let .applyCircle(name): switch variant { case .active: "\(name).circle.fill" case .inactive: "\(name).circle" case nil: name } case let .custom(value): value(variant) } } } let id: UUID let variantApplicationStrategy: VariantApplicationStrategy let source: Source var appliedVariant: Variant? public init(_ variantApplicationStrategy: VariantApplicationStrategy, source: Source = .system) { self.id = .init() self.variantApplicationStrategy = variantApplicationStrategy self.source = source } public init(_ name: String, source: Source = .system) { self.init(.baseOnly(name: name), source: source) } public func computeImageName() -> String { variantApplicationStrategy.computeImageName(variant: appliedVariant) } public static func applySquare(_ name: String) -> Self { self.init(.applySquare(name: name)) } public static func applyCircle(_ name: String) -> Self { self.init(.applyCircle(name: name)) } public static func custom(_ customStrategy: @escaping (Variant?) -> String) -> Self { self.init(.custom(customStrategy)) } private func applyVariant(_ newVariant: Variant) -> Icon { var new = self new.appliedVariant = newVariant return new } public func representingState(active state: Bool) -> Icon { applyVariant(state ? .active : .inactive) } public func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(appliedVariant) } public static func == (lhs: Icon, rhs: Icon) -> Bool { lhs.id == rhs.id && lhs.appliedVariant == rhs.appliedVariant } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Button+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-10. // import SwiftUI public extension Button where Label == SwiftUI.Label { nonisolated init( _ title: LocalizedStringResource, icon: Icon, role: ButtonRole? = nil, action: @escaping @MainActor () -> Void ) { self.init(LocalizedStringKey(title.key), systemImage: icon.computeImageName(), role: role, action: action) } @_disfavoredOverload nonisolated init( _ title: String, icon: Icon, role: ButtonRole? = nil, action: @escaping @MainActor () -> Void ) { self.init(title, systemImage: icon.computeImageName(), role: role, action: action) } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Image+Extensions.swift ================================================ // // Image+Extensions.swift // Icons // // Created by Sjmarf on 2025-04-06. // import SwiftUI public extension Image { init(icon: Icon) { let name = icon.computeImageName() switch icon.source { case .custom: self.init(name) case .system: self.init(systemName: name) } } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Label+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-10. // import SwiftUI public extension Label where Title == Text, Icon == Image { init(_ title: LocalizedStringKey, icon: Icons.Icon) { switch icon.source { case .system: self.init(title, systemImage: icon.computeImageName()) case .custom: self.init(title, image: icon.computeImageName()) } } @_disfavoredOverload init(_ title: some StringProtocol, icon: Icons.Icon) { switch icon.source { case .system: self.init(title, systemImage: icon.computeImageName()) case .custom: self.init(title, image: icon.computeImageName()) } } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Menu+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-10. // import SwiftUI public extension Menu where Label == SwiftUI.Label { nonisolated init(_ title: LocalizedStringResource, icon: Icon, @ViewBuilder content: () -> Content) { self.init(title.key, systemImage: icon.computeImageName(), content: content) } @_disfavoredOverload nonisolated init(_ title: some StringProtocol, icon: Icon, @ViewBuilder content: () -> Content) { self.init(title, systemImage: icon.computeImageName(), content: content) } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Picker+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-14. // import SwiftUI public extension Picker where Label == SwiftUI.Label { nonisolated init( _ titleKey: LocalizedStringKey, icon: Icon, selection: Binding, @ViewBuilder content: () -> Content ) { self.init(titleKey, systemImage: icon.computeImageName(), selection: selection, content: content) } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/Toggle+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-12. // import SwiftUI public extension Toggle where Label == SwiftUI.Label { nonisolated init(_ title: LocalizedStringResource, icon: Icon, isOn: Binding) { self.init(LocalizedStringKey(title.key), systemImage: icon.computeImageName(), isOn: isOn) } @_disfavoredOverload nonisolated init(_ title: some StringProtocol, icon: Icon, isOn: Binding) { self.init(title, systemImage: icon.computeImageName(), isOn: isOn) } } ================================================ FILE: Mlem/Packages/Icons/Sources/Icons/ViewExtensions/UIImage+Extensions.swift ================================================ // // File.swift // Icons // // Created by Sjmarf on 2025-04-12. // import UIKit public extension UIImage { convenience init?(icon: Icon) { self.init(systemName: icon.computeImageName()) } } ================================================ FILE: Mlem/Packages/Media/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/Media/Package.resolved ================================================ { "originHash" : "9f566dd1a015f0bc4788be19b84e8a555f2d5656e28b7614517b9d04c3ddb2ba", "pins" : [ { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", "state" : { "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", "version" : "0.2.0" } }, { "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", "version" : "1.5.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke.git", "state" : { "revision" : "0ead44350d2737db384908569c012fe67c421e4d", "version" : "12.8.0" } }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", "version" : "5.21.0" } }, { "identity" : "sdwebimagewebpcoder", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", "state" : { "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", "version" : "0.14.6" } }, { "identity" : "semaphore", "kind" : "remoteSourceControl", "location" : "https://github.com/groue/Semaphore.git", "state" : { "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", "version" : "0.1.0" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/Media/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Media", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Media", targets: ["Media"] ) ], dependencies: [ .package(url: "https://github.com/kean/Nuke.git", .upToNextMajor(from: "12.6.0")), .package(url: "https://github.com/SDWebImage/SDWebImageWebPCoder", .upToNextMajor(from: "0.14.6")), .package(path: "../MlemMiddleware"), .package(path: "../Rest"), .package(path: "../Theming") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Media", dependencies: [ .product(name: "Nuke", package: "Nuke"), .product(name: "SDWebImageWebPCoder", package: "SDWebImageWebPCoder"), .byName(name: "MlemMiddleware"), .byName(name: "Rest"), .byName(name: "Theming") ], swiftSettings: [.swiftLanguageMode(.v5)] ) ] ) ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/Animated/AnimatedImageView.swift ================================================ // // WebpView.swift // Mlem // // Created by Eric Andrews on 2024-12-06. // import MlemLogger import os import SDWebImage import SwiftUI @preconcurrency struct AnimatedImageView: UIViewRepresentable { private let log: Logger = .mlemLogger() @Environment(MediaControlState.self) var controlState let data: Data @State var player: SDAnimatedImagePlayer? @State var observer: NSKeyValueObservation? func makeUIView(context: Context) -> SDAnimatedImageView { let imageView = SDAnimatedImageView() imageView.autoPlayAnimatedImage = controlState.animating guard let animatedImage = SDAnimatedImage(data: data) else { log.error("Could not create animated image") return imageView } // gifs and webps can be "animated" with only 1 frame, in which case they should be treated as still images guard animatedImage.animatedImageFrameCount > 1 else { controlState.animationAvailable = false return imageView } if controlState.scrubbingAvailable { // loads all frames, which enables smooth backwards scrubbing Task { animatedImage.preloadAllFrames() } } // compute real time duration Task { var total: TimeInterval = 0 for index in 0 ..< animatedImage.animatedImageFrameCount { total += animatedImage.animatedImageDuration(at: index) } controlState.duration = total } // set up player with observation to update controlState.playbackPosition DispatchQueue.main.async { guard let player = imageView.player else { assertionFailure("ImageView had nil player") return } observer = player.observe(\.currentFrameIndex) { player, _ in controlState.playbackPosition = CGFloat(player.currentFrameIndex) / CGFloat(player.totalFrameCount) } self.player = player } imageView.image = animatedImage // fit parent view imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) return imageView } func updateUIView(_ uiView: SDAnimatedImageView, context: Context) { guard let player else { return } if let scrubTarget = controlState.scrubTarget { if player.isPlaying { player.pausePlaying() } player.seekToFrame( at: .init((scrubTarget * CGFloat(player.totalFrameCount)).rounded()), loopCount: 0 ) } else if controlState.animating != player.isPlaying { if controlState.animating { player.startPlaying() } else { player.pausePlaying() } } } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/Animated/VideoView.swift ================================================ // // VideoView.swift // Mlem // // Created by Eric Andrews on 2024-09-23. // import AVFoundation import AVKit import MlemLogger import NukeVideo import os import SwiftUI struct VideoView: View { private let log: Logger = .mlemLogger() @Environment(MediaControlState.self) var controlState let player: AVQueuePlayer let playerLooper: AVPlayerLooper @State var timescale: CMTimeScale? var timer = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() init(asset: AVAsset) { // set up AVQueuePlayer and AVPlayerLooper to loop the video let playerItem: AVPlayerItem = .init(asset: asset) let player: AVQueuePlayer = .init(playerItem: playerItem) self.player = player self.playerLooper = .init(player: player, templateItem: playerItem) } var body: some View { VideoPlayer(player: player) .disabled(true) .task { // parse whether the video has audio or not before playing so we can appropriately display audio controls do { controlState.audioAvailable = try await player.isAudioAvailable() ?? false } catch { log.error("\(error.localizedDescription)") } } .task { do { guard let asset = player.currentItem?.asset else { assertionFailure("Could not find AVAsset") return } let cmTime = try await asset.load(.duration) controlState.duration = cmTime.seconds timescale = cmTime.timescale } catch { log.error("\(error.localizedDescription)") } } .onChange(of: controlState.animating, initial: true) { if controlState.animating { player.play() } else { player.pause() } } .onChange(of: controlState.muted, initial: true) { player.volume = controlState.muted ? 0 : 1 } .onChange(of: controlState.scrubTarget) { guard let timescale, let duration = controlState.duration, let playerItem = player.currentItem else { assertionFailure("Duration or playerItem not present") return } if let target = controlState.scrubTarget { controlState.animating = false playerItem.seek( to: .init(seconds: target * duration, preferredTimescale: timescale), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero, completionHandler: nil ) } else { controlState.animating = true } } .onReceive(timer) { _ in if let duration = controlState.duration, let playerItem = player.currentItem { let currentTime = playerItem.currentTime().seconds controlState.playbackPosition = currentTime / duration } } } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/CoreMediaView+Logic.swift ================================================ // // MediaView+Helpers.swift // Mlem // // Created by Eric Andrews on 2025-03-10. // import Foundation import SwiftUI import Theming public extension CoreMediaView { // MARK: Types enum AspectRatioBounds { /// Specify an aspect ratio not taller than .vertical and not wider than the .horizontal case bounded(vertical: CGSize?, horizontal: CGSize?) /// Specify an exact aspect ratio case absolute(CGSize) public var defaultSize: CGSize { switch self { case let .bounded(vertical, horizontal): vertical ?? horizontal ?? .init(width: 1, height: 1) case let .absolute(size): size } } public var boundsAreSane: Bool { switch self { case let .bounded(vertical, horizontal): if let vertical, let horizontal { // if both horizontal and vertical bound defined, ensure vertical bound taller than horizontal return vertical.aspectRatio > horizontal.aspectRatio } else { return true } case .absolute: return true } } public static var imageDefault: AspectRatioBounds { .bounded(vertical: .init(width: 4, height: 5), horizontal: nil) } public static var absoluteSquare: AspectRatioBounds { .absolute(.init(width: 1, height: 1)) } } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/CoreMediaView.swift ================================================ // // CoreMediaView.swift // Mlem // // Created by Eric Andrews on 2025-04-20. // import SwiftUI /// Struct to actually render the media. /// This is declared as its own struct to prevent state updates from the parent view causing unwanted behavior. public struct CoreMediaView: View { @Environment(MediaControlState.self) var controlState let media: MediaType let aspectRatio: CGSize let contentMode: ContentMode public init(media: MediaType, aspectRatio: CGSize, contentMode: ContentMode) { self.media = media self.aspectRatio = aspectRatio self.contentMode = contentMode } var uiImage: UIImage { media.image } public var body: some View { // WARNING: the combination of .aspectRatio and .frame modifiers in this view is very precise and // breaks easily. If you have to modify it, be sure to thoroughly regression test! // More info here: https://alejandromp.com/development/blog/image-aspectratio-without-frames/ Group { if contentMode == .fit { content } else if contentMode == .fill { content .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity ) } } .aspectRatio(aspectRatio, contentMode: contentMode) .allowsHitTesting(false) } @ViewBuilder var content: some View { if controlState.canAnimate, media.isAnimated { animatedContent } else { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: contentMode) } } @ViewBuilder var animatedContent: some View { Group { switch media { case let .video(_, animated): VideoView(asset: animated) case let .animated(_, animated): AnimatedImageView(data: animated) default: EmptyView() } } .aspectRatio(uiImage.size, contentMode: contentMode) } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/MediaControlState.swift ================================================ // // MediaControlState.swift // Mlem // // Created by Eric Andrews on 2025-02-05. // import Foundation import Observation @Observable public class MediaControlState { /// True if the media should be blurred, false otherwise public var blurred: Bool /// True if the media, if animated, should be playing public var animating: Bool /// True if the media, if animated, should autoplay; this is the initial value of `animating` public let autoplay: Bool /// True if the media should animate, false to suppress animation public var enableAnimation: Bool /// True if the media, if audio available, should not play audio public var muted: Bool /// Target playback position of animated media public var scrubTarget: CGFloat? /// True if the media is in a context where scrubbing is possible. Used to determine whether to aggressively /// load image data into memory to improve scrubbing performance. /// - Warning: This does NOT enable any form of scrubbing control! It only informs the underlying view whether to prepare /// appropriately for scrubbing. var scrubbingAvailable: Bool /// True if the media is animated. /// - Note: This must be set by MediaView after the media type resolves public var animationAvailable: Bool = false /// True when the media has an audio track, false otherwise. /// - Note: This must be set by the relevant nested media view once it has extracted audio data public var audioAvailable: Bool = false /// Current playback position of animated media /// - Note: This should only be set by the nested media view; to scrub, update scrubTarget public var playbackPosition: CGFloat = 0 /// Duration of animated media /// - Note: This should only be set by the nested media view public var duration: TimeInterval? /// Current loading state of the media public var loading: MediaLoadingState? public var playbackReadouts: (position: String, duration: String)? { guard let duration else { return nil } return (position: minuteSecondString(from: playbackPosition * duration), duration: minuteSecondString(from: duration)) } public var canAnimate: Bool { animationAvailable && enableAnimation } public var url: URL? /// Creates a new MediaControlState /// - Parameters: /// - blurred: true if the media should be blurred /// - animating: true if animated media should currently be animating. If initialized with `true`, animated media will autoplay. /// - overlays: set of overlays to use /// - enableAnimation: true if the media should animate at all, false otherwise /// - muted: true if the media should be muted, false otherwise. Defaults to Settings.main.muteVideos. /// - audioAvailable: true if the media has an audio track, false otherwise. Defaults to false. public init( blurred: Bool, animating: Bool, enableAnimation: Bool = true, muted: Bool, scrubbingAvailable: Bool = false ) { self.blurred = blurred self.animating = animating self.autoplay = animating self.enableAnimation = enableAnimation self.muted = muted self.scrubbingAvailable = scrubbingAvailable } private func minuteSecondString(from timeInterval: TimeInterval) -> String { Duration.seconds(timeInterval).formatted(.time(pattern: .minuteSecond)) } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Core/MediaLoader.swift ================================================ // // MediaLoader.swift // Mlem // // Created by Sjmarf on 10/08/2024. // import AVFoundation import Foundation import MlemMiddleware import Nuke import Rest import SwiftUI // MARK: Types public enum ImageLoadingError { case proxyFailure(proxyBypass: URL) case error(error: Error) } public enum MediaType { case image(UIImage) case video(still: UIImage, animated: AVAsset) case animated(still: UIImage, animated: Data) public var image: UIImage { switch self { case let .image(image), let .video(image, _), let .animated(image, _): image } } public var isAnimated: Bool { switch self { case .image: false default: true } } } public enum MediaLoadingState { case loading, done, proxyFailed, failed } // MARK: Core @Observable public class MediaLoader { public private(set) var url: URL? public private(set) var mediaType: MediaType? public private(set) var loading: MediaLoadingState public private(set) var error: ImageLoadingError? @MainActor func setUrl(_ newValue: URL?) { url = newValue } @MainActor func setMediaType(_ newValue: MediaType?) { mediaType = newValue } @MainActor func setLoading(_ newValue: MediaLoadingState) { loading = newValue } @MainActor func setError(_ newValue: ImageLoadingError?) { error = newValue } private let autoBypassImageProxy: Bool private var proxyBypass: URL? private let size: CGSize? private let processors: [any ImageProcessing] public init(url: URL? = nil, size: CGSize? = nil, autoBypassImageProxy: Bool) { self.url = url self.size = size self.autoBypassImageProxy = autoBypassImageProxy if let size { self.processors = [.resize(size: size)] } else { self.processors = .init() } self.proxyBypass = computeProxyBypass(for: url) if let cachedImage = retrieveCachedImage(for: url, with: processors) { self.mediaType = cachedImage self.loading = .done return } self.mediaType = nil self.loading = url == nil ? .failed : .loading } /// Loads the given url. public func load(_ url: URL?) async { // noop if url unchanged and loading done guard !(url == self.url && loading == .done) else { return } // reset everything await setUrl(url) await setMediaType(nil) await setLoading(.loading) await setError(nil) proxyBypass = computeProxyBypass(for: url) // easy case: nil url guard let url else { await setLoading(.failed) return } // handle previews #if DEBUG if url.scheme == "mlempreview" { await setMediaType(.image(.init(named: url.lastPathComponent)!)) await setLoading(.done) return } #endif // if already in cache, take the cached value if let mediaType = retrieveCachedImage(for: url, with: processors) { await setMediaType(mediaType) await setLoading(.done) return } // otherwise actually load the image do { let imageTask = ImagePipeline.shared.imageTask(with: .init( urlRequest: mlemUrlRequest(url: url), processors: processors )) imageTask.priority = .veryHigh let container = try await imageTask.response.container await setMediaType(container.animatedMediaType) await setLoading(.done) return } catch { if let proxyBypass, autoBypassImageProxy { await load(proxyBypass) } else { if let proxyBypass { await setError(.proxyFailure(proxyBypass: proxyBypass)) await setLoading(.proxyFailed) } else { await setError(.error(error: error)) await setLoading(.failed) } } } } } // MARK: Helpers func retrieveCachedImage(for url: URL?, with processors: [ImageProcessing]) -> MediaType? { if let url, let container = ImagePipeline.shared.cache.cachedImage(for: .init( urlRequest: mlemUrlRequest(url: url), processors: processors )) { return container.animatedMediaType } return nil } func computeProxyBypass(for url: URL?) -> URL? { if let url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let base = components.queryItems?.first(where: { $0.name == "url" })?.value { return .init(string: base) } return nil } extension ImageContainer { var animatedMediaType: MediaType { switch type { case .gif, .webp: if let data { .animated(still: image, animated: data) } else { .image(image) } case .m4v, .mov, .mp4: if let asset = userInfo[.videoAssetKey] as? AVAsset { .video(still: image, animated: asset) } else { .image(image) } default: .image(image) } } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Decoders/MlemVideoDecoder.swift ================================================ // // MlemVideoDecoder.swift // Mlem // // Created by Eric Andrews on 2025-03-09. // // Source: https://github.com/kean/Nuke/issues/811 // Wraps NukeVideo's decoder to ensure a thumbnail is always generated import Foundation import Nuke public class MlemVideoDecoder: ImageDecoding, @unchecked Sendable { private let decoder: ImageDecoders.Video public var isAsynchronous: Bool { decoder.isAsynchronous } public init?(context: ImageDecodingContext) { guard let decoder = ImageDecoders.Video(context: context) else { return nil } self.decoder = decoder } public func decode(_ data: Data) throws -> ImageContainer { if let image = decoder.decodePartiallyDownloadedData(data) { return image } return try decoder.decode(data) } public func decodePartiallyDownloadedData(_ data: Data) -> ImageContainer? { decoder.decodePartiallyDownloadedData(data) } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Decoders/NukeWebpBridgeDecoder.swift ================================================ // // NukeWebpBridgeDecoder.swift // Mlem // // Created by Eric Andrews on 2024-09-24. // import Foundation import Nuke import SDWebImageWebPCoder import UIKit /// Custom Nuke decoder that processes the image using SDWebImage. The resulting ImageContainer will have the following properties: /// `image`: the first frame of the decoded webp /// `type`: `.webp` /// `data`: the raw webp data if the webp is animated, nil otherwise public struct NukeWebpBridgeDecoder: ImageDecoding { public init?(context: ImageDecodingContext) { guard let type = AssetType(context.data), type == .webp, context.data.isAnimatedWebp() // only use this for animated webp, fall back on Nuke default for non-animated else { return nil } } public func decode(_ data: Data) throws -> ImageContainer { // decode the first frame to use as thumbnail let decoded = SDImageWebPCoder().decodedImage(with: data, options: [.decodeFirstFrameOnly: true]) if let ret = decoded?.cgImage { return .init(image: .init(cgImage: ret), type: .webp, data: data) } else { return .init(image: .init()) } } } /// Raw values of "ANIM", which we can use to identify whether a webp is animated or not without decoding it /// https://stackoverflow.com/questions/45190469/how-to-identify-whether-webp-image-is-static-or-animated private let animHeader: Data = .init([65, 78, 73, 77]) private extension Data { /// If the given data is a webp, returns true if that webp is animated and false otherwise. /// - Warning: This function's behavior is undefined if the provided data is not a webp func isAnimatedWebp() -> Bool { // This function is built to run fast, banking on the fact that it's being passed in after Nuke checks that // the image is a webp to guarantee safety. The check itself therefore only targets the bytes that, if the // data is a webp, indicate an animated webp; it is assumed that the data is long enough and correctly formatted. // Sanity checks that the data conforms to the webp spec assert(count >= 33, "Invalid data (too short)") assert(self[..<4] == Data([82, 73, 70, 70]), "Invalid data (no RIFF header)") assert(self[8 ..< 12] == Data([87, 69, 66, 80]), "Invalid data (no WEBP header)") assert(self[12 ..< 15] == Data([86, 80, 56]), "Invalid data (no VP8X header)") return self[30 ..< 34] == animHeader } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Extensions/AVPlayer+Extensions.swift ================================================ // // AVPlayer+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-12-09. // // From https://stackoverflow.com/questions/11704322/how-to-check-if-avplayer-has-video-or-just-audio import AVFoundation extension AVPlayer { func isAudioAvailable() async throws -> Bool? { try await currentItem?.asset.loadTracks(withMediaType: .audio).count != 0 } } ================================================ FILE: Mlem/Packages/Media/Sources/Media/Resources/Extensions/CGSize+Extensions.swift ================================================ // // CGSize+Extensions.swift // Media // // Created by Eric Andrews on 2025-04-20. // import Foundation extension CGSize { var aspectRatio: Double { height / width } } ================================================ FILE: Mlem/Packages/MlemBackend/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/MlemBackend/Package.resolved ================================================ { "originHash" : "433eb46e437fba071b47444bcf712c57872e1973de4812d146c7db18e50834e2", "pins" : [ { "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", "version" : "1.5.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke.git", "state" : { "revision" : "0ead44350d2737db384908569c012fe67c421e4d", "version" : "12.8.0" } }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", "version" : "5.21.0" } }, { "identity" : "sdwebimagewebpcoder", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", "state" : { "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", "version" : "0.14.6" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/MlemBackend/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "MlemBackend", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "MlemBackend", targets: ["MlemBackend"] ) ], dependencies: [ .package(path: "../MlemLogger"), .package(path: "../Rest") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "MlemBackend", dependencies: [ .byName(name: "Rest"), .byName(name: "MlemLogger") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("FullTypedThrows"), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/BackendClient+Requests.swift ================================================ // // BackendClient+Requests.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Foundation import os import Rest import SwiftUI import MlemLogger extension BackendClient { public func healthCheck() async throws -> BackendHealthCheck { try await perform(BackendHealthCheckRequest()) } public func getInstances() async throws -> [InstanceSummary] { try await perform(BackendListInstancesRequest( minTotalUsers: 20, minMonthlyUsers: 1 )) } internal func fetchTestflightUpdate() async throws { let response = try await perform(BackendGetTestflightUpdateRequest()) self.testflightUpdate = response.url } internal func fetchFlairs(enabledOnly: Bool = true) async throws { let response = try await perform(BackendListFlairsRequest(enabledOnly: enabledOnly)) self.flairs = .init(developers: .init( response .filter { [.activeDev, .inactiveDev].contains($0.flairType) } .map(\.apId) )) } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/BackendClient.swift ================================================ // // BackendClient.swift // MlemMiddleware // // Created by Eric Andrews on 2025-05-31. // import Foundation import os import Rest import SwiftUI import MlemLogger public enum BackendEnvironment { case qualityControl, production var address: URL { switch self { case .production: .init(string: "https://backend.mlemapp.org:8443/")! case .qualityControl: .init(string: "https://backend.mlemapp.org:2096/")! } } } @Observable public class BackendClient { let log: Logger = .mlemLogger() internal let restClient = RestClient(convertParamsToSnakeCase: false, decoder: .backendDecoder) public internal(set) var environment: BackendEnvironment = .production public internal(set) var flairs: MlemFlairs = .init(developers: .init()) public internal(set) var testflightUpdate: URL? internal var baseUrl: URL { environment.address } public init() { refresh() } public func changeEnvironment(to environment: BackendEnvironment) { self.environment = environment refresh() } @discardableResult internal func perform(_ request: Request) async throws -> Request.Response { return try await restClient.perform(baseUrl: baseUrl, request, token: nil) } internal func refresh() { Task { do { try await fetchFlairs() try await fetchTestflightUpdate() } catch { log.error("\(error.localizedDescription)") } } } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/BackendClientError.swift ================================================ // // BackendClientError.swift // MlemMiddleware // // Created by Eric Andrews on 2025-05-31. // enum BackendClientError: Error { case malformedUrl } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/BackendHealthCheck.swift ================================================ // // BackendHealthCheck.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-14. // import Foundation public struct BackendHealthCheck: Decodable { var dbConnection: Bool var lastInstanceFetch: Date public var unhealthyReasons: [String] { guard let minimumAllowableFetch = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { assertionFailure("Could not compute minimum allowable fetch") return ["Could not compute minimum allowable fetch"] } var ret: [String] = .init() if !dbConnection { ret.append("No database connection") } if lastInstanceFetch <= minimumAllowableFetch { ret.append("Last fetch was \(lastInstanceFetch.formatted(date: .abbreviated, time: .standard))") } return ret } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/InstanceSummary.swift ================================================ // // InstanceSummary.swift // MlemMiddleware // // Created by Eric Andrews on 2025-06-01. // import Foundation // The specification defined in https://github.com/mlemgroup/mlem-backend public struct InstanceSummary: Codable, Hashable, Identifiable { public let displayName: String public let name: String public let totalUsers: Int public let avatar: URL? public let software: InstanceSummarySoftware public init( displayName: String, name: String, totalUsers: Int, avatar: URL? = nil, software: InstanceSummarySoftware ) { self.displayName = displayName self.name = name self.totalUsers = totalUsers self.avatar = avatar self.software = software } enum CodingKeys: String, CodingKey { case displayName = "name" case name = "host" case userCount // Removed in Mlem 2.4 case totalUsers case avatar case software } public var host: String { name } public var url: URL? { URL(string: "https://\(host)/") } public var id: String { host } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.displayName = try container.decode(String.self, forKey: .displayName) self.name = try container.decode(String.self, forKey: .name) if let totalUsers = try container.decodeIfPresent(Int.self, forKey: .totalUsers) { self.totalUsers = totalUsers } else if let totalUsers = try container.decodeIfPresent(Int.self, forKey: .userCount) { self.totalUsers = totalUsers } else { throw DecodingError.dataCorruptedError(forKey: .totalUsers, in: container, debugDescription: "") } self.avatar = try container.decode(URL?.self, forKey: .avatar) self.software = try container.decode(InstanceSummarySoftware.self, forKey: .software) } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(displayName, forKey: .displayName) try container.encode(name, forKey: .name) try container.encode(totalUsers, forKey: .totalUsers) try container.encode(avatar, forKey: .avatar) try container.encode(software, forKey: .software) } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/InstanceSummarySoftware.swift ================================================ // // File.swift // MlemBackend // // Created by Sjmarf on 2026-03-18. // import Foundation public struct InstanceSummarySoftware: Codable, Hashable { public let type: InstanceSummarySoftwareType public let version: String public init(type: InstanceSummarySoftwareType, version: String) { self.type = type self.version = version } } public enum InstanceSummarySoftwareType: String, Codable, Hashable { case lemmy, pieFed } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/JSONDecoder+Extensions.swift ================================================ // // JSONDecoder+Extensions.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Foundation extension JSONDecoder { internal static let backendDecoder: JSONDecoder = { let decoder: JSONDecoder = .init() decoder.dateDecodingStrategy = .custom { decoder in let formatter: ISO8601DateFormatter = .init() formatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] let dateStr = try decoder.singleValueContainer().decode(String.self) if let date = formatter.date(from: dateStr) { return date } throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid date")) } return decoder }() } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/MlemDeveloper.swift ================================================ // // MlemDeveloper.swift // MlemMiddleware // // Created by Eric Andrews on 2025-05-31. // public struct MlemDeveloper: Codable { public let apId: String public let active: Bool } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/MlemFlairs.swift ================================================ // // Flairs.swift // MlemMiddleware // // Created by Eric Andrews on 2025-06-05. // public enum MlemFlairType: String, Codable { case activeDev, inactiveDev } struct MlemFlair: Codable { let apId: String let flairType: MlemFlairType let flairEnabled: Bool } public struct MlemFlairs { /// apIds of users who should have the Developer flair public let developers: Set } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/Requests/BackendGetTestflightUpdateRequest.swift ================================================ // // BackendGetTestflightUpdateRequest.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Rest internal struct BackendGetTestflightUpdateRequest: GetRequest { typealias Parameters = Never typealias Response = TestflightUpdate let path: String = "v0/mlem/testflight" let parameters: Parameters? = nil } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/Requests/BackendHealthCheckRequest.swift ================================================ // // BackendHealthCheckRequest.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Rest internal struct BackendHealthCheckRequest: GetRequest { typealias Parameters = Never typealias Response = BackendHealthCheck let path: String = "v0/health" let parameters: Parameters? = nil } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/Requests/BackendListFlairsRequest.swift ================================================ // // BackendListFlairsRequest.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Foundation import Rest internal struct BackendListFlairsRequest: GetRequest { struct Parameters: Encodable { let enabledOnly: Bool } typealias Response = [MlemFlair] let path: String = "v0/mlem/flairs" var parameters: Parameters? init(enabledOnly: Bool) { self.parameters = .init(enabledOnly: enabledOnly) } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/Requests/BackendListInstancesRequest.swift ================================================ // // BackendListInstancesRequest.swift // Mlem // // Created by Sjmarf on 2026-03-19. // import Foundation import Rest internal struct BackendListInstancesRequest: GetRequest { struct Parameters: Encodable { let minTotalUsers: Int let minMonthlyUsers: Int } typealias Response = [InstanceSummary] let path: String = "v1/stats/instances" var parameters: Parameters? init(minTotalUsers: Int, minMonthlyUsers: Int) { self.parameters = .init( minTotalUsers: minTotalUsers, minMonthlyUsers: minMonthlyUsers ) } } ================================================ FILE: Mlem/Packages/MlemBackend/Sources/MlemBackend/TestflightUpdate.swift ================================================ // // TestflightUpdate.swift // MlemMiddleware // // Created by Eric Andrews on 2025-06-02. // import Foundation struct TestflightUpdate: Codable { let updatedAt: Date let url: URL } ================================================ FILE: Mlem/Packages/MlemLogger/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/MlemLogger/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "MlemLogger", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "MlemLogger", targets: ["MlemLogger"] ) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "MlemLogger" ) ] ) ================================================ FILE: Mlem/Packages/MlemLogger/Sources/MlemLogger/MlemLogger.swift ================================================ // // MlemLogger.swift // MlemMiddleware // // Created by Eric Andrews on 2025-10-24. // import os public extension Logger { static func mlemLogger(file: String = #file) -> Logger { let splitFile = file.split(separator: "/") return Logger(subsystem: String(splitFile.first ?? "Unknown"), category: String(splitFile.last ?? "Unknown")) } /// Singleton logger to be used ONLY where access to a relevant specific logger is not available. Use of /// this logger is discouraged except where absolutely necessary. Ensure any messages sent to this logger /// contain enough contextual information to determine their source. static let universal: Logger = .init(subsystem: "Universal", category: "Logger") #if DEBUG /// Singleton logger for temporary development logs. /// - Warning: by design, release builds will fail if any messages to this logger are present static let dev: Logger = .init(subsystem: "Dev", category: "Logger") #endif } ================================================ FILE: Mlem/Packages/MlemMiddleware/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/MlemMiddleware/CODEOWNERS ================================================ * @mlemgroup/mlem-dev-team ================================================ FILE: Mlem/Packages/MlemMiddleware/LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ---- The MlemMiddleware iOS developers are aware that the terms of service that apply to apps distributed via Apple's App Store services may conflict with rights granted under the MlemMiddleware iOS license, the "GNU GPLv3". We have committed not to pursue any license violation that results solely from the conflict between the "GNU GPLv3" or any later version and the Apple App Store terms of service. ================================================ FILE: Mlem/Packages/MlemMiddleware/NOTICE.md ================================================ # NOTICE This project makes use of several other open source projects; their names and licenses are listed below. We thank all of the developers for their hard work. ## Semaphore https://github.com/groue/Semaphore ``` MIT License Copyright (c) 2022 Gwendal Roué Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ## Nuke https://github.com/kean/Nuke ``` The MIT License (MIT) Copyright (c) 2015-2024 Alexander Grebenyuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ## ReactiveSwift https://github.com/ReactiveCocoa/ReactiveSwift ``` Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ================================================ FILE: Mlem/Packages/MlemMiddleware/Package.resolved ================================================ { "originHash" : "9aa28d6a6e9458feceedf48c99d995409fde77733c5c4421fc7c67a330763bb6", "pins" : [ { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", "state" : { "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", "version" : "0.2.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { "revision" : "8e431251dea0081b6ab154dab61a6ec74e4b6577", "version" : "12.6.0" } }, { "identity" : "semaphore", "kind" : "remoteSourceControl", "location" : "https://github.com/groue/Semaphore", "state" : { "revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", "version" : "0.0.8" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/MlemMiddleware/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "MlemMiddleware", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "MlemMiddleware", targets: ["MlemMiddleware"] ) ], dependencies: [ .package(url: "https://github.com/groue/Semaphore.git", .upToNextMajor(from: "0.0.8")), .package(url: "https://github.com/kean/Nuke.git", .upToNextMajor(from: "12.6.0")), .package(url: "https://github.com/JohnSundell/CollectionConcurrencyKit.git", .upToNextMajor(from: "0.2.0")), .package(path: "../Rest"), .package(path: "../MlemBackend") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "MlemMiddleware", dependencies: [ .product(name: "Semaphore", package: "Semaphore"), .product(name: "Nuke", package: "Nuke"), .product(name: "CollectionConcurrencyKit", package: "CollectionConcurrencyKit"), .byName(name: "Rest"), .byName(name: "MlemBackend") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ), .testTarget( name: "MlemMiddlewareTests", dependencies: ["MlemMiddleware"] ) ] ) ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Caches.swift ================================================ // // ApiClient+Caches.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation public struct WeakReference { public weak var content: Content? public init(content: Content) { self.content = content } } public protocol CacheIdentifiable { var cacheId: Int { get } } extension ApiClient { struct BaseCacheGroup { var instance: InstanceCache = .init() var community: CommunityCache = .init() var person: PersonCache = .init() var post: PostCache = .init() var comment: CommentCache = .init() var message1: Message1Cache = .init() var message2: Message2Cache = .init() var imageUpload1: ImageUpload1Cache = .init() var report: ReportCache = .init() var personVote: PersonVoteCache = .init() var registrationApplication: RegistrationApplicationCache = .init() var notification: NotificationCache = .init() func clean() { instance.clean() community.clean() person.clean() post.clean() comment.clean() message1.clean() message2.clean() imageUpload1.clean() report.clean() personVote.clean() registrationApplication.clean() notification.clean() } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Comment.swift ================================================ // // ApiClient+Comment.swift // // // Created by Sjmarf on 24/06/2024. // import Foundation public extension ApiClient { func getComment(id: Int) async throws -> Comment { let snapshot = try await repository.getComment(id: id) return await caches.comment.getModel(api: self, from: .comment2(snapshot)) } func getComment(url: URL) async throws -> Comment { let snapshot = try await repository.getComment(url: url) return await caches.comment.getModel(api: self, from: .comment2(snapshot)) } func getComments( sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment] { let snapshots = try await repository.getComments( sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) return await caches.comment.getModels(api: self, from: snapshots.map { .comment2($0) }) } func getComments( postId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment] { let snapshots = try await repository.getComments( postId: postId, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) return await caches.comment.getModels(api: self, from: snapshots.map { .comment2($0) }) } func getComments( parentId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment] { let snapshots = try await repository.getComments( parentId: parentId, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) return await caches.comment.getModels(api: self, from: snapshots.map { .comment2($0) }) } func getCommentHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (comments: [Comment], cursor: String?) { let response = try await repository.getCommentHistory( type: type, page: page, cursor: cursor, limit: limit ) return await ( comments: caches.comment.getModels(api: self, from: response.comments.map { .comment2($0) }), cursor: response.cursor ) } // TODO: Remove in favor of the below method once we drop support for versions before Lemmy 1.0 func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: CommentSortType = .top(.allTime) ) async throws -> [Comment] { let snapshots = try await repository.searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) return await caches.comment.getModels(api: self, from: snapshots.map { .comment2($0) }) } func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Comment] { let snapshots = try await repository.searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) return await caches.comment.getModels(api: self, from: snapshots.map { .comment2($0) }) } // TODO: UpdateQueue remove (currently needed for Reply) @discardableResult func voteOnComment(id: Int, score: ScoringOperation, semaphore: UInt? = nil) async throws -> Comment { let snapshot = try await repository.voteOnComment(id: id, score: score) return await caches.comment.getModel( api: self, from: .comment2(snapshot), semaphore: semaphore ) } // TODO: UpdateQueue remove (currently needed for Reply) @discardableResult func saveComment(id: Int, save: Bool, semaphore: UInt? = nil) async throws -> Comment { let snapshot = try await repository.saveComment(id: id, save: save) return await caches.comment.getModel( api: self, from: .comment2(snapshot), semaphore: semaphore ) } // There's also a `replyToPost` method in `ApiClient+Post` for creating a comment on a post func replyToComment(postId: Int, parentId: Int?, content: String, languageId: Int? = nil) async throws -> Comment { let snapshot = try await repository.replyToComment( postId: postId, parentId: parentId, content: content, languageId: languageId ) return await caches.comment.getModel(api: self, from: .comment2(snapshot)) } @discardableResult func reportComment(id: Int, reason: String) async throws -> Report { let snapshot = try await repository.reportComment(id: id, reason: reason) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId ) } func purgeComment(id: Int, reason: String?) async throws { try await repository.purgeComment(id: id, reason: reason) } @discardableResult func getCommentVotes( id: Int, communityId: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVote] { let snapshot = try await repository.getCommentVotes( id: id, page: page, limit: limit ) return await caches.personVote.getModels( api: self, from: snapshot, target: .comment(id: id), communityId: communityId ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Community.swift ================================================ // // NewApiClient+Requests.swift // Mlem // // Created by Sjmarf on 10/02/2024. // import Foundation public extension ApiClient { func decodeCommunity(_ data: Community.CodedData) async throws -> Community { guard data.apiUrl == baseUrl else { throw ApiClientError.mismatchingUrl } guard try await data.apiMyPersonId == myPersonId else { throw ApiClientError.mismatchingPersonId } return try await caches.community.getModel( api: self, from: .community1(.init(from: data.apiCommunity)), isStale: true ) } func getCommunity(id: Int) async throws -> Community { let snapshot = try await repository.getCommunity(id: id) return await caches.community.getModel(api: self, from: .community3(snapshot)) } func getCommunity(url: URL) async throws -> Community { let snapshot: Community2Snapshot = try await repository.getCommunity(url: url) return await caches.community.getModel(api: self, from: .community2(snapshot)) } func searchCommunities( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort sort_: SearchSortType? = nil, hostApi: ApiClient? = nil ) async throws -> [Community] { let sort: SearchSortType if let sort_ { sort = sort_ } else if try await software.supports(.searchSortType(.top(.allTime))) { sort = .top(.allTime) } else { sort = .top(.limited(.month)) } let snapshots = try await repository.searchCommunities( query: query, page: page, limit: limit, filter: filter, sort: sort ) let ret = await caches.community.getModels(api: self, from: snapshots.map { .community2($0) }) if let subscriptionInfo = hostApi?.subscriptions { for community in ret { if let subscribedCommunity = subscriptionInfo.communities.first(where: { $0.actorId == community.actorId }) { community.subscription.addSibling(subscribedCommunity.subscription) } // TODO: favorites } } // if on a foreign host, resolve communities to populate subscription status. if let hostApi, hostApi !== self { do { let resolvedCommunities: [URL: Community] = try await hostApi.resolve(urls: ret.map { $0.resolvableUrl(from: .host) }) for community in ret { if let resolvedCommunity = resolvedCommunities[community.resolvableUrl(from: .host)] { community.blocked_.addSibling(resolvedCommunity.blocked_) } } } catch { // if this fails, don't fail the whole call // TODO: error toast (depends on packaged error handling) log.error("Failed to resolve community URLs: \(error)") } } return ret } func setupSubscriptionList( getFavorites: @escaping () -> Set = { [] }, setFavorites: @escaping (Set) -> Void = { _ in } ) -> SubscriptionList { if let subscriptions { return subscriptions } else { let new: SubscriptionList = .init(apiClient: self, getFavorites: getFavorites, setFavorites: setFavorites) subscriptions = new return new } } @discardableResult func getSubscriptionList() async throws -> SubscriptionList { let subscriptionList = setupSubscriptionList() let limit = 50 var page = 1 var hasMorePages = true var communities = [Community2Snapshot]() repeat { let snapshots = try await repository.getSubscriptionList(page: page, limit: limit) communities.append(contentsOf: snapshots) hasMorePages = snapshots.count >= limit page += 1 } while hasMorePages let models: Set = await Set(caches.community.getModels(api: self, from: communities.map { .community2($0) })) await subscriptionList.updateCommunities(with: models) subscriptionList.hasLoaded = true return subscriptionList } func purgeCommunity(id: Int, reason: String?) async throws { try await repository.purgeCommunity(id: id, reason: reason) caches.community.retrieveModel(cacheId: id)?.purged = true } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+General.swift ================================================ // // ApiClient+General.swift // // // Created by Sjmarf on 25/06/2024. // import Foundation public extension ApiClient { var isAdmin: Bool { myInstance?.administrators.value?.contains(where: { $0.id == myPerson?.id }) ?? false } /// Returns true if both myPerson and the given person are admins on this instance and myPerson outranks the given person, false otherwise func isHigherAdmin(than person: Person) -> Bool { guard person.api.actorId == actorId, let myPerson, let myAdminIndex = myInstance?.administrators.value?.firstIndex(of: myPerson), let targetAdminIndex = myInstance?.administrators.value?.firstIndex(where: { $0.actorId == person.actorId }) else { return false } return myAdminIndex < targetAdminIndex } func getAccountToken(usernameOrEmail: String, password: String, totpToken: String?) async throws -> String { try await repository.getAccountToken(usernameOrEmail: usernameOrEmail, password: password, totpToken: totpToken) } func getUsernameFromToken(token: String) async throws -> String { try await repository.getUsernameFromToken(token: token) } func login(password: String, totpToken: String?) async throws { guard let username else { throw ApiClientError.notLoggedIn } let token = try await getAccountToken(usernameOrEmail: username, password: password, totpToken: totpToken) updateToken(token) } func signUp( username: String, password: String, confirmPassword: String, showNsfw: Bool, email: String?, captcha: Captcha?, captchaAnswer: String?, applicationQuestionResponse: String? ) async throws -> SignUpResponse { try await repository.signUp(username: username, password: password, confirmPassword: confirmPassword, showNsfw: showNsfw, email: email, captcha: captcha, captchaAnswer: captchaAnswer, applicationQuestionResponse: applicationQuestionResponse) } @discardableResult func changePassword( newPassword: String, confirmNewPassword: String, oldPassword: String ) async throws -> String { let token = try await repository.changePassword(newPassword: newPassword, confirmNewPassword: confirmNewPassword, oldPassword: oldPassword) updateToken(token) return token } func getCaptcha() async throws -> Captcha { try await repository.getCaptcha() } /// Returns an object associated with the given URL. /// /// ## Overview /// /// The backend performs two steps to do this: /// 1) Check it already has the given actorId mapped in the database, in which case it returns the entity. /// 2) If the entity is not present in the database, it contacts the URL host to ask for it, then returns it back to us. /// When this happens, the call will take longer to resolve. /// /// **Importantly, step 2) is only performed if the `ApiClient` is authenticated.** /// func resolve(url: URL) async throws -> (any ActorIdentifiable & Sharable) { let response = try await repository.resolve(url: url) return switch response { case let .comment(comment): await caches.comment.getModel(api: self, from: .comment2(comment)) case let .post(post): await caches.post.getModel(api: self, from: .post2(post)) case let .community(community): await caches.community.getModel(api: self, from: .community2(community)) case let .person(person): await caches.person.getModel(api: self, from: .person2(person)) } } func resolve(urls: [URL]) async throws -> [URL: Value] { try await withThrowingTaskGroup(of: (url: URL, value: Value)?.self) { group in for url in urls { group.addTask { if let value = try await self.resolve(url: url) as? Value { return (url, value) } return nil } } var collected: [URL: Value] = .init() for try await result in group { if let result { collected[result.url] = result.value } } return collected } } func getBlocked() async throws -> (people: [Person], communities: [Community], instances: [Instance]) { let snapshots = try await repository.getBlocked() return await ( people: caches.person.getModels(api: self, from: snapshots.people.map { .person1($0) }), communities: caches.community.getModels(api: self, from: snapshots.communities.map { .community1($0) }), instances: caches.instance.getModels(api: self, from: snapshots.instances.map { .instance1($0) }) ) } func getModlog( page: Int = 1, limit: Int = 20, communityId: Int? = nil, moderatorId: Int? = nil, subjectPersonId: Int? = nil, postId: Int? = nil, commentId: Int? = nil, type: ModlogEntryType? = nil ) async throws -> [ModlogEntry] { let snapshots = try await repository.getModlog( page: page, limit: limit, communityId: communityId, moderatorId: moderatorId, subjectPersonId: subjectPersonId, postId: postId, commentId: commentId, type: type ) return await createModlogEntries(snapshots) } @MainActor private func createModlogEntries(_ entries: [ModlogEntrySnapshot]) -> [ModlogEntry] { entries.map { entry in return ModlogEntry( api: self, created: entry.created, moderator: caches.person.getOptionalModel( api: self, from: .person1(entry.moderator) ), type: .init(from: entry.type, api: self) ) } } func getPostLink(url: URL) async throws -> PostLink { try await repository.getPostLink(url: url) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Image.swift ================================================ // // ApiClient+Image.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation import Rest public extension ApiClient { func uploadImage( _ imageData: Data, fileExtension: String, onProgress progressCallback: @escaping (_ progress: Double) -> Void = { _ in } ) async throws -> ImageUpload1 { let file = try await repository.uploadImage(imageData, fileExtension: fileExtension, onProgress: progressCallback) return caches.imageUpload1.getModel(api: self, from: file) } func deleteImage(alias: String, deleteToken: String) async throws { try await repository.deleteImage(alias: alias, deleteToken: deleteToken) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Inbox.swift ================================================ // // ApiClient+Inbox.swift // // // Created by Sjmarf on 04/07/2024. // import Foundation public extension ApiClient { func getMessages( creatorId: Int? = nil, page: Int, limit: Int, unreadOnly: Bool = false ) async throws -> [Message2] { let snapshots = try await repository.getMessages( creatorId: creatorId, page: page, limit: limit, unreadOnly: unreadOnly ) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.message2.getModels( api: self, from: snapshots, myPersonId: myPersonId ) } func getReplyNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotification], cursor: String?) { guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } let response = try await repository.getReplyNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) return await ( notifications: caches.notification.getModels(api: self, from: response.notifications, myPersonId: myPersonId), cursor: response.cursor ) } func getMentionNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotification], cursor: String?) { guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } let response = try await repository.getMentionNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) return await ( notifications: caches.notification.getModels(api: self, from: response.notifications, myPersonId: myPersonId), cursor: response.cursor ) } func getMessageNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotification], cursor: String?) { guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } let response = try await repository.getMessageNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) return await ( notifications: caches.notification.getModels(api: self, from: response.notifications, myPersonId: myPersonId), cursor: response.cursor ) } func markAllAsRead() async throws { try await repository.markAllAsRead() _ = await Task { @MainActor in for notification in caches.notification.itemCache.value.values { notification.content?.read = true } }.result unreadCount?.clear(.personal) } /// Get an ``UnreadCount`` object that continues to be updated by the ``ApiClient`` whenever an inbox item is marked read/unread. func getUnreadCount(alwaysMakeCalls: Bool = false) async throws -> UnreadCount { let unreadCount = unreadCount ?? .init(api: self) try await unreadCount.refresh(alwaysMakeCalls: alwaysMakeCalls) self.unreadCount = unreadCount return unreadCount } func createMessage(personId: Int, content: String) async throws -> Message2 { let snapshot = try await repository.createMessage(personId: personId, content: content) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.message2.getModel( api: self, from: snapshot, myPersonId: myPersonId ) } @discardableResult func editMessage(id: Int, content: String) async throws -> Message2 { let snapshot = try await repository.editMessage(id: id, content: content) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.message2.getModel( api: self, from: snapshot, myPersonId: myPersonId ) } @discardableResult func reportMessage(id: Int, reason: String) async throws -> Report { let snapshot = try await repository.reportMessage(id: id, reason: reason) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId ) } @discardableResult func deleteMessage(id: Int, delete: Bool, semaphore: UInt? = nil) async throws -> Message2 { let snapshot = try await repository.deleteMessage(id: id, delete: delete) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.message2.getModel( api: self, from: snapshot, myPersonId: myPersonId, semaphore: semaphore ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Instance.swift ================================================ // // NewApiClient+Site.swift // Mlem // // Created by Sjmarf on 12/02/2024. // import Foundation public extension ApiClient { func getMyInstance() async throws -> Instance { let snapshot = try await repository.getMyInstance() let model = await caches.instance.getModel(api: self, from: .instance3(snapshot)) model.local = true _ = await Task { @MainActor in myInstance = model }.result return model } /// Returns `true` if federated, `false` if not federated, or `nil` if the status could not be determined. func federatedWith(with url: URL) async throws -> FederationStatus? { guard let domain = url.host() else { throw ApiClientError.invalidInput } let federatedInstances = try await repository.getFederatedInstances() if !federatedInstances.blocked.isEmpty { return federatedInstances.blocked.contains(domain) ? .explicitlyBlocked : .implicitlyAllowed } else if !federatedInstances.allowed.isEmpty { return federatedInstances.allowed.contains(domain) ? .explicitlyAllowed : .implicitlyBlocked } return nil } /// Get any `Community3` hosted on the given instance. internal func getCommunityOfInstance(actorId: ActorIdentifier) async throws -> Community { let externalApi: ApiClient = .getApiClient(url: actorId.url, username: nil) let response = try await externalApi.getPosts( feed: .local, sort: .new, page: 1, cursor: nil, limit: 1 ) guard let post = response.posts.first else { throw InstanceUpgradeError.noPostReturned } guard let community = post.community.value_ else { throw InstanceUpgradeError.noCommunityReturned } return try await self.getCommunity(url: community.actorId.url) } func getInstanceId(actorId: ActorIdentifier) async throws -> Int { let comm = try await self.getCommunityOfInstance(actorId: actorId) return comm.instanceId } /// `instanceId` is distinct from `id`. Make sure to pass `instance.instanceId` and not `id`. /// Technically only `instanceId` is needed to perform this request, but `actorId` is also needed to properly update the `BlockList`. func blockInstance(url: URL, instanceId: Int, block: Bool, semaphore: UInt? = nil) async throws { guard let host = url.host() else { throw ApiClientError.invalidInput } let actorId: ActorIdentifier = .instance(host: host) try await repository.blockInstance(instanceId: instanceId, block: block) let newBlockState: Bool = block if let instance = caches.instance.retrieveModel(instanceId: instanceId) { instance.blocked_.set(newBlockState) } if newBlockState { blocks?.instances[actorId] = instanceId } else { blocks?.instances.removeValue(forKey: actorId) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Mock.swift ================================================ // // ApiClient+Mock.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-02. // import Foundation import Rest // TODO: updated mocks //#if DEBUG // //public extension ApiClient { // static let mock: MockApiClient = .init() //} // //public class MockApiClient: ApiClient { // public init( // posts: [Post2] = [], // communities: [Community2] = [], // people: [Person2] = [], // comments: [Comment2] = [] // ) { // let url = URL(string: "https://lemmy.world/")! // let username = "" // super.init( // url: url, // username: username // ) // // self.repository = MockApiRepository(url: url, username: username, posts: posts, communities: communities, people: people, comments: comments) // } // // private var mockRepository: MockApiRepository { repository as! MockApiRepository } // // public func setPosts(_ posts: [Post2]) { // mockRepository.posts = posts // } // // public func setCommunities(_ communities: [Community2]) { // mockRepository.communities = communities // } // // public func setPeople(_ people: [Person2]) { // mockRepository.people = people // } //} // //#endif ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Person.swift ================================================ // // NewApiClient+User.swift // Mlem // // Created by Sjmarf on 12/02/2024. // import Foundation public extension ApiClient { func decodePerson(_ data: Person.CodedData) async throws -> Person { guard data.apiUrl == baseUrl else { throw ApiClientError.mismatchingUrl } guard try await data.apiMyPersonId == myPersonId else { throw ApiClientError.mismatchingPersonId } return try await caches.person.getModel( api: self, from: .person1(.init(from: data.apiPerson)), isStale: true ) } func getPerson(id: Int) async throws -> Person { let snapshot = try await repository.getPerson(id: id) return await caches.person.getModel(api: self, from: .person3(snapshot)) } func getPerson(url: URL) async throws -> Person { let snapshot: Person2Snapshot = try await repository.getPerson(url: url) return await caches.person.getModel(api: self, from: .person2(snapshot)) } func getPerson(username: String) async throws -> Person { let snapshot: Person3Snapshot = try await repository.getPerson(username: username) return await caches.person.getModel(api: self, from: .person3(snapshot)) } /// `filter` can be set to `.local` from 0.19.4 onwards. func searchPeople( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Person] { let snapshots = try await repository.searchPeople( query: query, page: page, limit: limit, filter: filter, sort: sort ) return await caches.person.getModels(api: self, from: snapshots.map { .person2($0) }) } @discardableResult func blockPerson(id: Int, block: Bool, semaphore: UInt? = nil) async throws -> Person { let snapshot = try await repository.blockPerson(id: id, block: block) return await caches.person.getModel( api: self, from: .person2(snapshot), semaphore: semaphore ) } @discardableResult func banPersonFromCommunity( personId: Int, communityId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person { let snapshot = try await repository.banPersonFromCommunity( personId: personId, communityId: communityId, ban: ban, removeContent: removeContent, reason: reason, expires: expires ) let person = await caches.person.getModel( api: self, from: .person1(snapshot) ) person.updateKnownCommunityBanState(id: communityId, banned: ban) return person } @discardableResult func banPersonFromInstance( personId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person { let snapshot = try await repository.banPersonFromInstance( personId: personId, ban: ban, removeContent: removeContent, reason: reason, expires: expires ) return await caches.person.getModel( api: self, from: .person2(snapshot) ) } func purgePerson(id: Int, reason: String?) async throws { try await repository.purgePerson(id: id, reason: reason) caches.person.retrieveModel(cacheId: id)?.purged = true } func getContent( authorId id: Int, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool? = nil, communityId: Int? = nil ) async throws -> (person: Person, posts: [Post], comments: [Comment]) { let snapshots = try await repository.getContent( authorId: id, sort: sort, page: page, limit: limit, savedOnly: savedOnly, communityId: communityId ) return await ( person: caches.person.getModel(api: self, from: .person3(snapshots.person)), posts: caches.post.getModels(api: self, from: snapshots.posts.map { .post2($0) }), comments: caches.comment.getModels(api: self, from: snapshots.comments.map { .comment2($0) }) ) } func getMyPerson() async throws -> (person: Person?, instance: Instance, blocks: BlockList?) { let snapshot = try await repository.getMyPerson() let snapshotPersonName = snapshot.person?.person.person.person.name guard snapshotPersonName == username else { assertionFailure( "Returned account name \(String(describing: snapshotPersonName)) does not match logged in username \(String(describing: username))" ) throw ApiClientError.mismatchingToken } let instance = await caches.instance.getModel(api: self, from: .instance3(snapshot.instance)) let person = await caches.person.getOptionalModel(api: self, from: .person4(snapshot.person)) var blocks: BlockList? = blocks if person != nil, let newBlocks = snapshot.blocks { if let blocks { blocks.update(blocks: newBlocks) } else { blocks = .init(api: self, blocks: newBlocks) } } _ = await Task { @MainActor in self.blocks = blocks myPerson = person myInstance = instance }.result return (person: person, instance: instance, blocks: blocks) } func deleteAccount(password: String, deleteContent: Bool) async throws { try await repository.deleteAccount(password: password, deleteContent: deleteContent) } func editProfile(_ details: ProfileDetails) async throws { try await repository.editProfile(details) } func editAccountSettings( showNsfw: Bool?, showScores: Bool?, theme: String?, defaultListingType: ListingType?, interfaceLanguage: String?, avatar: String?, banner: String?, displayName: String?, email: String?, bio: String?, matrixUserId: String?, showAvatars: Bool?, sendNotificationsToEmail: Bool?, botAccount: Bool?, showBotAccounts: Bool?, showReadPosts: Bool?, discussionLanguages: [Int]?, openLinksInNewTab: Bool?, blurNsfw: Bool?, autoExpand: Bool?, infiniteScrollEnabled: Bool?, postListingMode: PostFeedViewMode?, enableKeyboardNavigation: Bool?, enableAnimatedImages: Bool?, collapseBotComments: Bool?, showUpvotes: Bool?, showDownvotes: Bool?, showUpvotePercentage: Bool? ) async throws { try await repository.editAccountSettings( showNsfw: showNsfw, showScores: showScores, theme: theme, defaultListingType: defaultListingType, interfaceLanguage: interfaceLanguage, avatar: avatar, banner: banner, displayName: displayName, email: email, bio: bio, matrixUserId: matrixUserId, showAvatars: showAvatars, sendNotificationsToEmail: sendNotificationsToEmail, botAccount: botAccount, showBotAccounts: showBotAccounts, showReadPosts: showReadPosts, discussionLanguages: discussionLanguages, openLinksInNewTab: openLinksInNewTab, blurNsfw: blurNsfw, autoExpand: autoExpand, infiniteScrollEnabled: infiniteScrollEnabled, postListingMode: postListingMode, enableKeyboardNavigation: enableKeyboardNavigation, enableAnimatedImages: enableAnimatedImages, collapseBotComments: collapseBotComments, showUpvotes: showUpvotes, showDownvotes: showDownvotes, showUpvotePercentage: showUpvotePercentage ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Post.swift ================================================ // // NewApiClient+Post.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation public extension ApiClient { // swiftlint:disable:next function_parameter_count func getPosts( communityId: Int, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post], cursor: String?) { let snapshots = try await repository.getPosts( communityId: communityId, sort: sort, page: page, cursor: cursor, limit: limit, filter: filter, showHidden: showHidden ) let posts = await caches.post.getModels( api: self, from: snapshots.posts.map { .post2($0) } ) return (posts: posts, cursor: snapshots.cursor) } // swiftlint:disable:next function_parameter_count func getPosts( feed: ListingType, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post], cursor: String?) { let snapshots = try await repository.getPosts( feed: feed, sort: sort, page: page, cursor: cursor, limit: limit, filter: filter, showHidden: showHidden ) let posts = await caches.post.getModels( api: self, from: snapshots.posts.map { .post2($0) } ) return (posts: posts, cursor: snapshots.cursor) } func getPosts( personId: Int, communityId: Int? = nil, sort: PostSortType = .new, page: Int, limit: Int, savedOnly: Bool = false ) async throws -> (person: Person, posts: [Post]) { let snapshots = try await repository.getPosts( personId: personId, communityId: communityId, sort: sort, page: page, limit: limit, savedOnly: savedOnly ) return await ( person: caches.person.getModel(api: self, from: .person3(snapshots.person)), posts: caches.post.getModels(api: self, from: snapshots.posts.map { .post2($0) }) ) } func getPostHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (posts: [Post], cursor: String?) { let snapshots = try await repository.getPostHistory( type: type, page: page, cursor: cursor, limit: limit ) let posts = await caches.post.getModels( api: self, from: snapshots.posts.map { .post2($0) } ) return (posts: posts, cursor: snapshots.cursor) } func getPost(id: Int) async throws -> Post { let snapshot = try await repository.getPost(id: id) return await caches.post.getModel(api: self, from: .post3(snapshot)) } func getPost(url: URL) async throws -> Post { let snapshot = try await repository.getPost(url: url) return await caches.post.getModel(api: self, from: .post2(snapshot)) } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: PostSortType ) async throws -> [Post] { let snapshots = try await repository.searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) return await caches.post.getModels(api: self, from: snapshots.map { .post2($0) }) } func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType ) async throws -> [Post] { let snapshots = try await repository.searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) return await caches.post.getModels(api: self, from: snapshots.map { .post2($0) }) } /// Mark the given posts as read. /// Calling this will also mark any queued posts as read unless `includeQueuedPosts` is set to `false`. func markPostsAsRead( ids: Set, includeQueuedPosts: Bool = true ) async throws { let idsToSend: Set let markReadQueueCopy: Set if includeQueuedPosts { markReadQueueCopy = await markReadQueue.popAll() idsToSend = ids.union(markReadQueueCopy) } else { markReadQueueCopy = [] idsToSend = ids } guard !idsToSend.isEmpty else { return } do { try await repository.markPostsAsRead(ids: idsToSend) await markReadQueue.subtract(ids) } catch { await markReadQueue.union(markReadQueueCopy) throw error } Task { @MainActor in for post in idsToSend.compactMap({ caches.post.retrieveModel(cacheId: $0) }) { post.queuedMarkReadCompleted() } } } func flushPostReadQueue() async throws { if await !markReadQueue.ids.isEmpty { try await markPostsAsRead(ids: []) } } func createPost( communityId: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post { let snapshot = try await repository.createPost( communityId: communityId, title: title, content: content, linkUrl: linkUrl, altText: altText, thumbnail: thumbnail, nsfw: nsfw, languageId: languageId ) return await caches.post.getModel(api: self, from: .post2(snapshot)) } func replyToPost(id: Int, content: String, languageId: Int? = nil) async throws -> Comment { let snapshot = try await repository.replyToPost(id: id, content: content, languageId: languageId) return await caches.comment.getModel(api: self, from: .comment2(snapshot)) } @discardableResult func reportPost(id: Int, reason: String) async throws -> Report { let snapshot = try await repository.reportPost(id: id, reason: reason) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId ) } func purgePost(id: Int, reason: String?) async throws { try await repository.purgePost(id: id, reason: reason) } @discardableResult func getPostVotes( id: Int, communityId: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVote] { let snapshot = try await repository.getPostVotes( id: id, page: page, limit: limit ) return await caches.personVote.getModels( api: self, from: snapshot, target: .post(id: id), communityId: communityId ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+RegistrationApplication.swift ================================================ // // ApiClient+RegistrationApplication.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-12. // import Foundation public extension ApiClient { func getRegistrationApplicationCount() async throws -> Int { try await repository.getRegistrationApplicationCount() } func getRegistrationApplications( page: Int = 1, limit: Int = 20, unreadOnly: Bool = false ) async throws -> [RegistrationApplication] { let snapshot = try await repository.getRegistrationApplications( page: page, limit: limit, unreadOnly: unreadOnly ) return await caches.registrationApplication.getModels(api: self, from: snapshot) } @discardableResult func approveRegistrationApplication( id: Int, semaphore: UInt? = nil ) async throws -> RegistrationApplication { let snapshot = try await repository.approveRegistrationApplication(id: id) return await caches.registrationApplication.getModel( api: self, from: snapshot, semaphore: semaphore ) } @discardableResult func denyRegistrationApplication( id: Int, reason: String?, semaphore: UInt? = nil ) async throws -> RegistrationApplication { let snapshot = try await repository.denyRegistrationApplication(id: id, reason: reason) return await caches.registrationApplication.getModel( api: self, from: snapshot, semaphore: semaphore ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient+Report.swift ================================================ // // ApiClient+Report.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-16. // import Foundation public extension ApiClient { func getPostReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, postId: Int? = nil ) async throws -> [Report] { let snapshot = try await repository.getPostReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, postId: postId ) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModels( api: self, from: snapshot, myPersonId: myPersonId ) } func getCommentReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, commentId: Int? = nil ) async throws -> [Report] { let snapshot = try await repository.getCommentReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, commentId: commentId ) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModels( api: self, from: snapshot, myPersonId: myPersonId ) } func getMessageReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false ) async throws -> [Report] { let snapshot = try await repository.getMessageReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly ) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModels( api: self, from: snapshot, myPersonId: myPersonId ) } @discardableResult func resolvePostReport( id: Int, resolved: Bool, semaphore: UInt? = nil ) async throws -> Report { let snapshot = try await repository.resolvePostReport(id: id, resolved: resolved) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId, semaphore: semaphore ) } @discardableResult func resolveCommentReport( id: Int, resolved: Bool, semaphore: UInt? = nil ) async throws -> Report { let snapshot = try await repository.resolveCommentReport(id: id, resolved: resolved) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId, semaphore: semaphore ) } @discardableResult func resolveMessageReport( id: Int, resolved: Bool, semaphore: UInt? = nil ) async throws -> Report { let snapshot = try await repository.resolveMessageReport(id: id, resolved: resolved) guard let myPersonId = try await myPersonId else { throw ApiClientError.notLoggedIn } return await caches.report.getModel( api: self, from: snapshot, myPersonId: myPersonId, semaphore: semaphore ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClient.swift ================================================ // // ApiClient.swift // Mlem // // Created by Sjmarf on 10/02/2024. // import Combine import Foundation import os import Rest @Observable public class ApiClient { let log: Logger = .mlemLogger() var repository: ApiRepository public var willSendToken: Bool { repository.token != nil } public internal(set) weak var myInstance: Instance? public internal(set) weak var myPerson: Person? public internal(set) weak var subscriptions: SubscriptionList? public internal(set) weak var blocks: BlockList? public internal(set) weak var unreadCount: UnreadCount? /// Stores the IDs of posts that are queued to be marked read. var markReadQueue: MarkReadQueue = .init() public func ensureContextPresence() async throws { try await repository.getConnection().ensureContextPresence() } public func supports(_ feature: Feature) async throws -> Bool { try await repository.getConnection().supports(feature) } /// Returns whether this `ApiClient` supports the given feature. If this information cannot be resolved, returns the provided `defaultValue` public func supports(_ feature: Feature, defaultValue: Bool) -> Bool { repository.connection?.supports(feature, defaultValue: defaultValue) ?? defaultValue } public var contextIsFetched: Bool { repository.connection?.contextIsFetched ?? false } public var username: String? { repository.username } public var baseUrl: URL { repository.baseUrl } public var token: String? { repository.token } public var myPersonId: Int? { get async throws { try await repository.getConnection().myPersonId } } public var software: SiteSoftware { get async throws { let connection = try await repository.getConnection() return try await .init(type: type(of: connection).softwareType, version: connection.version) } } public var voteFederationMode: VoteFederationMode { myInstance?.voteFederationMode.value ?? .all } // MARK: caching /// Caches of objects stored per ApiClient instance /// - Warning: DO NOT access this outside of ApiClient! var caches: BaseCacheGroup = .init() /// Caches of Instance objects, shared across all ApiClient instances /// - Warning: DO NOT access this outside of ApiClient! static var apiClientCache: ApiClientCache = .init() /// Creates or retrieves an API client for the given connection parameters public static func getApiClient(url: URL, username: String?) -> ApiClient { apiClientCache.createOrRetrieveApiClient(url: url, username: username) } /// This should never be used outside of ApiClientCache (and MockApiClient), as the caching system depends on one ApiClient existing for any given session. init( url: URL, username: String? = nil ) { self.repository = .init(baseUrl: url, username: username) } public func cleanCaches() { caches.clean() ApiClient.apiClientCache.clean() } /// Return a new guest `ApiClient`. public func asGuest() -> ApiClient { .getApiClient(url: baseUrl, username: nil) } /// Return a new `ApiClient` targeting the given user. public func asUser(name: String) -> ApiClient { .getApiClient(url: baseUrl, username: name) } /// This should **only** be used when we get a new token for **the same** account! public func updateToken(_ newToken: String) { repository.updateToken(newToken) } } extension ApiClient: CacheIdentifiable { public var cacheId: Int { ApiClient.apiClientCache.getCacheId(url: baseUrl, username: username) } } extension ApiClient: ActorIdentifiable { public var actorId: ActorIdentifier { .instance(host: baseUrl.host()!) } } extension ApiClient: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(baseUrl) hasher.combine(username) } public static func == (lhs: ApiClient, rhs: ApiClient) -> Bool { lhs === rhs } } extension ApiClient: CustomDebugStringConvertible { public var debugDescription: String { "ApiClient(\(host), authenticated: \(repository.token != nil))" } } // MARK: ApiClientCache // This needs to be declared in this file to have access to the private initializer extension ApiClient { /// Cache for ApiClient--exception case because there's no ApiType and it may need to perform ApiClient bootstrapping class ApiClientCache: CoreCache { func getCacheId(url: URL, username: String?) -> Int { var hasher: Hasher = .init() hasher.combine(url.removingPathComponents().appendingPathComponent("/")) hasher.combine(username) return hasher.finalize() } func createOrRetrieveApiClient(url: URL, username: String?) -> ApiClient { let url = url.removingPathComponents().appendingPathComponent("/") if let client = retrieveModel(cacheId: getCacheId(url: url, username: username)) { return client } let ret: ApiClient = .init(url: url, username: username) itemCache.put(ret) return ret } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiClientError.swift ================================================ // // ApiClientError.swift // Mlem // // Created by Sjmarf on 17/02/2024. // import Foundation import Rest enum HTTPMethod { case get case post(Data) } public enum ApiClientError: Error { case encoding(Error) case networking(Error) case serverError(statusCode: Int) case response(ApiErrorResponse, Int) case cancelled case notLoggedIn case invalidSession case decoding(Data, Error?) case insufficientPermissions /// Thrown when a `false` value of `SuccessResponse` is returned case unsuccessful case featureUnsupported case noEntityFound case invalidInput case imageTooLarge case mismatchingUrl case mismatchingPersonId case mismatchingToken case noToken case responseMissingRequiredData(_ message: String) case unableToDetermineSoftware init(from error: RestError) { self = switch error { case let .serverError(statusCode): .serverError(statusCode: statusCode) case let .response(string, statusCode): .response(.init(error: string), statusCode) case let .encoding(error): .encoding(error) case let .parameterEncoding(error): .encoding(error) case let .decoding(data, error): .decoding(data, error) case let .networking(error): .networking(error) case .cancelled: .cancelled } } } extension ApiClientError: CustomStringConvertible { public var description: String { switch self { case .insufficientPermissions: return "Insufficient permissions. Check `ApiClient.permissions`" case let .encoding(error): return "Unable to encode: \(String(describing: error))" case let .networking(error): return "Networking error: \(String(describing: error))" case let .response(errorResponse, status): return "Response error: \(errorResponse) with status \(status)" case let .serverError(status): return "Server error: \(status)" case .cancelled: return "Cancelled" case .invalidSession: return "Invalid session. There is a token applied to the ApiClient, but it has expired." case .notLoggedIn: return "Tried to perform an action that requires authentication on a guest ApiClient." case .imageTooLarge: return "Image too large" case let .decoding(data, error): guard let string = String(data: data, encoding: .utf8) else { return localizedDescription } if let error { return "Unable to decode: \(string)\nError: \(String(describing: error))" } return "Unable to decode: \(string)" case .unsuccessful: return "Operation was unsuccessful." case .featureUnsupported: return "This instance doesn't support that operation." case .noEntityFound: return "No entity returned in response." case .invalidInput: return "Invalid input" case .mismatchingUrl: return "URL of the decoding ApiClient doesn't match the URL of the ApiClient that encoded the data" case .mismatchingPersonId: return "Person ID of the decoding ApiClient doesn't match the Person ID of the ApiClient that encoded the data" case .mismatchingToken: return "A valid token was assigned to an ApiClient for the wrong account." case .noToken: return "A call was made to an ApiClient that doesn't have a token yet." case let .responseMissingRequiredData(message): return "An API response was missing required data: \(message)" case .unableToDetermineSoftware: return "Unable to determine software" } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiErrorResponse.swift ================================================ // // ApiErrorResponse.swift // Mlem // // Created by Nicholas Lawson on 06/06/2023. // import Foundation // TODO: 0.19 support add all the error types (https://github.com/LemmyNet/lemmy-js-client/blob/b2edfeeaffd189a51150362cc8ead03c65ee2652/src/types/LemmyErrorType.ts) public struct ApiErrorResponse: Decodable, CustomStringConvertible { public let error: String public var description: String { error } } private let possibleCredentialErrors: Set = [ "incorrect_password", "password_incorrect", "incorrect_login", "couldnt_find_that_username_or_email" ] private let possibleAuthenticationErrors: Set = [ "incorrect_password", "password_incorrect", "incorrect_login", "not_logged_in" ] private let possible2FAErrors: Set = [ "missing_totp_token", "incorrect_totp_token" ] private let couldntFindObjectErrors: Set = [ "couldnt_find_person", "couldnt_find_object", "No object found." ] public extension ApiErrorResponse { var requires2FA: Bool { possible2FAErrors.contains(error) } var isNotLoggedIn: Bool { possibleAuthenticationErrors.contains(error) } var instanceIsPrivate: Bool { error == "instance_is_private" } var registrationApplicationIsPending: Bool { error == "registration_application_is_pending" } var emailNotVerified: Bool { error == "email_not_verified" } var couldntFindObject: Bool { couldntFindObjectErrors.contains(error) } var notModOrAdmin: Bool { error == "not_a_mod_or_admin" } var notAdmin: Bool { error == "not_an_admin" } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/ApiSession.swift ================================================ // // ApiSession.swift // Mlem // // Created by mormaer on 02/09/2023. // // import Foundation enum ApiSessionError: Error { case authenticationNotPresent case undefined } /// An enumeration representing possible session states enum ApiSession { case authenticated(URL, String) case unauthenticated(URL) case undefined var token: String { get throws { guard case let .authenticated(_, token) = self else { throw ApiSessionError.authenticationNotPresent } return token } } var instanceUrl: URL { get throws { switch self { case let .authenticated(url, _): return url case let .unauthenticated(url): return url case .undefined: throw ApiSessionError.undefined } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/ApiTypeBackedCache.swift ================================================ // // ContentCache.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation /// Class providing caching behavior for models associated with API types class ApiTypeBackedCache: CoreCache { // TODO: Unified RegistrationApplication remove semaphore @MainActor func getModel( api: ApiClient, from apiType: ApiType, // If `true`, the model will not be updated with the incoming data if the model already exists. isStale: Bool = false, semaphore: UInt? = nil ) -> Content { if let item = retrieveModel(cacheId: apiType.cacheId) { if !isStale { updateModel(item, with: apiType, semaphore: semaphore) } return item } let newItem: Content = performModelTranslation(api: api, from: apiType) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from apiTypes: any Sequence, isStale: Bool = false, semaphore: UInt? = nil ) -> [Content] { apiTypes.map { getModel(api: api, from: $0, isStale: isStale, semaphore: semaphore) } } @MainActor func getOptionalModel( api: ApiClient, from apiType: ApiType?, isStale: Bool = false, semaphore: UInt? = nil ) -> Content? { if let apiType { return getModel(api: api, from: apiType, isStale: isStale, semaphore: semaphore) } return nil } /// Initializes a new middleware model from the associated API type /// - Warning: This method DOES NOT CACHE! You almost certainly want to be using `getModel` instead. @MainActor func performModelTranslation(api: ApiClient, from apiType: ApiType) -> Content { // the name of this method is intentionally unwieldy to further discourage accidental use preconditionFailure("This method must be overridden by the instantiating class: \(self)") } @MainActor func updateModel(_ item: Content, with apiType: ApiType, semaphore: UInt? = nil) { preconditionFailure("This method must be overridden by the instantiating class: \(self)") } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Atomic.swift ================================================ // // Atomic.swift // ReactiveSwift // // Created by Justin Spahr-Summers on 2014-06-10. // Copyright (c) 2014 GitHub. All rights reserved. // // See NOTICE.md for license import Foundation #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) import MachO #endif /// A simple, generic lock-free finite state machine. /// /// - warning: `deinitialize` must be called to dispose of the consumed memory. private struct UnsafeAtomicState where State.RawValue == Int32 { typealias Transition = (expected: State, next: State) #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) private let value: UnsafeMutablePointer /// Create a finite state machine with the specified initial state. /// /// - parameters: /// - initial: The desired initial state. init(_ initial: State) { self.value = UnsafeMutablePointer.allocate(capacity: 1) value.initialize(to: initial.rawValue) } /// Deinitialize the finite state machine. func deinitialize() { value.deinitialize(count: 1) value.deallocate() } /// Compare the current state with the specified state. /// /// - parameters: /// - expected: The expected state. /// /// - returns: `true` if the current state matches the expected state. /// `false` otherwise. func `is`(_ expected: State) -> Bool { expected.rawValue == value.pointee } /// Try to transition from the expected current state to the specified next /// state. /// /// - parameters: /// - expected: The expected state. /// - next: The state to transition to. /// /// - returns: `true` if the transition succeeds. `false` otherwise. func tryTransition(from expected: State, to next: State) -> Bool { OSAtomicCompareAndSwap32Barrier( expected.rawValue, next.rawValue, value ) } #else private let value: Atomic /// Create a finite state machine with the specified initial state. /// /// - parameters: /// - initial: The desired initial state. init(_ initial: State) { self.value = Atomic(initial.rawValue) } /// Deinitialize the finite state machine. func deinitialize() {} /// Compare the current state with the specified state. /// /// - parameters: /// - expected: The expected state. /// /// - returns: `true` if the current state matches the expected state. /// `false` otherwise. func `is`(_ expected: State) -> Bool { value.value == expected.rawValue } /// Try to transition from the expected current state to the specified next /// state. /// /// - parameters: /// - expected: The expected state. /// /// - returns: `true` if the transition succeeds. `false` otherwise. func tryTransition(from expected: State, to next: State) -> Bool { value.modify { value in if value == expected.rawValue { value = next.rawValue return true } return false } } #endif } /// `Lock` exposes `os_unfair_lock` on supported platforms, with pthread mutex as the /// fallback. private class Lock { #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) @available(iOS 10.0, *) @available(macOS 10.12, *) @available(tvOS 10.0, *) @available(watchOS 3.0, *) final class UnfairLock: Lock { private let _lock: os_unfair_lock_t override init() { self._lock = .allocate(capacity: 1) _lock.initialize(to: os_unfair_lock()) super.init() } override func lock() { os_unfair_lock_lock(_lock) } override func unlock() { os_unfair_lock_unlock(_lock) } override func `try`() -> Bool { os_unfair_lock_trylock(_lock) } deinit { _lock.deinitialize(count: 1) _lock.deallocate() } } #endif final class PthreadLock: Lock { private let _lock: UnsafeMutablePointer init(recursive: Bool = false) { self._lock = .allocate(capacity: 1) _lock.initialize(to: pthread_mutex_t()) let attr = UnsafeMutablePointer.allocate(capacity: 1) attr.initialize(to: pthread_mutexattr_t()) pthread_mutexattr_init(attr) defer { pthread_mutexattr_destroy(attr) attr.deinitialize(count: 1) attr.deallocate() } pthread_mutexattr_settype(attr, Int32(recursive ? PTHREAD_MUTEX_RECURSIVE : PTHREAD_MUTEX_ERRORCHECK)) let status = pthread_mutex_init(_lock, attr) assert(status == 0, "Unexpected pthread mutex error code: \(status)") super.init() } override func lock() { let status = pthread_mutex_lock(_lock) assert(status == 0, "Unexpected pthread mutex error code: \(status)") } override func unlock() { let status = pthread_mutex_unlock(_lock) assert(status == 0, "Unexpected pthread mutex error code: \(status)") } override func `try`() -> Bool { let status = pthread_mutex_trylock(_lock) switch status { case 0: return true case EBUSY, EAGAIN: return false default: assertionFailure("Unexpected pthread mutex error code: \(status)") return false } } deinit { let status = pthread_mutex_destroy(_lock) assert(status == 0, "Unexpected pthread mutex error code: \(status)") _lock.deinitialize(count: 1) _lock.deallocate() } } static func make() -> Lock { #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) if #available(*, iOS 10.0, macOS 10.12, tvOS 10.0, watchOS 3.0) { return UnfairLock() } #endif return PthreadLock() } private init() {} func lock() { fatalError() } func unlock() { fatalError() } func `try`() -> Bool { fatalError() } } /// An atomic variable. public final class Atomic { private let lock: Lock private var _value: Value /// Atomically get or set the value of the variable. public var value: Value { get { withValue { $0 } } set(newValue) { swap(newValue) } } /// Initialize the variable with the given initial value. /// /// - parameters: /// - value: Initial value for `self`. public init(_ value: Value) { self._value = value self.lock = Lock.make() } /// Atomically modifies the variable. /// /// - parameters: /// - action: A closure that takes the current value. /// /// - returns: The result of the action. @discardableResult public func modify(_ action: (inout Value) throws -> Result) rethrows -> Result { lock.lock() defer { lock.unlock() } return try action(&_value) } /// Atomically perform an arbitrary action using the current value of the /// variable. /// /// - parameters: /// - action: A closure that takes the current value. /// /// - returns: The result of the action. @discardableResult public func withValue(_ action: (Value) throws -> Result) rethrows -> Result { lock.lock() defer { lock.unlock() } return try action(_value) } /// Atomically replace the contents of the variable. /// /// - parameters: /// - newValue: A new value for the variable. /// /// - returns: The old value. @discardableResult public func swap(_ newValue: Value) -> Value { modify { (value: inout Value) in let oldValue = value value = newValue return oldValue } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/CommentCaches.swift ================================================ // // CommentCache.swift // // // Created by Sjmarf on 24/06/2024. // import Foundation public enum AnyCommentSnapshot: CacheIdentifiable { case comment1(Comment1Snapshot) case comment2(Comment2Snapshot) public var cacheId: Int { switch self { case let .comment1(snapshot): snapshot.cacheId case let .comment2(snapshot): snapshot.cacheId } } } class CommentCache: ApiTypeBackedCache { override func performModelTranslation(api: ApiClient, from apiType: AnyCommentSnapshot) -> Comment { return .init(api: api, properties: .init(api: api, snapshot: apiType)) } override func updateModel(_ item: Comment, with apiType: AnyCommentSnapshot, semaphore: UInt? = nil) { // attempt a direct update through the queue to avoid overwriting more recent data, and also // synchronously perform softUpdate to ensure high-tier data is available where expected let properties: CommentProperties = .init(api: item.api, snapshot: apiType) Task { await item.updateQueue.attemptDirectUpdate(with: properties) } item.softUpdate(with: properties) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/CommunityCache.swift ================================================ // // CommunityCache.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation public enum AnyCommunitySnapshot: CacheIdentifiable { case community1(Community1Snapshot) case community2(Community2Snapshot) case community3(Community3Snapshot) public var cacheId: Int { switch self { case let .community1(snapshot): snapshot.cacheId case let .community2(snapshot): snapshot.cacheId case let .community3(snapshot): snapshot.cacheId } } } class CommunityCache: ApiTypeBackedCache { override func performModelTranslation(api: ApiClient, from apiType: AnyCommunitySnapshot) -> Community { return .init(api: api, properties: .init(api: api, snapshot: apiType)) } override func updateModel(_ item: Community, with apiType: AnyCommunitySnapshot, semaphore: UInt? = nil) { // attempt a direct update through the queue to avoid overwriting more recent data, and also // synchronously perform softUpdate to ensure high-tier data is available where expected let properties: CommunityProperties = .init(api: item.api, snapshot: apiType) Task { await item.updateQueue.attemptDirectUpdate(with: properties) } item.softUpdate(with: properties) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/ImageUploadCaches.swift ================================================ // // File.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation class ImageUpload1Cache: CoreCache { func getModel(api: ApiClient, from snapshot: ImageUpload1Snapshot, semaphore: UInt? = nil) -> ImageUpload1 { if let item = retrieveModel(cacheId: snapshot.cacheId) { return item } let newItem: ImageUpload1 = .init( api: api, url: snapshot.url, alias: snapshot.alias, deleteToken: snapshot.deleteToken ) itemCache.put(newItem) return newItem } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/InstanceCache.swift ================================================ // // InstanceCaches.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation public enum AnyInstanceSnapshot: CacheIdentifiable { case instance1(Instance1Snapshot) case instance2(Instance2Snapshot) case instance3(Instance3Snapshot) public var cacheId: Int { switch self { case let .instance1(snapshot): snapshot.cacheId case let .instance2(snapshot): snapshot.cacheId case let .instance3(snapshot): snapshot.cacheId } } } class InstanceCache: CoreCache { public var instanceIdCache: ItemCache = .init() @MainActor func getModel(api: ApiClient, from snapshot: AnyInstanceSnapshot) -> Instance { if let item = retrieveModel(cacheId: snapshot.cacheId) { item.update(with: .init(api: api, snapshot: snapshot)) return item } let newItem: Instance = .init( api: api, properties: .init(api: api, snapshot: snapshot) ) itemCache.put(newItem) instanceIdCache.put(newItem, overrideCacheId: newItem.instanceId) return newItem } @MainActor func getModels(api: ApiClient, from snapshots: [AnyInstanceSnapshot]) -> [Instance] { snapshots.map { getModel(api: api, from: $0) } } /// Get an instance with the given `instanceId` - this is different from the `id` of the instance. public func retrieveModel(instanceId: Int) -> Instance? { instanceIdCache.get(instanceId) } override func clean() { Task { await itemCache.clean() await instanceIdCache.clean() } } /// Convenience method for getting an optional site @MainActor func getOptionalModel(api: ApiClient, from snapshot: AnyInstanceSnapshot?) -> Instance? { if let snapshot { return getModel(api: api, from: snapshot) } return nil } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/MessageCaches.swift ================================================ // // MessageCaches.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation class Message1Cache: CoreCache { @MainActor func getModel( api: ApiClient, from snapshot: Message1Snapshot, myPersonId: Int, semaphore: UInt? = nil ) -> Message1 { if let item = retrieveModel(cacheId: snapshot.cacheId) { item.update(with: snapshot, semaphore: semaphore) return item } let newItem: Message1 = .init( api: api, actorId: snapshot.actorId, id: snapshot.id, creatorId: snapshot.creatorId, recipientId: snapshot.recipientId, isOwnMessage: myPersonId == snapshot.creatorId, content: snapshot.content, deleted: snapshot.deleted, created: snapshot.created, updated: snapshot.updated ) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from snapshots: [Message1Snapshot], myPersonId: Int, semaphore: UInt? = nil ) -> [Message1] { snapshots.map { getModel(api: api, from: $0, myPersonId: myPersonId, semaphore: semaphore) } } } class Message2Cache: CoreCache { @MainActor func getModel( api: ApiClient, from snapshot: Message2Snapshot, myPersonId: Int, semaphore: UInt? = nil ) -> Message2 { if let item = retrieveModel(cacheId: snapshot.cacheId) { item.update(with: snapshot, semaphore: semaphore) return item } let newItem: Message2 = .init( api: api, message1: api.caches.message1.getModel(api: api, from: snapshot.message, myPersonId: myPersonId), creator: api.caches.person.getModel(api: api, from: .person1(snapshot.creator)), recipient: api.caches.person.getModel(api: api, from: .person1(snapshot.recipient)) ) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from snapshots: [Message2Snapshot], myPersonId: Int, semaphore: UInt? = nil ) -> [Message2] { snapshots.map { getModel(api: api, from: $0, myPersonId: myPersonId, semaphore: semaphore) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/NotificationCaches.swift ================================================ // // NotificationCaches.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation class NotificationCache: CoreCache { @MainActor func getModel( api: ApiClient, from snapshot: InboxNotificationSnapshot, myPersonId: Int, semaphore: UInt? = nil ) -> InboxNotification { if let item = retrieveModel(cacheId: snapshot.cacheId) { Task { await item.updateQueue.attemptDirectUpdate(with: snapshot) } return item } let content: InboxNotificationContent = switch snapshot.content { case let .reply(commentSnapshot): .reply(api.caches.comment.getModel(api: api, from: .comment2(commentSnapshot))) case let .mention(commentSnapshot): .mention(api.caches.comment.getModel(api: api, from: .comment2(commentSnapshot))) case let .message(messageSnapshot): .message(api.caches.message2.getModel(api: api, from: messageSnapshot, myPersonId: myPersonId)) } let read: Bool = switch snapshot.content { case let .message(messageSnapshot): messageSnapshot.creator.id == myPersonId ? true : snapshot.read default: snapshot.read } let newItem: InboxNotification = .init( api: api, id: snapshot.id, contentId: snapshot.contentId, read: read, content: content ) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from snapshots: [InboxNotificationSnapshot], myPersonId: Int, semaphore: UInt? = nil ) -> [InboxNotification] { snapshots.map { getModel(api: api, from: $0, myPersonId: myPersonId, semaphore: semaphore) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/PersonCache.swift ================================================ // // PersonCache.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation public enum AnyPersonSnapshot: CacheIdentifiable { case person1(Person1Snapshot) case person2(Person2Snapshot) case person3(Person3Snapshot) case person4(Person4Snapshot) static func person1(_ snapshot: Person1Snapshot?) -> AnyPersonSnapshot? { if let snapshot { return .person1(snapshot) } return nil } static func person2(_ snapshot: Person2Snapshot?) -> AnyPersonSnapshot? { if let snapshot { return .person2(snapshot) } return nil } static func person3(_ snapshot: Person3Snapshot?) -> AnyPersonSnapshot? { if let snapshot { return .person3(snapshot) } return nil } static func person4(_ snapshot: Person4Snapshot?) -> AnyPersonSnapshot? { if let snapshot { return .person4(snapshot) } return nil } public var cacheId: Int { switch self { case let .person1(snapshot): snapshot.cacheId case let .person2(snapshot): snapshot.cacheId case let .person3(snapshot): snapshot.cacheId case let .person4(snapshot): snapshot.cacheId } } } class PersonCache: ApiTypeBackedCache { override func performModelTranslation(api: ApiClient, from apiType: AnyPersonSnapshot) -> Person { return .init(api: api, properties: .init(api: api, snapshot: apiType)) } override func updateModel(_ item: Person, with apiType: AnyPersonSnapshot, semaphore: UInt? = nil) { // attempt a direct update through the queue to avoid overwriting more recent data, and also // synchronously perform softUpdate to ensure high-tier data is available where expected let properties: PersonProperties = .init(api: item.api, snapshot: apiType) Task { await item.updateQueue.attemptDirectUpdate(with: properties) } item.softUpdate(with: properties) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/PersonVoteCaches.swift ================================================ // // PersonVoteCaches.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-18. // import Foundation class PersonVoteCache: CoreCache { @MainActor func getModel( api: ApiClient, from snapshot: PersonVoteSnapshot, target: PersonVote.Target, communityId: Int, semaphore: UInt? = nil ) -> PersonVote { if let item = retrieveModel(cacheId: getCacheId(target: target, creatorId: snapshot.creator.id)) { item.update(with: snapshot, semaphore: semaphore) return item } let newItem: PersonVote = .init( api: api, target: target, communityId: communityId, creator: api.caches.person.getModel(api: api, from: .person1(snapshot.creator)), vote: .init(rawValue: snapshot.score) ?? .none, creatorBannedFromCommunity: snapshot.creatorBannedFromCommunity ) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from snapshots: [PersonVoteSnapshot], target: PersonVote.Target, communityId: Int, semaphore: UInt? = nil ) -> [PersonVote] { snapshots.map { getModel( api: api, from: $0, target: target, communityId: communityId, semaphore: semaphore ) } } private func getCacheId(target: PersonVote.Target, creatorId: Int) -> Int { var hasher = Hasher() hasher.combine(target) hasher.combine(creatorId) return hasher.finalize() } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/PostCache.swift ================================================ // // PostCache.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-03. // public enum AnyPostSnapshot: CacheIdentifiable { case post1(Post1Snapshot) case post2(Post2Snapshot) case post3(Post3Snapshot) public var cacheId: Int { switch self { case let .post1(snapshot): snapshot.cacheId case let .post2(snapshot): snapshot.cacheId case let .post3(snapshot): snapshot.cacheId } } } class PostCache: ApiTypeBackedCache { override func performModelTranslation(api: ApiClient, from apiType: AnyPostSnapshot) -> Post { return .init(api: api, properties: .init(api: api, snapshot: apiType)) } override func updateModel(_ item: Post, with apiType: AnyPostSnapshot, semaphore: UInt? = nil) { // attempt a direct update through the queue to avoid overwriting more recent data, and also // synchronously perform softUpdate to ensure high-tier data is available where expected let properties: PostProperties = .init(api: item.api, snapshot: apiType) Task { await item.updateQueue.attemptDirectUpdate(with: properties) } item.softUpdate(with: properties) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/RegistrationApplicationCaches.swift ================================================ // // RegistrationApplicationCaches.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-12. // import Foundation class RegistrationApplicationCache: ApiTypeBackedCache { @MainActor override func performModelTranslation( api: ApiClient, from snapshot: RegistrationApplicationSnapshot ) -> RegistrationApplication { .init( api: api, id: snapshot.id, questionResponse: snapshot.questionResponse, creator: api.caches.person.getModel(api: api, from: .person1(snapshot.creator)), resolver: api.caches.person.getOptionalModel(api: api, from: .person1(snapshot.resolver)), email: snapshot.email, emailVerified: snapshot.emailVerified, showNsfw: snapshot.showNsfw, created: snapshot.created, resolution: snapshot.resolution ) } @MainActor override func updateModel( _ item: RegistrationApplication, with snapshot: RegistrationApplicationSnapshot, semaphore: UInt? = nil ) { item.update(with: snapshot, semaphore: semaphore) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Caches/ReportCaches.swift ================================================ // // ReportCaches.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-16. // import Foundation // Report can be created from any ReportApiBacker, so we can't use ApiTypeBackedCache class ReportCache: CoreCache { @MainActor func getModel( api: ApiClient, from snapshot: ReportSnapshot, myPersonId: Int, semaphore: UInt? = nil ) -> Report { if let item = retrieveModel(cacheId: snapshot.cacheId) { item.update(with: snapshot, semaphore: semaphore) return item } let newItem: Report = .init( api: api, id: snapshot.id, creator: api.caches.person.getModel(api: api, from: .person1(snapshot.creator), semaphore: semaphore), resolver: api.caches.person.getOptionalModel(api: api, from: .person1(snapshot.resolver), semaphore: semaphore), target: .init(from: snapshot.target, api: api, myPersonId: myPersonId), resolved: snapshot.resolved, reason: snapshot.reason, created: snapshot.created, updated: snapshot.updated ) itemCache.put(newItem) return newItem } @MainActor func getModels( api: ApiClient, from snapshots: [ReportSnapshot], myPersonId: Int, semaphore: UInt? = nil ) -> [Report] { snapshots.map { getModel(api: api, from: $0, myPersonId: myPersonId, semaphore: semaphore) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Conformance/ImageUpload+CacheExtensions.swift ================================================ // // ImageUpload+CacheExtensions.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation extension ImageUpload1: CacheIdentifiable { public var cacheId: Int { alias.hashValue } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Conformance/InboxNotification+CacheExtensions.swift ================================================ // // Notification+CacheExtensions.swift // Mlem // // Created by Eric Andrews on 2024-03-02. // extension InboxNotification: CacheIdentifiable { public var cacheId: Int { id } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Conformance/Message+CacheExtensions.swift ================================================ // // Message+CacheExtensions.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation extension Message1: CacheIdentifiable { public var cacheId: Int { id } @MainActor func update(with snapshot: Message1Snapshot, semaphore: UInt? = nil) { setIfChanged(\.content, snapshot.content) setIfChanged(\.updated, snapshot.updated) deletedManager.updateWithReceivedValue(snapshot.deleted, semaphore: semaphore) } } extension Message2: CacheIdentifiable { public var cacheId: Int { id } @MainActor func update(with snapshot: Message2Snapshot, semaphore: UInt? = nil) { message1.update(with: snapshot.message, semaphore: semaphore) Task { await creator.updateQueue.attemptDirectUpdate(with: .init(api: api, snapshot: .person1(snapshot.creator))) await recipient.updateQueue.attemptDirectUpdate(with: .init(api: api, snapshot: .person1(snapshot.recipient))) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Conformance/RegistrationApplication+CacheExtensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-12. // import Foundation extension RegistrationApplication: CacheIdentifiable { public var cacheId: Int { id } @MainActor func update(with snapshot: RegistrationApplicationSnapshot, semaphore: UInt? = nil) { setIfChanged(\.questionResponse, snapshot.questionResponse) resolutionManager.updateWithReceivedValue(resolution, semaphore: semaphore) setIfChanged(\.resolver, api.caches.person.getOptionalModel(api: api, from: .person1(snapshot.resolver))) setIfChanged(\.email, snapshot.email) setIfChanged(\.emailVerified, snapshot.emailVerified) setIfChanged(\.showNsfw, snapshot.showNsfw) Task { await creator.updateQueue.attemptDirectUpdate(with: .init(api: api, snapshot: .person1(snapshot.creator))) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/Conformance/Report+CacheExtensions.swift ================================================ // // Report.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-16. // import Foundation extension Report { public var cacheId: Int { var hasher = Hasher() hasher.combine(target.type) hasher.combine(id) return hasher.finalize() } @MainActor func update(with snapshot: ReportSnapshot, semaphore: UInt? = nil) { setIfChanged(\.updated, snapshot.updated) setIfChanged(\.reason, snapshot.reason) setIfChanged(\.resolver, api.caches.person.getOptionalModel(api: api, from: .person1(snapshot.resolver))) resolvedManager.updateWithReceivedValue(snapshot.resolved, semaphore: semaphore) target.update(with: snapshot.target) Task { await creator.updateQueue.attemptDirectUpdate(with: .init(api: api, snapshot: .person1(snapshot.creator))) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Caching/CoreCache.swift ================================================ // // CoreCache.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation import MlemLogger import os import Semaphore /// Class providing common caching behavior open class CoreCache { public var itemCache: ItemCache = .init() public init() { self.itemCache = .init() } public class ItemCache { private let log: Logger = .mlemLogger() private var cachedItems: Atomic<[Int: WeakReference]> = .init(.init()) private let cleaningSemaphore: AsyncSemaphore = .init(value: 1) var value: [Int: WeakReference] { cachedItems.value } public func put(_ item: Content, overrideCacheId: Int? = nil) { let cacheId = overrideCacheId ?? item.cacheId cachedItems.value[cacheId] = .init(content: item) } public func get(_ cacheId: Int) -> Content? { cachedItems.value[cacheId]?.content } public func remove(_ cacheId: Int) { log.debug("Removed \(cacheId)") cachedItems.value[cacheId] = nil } public func clean() async { await cleaningSemaphore.wait() defer { cleaningSemaphore.signal() } for (key, value) in cachedItems.value where value.content == nil { remove(key) } } } /// Retrieves the cached model with the given cacheId, if present /// - Parameter cacheId: cacheId of the model to retrieve /// - Returns: cached model if present, nil otherwise public func retrieveModel(cacheId: Int) -> Content? { itemCache.get(cacheId) } /// Remove dead references public func clean() { Task { await itemCache.clean() } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Helpers/MarkReadQueue.swift ================================================ // // MarkReadQueue.swift // // // Created by Sjmarf on 29/05/2024. // import Foundation actor MarkReadQueue { var ids: Set = .init() func popAll() -> Set { defer { ids.removeAll() } return ids } func add(_ postId: Int) { ids.insert(postId) } func remove(_ postId: Int) { ids.remove(postId) } func union(_ other: Set) { ids.formUnion(other) } func subtract(_ other: Set) { ids.subtract(other) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Client/Helpers/SharedTaskManager.swift ================================================ // // SharedTaskManager.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-27. // import Foundation public class SharedTaskManager { var fetchTask: (() async throws -> TaskResponse)! var createValue: ((TaskResponse) -> Value)! var ongoingTask: Task? var fetchedValue: Value? init( fetchTask: (() async throws -> TaskResponse)? = nil, createValue: ((TaskResponse) -> Value)? = nil ) { self.fetchTask = fetchTask self.createValue = createValue } @discardableResult public func getValue(task: Task? = nil) async throws -> Value { if let fetchedValue { return fetchedValue } else { if let ongoingTask { let result = await ongoingTask.result return try createValue(result.get()) } else { defer { ongoingTask = nil } let task = task ?? ongoingTask ?? Task { try await fetchTask() } ongoingTask = task let result = await task.result fetchedValue = try createValue(result.get()) return try await createValue(fetchTask()) } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Comment.swift ================================================ // // ApiRepository+Comment.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-02. // import Foundation extension ApiRepository { func getComment(id: Int) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.getComment(id: id) } } func getComment(url: URL) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.getComment(url: url) } } func getComments( sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { try await performingForConnection { connection in try await connection.getComments( sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) } } func getComments( postId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { try await performingForConnection { connection in try await connection.getComments( postId: postId, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) } } func getComments( parentId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { try await performingForConnection { connection in try await connection.getComments( parentId: parentId, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) } } func getCommentHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (comments: [Comment2Snapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getCommentHistory( type: type, page: page, cursor: cursor, limit: limit ) } } // TODO: Remove in favor of the below method once we drop support for versions before Lemmy 1.0 func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: CommentSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { try await performingForConnection { connection in try await connection.searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) } } func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { try await performingForConnection { connection in try await connection.searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) } } func voteOnComment(id: Int, score: ScoringOperation, semaphore: UInt? = nil) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.voteOnComment(id: id, score: score) } } func saveComment(id: Int, save: Bool, semaphore: UInt? = nil) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.saveComment(id: id, save: save) } } func deleteComment(id: Int, delete: Bool, semaphore: UInt? = nil) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.deleteComment(id: id, delete: delete) } } func editComment( id: Int, content: String, languageId: Int? ) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.editComment( id: id, content: content, languageId: languageId ) } } // There's also a `replyToPost` method in `ApiRepository+Post` for creating a comment on a post func replyToComment(postId: Int, parentId: Int?, content: String, languageId: Int? = nil) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.replyToComment( postId: postId, parentId: parentId, content: content, languageId: languageId ) } } func reportComment(id: Int, reason: String) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.reportComment(id: id, reason: reason) } } func purgeComment(id: Int, reason: String?) async throws { try await performingForConnection { connection in try await connection.purgeComment(id: id, reason: reason) } } func removeComment( id: Int, remove: Bool, reason: String?, semaphore: UInt? = nil ) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.removeComment(id: id, remove: remove, reason: reason) } } func getCommentVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { try await performingForConnection { connection in try await connection.getCommentVotes( id: id, page: page, limit: limit ) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Community.swift ================================================ // // ApiRepository+Community.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-02. // import Foundation extension ApiRepository { func getCommunity(id: Int) async throws -> Community3Snapshot { try await performingForConnection { connection in try await connection.getCommunity(id: id) } } func getCommunity(url: URL) async throws -> Community2Snapshot { try await performingForConnection { connection in try await connection.getCommunity(url: url) } } func searchCommunities( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType, hostApi: ApiClient? = nil ) async throws -> [Community2Snapshot] { try await performingForConnection { connection in try await connection.searchCommunities( query: query, page: page, limit: limit, filter: filter, sort: sort ) } } func editCommunityDescription(id: Int, newValue: String?) async throws -> Community2Snapshot { try await performingForConnection { connection in try await connection.editCommunityDescription(id: id, newValue: newValue) } } func getSubscriptionList(page: Int, limit: Int) async throws -> [Community2Snapshot] { try await performingForConnection { connection in try await connection.getSubscriptionList(page: page, limit: limit) } } func subscribeToCommunity(id: Int, subscribe: Bool) async throws -> Community2Snapshot { try await performingForConnection { connection in try await connection.subscribeToCommunity(id: id, subscribe: subscribe) } } func blockCommunity(id: Int, block: Bool, semaphore: UInt? = nil) async throws -> Community2Snapshot { try await performingForConnection { connection in try await connection.blockCommunity(id: id, block: block) } } func removeCommunity( id: Int, remove: Bool, reason: String? ) async throws -> Community2Snapshot { try await performingForConnection { connection in try await connection.removeCommunity( id: id, remove: remove, reason: reason ) } } func purgeCommunity(id: Int, reason: String?) async throws { try await performingForConnection { connection in try await connection.purgeCommunity(id: id, reason: reason) } } func addModerator(communityId: Int, personId: Int, added: Bool) async throws -> (moderators: [Person1Snapshot], community: Community1Snapshot) { try await performingForConnection { connection in try await connection.addModerator( communityId: communityId, personId: personId, added: added ) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+General.swift ================================================ // // ApiRepository+General.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-02. // import Foundation extension ApiRepository { func getAccountToken(usernameOrEmail: String, password: String, totpToken: String?) async throws -> String { try await performingForConnection { connection in try await connection.getAccountToken( usernameOrEmail: usernameOrEmail, password: password, totpToken: totpToken ) } } func getUsernameFromToken(token: String) async throws -> String { try await performingForConnection { connection in try await connection.getUsernameFromToken(token: token) } } func signUp( username: String, password: String, confirmPassword: String, showNsfw: Bool, email: String?, captcha: Captcha?, captchaAnswer: String?, applicationQuestionResponse: String? ) async throws -> SignUpResponse { try await performingForConnection { connection in try await connection.signUp( username: username, password: password, confirmPassword: confirmPassword, showNsfw: showNsfw, email: email, captcha: captcha, captchaAnswer: captchaAnswer, applicationQuestionResponse: applicationQuestionResponse ) } } func changePassword( newPassword: String, confirmNewPassword: String, oldPassword: String ) async throws -> String { try await performingForConnection { connection in try await connection.changePassword( newPassword: newPassword, confirmNewPassword: confirmNewPassword, oldPassword: oldPassword ) } } func getCaptcha() async throws -> Captcha { try await performingForConnection { connection in try await connection.getCaptcha() } } func resolve(url: URL) async throws -> ResolvedContent { try await performingForConnection { connection in try await connection.resolve(url: url) } } func getBlocked() async throws -> (people: [Person1Snapshot], communities: [Community1Snapshot], instances: [Instance1Snapshot]) { try await performingForConnection { connection in try await connection.getBlocked() } } func getModlog( page: Int = 1, limit: Int = 20, communityId: Int? = nil, moderatorId: Int? = nil, subjectPersonId: Int? = nil, postId: Int? = nil, commentId: Int? = nil, type: ModlogEntryType? = nil ) async throws -> [ModlogEntrySnapshot] { try await performingForConnection { connection in try await connection.getModlog( page: page, limit: limit, communityId: communityId, moderatorId: moderatorId, subjectPersonId: subjectPersonId, postId: postId, commentId: commentId, type: type ) } } func getPostLink(url: URL) async throws -> PostLink { try await performingForConnection { connection in try await connection.getPostLink(url: url) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Image.swift ================================================ // // ApiRepository+Image.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // import Foundation import Rest extension ApiRepository { func uploadImage( _ imageData: Data, fileExtension: String, onProgress progressCallback: @escaping (_ progress: Double) -> Void = { _ in } ) async throws -> ImageUpload1Snapshot { try await performingForConnection { connection in try await connection.uploadImage(imageData, fileExtension: fileExtension, onProgress: progressCallback) } } func deleteImage(alias: String, deleteToken: String) async throws { try await performingForConnection { connection in try await connection.deleteImage(alias: alias, deleteToken: deleteToken) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Inbox.swift ================================================ // // ApiRepository+Inbox.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // extension ApiRepository { func getMessages( creatorId: Int? = nil, page: Int, limit: Int, unreadOnly: Bool = false ) async throws -> [Message2Snapshot] { try await performingForConnection { connection in try await connection.getMessages( creatorId: creatorId, page: page, limit: limit, unreadOnly: unreadOnly ) } } func getReplyNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getReplyNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) } } func getMentionNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getMentionNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) } } func getMessageNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getMessageNotifications( page: page, cursor: cursor, limit: limit, unreadOnly: unreadOnly ) } } func markNotificationAsRead( type: InboxNotificationContentType, id: Int, contentId: Int, read: Bool ) async throws { try await performingForConnection { connection in try await connection.markNotificationAsRead( type: type, id: id, contentId: contentId, read: read ) } } func markAllAsRead() async throws { try await performingForConnection { connection in try await connection.markAllAsRead() } } func getPersonalUnreadCount() async throws -> PersonalUnreadCountSnapshot { try await performingForConnection { connection in try await connection.getPersonalUnreadCount() } } func createMessage(personId: Int, content: String) async throws -> Message2Snapshot { try await performingForConnection { connection in try await connection.createMessage(personId: personId, content: content) } } func editMessage(id: Int, content: String) async throws -> Message2Snapshot { try await performingForConnection { connection in try await connection.editMessage(id: id, content: content) } } func reportMessage(id: Int, reason: String) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.reportMessage(id: id, reason: reason) } } func deleteMessage(id: Int, delete: Bool, semaphore: UInt? = nil) async throws -> Message2Snapshot { try await performingForConnection { connection in try await connection.deleteMessage(id: id, delete: delete) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Instance.swift ================================================ // // ApiRepository+Instance.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-02. // extension ApiRepository { func getMyInstance() async throws -> Instance3Snapshot { return try await performingForConnection { connection in try await connection.getMyInstance() } } func getFederatedInstances() async throws -> FederationPolicy { let response = try await performingForConnection { connection in try await connection.getFederatedInstances() } return response } func blockInstance(instanceId: Int, block: Bool) async throws { try await performingForConnection { connection in try await connection.blockInstance(instanceId: instanceId, block: block) } } func addAdmin(personId: Int, added: Bool) async throws -> [Person2Snapshot] { try await performingForConnection { connection in try await connection.addAdmin(personId: personId, added: added) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Mock.swift ================================================ // // ApiRepository+Mock.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // import Foundation import Rest // TODO: updated mocks //#if DEBUG // // class MockApiRepository: ApiRepository { // var posts: [Post2] // var communities: [Community2] // var people: [Person2] // var comments: [Comment2] // // init( // url: URL, // username: String, // posts: [Post2] = [], // communities: [Community2] = [], // people: [Person2] = [], // comments: [Comment2] = [] // ) { // self.posts = posts // self.communities = communities // self.people = people // self.comments = comments // // super.init( // baseUrl: url, // username: username // ) // self.token = "" // Not nil so that the views are interactable // let connection = LemmyConnection(baseUrl: url, token: "") // connection.setMockContext(.init(siteVersion: .init("0.19.0"), myPersonId: nil)) // self.connection = connection // } // // override func perform( // _ request: Request, // tokenOverride: String? = nil, // requiresToken: Bool = true // ) async throws -> Request.Response { // if let request = request as? LemmyListPostsRequest, request.parameters != nil { // return LemmyGetPostsResponse( // posts: posts.map(\.apiPostView), // nextPage: nil // ) as! Request.Response // } // // if let request = request as? LemmyListCommentsRequest, request.parameters != nil { // return LemmyGetCommentsResponse(comments: comments.map(\.apiCommentView)) as! Request.Response // } // // if let request = request as? LemmyReadPersonRequest, let params = request.parameters { // if let person = people.first(where: { $0.id == params.personId })?.apiPersonView { // return LemmyGetPersonDetailsResponse( // personView: person, // comments: nil, // posts: posts.filter { $0.creator.id == params.personId }.map(\.apiPostView), // moderates: [], // site: nil, // multiCommunitiesCreated: nil // ) as! Request.Response // } // } // // if let request = request as? LemmyResolveObjectRequest, let params = request.parameters { // return LemmyResolveObjectResponse( // comment: comments.first(where: { $0.actorId.description == params.q })?.apiCommentView, // post: posts.first(where: { $0.actorId.description == params.q })?.apiPostView, // community: communities.first(where: { $0.actorId.description == params.q })?.apiCommunityView, // person: people.first(where: { $0.actorId.description == params.q })?.apiPersonView // ) as! Request.Response // } // // if let request = request as? LemmyGetCommunityRequest, let params = request.parameters { // if let community = communities.first(where: { $0.id == params.id })?.apiCommunityView { // return LemmyGetCommunityResponse( // communityView: community, // site: nil, // moderators: [], // discussionLanguages: [] // ) as! Request.Response // } // } // // if let request = request as? LemmySearchRequest, let params = request.parameters { // return LemmySearchResponse( // type_: params.type_, // comments: [], // posts: [], // communities: params.type_ == .communities ? communities.map(\.apiCommunityView) : [], // users: params.type_ == .users ? people.map(\.apiPersonView) : [], // resolve: nil, // search: nil, // nextPage: nil, // prevPage: nil // ) as! Request.Response // } // // throw ApiClientError.insufficientPermissions // } // } // //#endif ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Person.swift ================================================ // // ApiRepository+Person.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // import Foundation extension ApiRepository { func getPerson(id: Int) async throws -> Person3Snapshot { try await performingForConnection { connection in try await connection.getPerson(id: id) } } func getPerson(url: URL) async throws -> Person2Snapshot { try await performingForConnection { connection in try await connection.getPerson(url: url) } } func getPerson(username: String) async throws -> Person3Snapshot { try await performingForConnection { connection in try await connection.getPerson(username: username) } } /// `filter` can be set to `.local` from 0.19.4 onwards. func searchPeople( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Person2Snapshot] { try await performingForConnection { connection in try await connection.searchPeople( query: query, page: page, limit: limit, filter: filter, sort: sort ) } } func blockPerson(id: Int, block: Bool, semaphore: UInt? = nil) async throws -> Person2Snapshot { try await performingForConnection { connection in try await connection.blockPerson(id: id, block: block) } } func banPersonFromCommunity( personId: Int, communityId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person1Snapshot { try await performingForConnection { connection in try await connection.banPersonFromCommunity( personId: personId, communityId: communityId, ban: ban, removeContent: removeContent, reason: reason, expires: expires ) } } func banPersonFromInstance( personId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person2Snapshot { try await performingForConnection { connection in try await connection.banPersonFromInstance( personId: personId, ban: ban, removeContent: removeContent, reason: reason, expires: expires ) } } func purgePerson(id: Int, reason: String?) async throws { try await performingForConnection { connection in try await connection.purgePerson(id: id, reason: reason) } } func getContent( authorId id: Int, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool? = nil, communityId: Int? = nil ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot], comments: [Comment2Snapshot]) { try await performingForConnection { connection in try await connection.getContent( authorId: id, sort: sort, page: page, limit: limit, savedOnly: savedOnly, communityId: communityId ) } } func getMyPerson() async throws -> (person: Person4Snapshot?, instance: Instance3Snapshot, blocks: BlockListSnapshot?) { try await performingForConnection { connection in try await connection.getMyPerson() } } func deleteAccount(password: String, deleteContent: Bool) async throws { try await performingForConnection { connection in try await connection.deleteAccount(password: password, deleteContent: deleteContent) } } func editNote(id: Int, content: String?) async throws { try await performingForConnection { connection in try await connection.editNote(id: id, content: content) } } func editProfile(_ details: ProfileDetails) async throws { try await performingForConnection { connection in try await connection.editProfile(details: details) } } func editAccountSettings( showNsfw: Bool?, showScores: Bool?, theme: String?, defaultListingType: ListingType?, interfaceLanguage: String?, avatar: String?, banner: String?, displayName: String?, email: String?, bio: String?, matrixUserId: String?, showAvatars: Bool?, sendNotificationsToEmail: Bool?, botAccount: Bool?, showBotAccounts: Bool?, showReadPosts: Bool?, discussionLanguages: [Int]?, openLinksInNewTab: Bool?, blurNsfw: Bool?, autoExpand: Bool?, infiniteScrollEnabled: Bool?, postListingMode: PostFeedViewMode?, enableKeyboardNavigation: Bool?, enableAnimatedImages: Bool?, collapseBotComments: Bool?, showUpvotes: Bool?, showDownvotes: Bool?, showUpvotePercentage: Bool? ) async throws { try await performingForConnection { connection in try await connection.editAccountSettings( showNsfw: showNsfw, showScores: showScores, theme: theme, defaultListingType: defaultListingType, interfaceLanguage: interfaceLanguage, avatar: avatar, banner: banner, displayName: displayName, email: email, bio: bio, matrixUserId: matrixUserId, showAvatars: showAvatars, sendNotificationsToEmail: sendNotificationsToEmail, botAccount: botAccount, showBotAccounts: showBotAccounts, showReadPosts: showReadPosts, discussionLanguages: discussionLanguages, openLinksInNewTab: openLinksInNewTab, blurNsfw: blurNsfw, autoExpand: autoExpand, infiniteScrollEnabled: infiniteScrollEnabled, postListingMode: postListingMode, enableKeyboardNavigation: enableKeyboardNavigation, enableAnimatedImages: enableAnimatedImages, collapseBotComments: collapseBotComments, showUpvotes: showUpvotes, showDownvotes: showDownvotes, showUpvotePercentage: showUpvotePercentage ) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Post.swift ================================================ // // ApiRepository+Post.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // import Foundation extension ApiRepository { // swiftlint:disable:next function_parameter_count func getPosts( communityId: Int, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getPosts( communityId: communityId, sort: sort, page: page, cursor: cursor, limit: limit, filter: filter, showHidden: showHidden ) } } // swiftlint:disable:next function_parameter_count func getPosts( feed: ListingType, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getPosts( feed: feed, sort: sort, page: page, cursor: cursor, limit: limit, filter: filter, showHidden: showHidden ) } } func getPosts( personId: Int, communityId: Int? = nil, sort: PostSortType = .new, page: Int, limit: Int, savedOnly: Bool = false ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot]) { try await performingForConnection { connection in try await connection.getPosts( personId: personId, communityId: communityId, sort: sort, page: page, limit: limit, savedOnly: savedOnly ) } } func getPostHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (posts: [Post2Snapshot], cursor: String?) { try await performingForConnection { connection in try await connection.getPostHistory( type: type, page: page, cursor: cursor, limit: limit ) } } func getPost(id: Int) async throws -> Post3Snapshot { try await performingForConnection { connection in try await connection.getPost(id: id) } } func getPost(url: URL) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.getPost(url: url) } } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: PostSortType ) async throws -> [Post2Snapshot] { try await performingForConnection { connection in try await connection.searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) } } func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType ) async throws -> [Post2Snapshot] { try await performingForConnection { connection in try await connection.searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, sort: sort ) } } func markPostAsRead(id: Int, read: Bool = true) async throws { try await performingForConnection { connection in try await connection.markPostAsRead(id: id, read: read) } } func markPostsAsRead(ids: Set, read: Bool = true) async throws { try await performingForConnection { connection in try await connection.markPostsAsRead(ids: ids, read: read) } } func voteOnPost(id: Int, score: ScoringOperation) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.voteOnPost(id: id, score: score) } } func savePost(id: Int, save: Bool) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.savePost(id: id, save: save) } } func deletePost(id: Int, delete: Bool) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.deletePost(id: id, delete: delete) } } /// Added in 0.19.4 func hidePost( id: Int, hide: Bool ) async throws { try await performingForConnection { connection in try await connection.hidePost(id: id, hide: hide) } } func createPost( communityId: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.createPost( communityId: communityId, title: title, content: content, linkUrl: linkUrl, altText: altText, thumbnail: thumbnail, nsfw: nsfw, languageId: languageId ) } } func editPost( id: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.editPost( id: id, title: title, content: content, linkUrl: linkUrl, altText: altText, thumbnail: thumbnail, nsfw: nsfw, languageId: languageId ) } } func replyToPost(id: Int, content: String, languageId: Int? = nil) async throws -> Comment2Snapshot { try await performingForConnection { connection in try await connection.replyToPost(id: id, content: content, languageId: languageId) } } func reportPost(id: Int, reason: String) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.reportPost(id: id, reason: reason) } } func purgePost(id: Int, reason: String?) async throws { try await performingForConnection { connection in try await connection.purgePost(id: id, reason: reason) } } func removePost( id: Int, remove: Bool, reason: String? ) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.removePost(id: id, remove: remove, reason: reason) } } func pinPost( id: Int, pin: Bool, to target: PostFeatureType ) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.pinPost(id: id, pin: pin, to: target) } } func lockPost( id: Int, lock: Bool ) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.lockPost(id: id, lock: lock) } } func setPostNsfw(id: Int, nsfw: Bool) async throws -> Post1Snapshot { try await performingForConnection { connection in try await connection.setPostNsfw(id: id, nsfw: nsfw) } } func getPostVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { try await performingForConnection { connection in try await connection.getPostVotes( id: id, page: page, limit: limit ) } } @discardableResult func voteInPoll(postId: Int, choiceIds: Set) async throws -> Post2Snapshot { try await performingForConnection { connection in try await connection.voteInPoll(postId: postId, choiceIds: choiceIds) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+RegistrationApplication.swift ================================================ // // ApiRepository+RegistrationApplication.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // extension ApiRepository { func getRegistrationApplicationCount() async throws -> Int { try await performingForConnection { connection in try await connection.getRegistrationApplicationCount() } } func getRegistrationApplications( page: Int = 1, limit: Int = 20, unreadOnly: Bool = false ) async throws -> [RegistrationApplicationSnapshot] { try await performingForConnection { connection in try await connection.getRegistrationApplications( page: page, limit: limit, unreadOnly: unreadOnly ) } } func approveRegistrationApplication(id: Int) async throws -> RegistrationApplicationSnapshot { try await performingForConnection { connection in try await connection.approveRegistrationApplication(id: id) } } func denyRegistrationApplication( id: Int, reason: String? ) async throws -> RegistrationApplicationSnapshot { try await performingForConnection { connection in try await connection.denyRegistrationApplication(id: id, reason: reason) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository+Report.swift ================================================ // // ApiRepository+Report.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-03. // extension ApiRepository { func getReportCount(communityId: Int? = nil) async throws -> ReportUnreadCountSnapshot { try await performingForConnection { connection in try await connection.getReportCount(communityId: communityId) } } func getPostReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, postId: Int? = nil ) async throws -> [ReportSnapshot] { try await performingForConnection { connection in try await connection.getPostReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, postId: postId ) } } func getCommentReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, commentId: Int? = nil ) async throws -> [ReportSnapshot] { try await performingForConnection { connection in try await connection.getCommentReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, commentId: commentId ) } } func getMessageReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false ) async throws -> [ReportSnapshot] { try await performingForConnection { connection in try await connection.getMessageReports( page: page, limit: limit, unresolvedOnly: unresolvedOnly ) } } func resolvePostReport( id: Int, resolved: Bool ) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.resolvePostReport(id: id, resolved: resolved) } } func resolveCommentReport( id: Int, resolved: Bool ) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.resolveCommentReport(id: id, resolved: resolved) } } func resolveMessageReport( id: Int, resolved: Bool ) async throws -> ReportSnapshot { try await performingForConnection { connection in try await connection.resolveMessageReport(id: id, resolved: resolved) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ApiRepository.swift ================================================ // // ApiRepository.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-02. // import Foundation import Rest /// This class represents an abstract interface on top of the underlying Connection; it is responsible for managing the Connection and /// serving consistent Snapshot models to higher layers. /// /// The data access methods in here are all intentionally dumb--they just make calls and return snapshots. Validation, enrichment, /// and other business logic that must occur before serving the final model to the app should be performed by the consumer of the repository. class ApiRepository { private static let supportedConnections: [any InstanceConnection.Type] = [LemmyConnection.self, PieFedConnection.self] private struct ConnectionWrapper { let wrappedValue: any InstanceConnection } let baseUrl: URL let username: String? private var connectionMultiplexer: ConnectionMultiplexer! let restClient = RestClient(errorType: ApiErrorResponse.self) var token: String? var connection: (any InstanceConnection)? { get { connectionMultiplexer.selectedCandidate?.wrappedValue } set { connectionMultiplexer.selectedCandidate = .init(wrappedValue: newValue!) } } init(baseUrl: URL, username: String? = nil) { self.baseUrl = baseUrl self.username = username self.connectionMultiplexer = .init { Self.supportedConnections.map { .init(wrappedValue: $0.init(baseUrl: self.baseUrl, token: self.token)) } } } func updateToken(_ newToken: String) { guard username != nil else { assertionFailure() return } connection?.updateToken(newToken) token = newToken } func perform( _ request: Request, tokenOverride: String? = nil, requiresToken: Bool = true // This should be `true` for the vast majority of requests, even GET requests ) async throws -> Request.Response { guard !requiresToken || username == nil || token != nil else { throw ApiClientError.noToken } let token = tokenOverride ?? token do throws(RestError) { return try await restClient.perform(baseUrl: baseUrl, request, token: token) } catch { switch error { case let RestError.response(response, statusCode: _): if ApiErrorResponse(error: response).isNotLoggedIn { throw token == nil ? ApiClientError.notLoggedIn : ApiClientError.invalidSession // (self) } else { throw ApiClientError(from: error) } default: throw ApiClientError(from: error) } } } func getConnection() async throws -> any InstanceConnection { try await connectionMultiplexer.getConnection { _ = try await getMyInstance() }.wrappedValue } @MainActor func performingForConnection( _ callback: @escaping (any InstanceConnection) async throws -> T, file: String = #fileID, function: String = #function, line: Int = #line ) async throws -> T { do { return try await connectionMultiplexer.perform { wrapper in try await callback(wrapper.wrappedValue) } } catch ConnectionMultiplexerError.allConnectionsFailed { throw ApiClientError.unableToDetermineSoftware } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/API Repository/ConnectionMultiplexer.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-08-09. // import Foundation import os enum ConnectionMultiplexerError: Error { case allConnectionsFailed } class ConnectionMultiplexer { private let log: Logger = .mlemLogger() private var ongoingTask: Task? var getCandidates: () -> [Candidate] var selectedCandidate: Candidate? init(getCandidates: @escaping () -> [Candidate]) { self.getCandidates = getCandidates } @MainActor func perform( _ callback: @escaping (Candidate) async throws -> T ) async throws -> T { // Iterate through all possible candidates, and call the callback on each in turn. // As soon as one of the calls succeeds, return the result and cancel the other ongoing calls. // Cache the `Candidate` that succeeded in the `self.selectedCandidate` property, and use that // for all subsequent calls of `perform`. // If `perform` is called and `self.selectedCandidate` is `nil` but there is another // `perform` call ongoing, it will wait for the other call to succeed first. _ = await self.ongoingTask?.result if let selectedCandidate { return try await callback(selectedCandidate) } let ongoingTask: Task = Task { try await withThrowingTaskGroup(of: (Int, Result).self) { group in let candidates = self.getCandidates() for (index, candidate) in candidates.enumerated() { group.addTask { do { let response = try await callback(candidate) return (index, .success(response)) } catch { return (index, .failure(error)) } } } var results: [(Int, Result)] = [] while !group.isEmpty { guard let result = try? await group.next() else { assertionFailure() continue } results.append(result) } results.sort(by: { $0.0 < $1.0 }) // Find first successful result in candidate order for (candidate, result) in zip(candidates, results.map(\.1)) { do { let value = try result.get() log.info("Selected \(String(describing: candidate))") self.selectedCandidate = candidate self.ongoingTask = nil return value } catch ApiClientError.serverError(404), ApiClientError.featureUnsupported { // no-op } catch { throw error } } throw ConnectionMultiplexerError.allConnectionsFailed } } self.ongoingTask = Task { _ = try? await ongoingTask.result.get() } return try await ongoingTask.result.get() } func getConnection(callback: () async throws -> Void) async throws -> Candidate { _ = await ongoingTask?.result if let selectedCandidate { return selectedCandidate } try await callback() if let selectedCandidate { return selectedCandidate } assertionFailure() throw ApiClientError.unsuccessful } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Bridges/LemmyBlockBridge.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-11-13. // import Foundation public struct LemmyCommunityBlockBridge: Codable, Hashable, Sendable { public let community: LemmyCommunity public init(from decoder: any Decoder) throws { if let community = try? LemmyCommunity(from: decoder) { self.community = community return } let view = try LemmyCommunityBlockView(from: decoder) self.community = view.community } } public struct LemmyPersonBlockBridge: Codable, Hashable, Sendable { public let person: LemmyPerson public init(from decoder: any Decoder) throws { if let person = try? LemmyPerson(from: decoder) { self.person = person return } let view = try LemmyPersonBlockView(from: decoder) self.person = view.target } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Bridges/LemmyInstanceWithFederationStateBridge.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-21. // import Foundation public struct LemmyInstanceWithFederationStateBridge: Codable, Hashable, Sendable { let domain: String public init(from decoder: any Decoder) throws { if let old = try? LemmyInstance(from: decoder) { self.domain = old.domain return } if let new = try? LemmyInstanceWithFederationState(from: decoder) { self.domain = new.domain return } throw DecodingError.dataCorrupted( .init(codingPath: decoder.codingPath, debugDescription: "LemmyInstanceWithFederationStateBridge error") ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Bridges/LemmySortTypeBridge.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-22. // import Foundation import Rest import URLEncoder // The `LemmySearch.sort` property uses `LemmySortType` pre-0.20 and // uses `LemmySearchSortType` post-0.20, even when interacting using the v3 api. // The type of that property is manually overriden with this type, which // can then be converted into either of those two types. public typealias ApiBridgeable = Codable & Hashable & Sendable public enum ApiBridge: Codable, Hashable, Sendable { case old(OldType) case new(NewType) public typealias RawValue = String var value: any ApiBridgeable { switch self { case let .old(old): old case let .new(new): new } } public static func oldOrUnsupported(_ value: OldType?) throws(ApiClientError) -> Self { if let value { return .old(value) } else { throw .featureUnsupported } } public static func newOrUnsupported(_ value: NewType?) throws(ApiClientError) -> Self { if let value { return .new(value) } else { throw .featureUnsupported } } } public extension ApiBridge { init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let new = try? container.decode(NewType.self) { self = .new(new) return } if let old = try? container.decode(OldType.self) { self = .old(old) return } throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unsupported value type")) } func encode(to encoder: any Encoder) throws { try value.encode(to: encoder) } } public typealias LemmySearchSortTypeBridge = ApiBridge public typealias LemmyPostSortTypeBridge = ApiBridge public typealias LemmyCommunitySortTypeBridge = ApiBridge ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Bridges/LemmyVoteShowBridge.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-11-13. // import Foundation public struct LemmyVoteShowBridge: Codable, Hashable, Sendable { let voteShow: LemmyVoteShow public var boolValue: Bool { get throws { switch voteShow { case .show: true case .hide: false case .showForOthers: throw LemmyEncodingError.lemmyVoteShowBridge } } } public init(showVotes: Bool) { self.voteShow = showVotes ? .show : .hide } public init(from decoder: any Decoder) throws { if let vote = try? LemmyVoteShow(from: decoder) { voteShow = vote } else { let bool = try Bool(from: decoder) self.voteShow = bool ? .show : .hide } } public func encode(to encoder: any Encoder) throws { switch try encoder.endpointVersion { case .v3: try boolValue.encode(to: encoder) case .v4: try voteShow.encode(to: encoder) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Constants/MiddlewareConstants.swift ================================================ // // App Constants.swift // Mlem // // Created by David Bureš on 03.05.2023. // import Foundation enum MiddlewareConstants { static let infiniteLoadThresholdOffset: Int = 10 static let maxRetries: Int = 3 } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ActiveUserCount.swift ================================================ // // ActiveUserCount.swift // // // Created by Sjmarf on 29/05/2024. // import Foundation public struct ActiveUserCount: Equatable { public let sixMonths: Int public let month: Int public let week: Int public let day: Int public init(sixMonths: Int, month: Int, week: Int, day: Int) { self.sixMonths = sixMonths self.month = month self.week = week self.day = day } public static let zero: ActiveUserCount = .init(sixMonths: 0, month: 0, week: 0, day: 0) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ActorIdentifiable.swift ================================================ // // ActorIdentifiable.swift // Mlem // // Created by Sjmarf on 15/02/2024. // import Foundation /// Represents a Lemmy entity that can be represented by an ``ActorIdentifier``. public protocol ActorIdentifiable { // An identifier that is unique across Lemmy instances. var actorId: ActorIdentifier { get } } public extension ActorIdentifiable { @inlinable var host: String { actorId.host } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ActorIdentifier.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-25. // import Foundation /// An identifier for an ActivityPub entity that is unique across all federated instances. This is a wrapper of `URL`. /// /// ## Discussion /// /// Avoid instantiating`ActorIdentifier`directly, and instead obtain /// instances by interacting with `ApiClient`. /// /// Lemmy uses the following ActorIdentifier formats (this list may be incomplete, I'm not sure): /// - `https://example.com` /// - `https://example.com/c/name` /// - `https://example.com/u/name` /// - `https://example.com/post/123` /// - `https://example.com/comment/123` /// - `https://example.com/private_message/123` /// /// In addition to these formats, an ActorIdentifier may use a non-Lemmy format such as: /// - `https://fedia.io/m/fedia` (Community URL for Kbin/Mbin) /// - `https://misskey.io/users/9h75uqwaa8` (Person URL for Misskey) /// /// It should be noted that private messages cannot be resolved using ``ResolveObjectRequest``. /// public struct ActorIdentifier: Hashable, Sendable { public let url: URL public let host: String /// Create an `ActorIdentifier` from a given URL. /// /// When you use this method, you *must* be sure that the provided URL is the actual ActivityPub /// ID for the given entity, and not just any URL pointing to it. If possible, avoid using this initialiser. /// public init?(url: URL) { guard let host = url.host() else { return nil } self.init(url: url, host: host) } private init(url: URL, host: String) { if url.pathComponents.isEmpty { self.url = url.appendingPathComponent("/") } else { self.url = url } self.host = host } public static func instance(host: String) -> Self { var components = URLComponents() components.scheme = "https" components.host = host return ActorIdentifier(url: components.url!, host: host) } public var hostUrl: URL { var components = URLComponents() components.scheme = "https" components.host = host return components.url! // This will always succeed } } extension ActorIdentifier: Codable { public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) guard let url = URL(string: string) else { throw Self.DecodingError.invalidUrl } if let actorId = ActorIdentifier(url: url) { self = actorId } else { throw Self.DecodingError.invalidUrl } } public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(url) } } extension ActorIdentifier: CustomStringConvertible { public var description: String { url.description } } extension ActorIdentifier: CustomDebugStringConvertible { public var debugDescription: String { "ActorIdentifier(\(url.description))" } } public extension ActorIdentifier { enum EntityType { case post, comment, message, person, community, instance } enum DecodingError: Error { case invalidUrl } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/BlockList.swift ================================================ // // BlockList.swift // // // Created by Sjmarf on 08/07/2024. // import Foundation @Observable public class BlockList { private let api: ApiClient /// Mapping `actorId` to `id`. var people: [ActorIdentifier: Int] = .init() /// Mapping `actorId` to `id`. var communities: [ActorIdentifier: Int] = .init() /// Mapping `actorId` to `instanceId`. var instances: [ActorIdentifier: Int] = .init() init( api: ApiClient, people: [ActorIdentifier: Int], communities: [ActorIdentifier: Int], instances: [ActorIdentifier: Int] ) { self.api = api self.people = people self.communities = communities self.instances = instances } convenience init(api: ApiClient, blocks: BlockListSnapshot) { self.init( api: api, people: blocks.people, communities: blocks.communities, instances: blocks.instances ) } func update(blocks: BlockListSnapshot) { // People let oldPeopleKeys = Set(people.keys) let newPeopleKeys = Set(blocks.people.keys) // bypasses queuing for blocked status for key in newPeopleKeys.subtracting(oldPeopleKeys) { if let id = blocks.people[key], let person = api.caches.person.retrieveModel(cacheId: id) { person.blocked_.set(true) } } for key in oldPeopleKeys.subtracting(newPeopleKeys) { if let id = people[key], let person = api.caches.person.retrieveModel(cacheId: id) { person.blocked_.set(false) } } // Communities let oldCommunitiesKeys = Set(communities.keys) let newCommunitiesKeys = Set(blocks.communities.keys) // bypasses queuing for blocked status for key in newCommunitiesKeys.subtracting(oldCommunitiesKeys) { if let id = blocks.communities[key], let community = api.caches.community.retrieveModel(cacheId: id) { community.blocked_.set(true) } } for key in oldCommunitiesKeys.subtracting(newCommunitiesKeys) { if let id = communities[key], let community = api.caches.community.retrieveModel(cacheId: id) { community.blocked_.set(false) } } // Instances let oldInstancesKeys = Set(instances.keys) let newInstancesKeys = Set(blocks.instances.keys) for key in newInstancesKeys.subtracting(oldInstancesKeys) { if let id = blocks.instances[key], let instance = api.caches.instance.retrieveModel(instanceId: id) { instance.blocked_.set(true) } } for key in oldInstancesKeys.subtracting(newInstancesKeys) { if let id = instances[key], let instance = api.caches.instance.retrieveModel(instanceId: id) { instance.blocked_.set(false) } } people = blocks.people communities = blocks.communities instances = blocks.instances } public func contains(personActorId: ActorIdentifier) -> Bool { people.keys.contains(personActorId) } public func contains(_ person: Person) -> Bool { people.keys.contains(person.actorId) } public func contains(communityActorId: ActorIdentifier) -> Bool { communities.keys.contains(communityActorId) } public func contains(_ community: Community) -> Bool { communities.keys.contains(community.actorId) } public func contains(instanceActorId: ActorIdentifier) -> Bool { instances.keys.contains(instanceActorId) } public func contains(_ instance: Instance) -> Bool { instances.keys.contains(instance.actorId) } public func idOfBlockedPerson(actorId: ActorIdentifier) -> Int? { people[actorId] } public func idOfBlockedCommunity(actorId: ActorIdentifier) -> Int? { communities[actorId] } public func instanceIdOfBlockedInstance(actorId: ActorIdentifier) -> Int? { instances[actorId] } public var personCount: Int { people.count } public var communityCount: Int { communities.count } public var instanceCount: Int { instances.count } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/BlockListSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-06. // import Foundation public struct BlockListSnapshot { /// Mapping `actorId` to `id`. var people: [ActorIdentifier: Int] = .init() /// Mapping `actorId` to `id`. var communities: [ActorIdentifier: Int] = .init() /// Mapping `actorId` to `instanceId`. var instances: [ActorIdentifier: Int] = .init() } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/CanModerateProviding.swift ================================================ // // CanModerateProviding.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-15. // import Foundation public protocol CanModerateProviding: ContentIdentifiable { var canModerate: Bool { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Captcha.swift ================================================ // // Captcha.swift // // // Created by Sjmarf on 06/09/2024. // import Foundation public struct Captcha: Identifiable { public let id: UUID public let imageData: Data init(id: UUID, imageData: Data) { self.id = id self.imageData = imageData } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/CaptchaDifficulty.swift ================================================ // // CaptchaDifficulty.swift // // // Created by Sjmarf on 28/05/2024. // import Foundation public enum CaptchaDifficulty: String, Codable { case easy, medium, hard } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/Comment+Conformance.swift ================================================ // // Comment+Conformance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-19. // import Foundation // MARK: CacheIdentifiable public extension Comment { var cacheId: Int { id } } // MARK: FeedLoadable public extension Comment { typealias FilterType = CommentFilterType func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } } // MARK: SelectableContentProviding public extension Comment { var selectableContent: String? { content } } // MARK: ContentIdentifiable public extension Comment { static var modelTypeId: ContentType { .comment } } // MARK: OwnershipProviding public extension Comment { func isOwnContent(myPersonId: Int) -> Bool { creatorId == myPersonId } } // MARK: Resolvable public extension Comment { /// Returns a `URL` that can be resolved by another `ApiClient`. func resolvableUrl(from instance: ContentModelUrlType) -> URL { switch instance { case .host: actorId.url case .provider: .comment(host: api.host, id: id) } } @inlinable var allResolvableUrls: [URL] { ContentModelUrlType.allCases.map { resolvableUrl(from: $0) } } } // MARK: Sharable public extension Comment { func url() -> URL { api.baseUrl.appending(path: "comment/\(id)") } } // MARK: InteractableProviding public extension Comment { var downvotesEnabled: Bool { api.voteFederationMode.commentDownvote != .disable } } // MARK: CanModerateProviding public extension Comment { var canModerate: Bool { guard let id = community.value_?.id as? Int, let myPersonModerates = api.myPerson?.moderates else { return false } return myPersonModerates(.id(id)) || api.isAdmin } } // MARK: CommentResolvable public extension Comment { func asComment() async throws -> Comment { self } } // MARK: PersonContentProviding public extension Comment { var userContent: PersonContent { .init(wrappedValue: .comment(self)) } } // MARK: ReportableProviding public extension Comment { func report(reason: String) async throws { try await api.reportComment(id: id, reason: reason) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/Comment+Mock.swift ================================================ // // Comment1+Mock.swift // MlemMiddleware // // Created by Sjmarf on 2025-03-15. // import Foundation // TODO: updated mocks //#if DEBUG // public extension Comment1 { // static func mock( // api: MockApiClient = .mock, // actorId: ActorIdentifier? = nil, // id: Int, // content: String, // removed: Bool, // created: Date, // updated: Date?, // deleted: Bool, // creatorId: Int, // postId: Int, // parentCommentIds: [Int], // distinguished: Bool, // languageId: Int // ) -> Comment1 { // .init( // api: api, // actorId: actorId ?? .init(url: URL(string: "https://\(api.host)/comment/\(id)")!)!, // id: id, // content: content, // removed: removed, // created: created, // updated: updated, // deleted: deleted, // creatorId: creatorId, // postId: postId, // parentCommentIds: parentCommentIds, // distinguished: distinguished, // languageId: languageId // ) // } // } //#endif //#if DEBUG // public extension Comment2 { // static func mock( // api: ApiClient, // comment1: Comment1, // creator: Person1, // post: UnifiedPostModel, // community: Community1, // votes: VotesModel, // saved: Bool, // creatorIsModerator: Bool, // creatorIsAdmin: Bool, // bannedFromCommunity: Bool, // commentCount: Int // ) -> Comment2 { // assert(api == comment1.api) // assert(api == creator.api) // assert(api == community.api) // assert(api == post.api) // return .init( // api: api, // comment1: comment1, // creator: creator, // post: post, // community: community, // votes: votes, // saved: saved, // creatorIsModerator: creatorIsModerator, // creatorIsAdmin: creatorIsAdmin, // creatorBannedFromCommunity: bannedFromCommunity, // commentCount: commentCount // ) // } // } //#endif //extension Comment2 { // var apiCommentView: LemmyCommentView { // LemmyCommentView( // comment: comment1.apiComment, // creator: creator.apiPerson, // post: post.apiPost, // community: community.apiCommunity, // counts: .init( // commentId: id, // score: votes.total, // upvotes: votes.upvotes, // downvotes: votes.downvotes, // published: created, // childCount: commentCount // ), // creatorBannedFromCommunity: creator.isBannedFromCommunity(id: community.id) ?? false, // creatorIsModerator: creatorIsModerator, // creatorIsAdmin: creatorIsAdmin, // subscribed: .notSubscribed, // saved: saved, // creatorBlocked: creator.blocked, // myVote: votes.myVote.rawValue, // bannedFromCommunity: false, // communityActions: nil, // commentActions: nil, // personActions: nil, // postTags: nil, // canMod: nil, // creatorBanned: nil, // creatorBanExpiresAt: nil, // creatorCommunityBanExpiresAt: nil // ) // } //} ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/Comment.swift ================================================ // // Comment.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-19. // import Observation import Foundation @Observable public class Comment: UnifiedModelProviding, FeedLoadable, SelectableContentProviding, ContentIdentifiable, OwnershipProviding, InteractableProviding, DeletableProviding, PurgableProviding, CommentResolvable, Sharable, PersonContentProviding { public typealias Properties = CommentProperties public var api: ApiClient private let properties: CommentProperties @ObservationIgnored lazy var updateQueue: UnifiedUpdateQueue = .init(parent: self, properties: properties) // MARK: Custom Properties // Mlem-specific properties that are not reflected in the API public var removedPending: Bool = false public var purged: Bool = false // MARK: API Properties // Properties that are provided by the API public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let postId: Int public let parentCommentIds: [Int] public let created: Date public var content: String public var updated: Date? public var distinguished: Bool public var languageId: Int public var deleted: Bool public var removed: Bool // from Comment2Snapshot public var creator: ExpectedValue public var post: ExpectedValue public var community: ExpectedValue public var commentCount: ExpectedValue public var creatorIsModerator: ExpectedValue public var creatorIsAdmin: ExpectedValue public var creatorBannedFromCommunity: ExpectedValue public var votes: ExpectedValue public var saved: ExpectedValue public init(api: ApiClient, properties: CommentProperties) { self.api = api self.properties = properties self.actorId = properties.actorId self.id = properties.id self.creatorId = properties.creatorId self.postId = properties.postId self.parentCommentIds = properties.parentCommentIds self.created = properties.created self.content = properties.content self.updated = properties.updated self.distinguished = properties.distinguished self.languageId = properties.languageId self.deleted = properties.deleted self.removed = properties.removed // because upgrade() is not available until all properties are initialized, first populate all properties // with ExpectedValues that don't actually do anything, then reassign them properly at the end of the init // this is somewhat cumbersome but avoids lazy vars, which are very awkward in Observables self.creator = dummyExpectedValue(properties.creator) self.post = dummyExpectedValue(properties.post) self.community = dummyExpectedValue(properties.community) self.commentCount = dummyExpectedValue(properties.commentCount) self.creatorIsModerator = dummyExpectedValue(properties.creatorIsModerator) self.creatorIsAdmin = dummyExpectedValue(properties.creatorIsAdmin) self.creatorBannedFromCommunity = dummyExpectedValue(properties.creatorBannedFromCommunity) self.votes = dummyExpectedValue(properties.votes) self.saved = dummyExpectedValue(properties.saved) func expectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { try await self.upgrade() }) } self.creator = expectedValue(properties.creator) self.post = expectedValue(properties.post) self.community = expectedValue(properties.community) self.commentCount = expectedValue(properties.commentCount) self.creatorIsModerator = expectedValue(properties.creatorIsModerator) self.creatorIsAdmin = expectedValue(properties.creatorIsAdmin) self.creatorBannedFromCommunity = expectedValue(properties.creatorBannedFromCommunity) self.votes = expectedValue(properties.votes) self.saved = expectedValue(properties.saved) } @MainActor public func update(with properties: CommentProperties) { if !properties.removed { setIfChanged(\.content, properties.content) } setIfChanged(\.updated, properties.updated) setIfChanged(\.distinguished, properties.distinguished) setIfChanged(\.languageId, properties.languageId) setIfChanged(\.deleted, properties.deleted) setIfChanged(\.removed, properties.removed) setIfNil(\.creator.value_, properties.creator) setIfNil(\.post.value_, properties.post) setIfNil(\.community.value_, properties.community) updateIfChanged(\.commentCount.value_, properties.commentCount) updateIfChanged(\.creatorIsModerator.value_, properties.creatorIsModerator) updateIfChanged(\.creatorIsAdmin.value_, properties.creatorIsAdmin) updateIfChanged(\.creatorBannedFromCommunity.value_, properties.creatorBannedFromCommunity) updateIfChanged(\.votes.value_, properties.votes) updateIfChanged(\.saved.value_, properties.saved) } @MainActor public func softUpdate(with properties: CommentProperties) { setIfNil(\.creator.value_, properties.creator) setIfNil(\.post.value_, properties.post) setIfNil(\.community.value_, properties.community) setIfNil(\.commentCount.value_, properties.commentCount) setIfNil(\.creatorIsModerator.value_, properties.creatorIsModerator) setIfNil(\.creatorIsAdmin.value_, properties.creatorIsAdmin) setIfNil(\.creatorBannedFromCommunity.value_, properties.creatorBannedFromCommunity) setIfNil(\.votes.value_, properties.votes) setIfNil(\.saved.value_, properties.saved) } // TODO: unified models move these into ContentModel public func upgrade() async throws { try await updateQueue.upgrade() } public func refresh() async throws { try await updateQueue.refresh() } public func fetchUpgraded() async throws -> CommentProperties { let snapshot = try await api.repository.getComment(id: id) return await .init(api: api, snapshot: .comment2(snapshot)) } public func resolve(with api: ApiClient) async throws -> Self { let stub = CommentStub(api: api, url: allResolvableUrls[0]) return try await stub.asComment() as! Self } } // MARK: - Computed public extension Comment { var depth: Int { parentCommentIds.count } var parentCommentId: Int? { parentCommentIds.last } } // MARK: - Interactions public extension Comment { // Vote var updateVote: ((ScoringOperation) -> Void)? { if let votes = votes.value { return { self.updateVote($0, votes: votes) } } return nil } private func updateVote(_ newValue: ScoringOperation, votes: VotesModel) { self.votes.value_ = votes.applyScoringOperation(operation: newValue) Task { await updateQueue.addItem { try await .init(api: self.api, snapshot: .comment2(self.api.repository.voteOnComment(id: self.id, score: newValue))) } } } // Save func updateSaved(_ newValue: Bool) { saved.value_ = newValue Task { await updateQueue.addItem { try await .init(api: self.api, snapshot: .comment2(self.api.repository.saveComment(id: self.id, save: newValue))) } } } // Remove func updateRemoved(_ newValue: Bool, reason: String?, callback: ((UpdateStatus) -> Void)?) { removed = newValue removedPending = true Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.removeComment(id: self.id, remove: newValue, reason: reason) callback?(.success) return await .init(api: self.api, snapshot: .comment2(snapshot)) } catch { callback?(.failure(error)) throw (error) } } } } // Reply func reply(content: String, languageId: Int? = nil) async throws -> Comment { try await api.replyToComment(postId: postId, parentId: id, content: content, languageId: languageId) } // Purge func purge(reason: String?) async throws { try await api.purgeComment(id: id, reason: reason) } // Delete func updateDeleted(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { deleted = newValue Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.deleteComment(id: self.id, delete: newValue) callback?(.success) return await .init(api: self.api, snapshot: .comment2(snapshot)) } catch { callback?(.failure(error)) throw (error) } } } } // Edit func edit(content: String, languageId: Int?) async throws { self.content = content if let languageId { self.languageId = languageId } Task { await updateQueue.addItem { try await .init( api: self.api, snapshot: .comment2(self.api.repository.editComment(id: self.id, content: content, languageId: languageId))) } } } // Get associated models /// Get the parent comment, or return `nil` if there is no parent func getParent(cachedValueAcceptable: Bool = false) async throws -> Comment? { if let parentId = parentCommentIds.last { if cachedValueAcceptable, let comment = api.caches.comment.retrieveModel(cacheId: parentId) { return comment } return try await api.getComment(id: parentId) } return nil } func getParents() async throws -> [Comment] { guard let first = parentCommentIds.first else { return [] } let comments = try await api.getComments( parentId: first, sort: .new, page: 1, maxDepth: parentCommentIds.count, limit: 1000 ) var i = 0 return comments.filter { comment in if comment.id == parentCommentIds[i] { i += 1 return true } return false } } func getChildren( sort: CommentSortType = .hot, includedParentCount: Int = 0, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment] { let parentId: Int if includedParentCount <= 0 { parentId = id } else { parentId = parentCommentIds.dropLast(includedParentCount - 1).last ?? parentCommentIds.first ?? id } let comments = try await api.getComments( parentId: parentId, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) if includedParentCount <= 0 { return comments } return comments.filter { $0.parentCommentIds.contains(id) || self.parentCommentIds.contains($0.id) || $0.id == self.id } } func getVotes(page: Int, limit: Int, communityId: Int) async throws -> [PersonVote] { try await api.getCommentVotes(id: id, communityId: communityId, page: page, limit: limit) } } // MARK: Shim public extension Comment { func takeSnapshot2() -> Comment2Snapshot? { guard let creator = creator.value_, let post = post.value_, let community = community.value_, let commentCount = commentCount.value_, let creatorIsModerator = creatorIsModerator.value_, let creatorIsAdmin = creatorIsAdmin.value_, let creatorBannedFromCommunity = creatorBannedFromCommunity.value_, let votes = votes.value_, let saved = saved.value_ else { assertionFailure("takeSnapshot2() called without high-tier fields available") return nil } return .init(comment: .init(actorId: actorId, id: id, creatorId: creatorId, postId: postId, parentCommentIds: parentCommentIds, created: created, content: content, updated: updated, distinguished: distinguished, languageId: languageId, deleted: deleted, removed: removed), creator: creator.takeSnapshot1(), post: .init( actorId: post.actorId, id: post.id, creatorId: post.creatorId, communityId: post.communityId, created: post.created, title: post.title, content: post.content, linkUrl: post.linkUrl, embed: post.embed, poll: post.poll, nsfw: post.nsfw, thumbnailUrl: post.thumbnailUrl, updated: post.updated, languageId: post.languageId, altText: post.altText, deleted: post.deleted, removed: post.removed, pinnedCommunity: post.pinnedCommunity, pinnedInstance: post.pinnedInstance, locked: post.locked), community: community.takeSnapshot1(), commentCount: commentCount, creatorIsModerator: creatorIsModerator, creatorIsAdmin: creatorIsAdmin, creatorBannedFromCommunity: creatorBannedFromCommunity, votes: votes, saved: saved) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/CommentProducing.swift ================================================ // // CommentProducing.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-24. // /// Protocol describing things that can be resolved to a comment public protocol CommentResolvable { func asComment() async throws -> Comment } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/CommentProperties.swift ================================================ // // CommentProperties.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-19. // import Foundation public struct CommentProperties: UnifiedPropertiesProviding { // From Comment1Snapshot, guaranteed to always be present let actorId: ActorIdentifier let id: Int let creatorId: Int let postId: Int let parentCommentIds: [Int] let created: Date var content: String var updated: Date? var distinguished: Bool var languageId: Int var deleted: Bool var removed: Bool // from Comment2Snapshot var creator: Person? var post: Post? var community: Community? var commentCount: Int? var creatorIsModerator: Bool? var creatorIsAdmin: Bool? var creatorBannedFromCommunity: Bool? var votes: VotesModel? var saved: Bool? /// Constructs a CommentProperties from a given snapshot @MainActor public init(api: ApiClient, snapshot: AnyCommentSnapshot) { let snapshot1: Comment1Snapshot let snapshot2: Comment2Snapshot? switch snapshot { case let .comment1(comment1Snapshot): snapshot1 = comment1Snapshot snapshot2 = nil case let .comment2(comment2Snapshot): snapshot1 = comment2Snapshot.comment snapshot2 = comment2Snapshot } if let snapshot2 { let newCreator: Person = api.caches.person.getModel(api: api, from: .person1(snapshot2.creator)) newCreator.updateKnownCommunityBanState(id: snapshot2.community.id, banned: snapshot2.creatorBannedFromCommunity) creator = newCreator post = api.caches.post.getModel(api: api, from: .post1(snapshot2.post)) community = api.caches.community.getModel(api: api, from: .community1(snapshot2.community)) commentCount = snapshot2.commentCount creatorIsModerator = snapshot2.creatorIsModerator creatorIsAdmin = snapshot2.creatorIsAdmin creatorBannedFromCommunity = snapshot2.creatorBannedFromCommunity votes = snapshot2.votes saved = snapshot2.saved } actorId = snapshot1.actorId id = snapshot1.id creatorId = snapshot1.creatorId postId = snapshot1.postId parentCommentIds = snapshot1.parentCommentIds created = snapshot1.created content = snapshot1.content updated = snapshot1.updated distinguished = snapshot1.distinguished languageId = snapshot1.languageId deleted = snapshot1.deleted removed = snapshot1.removed } public mutating func merge(_ other: CommentProperties) { // tier 1 properties: simple assignment self.content = other.content self.updated = other.updated self.distinguished = other.distinguished self.languageId = other.languageId self.deleted = other.deleted self.removed = other.removed // tier 2 properties: only assign if incoming non-nil self.creator = other.creator ?? self.creator self.post = other.post ?? self.post self.community = other.community ?? self.community self.commentCount = other.commentCount ?? self.commentCount self.creatorIsModerator = other.creatorIsModerator ?? self.creatorIsModerator self.creatorIsAdmin = other.creatorIsAdmin ?? self.creatorIsAdmin self.creatorBannedFromCommunity = other.creatorBannedFromCommunity ?? self.creatorBannedFromCommunity self.votes = other.votes ?? self.votes self.saved = other.saved ?? self.saved } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Comment/CommentStub.swift ================================================ // // CommentStub.swift // // // Created by Sjmarf on 24/06/2024. // import Foundation public struct CommentStub: Hashable, CommentResolvable { public var api: ApiClient public let url: URL public init(api: ApiClient, url: URL) { self.api = api self.url = url } public func asLocal() -> Self { .init(api: .getApiClient(url: url.removingPathComponents(), username: nil), url: url) } public func hash(into hasher: inout Hasher) { hasher.combine(url) } public static func == (lhs: CommentStub, rhs: CommentStub) -> Bool { lhs.url == rhs.url } public func asComment() async throws -> Comment { try await api.getComment(url: resolvableUrl) } } // Resolvable conformance public extension CommentStub { var resolvableUrl: URL { url } @inlinable var allResolvableUrls: [URL] { [resolvableUrl] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Community/Community+Conformance.swift ================================================ // // Community+Conformance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-14. // import Foundation // MARK: CacheIdentifiable public extension Community { var cacheId: Int { id } } // MARK: CommunityOrPerson public extension Community { static var identifierPrefix: String { "!" } } // MARK: ProfileProviding public extension Community { var profileCreated: Date? { created } } // MARK: Blockable public extension Community { var updateBlocked: ((Bool, ((Bool) -> Void)?) -> Void)? { self._updateBlocked } private func _updateBlocked(_ newValue: Bool, callback: ((Bool) -> Void)? = nil) { let oldValue = blocked_.realizedValue blocked_.set(newValue) Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.blockCommunity(id: self.id, block: newValue) callback?(true) if newValue { self.api.blocks?.communities[self.actorId] = self.id } else { self.api.blocks?.communities.removeValue(forKey: self.actorId) } return await .init(api: self.api, snapshot: .community2(snapshot)) } catch { // need to manually roll back because blocked is not included in snapshot informatoin self.blocked_.set(oldValue) callback?(false) throw error } } } } } // MARK: ContentIdentifiable public extension Community { static var modelTypeId: ContentType { .community } } // MARK: CanModerateProviding public extension Community { var canModerate: Bool { guard let myPersonModerates = api.myPerson?.moderates else { return false } return myPersonModerates(.id(id)) || api.isAdmin } } // MARK: FeedLoadable public extension Community { typealias FilterType = CommunityFilterType func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } } // MARK: Sharable public extension Community { func url() -> URL { if apiIsLocal { api.baseUrl.appending(path: "c/\(name)") } else { api.baseUrl.appending(path: "c/\(name)@\(host)") } } } // MARK: Resolvable public extension Community { /// Returns a `URL` that can be resolved by another `ApiClient`. func resolvableUrl(from instance: ContentModelUrlType) -> URL { switch instance { case .host: actorId.url case .provider: .community(host: api.host, name: name) } } @inlinable var allResolvableUrls: [URL] { ContentModelUrlType.allCases.map { resolvableUrl(from: $0) } } } // MARK: Codable public extension Community { struct CodedData: Codable { let apiUrl: URL let apiMyPersonId: Int? let apiCommunity: LemmyCommunity } internal var apiCommunity: LemmyCommunity { LemmyCommunity( id: id, name: name, title: displayName, description: description, removed: removed, published: created, updated: updated, deleted: deleted, nsfw: nsfw, actorId: actorId, local: apiIsLocal, icon: avatar, banner: banner, hidden: hidden, postingRestrictedToMods: onlyModeratorsCanPost, instanceId: instanceId, visibility: nil, sidebar: nil, publishedAt: created, updatedAt: updated, apId: actorId, lastRefreshedAt: nil, summary: nil, subscribers: nil, posts: nil, comments: nil, usersActiveDay: nil, usersActiveWeek: nil, usersActiveMonth: nil, usersActiveHalfYear: nil, subscribersLocal: nil, reportCount: nil, unresolvedReportCount: nil, localRemoved: nil ) } func codedData() async throws -> CodedData { try await .init( apiUrl: api.baseUrl, apiMyPersonId: api.myPersonId, apiCommunity: apiCommunity ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Community/Community+Mock.swift ================================================ // // Community+Mock.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-17. // // TODO: updated mocks //#if DEBUG // public extension Community1 { // static func mock( // api: MockApiClient = .mock, // actorId: ActorIdentifier? = nil, // id: Int, // name: String, // created: Date, // instanceId: Int, // updated: Date?, // displayName: String, // description: String?, // removed: Bool, // deleted: Bool, // nsfw: Bool, // avatar: URL?, // banner: URL?, // hidden: Bool, // onlyModeratorsCanPost: Bool, // blocked: Bool // ) -> Community1 { // .init( // api: api, // actorId: actorId ?? .init(url: URL(string: "https://\(api.host)/u/\(id)")!)!, // id: id, // name: name, // created: created, // instanceId: instanceId, // updated: updated, // displayName: displayName, // description: description, // removed: removed, // deleted: deleted, // nsfw: nsfw, // avatar: avatar, // banner: banner, // hidden: hidden, // onlyModeratorsCanPost: onlyModeratorsCanPost, // blocked: blocked // ) // } // } //#endif //#if DEBUG // public extension Community2 { // static func mock( // community1: Community1, // subscriberCount: Int, // localSubscriberCount: Int, // subscribed: Bool, // subscriptionPending: Bool, // postCount: Int, // commentCount: Int, // activeUserCount: ActiveUserCount, // bannedFromCommunity: Bool? // ) -> Community2 { // .init( // api: community1.api, // community1: community1, // subscription: .init( // total: subscriberCount, // local: localSubscriberCount, // subscribed: subscribed, // pending: subscriptionPending // ), // postCount: postCount, // commentCount: commentCount, // activeUserCount: activeUserCount, // bannedFromCommunity: bannedFromCommunity // ) // } // } //#endif ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Community/Community.swift ================================================ // // Community.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-14. // import Observation import Foundation public enum SubscriptionTier { case unsubscribed, subscribed, favorited } @Observable public final class Community: UnifiedModelProviding, ProfileProviding, CommunityOrPerson, Blockable, ContentIdentifiable, RemovableProviding, PurgableProviding, Sharable, FeedLoadable { public typealias Properties = CommunityProperties public var api: ApiClient private let properties: CommunityProperties @ObservationIgnored lazy var updateQueue: UnifiedUpdateQueue = .init(parent: self, properties: properties) // MARK: Custom Properties // Mlem-specific properties that are not reflected in the API public var blocked: any RealizedValueProviding { blocked_ } public var blocked_: SyntheticRealizedValue public var removedPending: Bool = false public var purged: Bool = false /// Used to state-fake internally. public var shouldBeFavorited: Bool = false // MARK: API Properties // Properties that are provided by the API public let actorId: ActorIdentifier public let id: Int public let name: String public let created: Date public let instanceId: Int public var updated: Date? public var displayName: String public var deleted: Bool public var removed: Bool public var nsfw: Bool public var avatar: URL? public var hidden: Bool public var onlyModeratorsCanPost: Bool public var description: String? public var banner: URL? public var subscription: SyntheticExpectedValue public var postCount: ExpectedValue public var commentCount: ExpectedValue public var activeUserCount: ExpectedValue public var bannedFromCommunity: ExpectedValue public var instance: ExpectedValue public var moderators: ExpectedValue<[Person]> public var discussionLanguageIds: ExpectedValue> // MARK: Initializers and Updates public init(api: ApiClient, properties: CommunityProperties) { self.api = api self.properties = properties self.blocked_ = .init(value: api.blocks?.communities.keys.contains(properties.actorId) ?? false, mergeType: .disjunctive) self.actorId = properties.actorId self.id = properties.id self.name = properties.name self.created = properties.created self.instanceId = properties.instanceId self.updated = properties.updated self.displayName = properties.displayName self.deleted = properties.deleted self.removed = properties.removed self.nsfw = properties.nsfw self.avatar = properties.avatar self.hidden = properties.hidden self.onlyModeratorsCanPost = properties.onlyModeratorsCanPost // nil-coalesced because PieFed doesn't return these values for some requests. self.description = properties.description ?? nil self.banner = properties.banner ?? nil // because upgrade() is not available until all properties are initialized, first populate all properties // with ExpectedValues that don't actually do anything, then reassign them properly at the end of the init // this is somewhat cumbersome but avoids lazy vars, which are very awkward in Observables self.subscription = dummySyntheticExpectedValue(properties.subscription) self.postCount = dummyExpectedValue(properties.postCount) self.commentCount = dummyExpectedValue(properties.commentCount) self.activeUserCount = dummyExpectedValue(properties.activeUserCount) self.bannedFromCommunity = dummyExpectedValue(properties.bannedFromCommunity) self.instance = dummyExpectedValue(properties.instance) self.moderators = dummyExpectedValue(properties.moderators) self.discussionLanguageIds = dummyExpectedValue(properties.discussionLanguageIds) func expectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { try await self.upgrade() }) } self.subscription = .init( value: properties.subscription, provideValue: { try await self.upgrade() }, mergeType: .disjunctive) self.postCount = expectedValue(properties.postCount) self.commentCount = expectedValue(properties.commentCount) self.activeUserCount = expectedValue(properties.activeUserCount) self.bannedFromCommunity = expectedValue(properties.bannedFromCommunity) self.instance = expectedValue(properties.instance) self.moderators = expectedValue(properties.moderators) self.discussionLanguageIds = expectedValue(properties.discussionLanguageIds) updateAuxiliaryModels(with: properties) self.shouldBeFavorited = favorited } @MainActor public func update(with properties: CommunityProperties) { setIfChanged(\.updated, properties.updated) setIfChanged(\.displayName, properties.displayName) setIfChanged(\.deleted, properties.deleted) setIfChanged(\.removed, properties.removed) setIfChanged(\.nsfw, properties.nsfw) setIfChanged(\.avatar, properties.avatar) setIfChanged(\.hidden, properties.hidden) setIfChanged(\.onlyModeratorsCanPost, properties.onlyModeratorsCanPost) if let description = properties.description { setIfChanged(\.description, description) } if let banner = properties.banner { setIfChanged(\.banner, banner) } updateIfChanged(\.subscription.value_, properties.subscription) updateIfChanged(\.postCount.value_, properties.postCount) updateIfChanged(\.commentCount.value_, properties.commentCount) updateIfChanged(\.activeUserCount.value_, properties.activeUserCount) updateIfChanged(\.bannedFromCommunity.value_, properties.bannedFromCommunity) setIfNil(\.instance.value_, properties.instance) updateIfChanged(\.moderators.value_, properties.moderators) updateIfChanged(\.discussionLanguageIds.value_, properties.discussionLanguageIds) updateAuxiliaryModels(with: properties) self.shouldBeFavorited = favorited } @MainActor public func softUpdate(with properties: CommunityProperties) { setIfNil(\.subscription.value_, properties.subscription) setIfNil(\.postCount.value_, properties.postCount) setIfNil(\.commentCount.value_, properties.commentCount) setIfNil(\.activeUserCount.value_, properties.activeUserCount) setIfNil(\.bannedFromCommunity.value_, properties.bannedFromCommunity) setIfNil(\.instance.value_, properties.instance) setIfNil(\.moderators.value_, properties.moderators) setIfNil(\.discussionLanguageIds.value_, properties.discussionLanguageIds) } /// Updates external models with relevant information from this Community's properties. Should be called in init and update. private func updateAuxiliaryModels(with properties: CommunityProperties) { // if subscription or favorited status changed, update API if properties.subscription != self.subscription.value_ || favorited != shouldBeFavorited { self.api.subscriptions?.updateCommunitySubscription(community: self) } // if favorited but not subscribed, remove from favorites if favorited, let subscribed = properties.subscription?.subscribed, !subscribed { self.api.subscriptions?.favoriteIDs.remove(id) } // if banned, update ban status if let bannedFromCommunity = properties.bannedFromCommunity as? Bool { api.myPerson?.updateKnownCommunityBanState(id: id, banned: bannedFromCommunity) } } // MARK: Upgrades public func upgrade() async throws { try await updateQueue.upgrade() } public func refresh() async throws { try await updateQueue.refresh() } public func fetchUpgraded() async throws -> CommunityProperties { let snapshot = try await api.repository.getCommunity(id: id) return await .init(api: api, snapshot: .community3(snapshot)) } public func resolve(with api: ApiClient) async throws -> Self { let stub = CommunityStub(api: api, url: allResolvableUrls[0]) return try await stub.getCommunity() as! Self } } // MARK: Computed public extension Community { var favorited: Bool { api.subscriptions?.isFavorited(self) ?? false } /// - Note: will trigger fetch if subscription value not present var subscriptionTier: SubscriptionTier { if favorited { return .favorited } if subscription.value?.subscribed ?? false { return .subscribed } return .unsubscribed } } // MARK: Interactions public extension Community { // Get Posts func getPosts( sort: PostSortType, page: Int = 1, cursor: String? = nil, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post], cursor: String?) { try await api.getPosts( communityId: id, sort: sort, page: page, cursor: cursor, limit: limit, filter: filter, showHidden: showHidden ) } // Subscribe var updateSubscribed: ((Bool) -> Void)? { if let subscription = subscription.value { return { self.updateSubscribed($0, subscription: subscription) } } return nil } private func updateSubscribed(_ newValue: Bool, subscription: SubscriptionModel) { self.subscription.value_ = subscription.withSubscriptionStatus(subscribed: newValue, isLocal: apiIsLocal) let oldFavorited = shouldBeFavorited if !newValue { self.shouldBeFavorited = false } Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.subscribeToCommunity(id: self.id, subscribe: newValue) return await .init(api: self.api, snapshot: .community2(snapshot)) } catch { self.shouldBeFavorited = oldFavorited throw error } } } } // Favorite var updateFavorite: ((Bool) -> Void)? { if let subscription = subscription.value { return { self.updateFavorite($0, subscription: subscription) } } return nil } private func updateFavorite(_ newValue: Bool, subscription: SubscriptionModel) { self.shouldBeFavorited = newValue if !subscription.subscribed, newValue { // if not subscribed already, subscribe. This automatically updates favorites tracked in ApiClient updateSubscribed(true, subscription: subscription) } else { // if already subscribed, just update favorites tracked in ApiClient api.subscriptions?.updateCommunitySubscription(community: self) } } // Remove func updateRemoved(_ newValue: Bool, reason: String?, callback: ((UpdateStatus) -> Void)?) { removed = newValue removedPending = true Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.removeCommunity(id: self.id, remove: newValue, reason: reason) callback?(.success) return await .init(api: self.api, snapshot: .community2(snapshot)) } catch { callback?(.failure(error)) throw (error) } } } } // Purge func purge(reason: String?) async throws { try await api.purgeCommunity(id: id, reason: reason) purged = true } // Edit Moderators func addModerator(personId: Int, added: Bool) { Task { await updateQueue.addItem { properties in var properties = properties let snapshots = try await self.api.repository.addModerator(communityId: self.id, personId: personId, added: added) if added { guard snapshots.moderators.contains(where: { $0.id == personId }) else { throw ApiClientError.unsuccessful } } else { guard !snapshots.moderators.contains(where: { $0.id == personId }) else { throw ApiClientError.unsuccessful } } let newModerators = await self.api.caches.person.getModels(api: self.api, from: snapshots.moderators.map { .person1($0) }) // update new moderator if let person = self.api.caches.person.retrieveModel(cacheId: personId) { await person.updateQueue.addItem { personProperties in var personProperties = personProperties var moderatedCommunities: [Community] = personProperties.moderatedCommunities ?? .init() if added { moderatedCommunities.append(self) } else { moderatedCommunities.removeAll(where: { $0.id == self.id }) } personProperties.moderatedCommunities = moderatedCommunities return personProperties } } properties.moderators = newModerators return properties } } } func addModerator(_ person: Person, added: Bool) async throws { addModerator(personId: person.id, added: added) } // Description func updateDescription(_ newValue: String?, callback: ((UpdateStatus) -> Void)?) { description = newValue Task { await updateQueue.addItem { do { let ret: CommunityProperties = try await .init( api: self.api, snapshot: .community2(self.api.repository.editCommunityDescription(id: self.id, newValue: newValue))) callback?(.success) return ret } catch { callback?(.failure(error)) throw(error) } } } } } // MARK: Shim public extension Community { var displayName_: String { displayName } var description_: String? { description } var banner_: URL? { banner } var created_: Date { created } var updated_: Date? { updated } internal func takeSnapshot1() -> Community1Snapshot { .init(actorId: actorId, id: id, name: name, created: created, instanceId: instanceId, updated: updated, displayName: displayName, description: description, deleted: deleted, removed: removed, nsfw: nsfw, avatar: avatar, banner: banner, hidden: hidden, onlyModeratorsCanPost: onlyModeratorsCanPost, allPropertiesPresent: false ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Community/CommunityProperties.swift ================================================ // // CommunityProperties.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-14. // import Foundation public struct CommunityProperties: UnifiedPropertiesProviding { // From Community1Snapshot, guaranteed to always be present let actorId: ActorIdentifier let id: Int let name: String let created: Date let instanceId: Int var updated: Date? var displayName: String var deleted: Bool var removed: Bool var nsfw: Bool var avatar: URL? var hidden: Bool var onlyModeratorsCanPost: Bool // From Community1Snapshot, but PieFed does not always provide these // https://codeberg.org/rimu/pyfedi/issues/882 var banner: URL?? var description: String?? // From Community2Snapshot var subscription: SubscriptionModel? var postCount: Int? var commentCount: Int? var activeUserCount: ActiveUserCount? var bannedFromCommunity: Bool?? // From Community3Snapshot var instance: Instance?? var moderators: [Person]? var discussionLanguageIds: Set? @MainActor public init(api: ApiClient, snapshot: AnyCommunitySnapshot) { let snapshot1: Community1Snapshot let snapshot2: Community2Snapshot? let snapshot3: Community3Snapshot? switch snapshot { case let .community1(snapshot): snapshot1 = snapshot snapshot2 = nil snapshot3 = nil case let .community2(snapshot): snapshot1 = snapshot.community snapshot2 = snapshot snapshot3 = nil case let .community3(snapshot): snapshot1 = snapshot.community.community snapshot2 = snapshot.community snapshot3 = snapshot } if let snapshot3 { if let instance1Snapshot = snapshot3.instance { instance = api.caches.instance.getOptionalModel(api: api, from: .instance1(instance1Snapshot)) } else { instance = nil } moderators = api.caches.person.getModels(api: api, from: snapshot3.moderators.map { .person1($0) }) discussionLanguageIds = snapshot3.discussionLanguageIds } if let snapshot2 { subscription = snapshot2.subscription postCount = snapshot2.postCount commentCount = snapshot2.commentCount activeUserCount = snapshot2.activeUserCount bannedFromCommunity = snapshot2.bannedFromCommunity } actorId = snapshot1.actorId id = snapshot1.id name = snapshot1.name created = snapshot1.created instanceId = snapshot1.instanceId updated = snapshot1.updated displayName = snapshot1.displayName deleted = snapshot1.deleted removed = snapshot1.removed nsfw = snapshot1.nsfw avatar = snapshot1.avatar hidden = snapshot1.hidden onlyModeratorsCanPost = snapshot1.onlyModeratorsCanPost if snapshot1.allPropertiesPresent { banner = snapshot1.banner description = snapshot1.description } } public mutating func merge(_ other: CommunityProperties) { // tier 1 properties: simple assignment self.updated = other.updated self.displayName = other.displayName self.deleted = other.deleted self.removed = other.removed self.nsfw = other.nsfw self.avatar = other.avatar self.hidden = other.hidden self.onlyModeratorsCanPost = other.onlyModeratorsCanPost // tier 2, 3 properties: only assign if incoming non-nil self.description = other.description ?? self.description self.banner = other.banner ?? self.banner self.subscription = other.subscription ?? self.subscription self.postCount = other.postCount ?? self.postCount self.commentCount = other.commentCount ?? self.commentCount self.activeUserCount = other.activeUserCount ?? self.activeUserCount self.bannedFromCommunity = other.bannedFromCommunity ?? self.bannedFromCommunity self.instance = other.instance ?? self.instance self.moderators = other.moderators ?? self.moderators self.discussionLanguageIds = other.discussionLanguageIds ?? self.discussionLanguageIds } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Community/CommunityStub.swift ================================================ // // CommunityStub.swift // Mlem // // Created by Sjmarf on 03/02/2024. // import Foundation public struct CommunityStub: Hashable { public var api: ApiClient public let url: URL public init(api: ApiClient, url: URL) { self.api = api self.url = url } public func asLocal() -> Self { .init(api: .getApiClient(url: url.removingPathComponents(), username: nil), url: url) } public func hash(into hasher: inout Hasher) { hasher.combine(url) } public static func == (lhs: CommunityStub, rhs: CommunityStub) -> Bool { lhs.url == rhs.url } public func getCommunity() async throws -> Community { try await api.getCommunity(url: url) } } // Resolvable conformance public extension CommunityStub { var resolvableUrl: URL { url } @inlinable var allResolvableUrls: [URL] { [resolvableUrl] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/CommunityOrPersonStub.swift ================================================ // // CommunityOrAccount.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation import Observation public protocol CommunityOrPerson: ContentModel, ActorIdentifiable { static var identifierPrefix: String { get } var name: String { get } } public extension CommunityOrPerson { var fullName: String { "\(name)@\(host)" } var fullNameWithPrefix: String { "\(Self.identifierPrefix)\(name)@\(host)" } } public protocol Blockable: ActorIdentifiable { /// Whether the entity knows itself to be blocked. /// - Note: Some types (e.g., `InstanceSummary`) do not track blocked status. For the most accurate blocked status, use /// `blocked(environment: EnvironmentValues)` as defined in Mlem /// - Warning: there is a Swift compiler bug that causes compilation to fail if you reference `blocked.realizedValue` in /// certain contexts. It is recommended to use `blocked_.realizedValue` any time you are working with a concrete type. var blocked: any RealizedValueProviding { get } /// Updates the blocked status to the given value /// - Parameters: /// - newValue: intended block status /// - callback: if present, will be called when the block completes with true if the update succeeds and false otherwise. var updateBlocked: ((Bool, ((Bool) -> Void)?) -> Void)? { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ContentModel.swift ================================================ // // ContentModel.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation public protocol ContentModel { var api: ApiClient { get } } extension ContentModel { @MainActor func setIfChanged(_ keyPath: ReferenceWritableKeyPath, _ value: T) { if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value } } } public extension ContentModel where Self: ActorIdentifiable { var apiIsLocal: Bool { api.host == "localhost" || api.host == host } } public protocol ContentIdentifiable: AnyObject, ContentModel, Hashable, Identifiable where ID == Int { static var modelTypeId: ContentType { get } } public extension ContentIdentifiable { var uid: Int { var hasher = Hasher() hasher.combine(Self.modelTypeId) hasher.combine(id) return hasher.finalize() } } public extension ContentIdentifiable { func hash(into hasher: inout Hasher) { hasher.combine(api) hasher.combine(id) hasher.combine(Self.modelTypeId) } static func == (lhs: Self, rhs: Self) -> Bool { lhs === rhs } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ContentModelUrlType.swift ================================================ // // ContentModelUrlType.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-26. // import Foundation public enum ContentModelUrlType: CaseIterable { /// Refers to the instance that this entity originally came from. case host /// Refers to the instance that provided this entity (e.g. the `ApiClient` attached to the entity). case provider } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ContentType.swift ================================================ // // Content Type.swift // Mlem // // Created by Eric Andrews on 2023-08-26. // import Foundation public enum ContentType: Int, Codable { case post, comment, community, person, message, mention, reply, instance, registrationApplication } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/DeletableProviding.swift ================================================ // // DeletableProviding.swift // // // Created by Sjmarf on 22/07/2024. // import Foundation public protocol DeletableProviding: OwnershipProviding { var deleted: Bool { get } func updateDeleted(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) } public extension DeletableProviding { func toggleDeleted(callback: ((UpdateStatus) -> Void)? = nil) { updateDeleted(!deleted, callback: callback) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Feature.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum Feature: Hashable { case postSortType(PostSortType) case commentSortType(CommentSortType) case searchSortType(SearchSortType) case sortTimeRange(SortTimeRange) case listingType(ListingType) case viewVotes case hidePosts case searchLocalPeople case searchLocalCommunities case searchLocalComments case modlog case viewInstanceCreationDate case viewInstanceSettings case viewCommunityActiveUsers case logIn case signUp case viewReports case viewMentionsAndPrivateMessages case editAndDeletePrivateMessages case undeletePrivateMessages case reportPrivateMessages case purgeContent case removeCommunity case banFromInstance case banFromCommunity case banFromNonLocalCommunity case unbanWithReason /// Add/remove moderators from a community case editModeratorList case editCommunityDescription case uploadImages case commentSearch case editProfile case editAccountSettings case editDisplayName /// Server automatically marks posts as read when voted on or saved case autoMarkPostReadOnInteract case blockInstances case viewInstanceBlockList case moderatorSetNsfw case fetchLinkMetadata case customPostThumbnail case userNotes } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/FederationPolicy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public struct FederationPolicy { let allowed: Set let blocked: Set init(from federatedInstances: LemmyFederatedInstances) { self.allowed = Set(federatedInstances.allowed.map(\.domain)) self.blocked = Set(federatedInstances.blocked.map(\.domain)) } init(from instances: [LemmyFederatedInstanceView]) { var allowed: Set = [] var blocked: Set = [] for instance in instances { if instance.allowed != nil { allowed.insert(instance.instance.domain) } if instance.blocked != nil { blocked.insert(instance.instance.domain) } } self.allowed = allowed self.blocked = blocked } } public enum FederationMode: Hashable { case all, local, disable } public struct VoteFederationMode: Hashable { public let postUpvote: FederationMode public let postDownvote: FederationMode public let commentUpvote: FederationMode public let commentDownvote: FederationMode public static let all: Self = .init( postUpvote: .all, postDownvote: .all, commentUpvote: .all, commentDownvote: .all ) public static let downvotesDisabled: Self = .init( postUpvote: .all, postDownvote: .disable, commentUpvote: .all, commentDownvote: .disable ) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/FederationStatus.swift ================================================ // // FederationStatus.swift // // // Created by Sjmarf on 10/06/2024. // import Foundation public enum FederationStatus { case explicitlyAllowed, explicitlyBlocked, implicitlyAllowed, implicitlyBlocked public var isExplicit: Bool { self == .explicitlyAllowed || self == .explicitlyBlocked } public var isAllowed: Bool { self == .explicitlyAllowed || self == .implicitlyAllowed } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/GetContentFilter.swift ================================================ // // GetContentFilter.swift // // // Created by Sjmarf on 24/06/2024. // import Foundation public enum GetContentFilter { case saved, upvoted, downvoted } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ImageUpload/ImageUpload1.swift ================================================ // // ImageUpload1.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation import Observation // There are no higher tiers of this model yet - in future `ImageUpload2` will be // created from `LemmyLocalImage` and `ImageUpload3` will be created from `LemmyLocalImageView`. @Observable public class ImageUpload1: ImageUpload1Providing { public var api: ApiClient public var mediaUpload1: ImageUpload1 { self } public let url: URL // This includes the file extension let alias: String? let deleteToken: String? public internal(set) var deleted: Bool = false init(api: ApiClient, url: URL, alias: String?, deleteToken: String?) { self.api = api self.url = url self.alias = alias self.deleteToken = deleteToken } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ImageUpload/ImageUpload1Providing.swift ================================================ // // ImageUpload1Providing.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation public protocol ImageUpload1Providing: ContentModel, Hashable { var mediaUpload1: ImageUpload1 { get } var url: URL { get } var deleted: Bool { get } } public extension ImageUpload1Providing { /// Delete the image. Doesn't state-fake. Can't be undone. func delete() async throws { guard let alias = mediaUpload1.alias, let deleteToken = mediaUpload1.deleteToken else { throw ApiClientError.featureUnsupported } try await api.deleteImage(alias: alias, deleteToken: deleteToken) mediaUpload1.deleted = true } func hash(into hasher: inout Hasher) { hasher.combine(url) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.hashValue == rhs.hashValue } } public typealias ImageUpload = ImageUpload1Providing ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/InboxItemProviding.swift ================================================ // // InboxItemProviding.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation public protocol InboxItemProviding: ContentIdentifiable, ContentModel, ReadableProviding { var created: Date { get } var read: Bool { get } @discardableResult func updateRead(_ newValue: Bool) -> Task } public extension InboxItemProviding { @discardableResult func toggleRead() -> Task { updateRead(!read) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Instance/Instance+Conformance.swift ================================================ // // Instance+Conformance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-03-13. // import Foundation // MARK: CacheIdentifiable public extension Instance { var cacheId: Int { id } } // MARK: ContentIdentifiable public extension Instance { static var modelTypeId: ContentType { .instance } } // MARK: ProfileProviding public extension Instance { var profileCreated: Date? { created } } // MARK: Blockable public extension Instance { var updateBlocked: ((Bool, ((Bool) -> Void)?) -> Void)? { self.api.token == nil ? nil : self._updateBlocked } private func _updateBlocked(_ newValue: Bool, callback: ((Bool) -> Void)? = nil) { let oldValue = blocked.realizedValue blocked_.set(newValue) Task { await updateQueue.addItem { properties in do { try await self.api.repository.blockInstance(instanceId: self.instanceId, block: newValue) callback?(true) if newValue { self.api.blocks?.instances[self.actorId] = self.instanceId } else { self.api.blocks?.instances.removeValue(forKey: self.actorId) } return properties } catch { self.blocked_.set(oldValue) callback?(false) throw error } } } } } // MARK: Sharable public extension Instance { func url() -> URL { actorId.url } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Instance/Instance.swift ================================================ // // Instance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-03-13. // import Observation import Foundation import os @Observable public final class Instance: UnifiedModelProviding, ActorIdentifiable, Blockable, ProfileProviding, ContentIdentifiable, Sharable { public typealias Properties = InstanceProperties public var api: ApiClient private let properties: InstanceProperties @ObservationIgnored lazy var updateQueue: UnifiedUpdateQueue = .init(parent: self, properties: properties) // MARK: Custom Properties // Mlem-specific properties that are not reflected in the API public var blocked: any RealizedValueProviding { blocked_ } public var blocked_: RealizedValue /// If this is `false`, The instance is *not* guaranteed to be non-local, particularly for locally running instances. public var local: Bool = false // MARK: API Properties // Properties that are provided by the API public let actorId: ActorIdentifier public let id: Int public let instanceId: Int public let created: Date public let updated: Date? public let publicKey: String public var displayName: String public var description: String? public var shortDescription: String? public var avatar: URL? public var banner: URL? public var lastRefresh: Date public var contentWarning: String? public var setup: ExpectedValue public var voteFederationMode: ExpectedValue public var nsfwContentEnabled: ExpectedValue public var communityCreationRestrictedToAdmins: ExpectedValue public var emailVerificationRequired: ExpectedValue public var applicationQuestion: ExpectedValue public var isPrivate: ExpectedValue public var defaultTheme: ExpectedValue public var defaultFeed: ExpectedValue public var legalInformation: ExpectedValue public var hideModlogNames: ExpectedValue public var emailApplicationsToAdmins: ExpectedValue public var emailReportsToAdmins: ExpectedValue public var slurFilterRegex: ExpectedValue public var actorNameMaxLength: ExpectedValue public var federationEnabled: ExpectedValue public var captchaEnabled: ExpectedValue public var captchaDifficulty: ExpectedValue public var registrationMode: ExpectedValue public var federationSignedFetch: ExpectedValue public var defaultPostListingMode: ExpectedValue public var defaultPostSortType: ExpectedValue public var userCount: ExpectedValue public var postCount: ExpectedValue public var commentCount: ExpectedValue public var communityCount: ExpectedValue public var activeUserCount: ExpectedValue public var allLanguages: ExpectedValue<[Locale.Language]> public var software: ExpectedValue public var allowedLanguageIds: ExpectedValue> public var blockedUrls: ExpectedValue<[InstanceUrlBlockRecord]?> public var administrators: ExpectedValue<[Person]> public init(api: ApiClient, properties: InstanceProperties) { self.api = api self.properties = properties self.blocked_ = .init(api.blocks?.instances.keys.contains(properties.actorId) ?? false) self.actorId = properties.actorId self.id = properties.id self.instanceId = properties.instanceId self.created = properties.created self.updated = properties.updated self.publicKey = properties.publicKey self.displayName = properties.displayName self.description = properties.description self.shortDescription = properties.shortDescription self.avatar = properties.avatar self.banner = properties.banner self.lastRefresh = properties.lastRefresh self.contentWarning = properties.contentWarning // because upgrade() is not available until all properties are initialized, first populate all properties // with ExpectedValues that don't actually do anything, then reassign them properly at the end of the init // this is somewhat cumbersome but avoids lazy vars, which are very awkward in Observables self.setup = dummyExpectedValue(properties.setup) self.voteFederationMode = dummyExpectedValue(properties.voteFederationMode) self.nsfwContentEnabled = dummyExpectedValue(properties.nsfwContentEnabled) self.communityCreationRestrictedToAdmins = dummyExpectedValue(properties.communityCreationRestrictedToAdmins) self.emailVerificationRequired = dummyExpectedValue(properties.emailVerificationRequired) self.applicationQuestion = dummyExpectedValue(properties.applicationQuestion) self.isPrivate = dummyExpectedValue(properties.isPrivate) self.defaultTheme = dummyExpectedValue(properties.defaultTheme) self.defaultFeed = dummyExpectedValue(properties.defaultFeed) self.legalInformation = dummyExpectedValue(properties.legalInformation) self.hideModlogNames = dummyExpectedValue(properties.hideModlogNames) self.emailApplicationsToAdmins = dummyExpectedValue(properties.emailApplicationsToAdmins) self.emailReportsToAdmins = dummyExpectedValue(properties.emailReportsToAdmins) self.slurFilterRegex = dummyExpectedValue(properties.slurFilterRegex) self.actorNameMaxLength = dummyExpectedValue(properties.actorNameMaxLength) self.federationEnabled = dummyExpectedValue(properties.federationEnabled) self.captchaEnabled = dummyExpectedValue(properties.captchaEnabled) self.captchaDifficulty = dummyExpectedValue(properties.captchaDifficulty) self.registrationMode = dummyExpectedValue(properties.registrationMode) self.federationSignedFetch = dummyExpectedValue(properties.federationSignedFetch) self.defaultPostListingMode = dummyExpectedValue(properties.defaultPostListingMode) self.defaultPostSortType = dummyExpectedValue(properties.defaultPostSortType) self.userCount = dummyExpectedValue(properties.userCount) self.postCount = dummyExpectedValue(properties.postCount) self.commentCount = dummyExpectedValue(properties.commentCount) self.communityCount = dummyExpectedValue(properties.communityCount) self.activeUserCount = dummyExpectedValue(properties.activeUserCount) self.allLanguages = dummyExpectedValue(properties.allLanguages) self.software = dummyExpectedValue(properties.software) self.allowedLanguageIds = dummyExpectedValue(properties.allowedLanguageIds) self.blockedUrls = dummyExpectedValue(properties.blockedUrls) self.administrators = dummyExpectedValue(properties.administrators) func expectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { try await self.upgrade() } ) } self.setup = expectedValue(properties.setup) self.voteFederationMode = expectedValue(properties.voteFederationMode) self.nsfwContentEnabled = expectedValue(properties.nsfwContentEnabled) self.communityCreationRestrictedToAdmins = expectedValue(properties.communityCreationRestrictedToAdmins) self.emailVerificationRequired = expectedValue(properties.emailVerificationRequired) self.applicationQuestion = expectedValue(properties.applicationQuestion) self.isPrivate = expectedValue(properties.isPrivate) self.defaultTheme = expectedValue(properties.defaultTheme) self.defaultFeed = expectedValue(properties.defaultFeed) self.legalInformation = expectedValue(properties.legalInformation) self.hideModlogNames = expectedValue(properties.hideModlogNames) self.emailApplicationsToAdmins = expectedValue(properties.emailApplicationsToAdmins) self.emailReportsToAdmins = expectedValue(properties.emailReportsToAdmins) self.slurFilterRegex = expectedValue(properties.slurFilterRegex) self.actorNameMaxLength = expectedValue(properties.actorNameMaxLength) self.federationEnabled = expectedValue(properties.federationEnabled) self.captchaEnabled = expectedValue(properties.captchaEnabled) self.captchaDifficulty = expectedValue(properties.captchaDifficulty) self.registrationMode = expectedValue(properties.registrationMode) self.federationSignedFetch = expectedValue(properties.federationSignedFetch) self.defaultPostListingMode = expectedValue(properties.defaultPostListingMode) self.defaultPostSortType = expectedValue(properties.defaultPostSortType) self.userCount = expectedValue(properties.userCount) self.postCount = expectedValue(properties.postCount) self.commentCount = expectedValue(properties.commentCount) self.communityCount = expectedValue(properties.communityCount) self.activeUserCount = expectedValue(properties.activeUserCount) self.allLanguages = expectedValue(properties.allLanguages) self.software = expectedValue(properties.software) self.allowedLanguageIds = expectedValue(properties.allowedLanguageIds) self.blockedUrls = expectedValue(properties.blockedUrls) self.administrators = expectedValue(properties.administrators) } @MainActor public func update(with properties: InstanceProperties) { setIfChanged(\.displayName, properties.displayName) setIfChanged(\.description, properties.description) setIfChanged(\.shortDescription, properties.shortDescription) setIfChanged(\.avatar, properties.avatar) setIfChanged(\.banner, properties.banner) setIfChanged(\.lastRefresh, properties.lastRefresh) setIfChanged(\.contentWarning, properties.contentWarning) updateIfChanged(\.setup.value_, properties.setup) updateIfChanged(\.voteFederationMode.value_, properties.voteFederationMode) updateIfChanged(\.nsfwContentEnabled.value_, properties.nsfwContentEnabled) updateIfChanged(\.communityCreationRestrictedToAdmins.value_, properties.communityCreationRestrictedToAdmins) updateIfChanged(\.emailVerificationRequired.value_, properties.emailVerificationRequired) updateIfChanged(\.applicationQuestion.value_, properties.applicationQuestion) updateIfChanged(\.isPrivate.value_, properties.isPrivate) updateIfChanged(\.defaultTheme.value_, properties.defaultTheme) updateIfChanged(\.defaultFeed.value_, properties.defaultFeed) updateIfChanged(\.legalInformation.value_, properties.legalInformation) updateIfChanged(\.hideModlogNames.value_, properties.hideModlogNames) updateIfChanged(\.emailApplicationsToAdmins.value_, properties.emailApplicationsToAdmins) updateIfChanged(\.emailReportsToAdmins.value_, properties.emailReportsToAdmins) updateIfChanged(\.slurFilterRegex.value_, properties.slurFilterRegex) updateIfChanged(\.actorNameMaxLength.value_, properties.actorNameMaxLength) updateIfChanged(\.federationEnabled.value_, properties.federationEnabled) updateIfChanged(\.captchaEnabled.value_, properties.captchaEnabled) updateIfChanged(\.captchaDifficulty.value_, properties.captchaDifficulty) updateIfChanged(\.registrationMode.value_, properties.registrationMode) updateIfChanged(\.federationSignedFetch.value_, properties.federationSignedFetch) updateIfChanged(\.defaultPostListingMode.value_, properties.defaultPostListingMode) updateIfChanged(\.defaultPostSortType.value_, properties.defaultPostSortType) updateIfChanged(\.userCount.value_, properties.userCount) updateIfChanged(\.postCount.value_, properties.postCount) updateIfChanged(\.commentCount.value_, properties.commentCount) updateIfChanged(\.communityCount.value_, properties.communityCount) updateIfChanged(\.activeUserCount.value_, properties.activeUserCount) setIfNil(\.allLanguages.value_, properties.allLanguages) // not expected to change updateIfChanged(\.software.value_, properties.software) updateIfChanged(\.allowedLanguageIds.value_, properties.allowedLanguageIds) updateIfChanged(\.blockedUrls.value_, properties.blockedUrls) updateIfChanged(\.administrators.value_, properties.administrators) } @MainActor public func softUpdate(with properties: InstanceProperties) { setIfNil(\.setup.value_, properties.setup) setIfNil(\.voteFederationMode.value_, properties.voteFederationMode) setIfNil(\.nsfwContentEnabled.value_, properties.nsfwContentEnabled) setIfNil(\.communityCreationRestrictedToAdmins.value_, properties.communityCreationRestrictedToAdmins) setIfNil(\.emailVerificationRequired.value_, properties.emailVerificationRequired) setIfNil(\.applicationQuestion.value_, properties.applicationQuestion) setIfNil(\.isPrivate.value_, properties.isPrivate) setIfNil(\.defaultTheme.value_, properties.defaultTheme) setIfNil(\.defaultFeed.value_, properties.defaultFeed) setIfNil(\.legalInformation.value_, properties.legalInformation) setIfNil(\.hideModlogNames.value_, properties.hideModlogNames) setIfNil(\.emailApplicationsToAdmins.value_, properties.emailApplicationsToAdmins) setIfNil(\.emailReportsToAdmins.value_, properties.emailReportsToAdmins) setIfNil(\.slurFilterRegex.value_, properties.slurFilterRegex) setIfNil(\.actorNameMaxLength.value_, properties.actorNameMaxLength) setIfNil(\.federationEnabled.value_, properties.federationEnabled) setIfNil(\.captchaEnabled.value_, properties.captchaEnabled) setIfNil(\.captchaDifficulty.value_, properties.captchaDifficulty) setIfNil(\.registrationMode.value_, properties.registrationMode) setIfNil(\.federationSignedFetch.value_, properties.federationSignedFetch) setIfNil(\.defaultPostListingMode.value_, properties.defaultPostListingMode) setIfNil(\.defaultPostSortType.value_, properties.defaultPostSortType) setIfNil(\.userCount.value_, properties.userCount) setIfNil(\.postCount.value_, properties.postCount) setIfNil(\.commentCount.value_, properties.commentCount) setIfNil(\.communityCount.value_, properties.communityCount) setIfNil(\.activeUserCount.value_, properties.activeUserCount) setIfNil(\.software.value_, properties.software) setIfNil(\.allowedLanguageIds.value_, properties.allowedLanguageIds) setIfNil(\.blockedUrls.value_, properties.blockedUrls) setIfNil(\.administrators.value_, properties.administrators) } // MARK: Upgrades public func upgrade() async throws { try await updateQueue.upgrade() } /// Gets this instance using the ApiClient local to this instance public func getLocal() async throws -> Instance { if apiIsLocal { return self } let localApi = ApiClient.getApiClient(url: actorId.hostUrl, username: nil) return try await localApi.getMyInstance() } public func refresh() async throws { try await updateQueue.refresh() } public func fetchUpgraded() async throws -> InstanceProperties { let externalApi: ApiClient = apiIsLocal ? api : .getApiClient(url: actorId.url, username: nil) let snapshot = try await externalApi.repository.getMyInstance() return await .init(api: api, snapshot: .instance3(snapshot)) } public func resolve(with api: ApiClient) async throws -> Instance { guard let instance = try await api.getCommunityOfInstance(actorId: actorId).instance.value as? Instance else { throw InstanceUpgradeError.noSiteReturned } return instance } } // MARK: Computed public extension Instance { @inlinable var name: String { host } func language(withId id: Int) -> Locale.Language? { guard let allLanguages = allLanguages.value else { return nil } return allLanguages[safeIndex: id - 1] } func getLanguageId(for language: Locale.Language) -> Int? { guard let allLanguages = allLanguages.value else { return nil } return allLanguages.firstIndex(of: language)?.advanced(by: 1) } func languages(withIds ids: Set) -> [Locale.Language] { ids.lazy.sorted(by: <).compactMap { self.language(withId: $0) } } var allowedLanguages: Set? { guard let allowedLanguageIds = allowedLanguageIds.value else { return nil } return Set(allowedLanguageIds.lazy.compactMap { self.language(withId: $0) }) } var guestApi: ApiClient { .getApiClient(url: local ? api.baseUrl : actorId.hostUrl, username: nil) } } // MARK: Interactions public extension Instance { // Add Admin func addAdmin(personId: Int, added: Bool) { Task { await updateQueue.addItem { properties in let snapshots = try await self.api.repository.addAdmin(personId: personId, added: added) let updatedAdministrators = await self.api.caches.person.getModels(api: self.api, from: snapshots.map { .person2($0) }) // update person's admin status // only need to do this manually if removing admin, otherwise handled by above caching logic if !added, let person = self.api.caches.person.retrieveModel(cacheId: personId) { person.isAdmin.value_ = false } var properties = properties properties.administrators = updatedAdministrators return properties } } } // Username Validity var usernameIsValidForNewAccount: ((String) async throws -> UsernameValidity)? { if let actorNameMaxLength = actorNameMaxLength.value { return { try await self.usernameIsValidForNewAccount($0, actorNameMaxLength: actorNameMaxLength) } } return nil } private func usernameIsValidForNewAccount(_ username: String, actorNameMaxLength: Int) async throws -> UsernameValidity { guard username.count >= 3 else { return .invalid(.tooShort(minLength: 3)) } guard username.count <= actorNameMaxLength else { return .invalid(.tooLong(maxLength: actorNameMaxLength)) } // Relevant backend code https://github.com/LemmyNet/lemmy/blob/5095092d3a6b0c194295e2cf3034d2b9abf8db54/crates/utils/src/utils/validation.rs#L94 let regex = /^(?:[a-zA-Z0-9_]+|[0-9_\p{Arabic}]+|[0-9_\p{Cyrillic}]+)$/ if try regex.wholeMatch(in: username) == nil { // If username isn't english, give a generic error let englishRegex = /[^\p{Arabic}\p{Cyrillic}]+/ if try englishRegex.wholeMatch(in: username) == nil { return .invalid(.other) } // If the username *is* in english, we can be more descriptive let invalidCharacters = username.filter { char in if char == "_" { return false } guard let scalar = char.unicodeScalars.first, char.unicodeScalars.count == 1 else { return true } if scalar.value >= 65, scalar.value <= 90 { return false } // Uppercase if scalar.value >= 97, scalar.value <= 122 { return false } // Lowercase if scalar.value >= 48, scalar.value <= 57 { return false } // Numbers return true } if !invalidCharacters.isEmpty { return .invalid(.containsInvalidCharacters(Set(invalidCharacters))) } assertionFailure() return .invalid(.other) } do { _ = try await api.getPerson(username: username) return .taken } catch ApiClientError.noEntityFound { return .available } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Instance/InstanceProperties.swift ================================================ // // InstanceProperties.swift // MlemMiddleware // // Created by Eric Andrews on 2026-03-13. // import Foundation public struct InstanceProperties: UnifiedPropertiesProviding { // From Instance1Snapshot, guaranteed to always be present let actorId: ActorIdentifier let id: Int let instanceId: Int let created: Date let updated: Date? let publicKey: String var displayName: String var description: String? var shortDescription: String? var avatar: URL? var banner: URL? var lastRefresh: Date var contentWarning: String? // From Instance2Snapshot var setup: Bool? var voteFederationMode: VoteFederationMode? var nsfwContentEnabled: Bool? var communityCreationRestrictedToAdmins: Bool? var emailVerificationRequired: Bool? var applicationQuestion: String?? var isPrivate: Bool? var defaultTheme: String? var defaultFeed: ListingType? var legalInformation: String?? var hideModlogNames: Bool? var emailApplicationsToAdmins: Bool? var emailReportsToAdmins: Bool? var slurFilterRegex: String?? var actorNameMaxLength: Int? var federationEnabled: Bool? var captchaEnabled: Bool? var captchaDifficulty: CaptchaDifficulty?? var registrationMode: RegistrationMode? var federationSignedFetch: Bool?? var defaultPostListingMode: PostFeedViewMode?? var defaultPostSortType: PostSortType?? var userCount: Int? var postCount: Int? var commentCount: Int? var communityCount: Int? var activeUserCount: ActiveUserCount? // From Instance3Snapshot let allLanguages: [Locale.Language]? var software: SiteSoftware? var allowedLanguageIds: Set? var blockedUrls: [InstanceUrlBlockRecord]?? var administrators: [Person]? // Constructs an InstanceProperties from a given snapshot @MainActor public init(api: ApiClient, snapshot: AnyInstanceSnapshot) { let snapshot1: Instance1Snapshot let snapshot2: Instance2Snapshot? let snapshot3: Instance3Snapshot? switch snapshot { case let .instance1(instance1Snapshot): snapshot1 = instance1Snapshot snapshot2 = nil snapshot3 = nil case let .instance2(instance2Snapshot): snapshot1 = instance2Snapshot.instance snapshot2 = instance2Snapshot snapshot3 = nil case let .instance3(instance3Snapshot): snapshot1 = instance3Snapshot.instance.instance snapshot2 = instance3Snapshot.instance snapshot3 = instance3Snapshot } if let snapshot3 { allLanguages = snapshot3.allLanguages software = snapshot3.software allowedLanguageIds = snapshot3.allowedLanguageIds blockedUrls = snapshot3.blockedUrls administrators = api.caches.person.getModels(api: api, from: snapshot3.administrators.map { .person2($0) }) } else { allLanguages = nil // needs special handling because it's a let } if let snapshot2 { setup = snapshot2.setup voteFederationMode = snapshot2.voteFederationMode nsfwContentEnabled = snapshot2.nsfwContentEnabled communityCreationRestrictedToAdmins = snapshot2.communityCreationRestrictedToAdmins emailVerificationRequired = snapshot2.emailVerificationRequired applicationQuestion = snapshot2.applicationQuestion isPrivate = snapshot2.isPrivate defaultTheme = snapshot2.defaultTheme defaultFeed = snapshot2.defaultFeed legalInformation = snapshot2.legalInformation hideModlogNames = snapshot2.hideModlogNames emailApplicationsToAdmins = snapshot2.emailApplicationsToAdmins emailReportsToAdmins = snapshot2.emailReportsToAdmins slurFilterRegex = snapshot2.slurFilterRegex actorNameMaxLength = snapshot2.actorNameMaxLength federationEnabled = snapshot2.federationEnabled captchaEnabled = snapshot2.captchaEnabled captchaDifficulty = snapshot2.captchaDifficulty registrationMode = snapshot2.registrationMode federationSignedFetch = snapshot2.federationSignedFetch defaultPostListingMode = snapshot2.defaultPostListingMode defaultPostSortType = snapshot2.defaultPostSortType userCount = snapshot2.userCount postCount = snapshot2.postCount commentCount = snapshot2.commentCount communityCount = snapshot2.communityCount activeUserCount = snapshot2.activeUserCount } actorId = snapshot1.actorId id = snapshot1.id instanceId = snapshot1.instanceId created = snapshot1.created updated = snapshot1.updated publicKey = snapshot1.publicKey displayName = snapshot1.displayName description = snapshot1.description shortDescription = snapshot1.shortDescription avatar = snapshot1.avatar banner = snapshot1.banner lastRefresh = snapshot1.lastRefresh contentWarning = snapshot1.contentWarning } public mutating func merge(_ other: InstanceProperties) { // tier 1 properties: simple assignment self.displayName = other.displayName self.description = other.description self.shortDescription = other.shortDescription self.avatar = other.avatar self.banner = other.banner self.lastRefresh = other.lastRefresh self.contentWarning = other.contentWarning // tier 2, 3 properties: only assign if incoming non-nil self.setup = other.setup ?? self.setup self.voteFederationMode = other.voteFederationMode ?? self.voteFederationMode self.nsfwContentEnabled = other.nsfwContentEnabled ?? self.nsfwContentEnabled self.communityCreationRestrictedToAdmins = other.communityCreationRestrictedToAdmins ?? self.communityCreationRestrictedToAdmins self.emailVerificationRequired = other.emailVerificationRequired ?? self.emailVerificationRequired self.applicationQuestion = other.applicationQuestion ?? self.applicationQuestion self.isPrivate = other.isPrivate ?? self.isPrivate self.defaultTheme = other.defaultTheme ?? self.defaultTheme self.defaultFeed = other.defaultFeed ?? self.defaultFeed self.legalInformation = other.legalInformation ?? self.legalInformation self.hideModlogNames = other.hideModlogNames ?? self.hideModlogNames self.emailApplicationsToAdmins = other.emailApplicationsToAdmins ?? self.emailApplicationsToAdmins self.emailReportsToAdmins = other.emailReportsToAdmins ?? self.emailReportsToAdmins self.slurFilterRegex = other.slurFilterRegex ?? self.slurFilterRegex self.actorNameMaxLength = other.actorNameMaxLength ?? self.actorNameMaxLength self.federationEnabled = other.federationEnabled ?? self.federationEnabled self.captchaEnabled = other.captchaEnabled ?? self.captchaEnabled self.captchaDifficulty = other.captchaDifficulty ?? self.captchaDifficulty self.registrationMode = other.registrationMode ?? self.registrationMode self.federationSignedFetch = other.federationSignedFetch ?? self.federationSignedFetch self.defaultPostListingMode = other.defaultPostListingMode ?? self.defaultPostListingMode self.defaultPostSortType = other.defaultPostSortType ?? self.defaultPostSortType self.userCount = other.userCount ?? self.userCount self.postCount = other.postCount ?? self.postCount self.commentCount = other.commentCount ?? self.commentCount self.communityCount = other.communityCount ?? self.communityCount self.activeUserCount = other.activeUserCount ?? self.activeUserCount self.software = other.software ?? self.software self.allowedLanguageIds = other.allowedLanguageIds ?? self.allowedLanguageIds self.blockedUrls = other.blockedUrls ?? self.blockedUrls self.administrators = other.administrators ?? self.administrators } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Instance/InstanceStub.swift ================================================ // // File.swift // // // Created by Sjmarf on 28/05/2024. // import Foundation public enum InstanceUpgradeError: Error { case noPostReturned case noCommunityReturned case noSiteReturned } public struct InstanceStub: Hashable { public var api: ApiClient public let actorId: ActorIdentifier public var local: Bool { actorId.url == api.baseUrl } public init(api: ApiClient, actorId: ActorIdentifier) { self.api = api self.actorId = actorId } public func asLocal() -> Self { .init(api: .getApiClient(url: actorId.hostUrl, username: nil), actorId: actorId) } public func hash(into hasher: inout Hasher) { hasher.combine(actorId) } public static func == (lhs: InstanceStub, rhs: InstanceStub) -> Bool { lhs.actorId == rhs.actorId } /// Gets the instance this stub refers to using that instance's local API public func getLocalInstance() async throws -> Instance { return try await self.asLocal().api.getMyInstance() } /// Gets the instance this stub refers to using the stub's current API public func getInstance() async throws -> Instance { let community = try await api.getCommunityOfInstance(actorId: actorId) let instance = try await community.fetchUpgraded().instance guard let instance = instance as? Instance else { throw InstanceUpgradeError.noSiteReturned } return instance } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/InstanceBanType.swift ================================================ // // BanType.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation public enum InstanceBanType: Equatable { case notBanned case permanentlyBanned case temporarilyBanned(expires: Date) var expiryDate: Date? { switch self { case let .temporarilyBanned(expires): expires default: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/InstanceUrlBlockRecord.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public struct InstanceUrlBlockRecord: Hashable { let id: Int let created: Date let updated: Date? let url: URL public init(from blocklist: LemmyLocalSiteUrlBlocklist) throws(ApiClientError) { self.id = blocklist.id if let published = blocklist.publishedAt ?? blocklist.published { self.created = published } else { throw .responseMissingRequiredData("LemmyLocalSiteUrlBlocklist published") } self.updated = blocklist.updatedAt ?? blocklist.updated guard let url = URL(string: blocklist.url) else { throw .responseMissingRequiredData("LemmyLocalSiteUrlBlocklist Invalid URL") } self.url = url } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Interactable/InteractableProviding.swift ================================================ // // InteractableProviding.swift // Mlem // // Created by Sjmarf on 30/03/2024. // import Foundation /// Represents a post/comment that you *should* be able to interact with, but you cannot actually interact with due to the model being too low-tier. public protocol InteractableProviding: AnyObject, ContentModel, ReportableProviding, ContentIdentifiable, RemovableProviding { var created: Date { get } var updated: Date? { get } var votes: ExpectedValue { get } var saved: ExpectedValue { get } var commentCount: ExpectedValue { get } var creator: ExpectedValue { get } var community: ExpectedValue { get } var creatorIsAdmin: ExpectedValue { get } var creatorIsModerator: ExpectedValue { get } var updateVote: ((ScoringOperation) -> Void)? { get } func updateSaved(_ newValue: Bool) func reply(content: String, languageId: Int?) async throws -> Comment var downvotesEnabled: Bool { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/LemmyURL.swift ================================================ // // LemmyURL.swift // Mlem // // Created by mormaer on 15/09/2023. // // import Foundation struct LemmyURL { let url: URL init?(string: String?) { guard let string else { return nil } if let url = URL(string: string) { self.url = url } else if let encoded = string.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), let url = URL(string: encoded) { self.url = url } else { return nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/ScoringOperation.swift ================================================ // // ScoringOperation.swift // Mlem // // Created by mormaer on 16/08/2023. // // import Foundation import SwiftUI public enum ScoringOperation: Int, Decodable, CustomStringConvertible { case upvote = 1 case downvote = -1 case none = 0 public var upvoteValue: Int { self == .upvote ? 1 : 0 } public var downvoteValue: Int { self == .downvote ? 1 : 0 } public var description: String { switch self { case .upvote: "Upvote" case .downvote: "Downvote" case .none: "No Vote" } } var booleanValue: Bool? { switch self { case .upvote: true case .downvote: false case .none: nil } } init(_ bool: Bool?) { self = switch bool { case true: .upvote case false: .downvote case nil: .none } } } public extension ScoringOperation { /// Non-optional initializer; if int is nil or invalid, returns .none static func guaranteedInit(from int: Int?) -> ScoringOperation { guard let int else { return .none } if let value = ScoringOperation(rawValue: int) { return value } else { return .none } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/SiteSoftware/SiteSoftware.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public struct SiteSoftware: Codable, Hashable { public let type: SiteSoftwareType public let version: SiteVersion public init(type: SiteSoftwareType, version: SiteVersion) { self.type = type self.version = version } public func supports(_ feature: Feature) -> Bool { switch type { case .lemmy: LemmyConnection.supports(feature, version: version) case .pieFed: PieFedConnection.supports(feature, version: version) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/SiteSoftware/SiteSoftwareType.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum SiteSoftwareType: String, Codable, Sendable, CaseIterable { case lemmy, pieFed } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/SiteVersion/SiteVersion+EndpointVersion.swift ================================================ // // SiteVersion+EndpointVersion.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-21. // import Foundation public enum LemmyEndpointVersion: Hashable, Sendable { case v3, v4 var pathComponent: String { switch self { case .v3: "v3" case .v4: "v4" } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Internal/SiteVersion/SiteVersion.swift ================================================ // // ApiSiteVersionNumber.swift // Mlem // // Created by Sjmarf on 09/09/2023. // import Foundation public enum SiteVersion: Equatable, Hashable { case release(major: Int, minor: Int, patch: Int) case other(String) case zero case infinity public init(_ version: String) { let parts = version.split(separator: "-") if let firstPart = parts.first { let components = firstPart.split(separator: ".").compactMap { Int($0) } if components.count == 3 { self = .release(major: components[0], minor: components[1], patch: components[2]) } else { self = .other(version) } } else { self = .other(version) } } // swiftlint: disable large_tuple public var parts: (Int, Int, Int)? { switch self { case let .release(major, minor, patch): return (major, minor, patch) default: return nil } } // swiftlint: enable large_tuple } extension SiteVersion: CustomStringConvertible { public var description: String { switch self { case .zero: return "zero" case .infinity: return "infinity" case let .release(major, minor, patch): return "\(major).\(minor).\(patch)" case let .other(string): return string } } } extension SiteVersion: Codable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let versionString = try container.decode(String.self) self.init(versionString) } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(String(describing: self)) } } extension SiteVersion: Comparable { public static func < (lhs: SiteVersion, rhs: SiteVersion) -> Bool { switch (lhs, rhs) { case (.release, .release): return lhs.parts! < rhs.parts! case (.zero, _), (_, .infinity): return true case (_, .zero), (.infinity, _): return false default: return false } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/BlockListSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension BlockListSnapshot { init(from myUserInfo: LemmyMyUserInfo) throws(ApiClientError) { self.people = myUserInfo.personBlocks.reduce(into: [:]) { if let actorId = $1.person.apId ?? $1.person.actorId { $0[actorId] = $1.person.id } } self.communities = myUserInfo.communityBlocks.reduce(into: [:]) { if let actorId = $1.community.apId ?? $1.community.actorId { $0[actorId] = $1.community.id } } if let instanceCommunitiesBlocks = myUserInfo.instanceCommunitiesBlocks { self.instances = instanceCommunitiesBlocks.reduce(into: [:]) { let actorId: ActorIdentifier = .instance(host: $1.domain) $0[actorId] = $1.id } } else if let instanceBlocks = myUserInfo.instanceBlocks { self.instances = instanceBlocks.reduce(into: [:]) { let actorId: ActorIdentifier = .instance(host: $1.instance.domain) $0[actorId] = $1.instance.id } } else { throw .responseMissingRequiredData("LemmyMyUserInfo instanceBlocks (BlockListSnapshot)") } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Comment1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Comment1Snapshot { init(from comment: LemmyComment) throws(ApiClientError) { let parentCommentIds = comment.path .split(separator: ".") .dropFirst() .dropLast() .compactMap { Int($0) } guard let published = comment.publishedAt ?? comment.published else { throw .responseMissingRequiredData("LemmyComment published") } self.init( actorId: comment.apId, id: comment.id, creatorId: comment.creatorId, postId: comment.postId, parentCommentIds: parentCommentIds, created: published, content: comment.content, updated: comment.updatedAt ?? comment.updated, distinguished: comment.distinguished, languageId: comment.languageId, deleted: comment.deleted, removed: comment.removed ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Comment2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Comment2Snapshot { init(from comment: LemmyCommentView) throws(ApiClientError) { guard let commentCount = comment.comment.childCount ?? comment.counts?.childCount else { throw .responseMissingRequiredData("LemmyCommentView childCount") } let saved: Bool if let saved_ = comment.saved { saved = saved_ } else { saved = comment.commentActions?.savedAt != nil } let votes: VotesModel if let counts = comment.counts { votes = .init(from: counts, myVote: .guaranteedInit(from: comment.myVote)) } else if let upvotes = comment.comment.upvotes, let downvotes = comment.comment.downvotes { votes = .init( upvotes: upvotes, downvotes: downvotes, myVote: .init(comment.commentActions?.voteIsUpvote) ) } else { throw .responseMissingRequiredData("LemmyCommentView score") } try self.init( comment: .init(from: comment.comment), creator: .init(from: comment.creator), post: .init(from: comment.post), community: .init(from: comment.community), commentCount: commentCount, creatorIsModerator: comment.creatorIsModerator, creatorIsAdmin: comment.creatorIsAdmin, creatorBannedFromCommunity: comment.creatorBannedFromCommunity, votes: votes, saved: saved ) } init(from report: LemmyCommentReportView) throws(ApiClientError) { guard let commentCount = report.comment.childCount ?? report.counts?.childCount else { throw .responseMissingRequiredData("LemmyCommentReportView childCount") } let saved: Bool if let saved_ = report.saved { saved = saved_ } else { saved = report.commentActions?.savedAt != nil } let votes: VotesModel if let counts = report.counts { votes = .init(from: counts, myVote: .guaranteedInit(from: report.myVote)) } else if let upvotes = report.comment.upvotes, let downvotes = report.comment.downvotes { votes = .init( upvotes: upvotes, downvotes: downvotes, myVote: .init(report.commentActions?.voteIsUpvote) ) } else { throw .responseMissingRequiredData("LemmyCommentReportView score") } try self.init( comment: .init(from: report.comment), creator: .init(from: report.commentCreator), post: .init(from: report.post), community: .init(from: report.community), commentCount: commentCount, creatorIsModerator: report.creatorIsModerator ?? false, creatorIsAdmin: report.creatorIsAdmin ?? false, creatorBannedFromCommunity: report.creatorBannedFromCommunity, votes: votes, saved: saved ) } init(from reply: LemmyCommentReplyView) throws(ApiClientError) { try self.init( comment: .init(from: reply.comment), creator: .init(from: reply.creator), post: .init(from: reply.post), community: .init(from: reply.community), commentCount: reply.comment.childCount ?? reply.counts.childCount, creatorIsModerator: reply.creatorIsModerator, creatorIsAdmin: reply.creatorIsAdmin, creatorBannedFromCommunity: reply.creatorBannedFromCommunity, votes: .init(from: reply.counts, myVote: .guaranteedInit(from: reply.myVote)), saved: reply.saved ) } init(from mention: LemmyPersonCommentMentionView) throws(ApiClientError) { try self.init( comment: .init(from: mention.comment), creator: .init(from: mention.creator), post: .init(from: mention.post), community: .init(from: mention.community), commentCount: mention.comment.childCount ?? mention.counts.childCount, creatorIsModerator: mention.creatorIsModerator, creatorIsAdmin: mention.creatorIsAdmin, creatorBannedFromCommunity: mention.creatorBannedFromCommunity, votes: .init(from: mention.counts, myVote: .guaranteedInit(from: mention.myVote)), saved: mention.saved ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/CommentSortType+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation // This is reluncantly public because Mlem uses it; this should really be `internal` public extension CommentSortType { init(_ apiSortType: LemmyCommentSortType) { self = switch apiSortType { case .hot: .hot case .top: .top(.allTime) case .new: .new case .old: .old case .controversial: .controversial } } internal func apiType(for endpoint: LemmyEndpointVersion) throws(ApiClientError) -> LemmySearchSortTypeBridge { switch endpoint { case .v3: .old(v3PostApiType) case .v4: try .newOrUnsupported(v4SearchApiType) } } var v3CommentApiType: LemmyCommentSortType { switch self { case .new: .new case .old: .old case .hot: .hot case .controversial: .controversial case .top: .top } } var v3PostApiType: LemmySortType { switch self { case .new: .new case .old: .old case .hot: .hot case .controversial: .controversial case .top: .topAll } } var v4SearchApiType: LemmySearchSortType? { switch self { case .new: .new case .old: .old case .top: .top default: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Community1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Community1Snapshot { init(from community: LemmyCommunity) throws(ApiClientError) { guard let actorId = community.apId ?? community.actorId else { throw .responseMissingRequiredData("LemmyCommunity actorId") } guard let published = community.publishedAt ?? community.published else { throw .responseMissingRequiredData("LemmyCommunity published") } let description: String? if let sidebar = community.sidebar { description = sidebar } else { description = community.description } self.init( actorId: actorId, id: community.id, name: community.name, created: published, instanceId: community.instanceId, updated: community.updatedAt ?? community.updated, displayName: community.title, description: description, deleted: community.deleted, removed: community.removed, nsfw: community.nsfw, avatar: community.icon, banner: community.banner, hidden: community.hidden ?? false, // TODO: 0.20 we shouldn't be null coalescing here onlyModeratorsCanPost: community.postingRestrictedToMods, allPropertiesPresent: true ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Community2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Community2Snapshot { init(from community: LemmyCommunityView) throws(ApiClientError) { guard let totalSubscribers = community.community.subscribers ?? community.counts?.subscribers, let localSubscribers = community.community.subscribersLocal ?? community.counts?.subscribersLocal else { throw .responseMissingRequiredData("LemmyCommunityView subscriber count") } let subscribed: Bool if let subscribed_ = community.subscribed?.isSubscribed { subscribed = subscribed_ } else { subscribed = community.communityActions?.followState?.isSubscribed ?? false } let subscription = SubscriptionModel( total: totalSubscribers, local: localSubscribers, subscribed: subscribed, pending: community.communityActions?.followState == .pending || community.subscribed == .pending ) guard let postCount = community.counts?.posts ?? community.community.posts else { throw .responseMissingRequiredData("LemmyCommunityView postCount") } guard let commentCount = community.counts?.comments ?? community.community.comments else { throw .responseMissingRequiredData("LemmyCommunityView commentCount") } guard let activeUsers6Months = community.counts?.usersActiveHalfYear ?? community.community.usersActiveHalfYear, let activeUsersMonth = community.counts?.usersActiveMonth ?? community.community.usersActiveMonth, let activeUsersWeek = community.counts?.usersActiveWeek ?? community.community.usersActiveWeek, let activeUsersDay = community.counts?.usersActiveDay ?? community.community.usersActiveDay else { throw .responseMissingRequiredData("LemmyCommunityView activeUserCount") } let activeUserCount = ActiveUserCount( sixMonths: activeUsers6Months, month: activeUsersMonth, week: activeUsersWeek, day: activeUsersDay ) let bannedFromCommunity: Bool? if let actions = community.communityActions { bannedFromCommunity = actions.banExpiresAt != nil } else { bannedFromCommunity = community.bannedFromCommunity } try self.init( community: .init(from: community.community), subscription: subscription, postCount: postCount, commentCount: commentCount, activeUserCount: activeUserCount, bannedFromCommunity: bannedFromCommunity ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Community3Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Community3Snapshot { init(from community: LemmyGetCommunityResponse) throws(ApiClientError) { let instance: Instance1Snapshot? if let site = community.site { instance = try .init(from: site) } else { instance = nil } var moderators = [Person1Snapshot]() for moderator in community.moderators { try moderators.append(.init(from: moderator.moderator)) } try self.init( community: .init(from: community.communityView), instance: instance, moderators: moderators, discussionLanguageIds: .init(community.discussionLanguages) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/FederationMode+Lemmy.swift ================================================ // // FederationMode+Lemmy.swift // MlemMiddleware // // Created by Sjmarf on 2025-11-30. // import Foundation extension FederationMode { init(from federationMode: LemmyFederationMode) { self = switch federationMode { case .all: .all case .local: .local case .disable: .disable } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ImageUpload1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension ImageUpload1Snapshot { init(from file: LemmyPictrsFile, baseUrl: URL) { self.init( url: baseUrl.appending(path: "pictrs/image/\(file.file)"), alias: file.file, deleteToken: file.deleteToken ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/InboxNotificationSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension InboxNotificationSnapshot { init(from replyView: LemmyCommentReplyView) throws(ApiClientError) { try self.init( id: LegacyNotificationIdWrapper(type: .reply, id: replyView.commentReply.id).hashValue, contentId: replyView.commentReply.id, read: replyView.commentReply.read, content: .reply(.init(from: replyView)) ) } init(from mentionView: LemmyPersonCommentMentionView) throws(ApiClientError) { try self.init( id: LegacyNotificationIdWrapper(type: .mention, id: mentionView.personMention.id).hashValue, contentId: mentionView.personMention.id, read: mentionView.personMention.read, content: .mention(.init(from: mentionView)) ) } init(from messageView: LemmyPrivateMessageView) throws(ApiClientError) { guard let read = messageView.privateMessage.read else { throw .responseMissingRequiredData("LemmyPrivateMessage read") } try self.init( id: LegacyNotificationIdWrapper(type: .message, id: messageView.privateMessage.id).hashValue, contentId: messageView.privateMessage.id, read: read, content: .message(.init(from: messageView)) ) } init(from notification: LemmyNotificationView) throws(ApiClientError) { let contentId: Int let content: InboxNotificationContentSnapshot switch notification.data { case let .privateMessage(message): contentId = message.privateMessage.id content = try .message(.init(from: message)) case let .comment(comment) where notification.notification.kind == .mention: contentId = comment.comment.id content = try .mention(.init(from: comment)) case let .comment(comment) where notification.notification.kind == .reply: contentId = comment.comment.id content = try .reply(.init(from: comment)) default: throw ApiClientError.featureUnsupported } self.init( id: notification.notification.id, contentId: contentId, read: notification.notification.read, content: content ) } } // This can be removed once we drop support for < Lemmy 1.0 private struct LegacyNotificationIdWrapper: Hashable { let type: InboxNotificationContentType let id: Int } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Instance1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Instance1Snapshot { init(from site: LemmySite) throws(ApiClientError) { guard let actorId = site.apId ?? site.actorId else { throw .responseMissingRequiredData("LemmySite actorId") } guard let published = site.publishedAt ?? site.published else { throw .responseMissingRequiredData("LemmySite published") } self.init( actorId: actorId, id: site.id, instanceId: site.instanceId, created: published, updated: site.updatedAt ?? site.updated, publicKey: site.publicKey ?? "", displayName: site.name, description: site.sidebar, shortDescription: site.description, avatar: site.icon, banner: site.banner, lastRefresh: site.lastRefreshedAt, contentWarning: site.contentWarning ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Instance2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Instance2Snapshot { init(from site: LemmySiteView) throws(ApiClientError) { let nsfwContentEnabled: Bool if let blockNsfw = site.localSite.nsfwContentDisallowed { nsfwContentEnabled = !blockNsfw } else if let enableNsfw = site.localSite.enableNsfw { nsfwContentEnabled = enableNsfw } else { throw .responseMissingRequiredData("ApiSiteView enableNsfw") } let userCount: Int let postCount: Int let commentCount: Int let communityCount: Int let activeUserCount: ActiveUserCount if let counts = site.counts { userCount = counts.users postCount = counts.posts commentCount = counts.comments communityCount = counts.communities activeUserCount = .init( sixMonths: counts.usersActiveHalfYear, month: counts.usersActiveMonth, week: counts.usersActiveWeek, day: counts.usersActiveDay ) } else { guard let users = site.localSite.users else { throw .responseMissingRequiredData("LemmySiteView users") } userCount = users guard let posts = site.localSite.posts else { throw .responseMissingRequiredData("LemmySiteView posts") } postCount = posts guard let comments = site.localSite.comments else { throw .responseMissingRequiredData("LemmySiteView comments") } commentCount = comments guard let communities = site.localSite.communities else { throw .responseMissingRequiredData("LemmySiteView communities") } communityCount = communities guard let sixMonths = site.localSite.usersActiveHalfYear else { throw .responseMissingRequiredData("LemmySiteView active users") } guard let month = site.localSite.usersActiveMonth else { throw .responseMissingRequiredData("LemmySiteView active users") } guard let week = site.localSite.usersActiveWeek else { throw .responseMissingRequiredData("LemmySiteView active users") } guard let day = site.localSite.usersActiveDay else { throw .responseMissingRequiredData("LemmySiteView active users") } activeUserCount = .init( sixMonths: sixMonths, month: month, week: week, day: day ) } let voteFederationMode: VoteFederationMode if let commentDownvotes = site.localSite.commentDownvotes, let commentUpvotes = site.localSite.commentUpvotes, let postDownvotes = site.localSite.postDownvotes, let postUpvotes = site.localSite.postUpvotes { voteFederationMode = .init( postUpvote: .init(from: postUpvotes), postDownvote: .init(from: postDownvotes), commentUpvote: .init(from: commentUpvotes), commentDownvote: .init(from: commentDownvotes) ) } else if let enableDownvotes = site.localSite.enableDownvotes { voteFederationMode = enableDownvotes ? .all : .downvotesDisabled } else { throw .responseMissingRequiredData("LemmySiteView downvoteFederationMode") } try self.init( instance: .init(from: site.site), setup: site.localSite.siteSetup, voteFederationMode: voteFederationMode, nsfwContentEnabled: nsfwContentEnabled, communityCreationRestrictedToAdmins: site.localSite.communityCreationAdminOnly, emailVerificationRequired: site.localSite.requireEmailVerification ?? true, applicationQuestion: site.localSite.applicationQuestion, isPrivate: site.localSite.privateInstance, defaultTheme: site.localSite.defaultTheme, defaultFeed: .init(from: site.localSite.defaultPostListingType), legalInformation: site.localSite.legalInformation, hideModlogNames: site.localSite.hideModlogModNames ?? true, // Always hidden in Lemmy 1.0 emailApplicationsToAdmins: site.localSite.applicationEmailAdmins, emailReportsToAdmins: site.localSite.reportsEmailAdmins, slurFilterRegex: site.localSite.slurFilterRegex, actorNameMaxLength: site.localSite.actorNameMaxLength ?? 20, federationEnabled: site.localSite.federationEnabled, captchaEnabled: site.localSite.captchaEnabled ?? false, captchaDifficulty: site.localSite.captchaDifficulty.map(CaptchaDifficulty.init) ?? .none, registrationMode: .init(from: site.localSite.registrationMode), federationSignedFetch: site.localSite.federationSignedFetch, defaultPostListingMode: site.localSite.defaultPostListingMode.map { .init(from: $0) }, defaultPostSortType: site.localSite.defaultSortType.map { .init($0) }, userCount: userCount, postCount: postCount, commentCount: commentCount, communityCount: communityCount, activeUserCount: activeUserCount ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Instance3Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Instance3Snapshot { init(from site: LemmyGetSiteResponse) throws(ApiClientError) { let blockedUrls: [InstanceUrlBlockRecord]? if let blockedUrls_ = site.blockedUrls { var newBlockedUrls: [InstanceUrlBlockRecord] = [] newBlockedUrls.reserveCapacity(blockedUrls_.count) for url in blockedUrls_ { try newBlockedUrls.append(.init(from: url)) } blockedUrls = newBlockedUrls } else { blockedUrls = nil } var administrators: [Person2Snapshot] = [] administrators.reserveCapacity(site.admins.count) for admin in site.admins { try administrators.append(.init(from: admin)) } try self.init( instance: .init(from: site.siteView), allLanguages: site.allLanguages.compactMap { .init($0) }, software: .init(type: .lemmy, version: .init(site.version)), allowedLanguageIds: Set(site.discussionLanguages).subtracting([0]), blockedUrls: blockedUrls, administrators: administrators ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/LegacySortTimeRangeLimit+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation internal extension LegacySortTimeRangeLimit { init?(_ legacyApiSortType: LemmySortType) { if let value: Self = switch legacyApiSortType { case .topHour: .hour case .topSixHour: .sixHour case .topTwelveHour: .twelveHour case .topDay: .day case .topWeek: .week case .topMonth: .month case .topThreeMonths: .threeMonth case .topSixMonths: .sixMonth case .topNineMonths: .nineMonth case .topYear: .year default: nil } { self = value } else { return nil } } var legacyApiSortType: LemmySortType { switch self { case .hour: .topHour case .sixHour: .topSixHour case .twelveHour: .topTwelveHour case .day: .topDay case .week: .topWeek case .month: .topMonth case .threeMonth: .topThreeMonths case .sixMonth: .topSixMonths case .nineMonth: .topNineMonths case .year: .topYear } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/LemmyPersonSavedCombinedView+Extensions.swift ================================================ // // LemmyPersonSavedCombinedView+Extensions.swift // MlemMiddleware // // Created by Sjmarf on 2025-11-25. // import Foundation extension LemmyPostCommentCombinedView { var postValue: LemmyPostView? { switch self { case let .post(post): post case .comment: nil } } var commentValue: LemmyCommentView? { switch self { case let .comment(comment): comment case .post: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ListingType+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension ListingType { init(from type: LemmyListingType) throws(ApiClientError) { let value: Self? = switch type { case .all: .all case .local: .local case .subscribed: .subscribed case .moderatorView: .moderated case .suggested: .suggested } guard let value else { throw .featureUnsupported } self = value } var apiType: LemmyListingType? { switch self { case .all: .all case .local: .local case .subscribed: .subscribed case .moderated: .moderatorView case .popular: nil case .suggested: .suggested } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Locale.Language+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Locale.Language { init?(_ apiLanguage: LemmyLanguage) { if apiLanguage.code == "und" { return nil } else { self = .init(identifier: apiLanguage.code) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Message1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Message1Snapshot { init(from message: LemmyPrivateMessage) throws(ApiClientError) { guard let published = message.publishedAt ?? message.published else { throw .responseMissingRequiredData("LemmyPrivateMessage published") } self.init( actorId: message.apId, id: message.id, creatorId: message.creatorId, recipientId: message.recipientId, created: published, content: message.content, updated: message.updatedAt ?? message.updated, read: message.read ?? false, // Temporary: Fix in 1.0 deleted: message.deleted ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Message2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Message2Snapshot { init(from message: LemmyPrivateMessageView) throws(ApiClientError) { try self.init( message: .init(from: message.privateMessage), creator: .init(from: message.creator), recipient: .init(from: message.recipient) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ModlogEntryContentSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-28. // import Foundation // MARK: Lemmy 1.0 extension ModlogEntryContentSnapshot { init?(from view: LemmyModlogView) throws(ApiClientError) { let value: Self? = switch view.modlog.kind { case .modRemovePost: // Temporarily disabled, see #2558 // try Self.modRemovePost(view: view) nil case .modLockPost: try Self.modLockPost(view: view) case .modRemoveComment: // Temporarily disabled, see #2558 // try Self.modRemoveComment(view: view) nil case .modBanFromCommunity: try Self.modBanFromCommunity(view: view) case .modTransferCommunity: try Self.modTransferCommunity(view: view) case .adminPurgePerson: try Self.adminPurgePerson(view: view) case .adminPurgeCommunity: try Self.adminPurgeCommunity(view: view) case .adminPurgePost: try Self.adminPurgePost(view: view) case .adminPurgeComment: try Self.adminPurgeComment(view: view) case .adminAdd: try Self.adminAdd(view: view) case .adminBan: try Self.adminBan(view: view) case .modAddToCommunity: try Self.modAddToCommunity(view: view) case .adminFeaturePostSite: try Self.adminFeaturePostSite(view: view) case .modFeaturePostCommunity: try Self.modFeaturePostCommunity(view: view) case .adminRemoveCommunity: try Self.adminRemoveCommunity(view: view) // These cases will not appear on Lemmy 1.0 case .modFeaturePost, // Renamed to `.modFeaturePostCommunity` .modRemoveCommunity, // Renamed to `.adminRemoveCommunity` .modAddCommunity, // Renamed to `.modAddToCommunity` .modAdd, // Renamed to `.adminAdd` .modBan, // Renamed to `.adminBan` .modHideCommunity, // Superceded by `.modChangeCommunityVisibility` .all: throw ApiClientError.featureUnsupported // These cases are new in Lemmy 1.0, and do not yet have matching ModlogEntryContentSnapshot cases. // Return `nil` rather than throwing so that the Modlog can still load. These cases will just be hidden. case .adminAllowInstance, .adminBlockInstance, .modChangeCommunityVisibility, .modLockComment, .modWarnPost, .modWarnComment: nil } if let value { self = value } else { return nil } } private static func modRemovePost(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modRemovePost) guard let post = view.targetPost, let community = view.targetCommunity else { throw ApiClientError.responseMissingRequiredData("modRemovePost target") } return .removePost( try .init(from: post), community: try .init(from: community), removed: !view.modlog.isRevert, reason: view.modlog.reason ) } private static func modLockPost(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modLockPost) guard let post = view.targetPost, let community = view.targetCommunity else { throw ApiClientError.responseMissingRequiredData("modLockPost target") } return try .lockPost( .init(from: post), community: .init(from: community), locked: !view.modlog.isRevert ) } private static func modRemoveComment(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modRemoveComment) guard let comment = view.targetComment, let post = view.targetPost, let community = view.targetCommunity, let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData( "modRemoveComment \(view.targetPost == nil) \(view.targetComment == nil) \(view.targetPerson == nil)" ) } return try .removeComment( .init(from: comment), creator: .init(from: person), post: .init(from: post), community: .init(from: community), removed: !view.modlog.isRevert, reason: view.modlog.reason ) } private static func modBanFromCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modBanFromCommunity) guard let community = view.targetCommunity, let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData("modBanFromCommunity target") } return try .banPersonFromCommunity( person: .init(from: person), community: .init(from: community), banned: !view.modlog.isRevert, reason: view.modlog.reason, expires: view.modlog.expiresAt ) } private static func modTransferCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modTransferCommunity) guard let community = view.targetCommunity, let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData("modTransferCommunity target") } return try .transferCommunityOwnership( person: .init(from: person), community: .init(from: community) ) } private static func adminPurgePerson(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminPurgePerson) return .purgePerson(reason: view.modlog.reason) } private static func adminPurgeCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminPurgeCommunity) return .purgeCommunity(reason: view.modlog.reason) } private static func adminPurgePost(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminPurgePost) return .purgePost(reason: view.modlog.reason) } private static func adminPurgeComment(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminPurgeComment) return .purgeComment(reason: view.modlog.reason) } private static func adminAdd(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminAdd) guard let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData("adminAdd target") } return try .updatePersonAdminStatus( person: .init(from: person), appointed: !view.modlog.isRevert ) } private static func adminBan(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminBan) guard let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData("adminBan target") } return try .banPersonFromInstance( person: .init(from: person), banned: !view.modlog.isRevert, reason: view.modlog.reason, expires: view.modlog.expiresAt ) } private static func modAddToCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modAddToCommunity) guard let community = view.targetCommunity, let person = view.targetPerson else { throw ApiClientError.responseMissingRequiredData("modAddToCommunity target") } return try .updatePersonModeratorStatus( person: .init(from: person), community: .init(from: community), appointed: !view.modlog.isRevert ) } private static func adminFeaturePostSite(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminFeaturePostSite) guard let post = view.targetPost, let community = view.targetCommunity else { throw ApiClientError.responseMissingRequiredData("adminFeaturePostSite target") } return try .pinPost( .init(from: post), community: .init(from: community), pinned: !view.modlog.isRevert, type: .instance ) } private static func modFeaturePostCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .modFeaturePostCommunity) guard let post = view.targetPost, let community = view.targetCommunity else { throw ApiClientError.responseMissingRequiredData("modFeaturePostCommunity target") } return try .pinPost( .init(from: post), community: .init(from: community), pinned: !view.modlog.isRevert, type: .community ) } private static func adminRemoveCommunity(view: LemmyModlogView) throws(ApiClientError) -> Self { assert(view.modlog.kind == .adminRemoveCommunity) guard let community = view.targetCommunity else { throw ApiClientError.responseMissingRequiredData("adminRemoveCommunity target") } return try .removeCommunity( .init(from: community), removed: !view.modlog.isRevert, reason: view.modlog.reason ) } } // MARK: Lemmy 0.19 extension ModlogEntryContentSnapshot { init(from view: LemmyModRemovePostView) throws(ApiClientError) { self = try .removePost( .init(from: view.post), community: .init(from: view.community), removed: view.modRemovePost.removed, reason: view.modRemovePost.reason ) } init(from view: LemmyModLockPostView) throws(ApiClientError) { self = try .lockPost( .init(from: view.post), community: .init(from: view.community), locked: view.modLockPost.locked ) } init(from view: LemmyModFeaturePostView) throws(ApiClientError) { self = try .pinPost( .init(from: view.post), community: .init(from: view.community), pinned: view.modFeaturePost.featured, type: view.modFeaturePost.isFeaturedCommunity ? .community : .instance ) } init(from view: LemmyAdminPurgePostView) throws(ApiClientError) { self = .purgePost(reason: view.adminPurgePost.reason) } init(from view: LemmyModRemoveCommentView) throws(ApiClientError) { self = try .removeComment( .init(from: view.comment), creator: .init(from: view.commenter), post: .init(from: view.post), community: .init(from: view.community), removed: view.modRemoveComment.removed, reason: view.modRemoveComment.reason ) } init(from view: LemmyAdminPurgeCommentView) throws(ApiClientError) { self = .purgeComment(reason: view.adminPurgeComment.reason) } init(from view: LemmyAdminRemoveCommunityView) throws(ApiClientError) { self = try .removeCommunity( .init(from: view.community), removed: view.modRemoveCommunity.removed, reason: view.modRemoveCommunity.reason ) } init(from view: LemmyAdminPurgeCommunityView) throws(ApiClientError) { self = .purgeCommunity(reason: view.adminPurgeCommunity.reason) } init(from view: LemmyModHideCommunityView) throws(ApiClientError) { self = try .hideCommunity( .init(from: view.community), hidden: view.modHideCommunity.hidden, reason: view.modHideCommunity.reason ) } init(from view: LemmyModTransferCommunityView) throws(ApiClientError) { self = try .transferCommunityOwnership( person: .init(from: view.moddedPerson), community: .init(from: view.community) ) } init(from view: LemmyModAddToCommunityView) throws(ApiClientError) { self = try .updatePersonModeratorStatus( person: .init(from: view.moddedPerson), community: .init(from: view.community), appointed: !view.modAddCommunity.removed ) } init(from view: LemmyAdminAddView) throws(ApiClientError) { self = try .updatePersonAdminStatus( person: .init(from: view.moddedPerson), appointed: !view.modAdd.removed ) } init(from view: LemmyModBanFromCommunityView) throws(ApiClientError) { self = try .banPersonFromCommunity( person: .init(from: view.bannedPerson), community: .init(from: view.community), banned: view.modBanFromCommunity.banned, reason: view.modBanFromCommunity.reason, expires: view.modBanFromCommunity.expires ) } init(from view: LemmyAdminBanView) throws(ApiClientError) { self = try .banPersonFromInstance( person: .init(from: view.bannedPerson), banned: view.modBan.banned, reason: view.modBan.reason, expires: view.modBan.expires ) } init(from view: LemmyAdminPurgePersonView) throws(ApiClientError) { self = .purgePerson(reason: view.adminPurgePerson.reason) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ModlogEntrySnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-28. // import Foundation extension ModlogEntrySnapshot { init?(from view: LemmyModlogView) throws(ApiClientError) { if let type = try ModlogEntryContentSnapshot(from: view) { try self.init( created: view.modlog.publishedAt, moderator: view.moderator.map(Person1Snapshot.init), type: type ) } else { return nil } } init(from view: LemmyModRemovePostView) throws(ApiClientError) { try self.init( created: view.modRemovePost.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModLockPostView) throws(ApiClientError) { try self.init( created: view.modLockPost.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModFeaturePostView) throws(ApiClientError) { try self.init( created: view.modFeaturePost.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminPurgePostView) throws(ApiClientError) { try self.init( created: view.adminPurgePost.when_, moderator: view.admin.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModRemoveCommentView) throws(ApiClientError) { try self.init( created: view.modRemoveComment.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminPurgeCommentView) throws(ApiClientError) { try self.init( created: view.adminPurgeComment.when_, moderator: view.admin.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminRemoveCommunityView) throws(ApiClientError) { try self.init( created: view.modRemoveCommunity.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminPurgeCommunityView) throws(ApiClientError) { try self.init( created: view.adminPurgeCommunity.when_, moderator: view.admin.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModHideCommunityView) throws(ApiClientError) { try self.init( created: view.modHideCommunity.when_, moderator: view.admin.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModTransferCommunityView) throws(ApiClientError) { try self.init( created: view.modTransferCommunity.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModAddToCommunityView) throws(ApiClientError) { try self.init( created: view.modAddCommunity.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminAddView) throws(ApiClientError) { try self.init( created: view.modAdd.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyModBanFromCommunityView) throws(ApiClientError) { try self.init( created: view.modBanFromCommunity.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminBanView) throws(ApiClientError) { try self.init( created: view.modBan.when_, moderator: view.moderator.map(Person1Snapshot.init), type: .init(from: view) ) } init(from view: LemmyAdminPurgePersonView) throws(ApiClientError) { try self.init( created: view.adminPurgePerson.when_, moderator: view.admin.map(Person1Snapshot.init), type: .init(from: view) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/PagedResponseUnion+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-12-05. // import Foundation extension LemmyListCommentsResponseUnion { var items: [LemmyCommentView] { switch self { case let .lemmyGetCommentsResponse(response): response.comments case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyGetCommentsResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } extension LemmyListPostsResponseUnion { var items: [LemmyPostView] { switch self { case let .lemmyGetPostsResponse(response): response.posts case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyGetPostsResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } extension LemmyListCommentLikesResponseUnion { var items: [LemmyVoteView] { switch self { case let .lemmyListCommentLikesResponse(response): response.commentLikes case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyListCommentLikesResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } extension LemmyListPostLikesResponseUnion { var items: [LemmyVoteView] { switch self { case let .lemmyListPostLikesResponse(response): response.postLikes case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyListPostLikesResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } extension LemmyListCommunitiesResponseUnion { var items: [LemmyCommunityView] { switch self { case let .lemmyListCommunitiesResponse(response): response.communities case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyListCommunitiesResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } extension LemmyListRegistrationApplicationsResponseUnion { var items: [LemmyRegistrationApplicationView] { switch self { case let .lemmyListRegistrationApplicationsResponse(response): response.registrationApplications case let .lemmyPagedResponse(response): response.items } } var nextPage: String? { switch self { case .lemmyListRegistrationApplicationsResponse: nil case let .lemmyPagedResponse(response): response.nextPage } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Person1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Person1Snapshot { init(from person: LemmyPerson) throws(ApiClientError) { guard let actorId = person.apId ?? person.actorId else { throw .responseMissingRequiredData("LemmyPerson actorId") } guard let published = person.publishedAt ?? person.published else { throw .responseMissingRequiredData("LemmyPerson published") } let instanceBan: InstanceBanType if person.banned ?? false { // TODO: We should not be coalescing here! https://github.com/mlemgroup/mlem/issues/2049 if let expires = person.banExpires { instanceBan = .temporarilyBanned(expires: expires) } else { instanceBan = .permanentlyBanned } } else { instanceBan = .notBanned } self.init( actorId: actorId, id: person.id, name: person.name, created: published, instanceId: person.instanceId, displayName: person.displayName ?? person.name, avatar: person.avatar, banner: person.banner, note: nil, updated: person.updatedAt ?? person.updated, description: person.bio, matrixUserId: person.matrixUserId, isBot: person.botAccount, instanceBan: instanceBan, deleted: person.deleted, allPropertiesPresent: true ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Person2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Person2Snapshot { init(from person: LemmyPersonView) throws(ApiClientError) { guard let postCount = person.person.postCount ?? person.counts?.postCount else { throw .responseMissingRequiredData("LemmyPersonView postCount") } guard let commentCount = person.person.commentCount ?? person.counts?.commentCount else { throw .responseMissingRequiredData("LemmyPersonView commentCount") } try self.init( person: .init(from: person.person), isAdmin: person.isAdmin, postCount: postCount, commentCount: commentCount ) } init(from localUser: LemmyLocalUserView) throws(ApiClientError) { guard let postCount = localUser.person.postCount ?? localUser.counts?.postCount else { throw .responseMissingRequiredData("LemmyLocalUserView postCount") } guard let commentCount = localUser.person.commentCount ?? localUser.counts?.commentCount else { throw .responseMissingRequiredData("LemmyLocalUserView commentCount") } try self.init( person: .init(from: localUser.person), isAdmin: localUser.localUser.admin, postCount: postCount, commentCount: commentCount ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Person3Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Person3Snapshot { init(from userInfo: LemmyMyUserInfo) throws(ApiClientError) { var moderatedCommunities: [Community1Snapshot] = [] moderatedCommunities.reserveCapacity(userInfo.moderates.count) for moderate in userInfo.moderates { try moderatedCommunities.append(.init(from: moderate.community)) } self.init( person: try .init(from: userInfo.localUserView), site: nil, moderatedCommunities: moderatedCommunities ) } init(from personDetails: LemmyGetPersonDetailsResponse) throws(ApiClientError) { var moderatedCommunities: [Community1Snapshot] = [] moderatedCommunities.reserveCapacity(personDetails.moderates.count) for moderate in personDetails.moderates { try moderatedCommunities.append(.init(from: moderate.community)) } self.init( person: try .init(from: personDetails.personView), site: try personDetails.site.map { site throws(ApiClientError) in try.init(from: site) }, moderatedCommunities: moderatedCommunities ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Person4Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Person4Snapshot { init(from userInfo: LemmyMyUserInfo) throws(ApiClientError) { let user = userInfo.localUserView.localUser guard let showScores = (user.showScore ?? user.showScores) else { throw .responseMissingRequiredData("LemmyMyUserInfo showScores") } try self.init( person: .init(from: userInfo), email: user.email, showNsfw: user.showNsfw, theme: user.theme, defaultListingType: .init(from: user.defaultListingType), interfaceLanguage: user.interfaceLanguage, showAvatars: user.showAvatars, sendNotificationsToEmail: user.sendNotificationsToEmail, showScores: showScores, showBotAccounts: user.showBotAccounts, showReadPosts: user.showReadPosts, discussionLanguageIds: .init(userInfo.discussionLanguages.filter { $0 != 0 }), emailVerified: user.emailVerified, acceptedApplication: user.acceptedApplication, openLinksInNewTab: user.openLinksInNewTab, blurNsfw: user.blurNsfw, autoExpandImages: user.autoExpand, infiniteScrollEnabled: user.infiniteScrollEnabled, postListingMode: .init(from: user.postListingMode), totp2faEnabled: user.totp2faEnabled, enableKeyboardNavigation: user.enableKeyboardNavigation, enableAnimatedImages: user.enableAnimatedImages, collapseBotComments: user.collapseBotComments ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/PersonVoteSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-18. // import Foundation extension PersonVoteSnapshot { init(from vote: LemmyVoteView) throws(ApiClientError) { let score: Int? if let isUpvote = vote.isUpvote { score = isUpvote ? 1 : -1 } else { score = vote.score } guard let score else { throw .responseMissingRequiredData("LemmyVoteView score") } try self.init( creator: .init(from: vote.creator), score: score, creatorBannedFromCommunity: vote.creatorBannedFromCommunity ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/PersonalUnreadCountSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension PersonalUnreadCountSnapshot { init(from response: LemmyGetUnreadCountResponse) throws(ApiClientError) { self.replies = response.replies self.mentions = response.mentions self.messages = response.privateMessages } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Post1Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Post1Snapshot { init(from post: LemmyPost) throws(ApiClientError) { guard let published = post.publishedAt ?? post.published else { throw .responseMissingRequiredData("LemmyPost published") } self.init( actorId: post.apId, id: post.id, creatorId: post.creatorId, communityId: post.communityId, created: published, title: post.name, content: post.body, linkUrl: post.linkUrl, embed: post.embed, poll: nil, nsfw: post.nsfw, thumbnailUrl: post.thumbnailImageUrl, updated: post.updatedAt ?? post.updated, languageId: post.languageId, altText: post.altText, deleted: post.deleted, removed: post.removed, pinnedCommunity: post.featuredCommunity, pinnedInstance: post.featuredLocal, locked: post.locked ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Post2Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Post2Snapshot { /// Instantiates a Post2Snapshot from a given LemmyPostView /// - Parameters: /// - post: LemmyPostView /// - overrideRead: if present, overrides the LemmyPostView's read value. This is required because Lemmy doesn't return `read: true` in some cases (e.g., save post) even if the value is updated server-side. init(from post: LemmyPostView, overrideRead: Bool? = nil) throws(ApiClientError) { let votes: VotesModel if let counts = post.counts { votes = .init(from: counts, myVote: .guaranteedInit(from: post.myVote)) } else if let upvotes = post.post.upvotes, let downvotes = post.post.downvotes { votes = .init( upvotes: upvotes, downvotes: downvotes, myVote: .init(post.postActions?.voteIsUpvote) ) } else { throw .responseMissingRequiredData("LemmyPostView scores") } let creatorBlocked: Bool if let personActions = post.personActions { creatorBlocked = personActions.blockedAt != nil } else if let creatorBlocked_ = post.creatorBlocked { creatorBlocked = creatorBlocked_ } else { // `personActions` is `nil` on Lemmy 1.0 for your own posts. // Therefore we can set `creatorBlocked` to `false`. creatorBlocked = false } let commentCount: Int let unreadCommentCount: Int if let comments = post.post.comments { commentCount = comments unreadCommentCount = comments - (post.postActions?.readCommentsAmount ?? 0) } else if let counts = post.counts, let unreadComments = post.unreadComments { commentCount = counts.comments unreadCommentCount = unreadComments } else { throw .responseMissingRequiredData("LemmyPostView commentCount") } let saved: Bool let read: Bool let hidden: Bool if let saved_ = post.saved, let read_ = post.read, let hidden_ = post.hidden { saved = saved_ read = overrideRead ?? read_ hidden = hidden_ } else { let actions = post.postActions saved = actions?.savedAt != nil read = overrideRead ?? (actions?.readAt != nil) hidden = actions?.hiddenAt != nil } try self.init( post: .init(from: post.post), creator: .init(from: post.creator), community: .init(from: post.community), commentCount: commentCount, unreadCommentCount: unreadCommentCount, creatorIsModerator: post.creatorIsModerator, creatorIsAdmin: post.creatorIsAdmin, creatorBannedFromCommunity: post.creatorBannedFromCommunity, creatorBlocked: creatorBlocked, votes: votes, saved: saved, read: read, hidden: hidden ) } init(from report: LemmyPostReportView) throws(ApiClientError) { let votes: VotesModel if let counts = report.counts { votes = .init(from: counts, myVote: .init(report.postActions?.voteIsUpvote)) } else if let upvotes = report.post.upvotes, let downvotes = report.post.downvotes { votes = .init( upvotes: upvotes, downvotes: downvotes, myVote: .init(report.postActions?.voteIsUpvote) ) } else { throw .responseMissingRequiredData("LemmyPostReportView scores") } guard let creatorBlocked = report.creatorBlocked else { throw .responseMissingRequiredData("LemmyPostReportView creatorBlocked") } let commentCount: Int let unreadCommentCount: Int if let actions = report.postActions, let comments = report.post.comments { commentCount = comments unreadCommentCount = comments - (actions.readCommentsAmount ?? 0) } else if let counts = report.counts, let unreadComments = report.unreadComments { commentCount = counts.comments unreadCommentCount = unreadComments } else { throw .responseMissingRequiredData("LemmyPostReportView commentCount") } let saved: Bool let read: Bool let hidden: Bool if let actions = report.postActions { saved = actions.savedAt != nil read = actions.readAt != nil hidden = actions.hiddenAt != nil } else if let saved_ = report.saved, let read_ = report.read, let hidden_ = report.hidden { saved = saved_ read = read_ hidden = hidden_ } else { throw .responseMissingRequiredData("LemmyPostReportView actions") } try self.init( post: .init(from: report.post), creator: .init(from: report.postCreator), community: .init(from: report.community), commentCount: commentCount, unreadCommentCount: unreadCommentCount, creatorIsModerator: report.creatorIsModerator ?? false, creatorIsAdmin: report.creatorIsAdmin ?? false, creatorBannedFromCommunity: report.creatorBannedFromCommunity, creatorBlocked: creatorBlocked, votes: votes, saved: saved, read: read, hidden: hidden ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/Post3Snapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension Post3Snapshot { init(from post: LemmyGetPostResponse) throws(ApiClientError) { var crossPosts: [Post2Snapshot] = [] for crossPost in post.crossPosts { try crossPosts.append(.init(from: crossPost)) } try self.init( post: .init(from: post.postView), community: .init(from: post.communityView), crossPosts: crossPosts ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/PostFeatureType+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-20. // import Foundation extension PostFeatureType { var apiType: LemmyPostFeatureType { switch self { case .community: .community case .instance: .local } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/PostSortType+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation // This is reluncantly public because Mlem uses it; this should really be `internal` public extension PostSortType { init(_ legacyApiSortType: LemmySortType) { switch legacyApiSortType { case .active: self = .active case .hot: self = .hot case .new: self = .new case .old: self = .old case .mostComments: self = .mostComments case .newComments: self = .newComments case .controversial: self = .controversial case .scaled: self = .scaled default: if let timeRange = SortTimeRange(legacyApiSortType) { self = .top(timeRange) } else { assertionFailure() self = .top(.allTime) } } } internal func apiType(for endpoint: LemmyEndpointVersion) throws(ApiClientError) -> LemmySearchSortTypeBridge { switch endpoint { case .v3: try .oldOrUnsupported(v3ApiType) case .v4: try .newOrUnsupported(v4SearchApiType) } } internal func apiType(for endpoint: LemmyEndpointVersion) throws(ApiClientError) -> LemmyPostSortTypeBridge { switch endpoint { case .v3: try .oldOrUnsupported(v3ApiType) case .v4: .new(v4PostApiType) } } var v3ApiType: LemmySortType? { switch self { case .active: .active case .hot: .hot case .new: .new case .old: .old case .mostComments: .mostComments case .newComments: .newComments case .controversial: .controversial case .scaled: .scaled case let .top(timeRange): timeRange.legacyApiSortType } } var v4PostApiType: LemmyPostSortType { switch self { case .active: .active case .hot: .hot case .new: .new case .old: .old case .mostComments: .mostComments case .newComments: .newComments case .controversial: .controversial case .scaled: .scaled case .top: .top } } var v4SearchApiType: LemmySearchSortType? { switch self { case .new: .new case .old: .old case .top: .top default: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/RegistrationApplicationSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-24. // import Foundation extension RegistrationApplicationSnapshot { init(from application: LemmyRegistrationApplicationView) throws(ApiClientError) { guard let published = application.registrationApplication.publishedAt ?? application.registrationApplication.published else { throw .responseMissingRequiredData("LemmyRegistrationApplication published") } let resolution: RegistrationApplication.ResolutionState if application.creatorLocalUser.acceptedApplication { resolution = .approved } else if application.admin != nil { resolution = .denied(reason: application.registrationApplication.denyReason) } else { resolution = .unresolved } try self.init( id: application.registrationApplication.id, created: published, questionResponse: application.registrationApplication.answer, email: application.creatorLocalUser.email, showNsfw: application.creatorLocalUser.showNsfw, creator: .init(from: application.creator), emailVerified: application.creatorLocalUser.emailVerified, resolver: application.admin.map { admin throws(ApiClientError) in try .init(from: admin) }, resolution: resolution ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/RegistrationMode+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension RegistrationMode { init(from mode: LemmyRegistrationMode) { self = switch mode { case .closed: .closed case .requireApplication: .requiresApplication case .open: .open } } var apiType: LemmyRegistrationMode { switch self { case .closed: .closed case .open: .open case .requiresApplication: .requireApplication } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ReportSnapshot+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-25. // import Foundation extension ReportSnapshot { init(from report: LemmyCommentReportView) throws(ApiClientError) { guard let published = report.commentReport.publishedAt ?? report.commentReport.published else { throw .responseMissingRequiredData("LemmyCommentReportView published") } try self.init( creator: .init(from: report.creator), id: report.commentReport.id, created: published, resolver: report.resolver.map { resolver throws(ApiClientError) in try .init(from: resolver) }, updated: report.commentReport.updatedAt ?? report.commentReport.updated, resolved: report.commentReport.resolved, reason: report.commentReport.reason, target: .comment(.init(from: report)) ) } init(from report: LemmyPostReportView) throws(ApiClientError) { guard let published = report.postReport.publishedAt ?? report.postReport.published else { throw .responseMissingRequiredData("LemmyPostReply published") } try self.init( creator: .init(from: report.creator), id: report.postReport.id, created: published, resolver: report.resolver.map { resolver throws(ApiClientError) in try .init(from: resolver) }, updated: report.postReport.updatedAt ?? report.postReport.updated, resolved: report.postReport.resolved, reason: report.postReport.reason, target: .post(.init(from: report)) ) } init(from report: LemmyPrivateMessageReportView) throws(ApiClientError) { guard let published = report.privateMessageReport.publishedAt ?? report.privateMessageReport.published else { throw .responseMissingRequiredData("LemmyPrivateMessageReport published") } let messageView = report.toPrivateMessageView() try self.init( creator: .init(from: report.creator), id: report.privateMessageReport.id, created: published, resolver: report.resolver.map { resolver throws(ApiClientError) in try .init(from: resolver) }, updated: report.privateMessageReport.updatedAt ?? report.privateMessageReport.updated, resolved: report.privateMessageReport.resolved, reason: report.privateMessageReport.reason, target: .message(.init(from: messageView)) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/ResolvedContent+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension ResolvedContent { init(from response: LemmyResolveObjectResponseUnion) throws(ApiClientError) { switch response { case let .lemmyResolveObjectResponse(value): try self.init(from: value) case let .lemmyResolveObjectView(value): try self.init(from: value) } } init(from response: LemmyResolveObjectResponse) throws(ApiClientError) { if let comment = response.comment { self = try .comment(.init(from: comment)) } else if let post = response.post { self = try .post(.init(from: post)) } else if let community = response.community { self = try .community(.init(from: community)) } else if let person = response.person { self = try .person(.init(from: person)) } else { throw .noEntityFound } } init(from response: LemmyResolveObjectView) throws(ApiClientError) { // This initializer is only used in 1.0.0 onwards, so we only need // to consider the `results` array and not the other arrays (which // are only used prior to 1.0.0) switch response { case let .comment(comment): self = try .comment(.init(from: comment)) case let .community(community): self = try .community(.init(from: community)) case .multiCommunity: throw .featureUnsupported case let .person(person): self = try .person(.init(from: person)) case let .post(post): self = try .post(.init(from: post)) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/SearchSortType+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension SearchSortType { init?(_ legacyApiSortType: LemmySortType) { switch legacyApiSortType { case .new: self = .new case .old: self = .old default: if let timeRange = SortTimeRange(legacyApiSortType) { self = .top(timeRange) } else { return nil } } } func apiType(for endpoint: LemmyEndpointVersion) throws(ApiClientError) -> LemmySearchSortTypeBridge { switch endpoint { case .v3: try .oldOrUnsupported(v3ApiType) case .v4: try .newOrUnsupported(v4ApiType) } } private var v3ApiType: LemmySortType? { switch self { case .new: .new case .old: .old case let .top(timeRange): timeRange.legacyApiSortType } } private var v4ApiType: LemmySearchSortType? { switch self { case .new: .new case .old: .old case .top: .top } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/LemmyExtensions/SortTimeRange+Lemmy.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-14. // import Foundation extension SortTimeRange { var legacyApiSortType: LemmySortType? { switch self { case .allTime: .topAll case let .limited(timeInterval): LegacySortTimeRangeLimit(timeInterval)?.legacyApiSortType } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ListingType.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-11. // import Foundation public enum ListingType: String, CaseIterable, Codable { case all, local, subscribed, moderated, popular, suggested } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message1.swift ================================================ // // Message1.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation import Observation @Observable public final class Message1: Message1Providing { public var api: ApiClient public var message1: Message1 { self } public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let recipientId: Int public var content: String public let created: Date public var updated: Date? public let isOwnMessage: Bool var deletedManager: StateManager public var deleted: Bool { deletedManager.displayedValue } init( api: ApiClient, actorId: ActorIdentifier, id: Int, creatorId: Int, recipientId: Int, isOwnMessage: Bool, content: String, deleted: Bool, created: Date, updated: Date?, ) { self.api = api self.actorId = actorId self.id = id self.creatorId = creatorId self.recipientId = recipientId self.isOwnMessage = isOwnMessage self.content = content self.deletedManager = .init(wrappedValue: deleted) self.created = created self.updated = updated } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message1Providing+Snapshots.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-12. // import Foundation extension Message1Providing { func takeSnapshot1() -> Message1Snapshot { .init( actorId: actorId, id: id, creatorId: creatorId, recipientId: recipientId, created: created, content: content, updated: updated, read: false, deleted: deleted ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message1Providing.swift ================================================ // // Message1Providing.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation public protocol Message1Providing: ContentModel, ActorIdentifiable, ContentIdentifiable, DeletableProviding, ReportableProviding, SelectableContentProviding { var message1: Message1 { get } var id: Int { get } var creatorId: Int { get } var recipientId: Int { get } var content: String { get } var deleted: Bool { get } var created: Date { get } var updated: Date? { get } var id_: Int? { get } var creatorId_: Int? { get } var recipientId_: Int? { get } var content_: String? { get } var deleted_: Bool? { get } var created_: Date? { get } var updated_: Date? { get } // From Message2Providing var creator_: Person? { get } var recipient_: Person? { get } } public typealias Message = Message1Providing // SelectableContentProviding conformance public extension Message1Providing { var selectableContent: String? { content } } public extension Message1Providing { static var modelTypeId: ContentType { .message } var actorId: ActorIdentifier { message1.actorId } var id: Int { message1.id } var creatorId: Int { message1.creatorId } var recipientId: Int { message1.recipientId } var content: String { message1.content } var deleted: Bool { message1.deleted } var created: Date { message1.created } var updated: Date? { message1.updated } var isOwnMessage: Bool { message1.isOwnMessage } var id_: Int? { message1.id } var creatorId_: Int? { message1.creatorId } var recipientId_: Int? { message1.recipientId } var content_: String? { message1.content } var deleted_: Bool? { message1.deleted } var created_: Date? { message1.created } var updated_: Date? { message1.updated } var isOwnMessage_: Bool? { message1.isOwnMessage } var creator_: Person? { nil } var recipient_: Person? { nil } } // ReportableProviding conformance public extension Message1Providing { func isOwnContent(myPersonId: Int) -> Bool { isOwnMessage } } public extension Message1Providing { private var deletedManager: StateManager { message1.deletedManager } func reply(content: String) async throws -> Message2 { try await api.createMessage(personId: recipientId, content: content) } func report(reason: String) async throws { try await api.reportMessage(id: id, reason: reason) } func updateDeleted(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { // TODO: UpdateQueue queued state management _ = deletedManager.performRequest(expectedResult: newValue) { semaphore in do { try await self.api.deleteMessage(id: self.id, delete: newValue, semaphore: semaphore) callback?(.success) } catch { callback?(.failure(error)) } } } func edit(content: String) async throws { try await api.editMessage(id: id, content: content) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message2.swift ================================================ // // Message2.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation @Observable public final class Message2: Message2Providing, FeedLoadable { public typealias FilterType = InboxItemFilterType public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } public var api: ApiClient public var message2: Message2 { self } public let message1: Message1 public let creator: Person public let recipient: Person init( api: ApiClient, message1: Message1, creator: Person, recipient: Person ) { self.api = api self.message1 = message1 self.creator = creator self.recipient = recipient } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message2Providing+Snapshots.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-12. // import Foundation extension Message2Providing { func takeSnapshot2() -> Message2Snapshot { .init( message: message1.takeSnapshot1(), creator: creator.takeSnapshot1(), recipient: recipient.takeSnapshot1() ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Message/Message2Providing.swift ================================================ // // Message2Providing.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation public protocol Message2Providing: Message1Providing, ActorIdentifiable { var message2: Message2 { get } var creator: Person { get } var recipient: Person { get } } public extension Message2Providing { var message1: Message1 { message2.message1 } var creator: Person { message2.creator } var recipient: Person { message2.recipient } var creator_: Person? { message2.creator } var recipient_: Person? { message2.recipient } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Modlog/ModlogEntry.swift ================================================ // // ModlogEntry.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-23. // import Foundation public struct ModlogEntry { public let api: ApiClient public let created: Date public let moderator: Person? public let type: ModlogEntryContent } extension ModlogEntry: FeedLoadable { public typealias FilterType = ModlogEntryFilterType public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } public static func == (lhs: ModlogEntry, rhs: ModlogEntry) -> Bool { lhs.created == rhs.created && lhs.type == rhs.type } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Modlog/ModlogEntryContent.swift ================================================ // // ModlogEntryContent.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-25. // import Foundation public enum ModlogEntryContent: Equatable { case removePost( _ post: Post, community: Community, removed: Bool, reason: String? ) case lockPost( _ post: Post, community: Community, locked: Bool ) case pinPost( _ post: Post, community: Community, pinned: Bool, type: PostFeatureType ) case purgePost(reason: String?) case removeComment( _ comment: Comment, creator: Person, post: Post, community: Community, removed: Bool, reason: String? ) case purgeComment(reason: String?) case removeCommunity( _ community: Community, removed: Bool, reason: String? ) case purgeCommunity(reason: String?) case hideCommunity( _ community: Community, hidden: Bool, reason: String? ) case transferCommunityOwnership( person: Person, community: Community ) case updatePersonModeratorStatus( person: Person, community: Community, appointed: Bool ) case updatePersonAdminStatus( person: Person, appointed: Bool ) case banPersonFromCommunity( person: Person, community: Community, banned: Bool, reason: String?, expires: Date? ) case banPersonFromInstance( person: Person, banned: Bool, reason: String?, expires: Date? ) case purgePerson(reason: String?) public var community: Community? { switch self { case let .removePost(_, community, _, _): community case let .lockPost(_, community, _): community case let .pinPost(_, community, _, _): community case let .removeComment(_, _, _, community, _, _): community case let .removeCommunity(community, _, _): community case let .hideCommunity(community, _, _): community case let .transferCommunityOwnership(_, community): community case let .updatePersonModeratorStatus(_, community, _): community case let .banPersonFromCommunity(_, community, _, _, _): community default: nil } } public var type: ModlogEntryType { switch self { case .removePost: .removePost case .lockPost: .lockPost case .pinPost: .pinPost case .purgePost: .purgePost case .removeComment: .removeComment case .purgeComment: .purgeComment case .removeCommunity: .removeCommunity case .purgeCommunity: .purgeCommunity case .hideCommunity: .hideCommunity case .transferCommunityOwnership: .transferCommunityOwnership case .updatePersonModeratorStatus: .updatePersonModeratorStatus case .updatePersonAdminStatus: .updatePersonAdminStatus case .banPersonFromCommunity: .banPersonFromCommunity case .banPersonFromInstance: .banPersonFromInstance case .purgePerson: .purgePerson } } @MainActor init(from snapshot: ModlogEntryContentSnapshot, api: ApiClient) { switch snapshot { case let .removePost(post, community, removed, reason): self = .removePost( api.caches.post.getModel(api: api, from: .post1(post)), community: api.caches.community.getModel(api: api, from: .community1(community)), removed: removed, reason: reason ) case let .lockPost(post, community, locked): self = .lockPost( api.caches.post.getModel(api: api, from: .post1(post)), community: api.caches.community.getModel(api: api, from: .community1(community)), locked: locked ) case let .pinPost(post, community, pinned, type): self = .pinPost( api.caches.post.getModel(api: api, from: .post1(post)), community: api.caches.community.getModel(api: api, from: .community1(community)), pinned: pinned, type: type ) case let .purgePost(reason): self = .purgePost(reason: reason) case let .removeComment(comment, creator, post, community, removed, reason): self = .removeComment( api.caches.comment.getModel(api: api, from: .comment1(comment)), creator: api.caches.person.getModel(api: api, from: .person1(creator)), post: api.caches.post.getModel(api: api, from: .post1(post)), community: api.caches.community.getModel(api: api, from: .community1(community)), removed: removed, reason: reason ) case let .purgeComment(reason): self = .purgeComment(reason: reason) case let .removeCommunity(community, removed, reason): self = .removeCommunity( api.caches.community.getModel(api: api, from: .community1(community)), removed: removed, reason: reason ) case let .purgeCommunity(reason): self = .purgeCommunity(reason: reason) case let .hideCommunity(community, hidden, reason): self = .hideCommunity( api.caches.community.getModel(api: api, from: .community1(community)), hidden: hidden, reason: reason ) case let .transferCommunityOwnership(person, community): self = .transferCommunityOwnership( person: api.caches.person.getModel(api: api, from: .person1(person)), community: api.caches.community.getModel(api: api, from: .community1(community)) ) case let .updatePersonModeratorStatus(person, community, appointed): self = .updatePersonModeratorStatus( person: api.caches.person.getModel(api: api, from: .person1(person)), community: api.caches.community.getModel(api: api, from: .community1(community)), appointed: appointed ) case let .updatePersonAdminStatus(person, appointed): self = .updatePersonAdminStatus( person: api.caches.person.getModel(api: api, from: .person1(person)), appointed: appointed ) case let .banPersonFromCommunity(person, community, banned, reason, expires): self = .banPersonFromCommunity( person: api.caches.person.getModel(api: api, from: .person1(person)), community: api.caches.community.getModel(api: api, from: .community1(community)), banned: banned, reason: reason, expires: expires ) case let .banPersonFromInstance(person, banned, reason, expires): self = .banPersonFromInstance( person: api.caches.person.getModel(api: api, from: .person1(person)), banned: banned, reason: reason, expires: expires ) case let .purgePerson(reason): self = .purgePerson(reason: reason) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ModlogEntryType.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum ModlogEntryType: CaseIterable { case removePost case lockPost case pinPost case purgePost case removeComment case purgeComment case removeCommunity case purgeCommunity case hideCommunity case transferCommunityOwnership case updatePersonModeratorStatus case updatePersonAdminStatus case banPersonFromCommunity case banPersonFromInstance case purgePerson init?(from type: LemmyModlogKind) throws(ApiClientError) { let result: Self? = switch type { case .all: nil case .modRemovePost: .removePost case .modLockPost: .lockPost case .modFeaturePost: .pinPost case .modRemoveComment: .removeComment case .modBanFromCommunity: .banPersonFromCommunity case .modAddToCommunity, .modAddCommunity: .updatePersonModeratorStatus case .modTransferCommunity: .transferCommunityOwnership case .modHideCommunity: .hideCommunity case .adminAdd, .modAdd: .updatePersonAdminStatus case .adminBan, .modBan: .banPersonFromInstance case .adminRemoveCommunity, .modRemoveCommunity: .removeCommunity case .adminPurgePerson: .purgePerson case .adminPurgeCommunity: .purgeCommunity case .adminPurgePost: .purgePost case .adminPurgeComment: .purgeComment case .modChangeCommunityVisibility: throw .featureUnsupported case .adminBlockInstance: throw .featureUnsupported case .adminAllowInstance: throw .featureUnsupported case .modLockComment: throw .featureUnsupported case .adminFeaturePostSite: throw .featureUnsupported case .modFeaturePostCommunity: throw .featureUnsupported case .modWarnPost: throw .featureUnsupported case .modWarnComment: throw .featureUnsupported } if let result { self = result } else { return nil } } var apiType: LemmyModlogKind { switch self { case .removePost: .modRemovePost case .lockPost: .modLockPost case .pinPost: .modFeaturePost case .purgePost: .adminPurgePost case .removeComment: .modRemoveComment case .purgeComment: .adminPurgeComment case .removeCommunity: .modRemoveCommunity case .purgeCommunity: .adminPurgeCommunity case .hideCommunity: .modHideCommunity case .transferCommunityOwnership: .modTransferCommunity case .updatePersonModeratorStatus: .modAddCommunity case .updatePersonAdminStatus: .modAdd case .banPersonFromCommunity: .modBanFromCommunity case .banPersonFromInstance: .modBan case .purgePerson: .adminPurgePerson } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Notification/InboxNotification+Snapshots.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-12. // import Foundation extension InboxNotification { @MainActor func snapshotUpdate(with snapshot: InboxNotificationSnapshot, isResultOfTask: Bool) { switch self.content { case let .message(message) where message.isOwnMessage: break default: setIfChanged(\.read, snapshot.read) } } func takeSnapshot() -> InboxNotificationSnapshot? { guard let snapshot = content.takeSnapshot() else { return nil } return .init( id: id, contentId: contentId, read: read, content: snapshot ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Notification/InboxNotification.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-01. // import Foundation @Observable public class InboxNotification: ContentModel, ReadableProviding, Identifiable { public var updateQueue: InboxNotificationUpdateQueue = .init() public var api: ApiClient public let id: Int // This can be removed when we drop support for < Lemmy 1.0 public let contentId: Int public var read: Bool public let content: InboxNotificationContent init( api: ApiClient, id: Int, contentId: Int, read: Bool, content: InboxNotificationContent ) { self.api = api self.id = id self.contentId = contentId self.read = read self.content = content Task { await updateQueue.setParent(self) } } public func updateRead(_ newValue: Bool) { read = newValue let type: InboxItemType = switch content.type { case .mention: .mention case .reply: .reply case .message: .message } api.unreadCount?.updateUnverifiedItem(itemType: type, isRead: newValue) Task { await updateQueue.addItem { try await self.api.repository.markNotificationAsRead( type: self.content.type, id: self.id, contentId: self.contentId, read: newValue ) // TODO: unified InboxItem update this whole thing to be properties-based guard var snapshot = self.takeSnapshot() else { assertionFailure("updateRead called on a notification that cannot take a snapshot") throw ApiClientError.invalidInput } snapshot.read = newValue self.api.unreadCount?.verifyItem(itemType: type, isRead: snapshot.read) return snapshot } } } public func toggleRead() { updateRead(!read) } public static func == (lhs: InboxNotification, rhs: InboxNotification) -> Bool { lhs.id == rhs.id } } extension InboxNotification: InboxIdentifiable { public var inboxId: Int { content.wrappedValue.actorId.hashValue } } extension InboxNotification: FeedLoadable { public typealias FilterType = InboxItemFilterType public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: switch self.content { case let .mention(comment), let .reply(comment): return .new(comment.created) case let .message(message): return .new(message.created) } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Notification/InboxNotificationContent.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-12. // import Foundation public enum InboxNotificationContent { case reply(Comment) case mention(Comment) case message(Message2) public var wrappedValue: any ContentModel & ActorIdentifiable { switch self { case let .reply(comment): comment case let .mention(comment): comment case let .message(message2): message2 } } public var type: InboxNotificationContentType { switch self { case .reply: .reply case .mention: .mention case .message: .message } } func takeSnapshot() -> InboxNotificationContentSnapshot? { switch self { case let .reply(comment): if let snapshot = comment.takeSnapshot2() { .reply(snapshot) } else { nil } case let .mention(comment): if let snapshot = comment.takeSnapshot2() { .mention(snapshot) } else { nil } case let .message(message): .message(message.takeSnapshot2()) } } } public enum InboxNotificationContentType: Hashable { case reply, mention, message } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/OwnershipProviding.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-17. // import Foundation public protocol OwnershipProviding: ContentIdentifiable { func isOwnContent(myPersonId: Int) -> Bool } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Person/Person+Conformance.swift ================================================ // // Person+Conformance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-26. // import Foundation // MARK: CacheIdentifiable public extension Person { var cacheId: Int { id } } // MARK: SelectableContentProviding public extension Person { var selectableContent: String? { description } } // MARK: ProfileProviding public extension Person { var profileCreated: Date? { created } } // MARK: CommunityOrPerson public extension Person { static var identifierPrefix: String { "@" } } // MARK: ContentIdentifiable public extension Person { static var modelTypeId: ContentType { .person } } // MARK: Resolvable public extension Person { /// Returns a `URL` that can be resolved by another `ApiClient`. func resolvableUrl(from instance: ContentModelUrlType) -> URL { switch instance { case .host: actorId.url case .provider: .person(host: api.host, name: name) } } @inlinable var allResolvableUrls: [URL] { ContentModelUrlType.allCases.map { resolvableUrl(from: $0) } } } // MARK: Sharable public extension Person { func url() -> URL { if apiIsLocal { api.baseUrl.appending(path: "u/\(name)") } else { api.baseUrl.appending(path: "u/\(name)@\(host)") } } } // MARK: FeedLoadable public extension Person { typealias FilterType = PersonFilterType func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } } // MARK: Blockable public extension Person { var updateBlocked: ((Bool, ((Bool) -> Void)?) -> Void)? { self._updateBlocked } private func _updateBlocked(_ newValue: Bool, callback: ((Bool) -> Void)? = nil) { let oldValue = blocked_.realizedValue blocked_.set(newValue) Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.blockPerson(id: self.id, block: newValue) callback?(true) if newValue { self.api.blocks?.people[self.actorId] = self.id } else { self.api.blocks?.people.removeValue(forKey: self.actorId) } return await .init(api: self.api, snapshot: .person2(snapshot)) } catch { self.blocked_.set(oldValue) callback?(false) throw error } } } } } // MARK: Codable public extension Person { struct CodedData: Codable { let apiUrl: URL let apiMyPersonId: Int? let apiPerson: LemmyPerson } internal var apiPerson: LemmyPerson { .init( id: id, name: name, displayName: displayName == name ? nil : displayName, avatar: avatar, banned: bannedFromInstance, published: created, updated: updated, actorId: actorId, bio: description, local: apiIsLocal, banner: banner, deleted: deleted, matrixUserId: matrixUserId, botAccount: isBot, banExpires: instanceBan.expiryDate, instanceId: instanceId, publishedAt: created, updatedAt: updated, apId: actorId, lastRefreshedAt: nil, postCount: nil, commentCount: nil ) } func codedData() async throws -> CodedData { try await .init( apiUrl: api.baseUrl, apiMyPersonId: api.myPersonId, apiPerson: apiPerson ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Person/Person.swift ================================================ // // Person.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-26. // import Observation import Foundation @Observable public final class Person: UnifiedModelProviding, Blockable, ContentIdentifiable, SelectableContentProviding, CommunityOrPerson, Resolvable, PurgableProviding, Sharable, FeedLoadable, ProfileProviding { public typealias Properties = PersonProperties public var api: ApiClient private let properties: PersonProperties @ObservationIgnored lazy var updateQueue: UnifiedUpdateQueue = .init(parent: self, properties: properties) // MARK: Custom Properties // Mlem-specific properties that are not reflected in the API public var blocked: any RealizedValueProviding { blocked_ } public var blocked_: RealizedValue public var purged: Bool = false // Communities from which this person is *known* to be banned. // If an ID is not in this set, its status is unknown. // // Don't make this public. Instead, use the `bannedFromCommunity` property of // Post2/Comment2/Reply2. Accessing it from there guarantees that the ban // status is known. Those properties access this set as a shared source-of-truth. var knownCommunityBanStates: [Int: Bool] = .init() // MARK: API Properties // Properties that are provided by the API public let actorId: ActorIdentifier public let id: Int public let name: String public let created: Date public let instanceId: Int public var displayName: String public var avatar: URL? public var note: String? public var updated: Date? public var matrixUserId: String? public var isBot: Bool public var instanceBan: InstanceBanType public var deleted: Bool public var description: String? public var banner: URL? public var isAdmin: ExpectedValue public var postCount: ExpectedValue public var commentCount: ExpectedValue public var instance: ExpectedValue public var moderatedCommunities: ExpectedValue<[Community]> public var email: ExpectedValue public var showNsfw: ExpectedValue public var theme: ExpectedValue public var defaultListingType: ExpectedValue public var interfaceLanguage: ExpectedValue public var showAvatars: ExpectedValue public var sendNotificationsToEmail: ExpectedValue public var showScores: ExpectedValue public var showBotAccounts: ExpectedValue public var showReadPosts: ExpectedValue public var discussionLanguageIds: ExpectedValue> public var emailVerified: ExpectedValue public var acceptedApplication: ExpectedValue public var openLinksInNewTab: ExpectedValue public var blurNsfw: ExpectedValue public var autoExpandImages: ExpectedValue public var infiniteScrollEnabled: ExpectedValue public var postListingMode: ExpectedValue public var totp2faEnabled: ExpectedValue public var enableKeyboardNavigation: ExpectedValue public var enableAnimatedImages: ExpectedValue public var collapseBotComments: ExpectedValue public init(api: ApiClient, properties: PersonProperties) { self.api = api self.properties = properties self.blocked_ = .init(api.blocks?.people.keys.contains(properties.actorId) ?? false) self.actorId = properties.actorId self.id = properties.id self.name = properties.name self.created = properties.created self.instanceId = properties.instanceId self.displayName = properties.displayName self.avatar = properties.avatar self.note = properties.note self.updated = properties.updated self.matrixUserId = properties.matrixUserId self.isBot = properties.isBot self.instanceBan = properties.instanceBan self.deleted = properties.deleted // nil-coalesced because PieFed doesn't return these values for some requests. self.description = properties.description ?? nil self.banner = properties.banner ?? nil // because upgrade() is not available until all properties are initialized, first populate all properties // with ExpectedValues that don't actually do anything, then reassign them properly at the end of the init // this is somewhat cumbersome but avoids lazy vars, which are very awkward in Observables self.isAdmin = dummyExpectedValue(properties.isAdmin) self.postCount = dummyExpectedValue(properties.postCount) self.commentCount = dummyExpectedValue(properties.commentCount) self.instance = dummyExpectedValue(properties.instance) self.moderatedCommunities = dummyExpectedValue(properties.moderatedCommunities) self.email = dummyExpectedValue(properties.email) self.showNsfw = dummyExpectedValue(properties.showNsfw) self.theme = dummyExpectedValue(properties.theme) self.defaultListingType = dummyExpectedValue(properties.defaultListingType) self.interfaceLanguage = dummyExpectedValue(properties.interfaceLanguage) self.showAvatars = dummyExpectedValue(properties.showAvatars) self.sendNotificationsToEmail = dummyExpectedValue(properties.sendNotificationsToEmail) self.showScores = dummyExpectedValue(properties.showScores) self.showBotAccounts = dummyExpectedValue(properties.showBotAccounts) self.showReadPosts = dummyExpectedValue(properties.showReadPosts) self.discussionLanguageIds = dummyExpectedValue(properties.discussionLanguageIds) self.emailVerified = dummyExpectedValue(properties.emailVerified) self.acceptedApplication = dummyExpectedValue(properties.acceptedApplication) self.openLinksInNewTab = dummyExpectedValue(properties.openLinksInNewTab) self.blurNsfw = dummyExpectedValue(properties.blurNsfw) self.autoExpandImages = dummyExpectedValue(properties.autoExpandImages) self.infiniteScrollEnabled = dummyExpectedValue(properties.infiniteScrollEnabled) self.postListingMode = dummyExpectedValue(properties.postListingMode) self.totp2faEnabled = dummyExpectedValue(properties.totp2faEnabled) self.enableKeyboardNavigation = dummyExpectedValue(properties.enableKeyboardNavigation) self.enableAnimatedImages = dummyExpectedValue(properties.enableAnimatedImages) self.collapseBotComments = dummyExpectedValue(properties.collapseBotComments) func expectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { try await self.upgrade() }) } self.isAdmin = expectedValue(properties.isAdmin) self.postCount = expectedValue(properties.postCount) self.commentCount = expectedValue(properties.commentCount) self.instance = expectedValue(properties.instance) self.moderatedCommunities = expectedValue(properties.moderatedCommunities) self.email = expectedValue(properties.email) self.showNsfw = expectedValue(properties.showNsfw) self.theme = expectedValue(properties.theme) self.defaultListingType = expectedValue(properties.defaultListingType) self.interfaceLanguage = expectedValue(properties.interfaceLanguage) self.showAvatars = expectedValue(properties.showAvatars) self.sendNotificationsToEmail = expectedValue(properties.sendNotificationsToEmail) self.showScores = expectedValue(properties.showScores) self.showBotAccounts = expectedValue(properties.showBotAccounts) self.showReadPosts = expectedValue(properties.showReadPosts) self.discussionLanguageIds = expectedValue(properties.discussionLanguageIds) self.emailVerified = expectedValue(properties.emailVerified) self.acceptedApplication = expectedValue(properties.acceptedApplication) self.openLinksInNewTab = expectedValue(properties.openLinksInNewTab) self.blurNsfw = expectedValue(properties.blurNsfw) self.autoExpandImages = expectedValue(properties.autoExpandImages) self.infiniteScrollEnabled = expectedValue(properties.infiniteScrollEnabled) self.postListingMode = expectedValue(properties.postListingMode) self.totp2faEnabled = expectedValue(properties.totp2faEnabled) self.enableKeyboardNavigation = expectedValue(properties.enableKeyboardNavigation) self.enableAnimatedImages = expectedValue(properties.enableAnimatedImages) self.collapseBotComments = expectedValue(properties.collapseBotComments) } public func update(with properties: PersonProperties) { setIfChanged(\.displayName, properties.displayName) setIfChanged(\.avatar, properties.avatar) setIfChanged(\.note, properties.note) setIfChanged(\.updated, properties.updated) setIfChanged(\.matrixUserId, properties.matrixUserId) setIfChanged(\.isBot, properties.isBot) setIfChanged(\.instanceBan, properties.instanceBan) setIfChanged(\.deleted, properties.deleted) if let description = properties.description { setIfChanged(\.description, description) } if let banner = properties.banner { setIfChanged(\.banner, banner) } updateIfChanged(\.isAdmin.value_, properties.isAdmin) updateIfChanged(\.postCount.value_, properties.postCount) updateIfChanged(\.commentCount.value_, properties.commentCount) setIfNil(\.instance.value_, properties.instance) updateIfChanged(\.moderatedCommunities.value_, properties.moderatedCommunities) updateIfChanged(\.email.value_, properties.email) updateIfChanged(\.showNsfw.value_, properties.showNsfw) updateIfChanged(\.theme.value_, properties.theme) updateIfChanged(\.defaultListingType.value_, properties.defaultListingType) updateIfChanged(\.interfaceLanguage.value_, properties.interfaceLanguage) updateIfChanged(\.showAvatars.value_, properties.showAvatars) updateIfChanged(\.sendNotificationsToEmail.value_, properties.sendNotificationsToEmail) updateIfChanged(\.showScores.value_, properties.showScores) updateIfChanged(\.showBotAccounts.value_, properties.showBotAccounts) updateIfChanged(\.showReadPosts.value_, properties.showReadPosts) updateIfChanged(\.discussionLanguageIds.value_, properties.discussionLanguageIds) updateIfChanged(\.emailVerified.value_, properties.emailVerified) updateIfChanged(\.acceptedApplication.value_, properties.acceptedApplication) updateIfChanged(\.openLinksInNewTab.value_, properties.openLinksInNewTab) updateIfChanged(\.blurNsfw.value_, properties.blurNsfw) updateIfChanged(\.autoExpandImages.value_, properties.autoExpandImages) updateIfChanged(\.infiniteScrollEnabled.value_, properties.infiniteScrollEnabled) updateIfChanged(\.postListingMode.value_, properties.postListingMode) updateIfChanged(\.totp2faEnabled.value_, properties.totp2faEnabled) updateIfChanged(\.enableKeyboardNavigation.value_, properties.enableKeyboardNavigation) updateIfChanged(\.enableAnimatedImages.value_, properties.enableAnimatedImages) updateIfChanged(\.collapseBotComments.value_, properties.collapseBotComments) } public func softUpdate(with properties: PersonProperties) { setIfNil(\.isAdmin.value_, properties.isAdmin) setIfNil(\.postCount.value_, properties.postCount) setIfNil(\.commentCount.value_, properties.commentCount) setIfNil(\.instance.value_, properties.instance) setIfNil(\.moderatedCommunities.value_, properties.moderatedCommunities) } // MARK: Upgrades public func upgrade() async throws { try await updateQueue.upgrade() } public func fetchUpgraded() async throws -> PersonProperties { let snapshot = try await api.repository.getPerson(id: id) return await .init(api: api, snapshot: .person3(snapshot)) } public func resolve(with api: ApiClient) async throws -> Self { let stub = PersonStub(api: api, url: allResolvableUrls[0]) return try await stub.getPerson() as! Self } // MARK: Logic func updateKnownCommunityBanState(id: Int, banned: Bool) { if banned { // This `if` statement avoids unneccessary state update if !(knownCommunityBanStates[id] ?? false) { knownCommunityBanStates[id] = true } } else { if knownCommunityBanStates[id] ?? true { knownCommunityBanStates[id] = false } } } } // MARK: - Computed public extension Person { var bannedFromInstance: Bool { instanceBan != .notBanned } func isBannedFromCommunity(id: Int) -> Bool? { knownCommunityBanStates[id] } func isBannedFromCommunity(_ community: Community) -> Bool? { isBannedFromCommunity(id: community.id) } func profileDetails() -> ProfileDetails { .init( avatar: avatar, banner: banner, displayName: displayName, description: description, matrixUserId: matrixUserId ) } var moderatedCommunityActorIds: Set? { if let moderatedCommunities = moderatedCommunities.value { .init(moderatedCommunities.map(\.actorId)) } else { nil } } var moderates: ((CommunityIdentifier) -> Bool)? { if let moderatedCommunities = moderatedCommunities.value { return { communityIdentifier in switch communityIdentifier { case let .id(id): moderatedCommunities.contains { $0.id == id } case let .actorId(actorId): moderatedCommunities.contains { $0.actorId == actorId } case let .community(community): moderatedCommunities.contains { $0.actorId == community.actorId } } } } return nil } /// Returns true if this person can perform moderator actions on the target person func canModerate(_ person: Person, communityModerators: [Person]) -> Bool { // admins can moderate anybody but a higher-ranking admin if isAdmin.value ?? false { if person.isAdmin.value ?? false { return api.isHigherAdmin(than: person) } return true } // if this person is not a mod, can't moderate guard let myModIndex = communityModerators.firstIndex(where: { $0.id == id }) else { return false } // if target is a mod, check that this person outranks them if let targetModIndex = communityModerators.firstIndex(where: { $0.id == person.id }) { return myModIndex < targetModIndex } // if target not a mod, can moderate return true } } // MARK: - Interactions public extension Person { // Get Content func getContent( community: Community? = nil, sort: PostSortType = .new, page: Int, limit: Int, savedOnly: Bool = false ) async throws -> (person: Person, posts: [Post], comments: [Comment]) { try await api.getContent( authorId: id, sort: sort, page: page, limit: limit, savedOnly: savedOnly, communityId: community?.id ) } // MARK: Ban func ban(from community: Community, removeContent: Bool, reason: String?, expires: Date?) async throws { try await api.banPersonFromCommunity( personId: id, communityId: community.id, ban: true, removeContent: removeContent, reason: reason, expires: expires ) } func unban(from community: Community, reason: String?) async throws { try await api.banPersonFromCommunity( personId: id, communityId: community.id, ban: false, removeContent: false, reason: reason ) } // MARK: Purge func purge(reason: String?) async throws { try await api.purgePerson(id: id, reason: reason) } func banFromInstance(removeContent: Bool, reason: String?, expires: Date?) async throws { try await api.banPersonFromInstance( personId: id, ban: true, removeContent: removeContent, reason: reason, expires: expires ) } func unbanFromInstance(reason: String?) async throws { try await api.banPersonFromInstance( personId: id, ban: false, removeContent: false, reason: reason, expires: nil ) } // Note func updateNote(content: String?) { note = content Task { await updateQueue.addItem { properties in var properties = properties try await self.api.repository.editNote(id: self.id, content: content) properties.note = content return properties } } } // Profile // TODO: NOW make a User concept? func updateProfile(_ details: ProfileDetails) async throws { let diff = ProfileDetailsMutation( originalDetails: profileDetails(), newDetails: details ) if try await !(diff.isValid(forSoftware: api.software)) { throw ApiClientError.invalidInput } avatar = details.avatar banner = details.banner displayName = details.displayName ?? displayName description = details.description matrixUserId = details.matrixUserId await updateQueue.addItem { properties in try await self.api.editProfile(details) var properties = properties properties.avatar = details.avatar properties.banner = details.banner properties.displayName = details.displayName ?? properties.displayName properties.description = details.description properties.matrixUserId = details.matrixUserId return properties } } struct ProfileSettings { let email: String? let matrixUserId: String? let showNsfw: Bool? let blurNsfw: Bool? let showBotAccounts: Bool? let discussionLanguageIds: Set? let sendNotificationsToEmail: Bool? let isBot: Bool? public init( email: String? = nil, matrixUserId: String? = nil, showNsfw: Bool? = nil, blurNsfw: Bool? = nil, showBotAccounts: Bool? = nil, discussionLanguageIds: Set? = nil, sendNotificationsToEmail: Bool? = nil, isBot: Bool? = nil, ) { self.email = email self.matrixUserId = matrixUserId self.showNsfw = showNsfw self.blurNsfw = blurNsfw self.showBotAccounts = showBotAccounts self.discussionLanguageIds = discussionLanguageIds self.sendNotificationsToEmail = sendNotificationsToEmail self.isBot = isBot } } var updateSettings: ((ProfileSettings) async throws -> Void)? { if let showNsfw = self.showNsfw.value, let showScores = self.showScores.value, let theme = self.theme.value, let defaultListingType = self.defaultListingType.value, let interfaceLanguage = self.interfaceLanguage.value, let email = self.email.value, let showAvatars = self.showAvatars.value, let sendNotificationsToEmail = self.sendNotificationsToEmail.value, let showBotAccounts = self.showBotAccounts.value, let showReadPosts = self.showReadPosts.value, let discussionLanguages = self.discussionLanguageIds.value, let openLinksInNewTab = self.openLinksInNewTab.value, let blurNsfw = self.blurNsfw.value, let autoExpandImages = self.autoExpandImages.value, let infiniteScrollEnabled = self.infiniteScrollEnabled.value, let postListingMode = self.postListingMode.value, let enableKeyboardNavigation = self.enableKeyboardNavigation.value, let enableAnimatedImages = self.enableAnimatedImages.value, let collapseBotComments = self.collapseBotComments.value { return { profileSettings in await self.updateQueue.addItem { properties in // this function has some untidy source-of-truth behavior--canonically we want to use the provided properties from the UpdateQueue, // but those are not guaranteed to have user-tier fields so we fall back on the guaranteed values from the `if let` wall above. // note also that a `nil` in `ProfileSettings` indicates no change let newEmail: String? = profileSettings.email ?? properties.email ?? email let newMatrixUserId: String? = profileSettings.matrixUserId ?? properties.matrixUserId let newShowNsfw: Bool = profileSettings.showNsfw ?? properties.showNsfw ?? showNsfw let newShowBotAccounts: Bool = profileSettings.showBotAccounts ?? properties.showBotAccounts ?? showBotAccounts let newDiscussionLanguageIds: Set = (profileSettings.discussionLanguageIds ?? properties.discussionLanguageIds ?? discussionLanguages) let newSendNotificationsToEmail: Bool = profileSettings.sendNotificationsToEmail ?? properties.sendNotificationsToEmail ?? sendNotificationsToEmail let newIsBot: Bool = profileSettings.isBot ?? properties.isBot try await self.api.editAccountSettings( showNsfw: newShowNsfw, showScores: properties.showScores ?? showScores, theme: properties.theme ?? theme, defaultListingType: properties.defaultListingType ?? defaultListingType, interfaceLanguage: properties.interfaceLanguage ?? interfaceLanguage, avatar: properties.avatar?.absoluteString ?? "", banner: properties.banner??.absoluteString ?? "", displayName: properties.displayName, email: newEmail, bio: properties.description ?? "", matrixUserId: newMatrixUserId, showAvatars: properties.showAvatars ?? showAvatars, sendNotificationsToEmail: newSendNotificationsToEmail, botAccount: newIsBot, showBotAccounts: newShowBotAccounts, showReadPosts: properties.showReadPosts ?? showReadPosts, discussionLanguages: newDiscussionLanguageIds.sorted(), openLinksInNewTab: properties.openLinksInNewTab ?? openLinksInNewTab, blurNsfw: profileSettings.blurNsfw ?? (properties.blurNsfw as? Bool) ?? blurNsfw, autoExpand: properties.autoExpandImages ?? autoExpandImages, infiniteScrollEnabled: properties.infiniteScrollEnabled ?? infiniteScrollEnabled, postListingMode: properties.postListingMode ?? postListingMode, enableKeyboardNavigation: properties.enableKeyboardNavigation ?? enableKeyboardNavigation, enableAnimatedImages: properties.enableAnimatedImages ?? enableAnimatedImages, collapseBotComments: properties.collapseBotComments ?? collapseBotComments, showUpvotes: nil, showDownvotes: nil, showUpvotePercentage: nil ) var properties = properties properties.email = newEmail properties.matrixUserId = newMatrixUserId properties.showNsfw = newShowNsfw properties.showBotAccounts = newShowBotAccounts properties.discussionLanguageIds = newDiscussionLanguageIds properties.sendNotificationsToEmail = newSendNotificationsToEmail properties.isBot = newIsBot return properties } } } return nil } } public enum CommunityIdentifier { case id(Int) case actorId(ActorIdentifier) case community(Community) } // MARK: Shim public extension Person { func takeSnapshot1() -> Person1Snapshot { return .init( actorId: actorId, id: id, name: name, created: created, instanceId: instanceId, displayName: displayName, avatar: avatar, banner: banner, note: note, updated: updated, description: description, matrixUserId: matrixUserId, isBot: isBot, instanceBan: instanceBan, deleted: deleted, allPropertiesPresent: true ) } } public extension Person { var displayName_: String? { displayName } var description_: String? { description } var banner_: URL? { banner } var created_: Date? { created } var updated_: Date? { updated } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Person/Person1+Mock.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-02. // import Foundation // TODO: updated mocks //#if DEBUG // public extension Person1 { // static func mock( // api: MockApiClient = .mock, // actorId: ActorIdentifier?, // id: Int, // name: String, // created: Date, // instanceId: Int, // updated: Date?, // displayName: String, // description: String?, // matrixUserId: String?, // avatar: URL?, // banner: URL?, // deleted: Bool, // isBot: Bool, // instanceBan: InstanceBanType, // blocked: Bool // ) -> Person1 { // .init( // api: api, // actorId: actorId ?? .init(url: URL(string: "https://\(api.host)/u/\(name)")!)!, // id: id, // name: name, // created: created, // instanceId: instanceId, // updated: updated, // displayName: displayName, // description: description, // matrixUserId: matrixUserId, // avatar: avatar, // banner: banner, // note: nil, // deleted: deleted, // isBot: isBot, // instanceBan: instanceBan, // blocked: blocked // ) // } // } //#endif //#if DEBUG // public extension Person2 { // static func mock( // person1: Person1, // postCount: Int, // commentCount: Int, // isAdmin: Bool // ) -> Person2 { // Person2( // api: person1.api, // person1: person1, // postCount: postCount, // commentCount: commentCount, // isAdmin: isAdmin // ) // } // } //#endif ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Person/PersonProperties.swift ================================================ // // PersonProperties.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-26. // import Foundation public struct PersonProperties: UnifiedPropertiesProviding { // From Person1Snapshot, guaranteed to always be present let actorId: ActorIdentifier let id: Int let name: String let created: Date let instanceId: Int var displayName: String var avatar: URL? var note: String? var updated: Date? var matrixUserId: String? var isBot: Bool var instanceBan: InstanceBanType var deleted: Bool // From Person1Snapshot, but PieFed does not always provide these // https://codeberg.org/rimu/pyfedi/issues/882 var description: String?? var banner: URL?? // From Person2Snapshot var isAdmin: Bool? var postCount: Int? var commentCount: Int? // From Person3Snapshot var instance: Instance? var moderatedCommunities: [Community]? // From Person4Snapshot var email: String?? var showNsfw: Bool? var theme: String? var defaultListingType: ListingType? var interfaceLanguage: String? var showAvatars: Bool? var sendNotificationsToEmail: Bool? var showScores: Bool? var showBotAccounts: Bool? var showReadPosts: Bool? var discussionLanguageIds: Set? var emailVerified: Bool? var acceptedApplication: Bool? var openLinksInNewTab: Bool?? var blurNsfw: Bool?? var autoExpandImages: Bool?? var infiniteScrollEnabled: Bool?? var postListingMode: PostFeedViewMode?? var totp2faEnabled: Bool?? var enableKeyboardNavigation: Bool?? var enableAnimatedImages: Bool?? var collapseBotComments: Bool?? @MainActor public init(api: ApiClient, snapshot: AnyPersonSnapshot) { let snapshot1: Person1Snapshot let snapshot2: Person2Snapshot? let snapshot3: Person3Snapshot? let snapshot4: Person4Snapshot? switch snapshot { case let .person1(person1Snapshot): snapshot1 = person1Snapshot snapshot2 = nil snapshot3 = nil snapshot4 = nil case let .person2(person2Snapshot): snapshot1 = person2Snapshot.person snapshot2 = person2Snapshot snapshot3 = nil snapshot4 = nil case let .person3(person3Snapshot): snapshot1 = person3Snapshot.person.person snapshot2 = person3Snapshot.person snapshot3 = person3Snapshot snapshot4 = nil case let .person4(person4Snapshot): snapshot1 = person4Snapshot.person.person.person snapshot2 = person4Snapshot.person.person snapshot3 = person4Snapshot.person snapshot4 = person4Snapshot } if let snapshot4 { email = snapshot4.email showNsfw = snapshot4.showNsfw theme = snapshot4.theme defaultListingType = snapshot4.defaultListingType interfaceLanguage = snapshot4.interfaceLanguage showAvatars = snapshot4.showAvatars sendNotificationsToEmail = snapshot4.sendNotificationsToEmail showScores = snapshot4.showScores showBotAccounts = snapshot4.showBotAccounts showReadPosts = snapshot4.showReadPosts discussionLanguageIds = snapshot4.discussionLanguageIds emailVerified = snapshot4.emailVerified acceptedApplication = snapshot4.acceptedApplication openLinksInNewTab = snapshot4.openLinksInNewTab blurNsfw = snapshot4.blurNsfw autoExpandImages = snapshot4.autoExpandImages infiniteScrollEnabled = snapshot4.infiniteScrollEnabled postListingMode = snapshot4.postListingMode totp2faEnabled = snapshot4.totp2faEnabled enableKeyboardNavigation = snapshot4.enableKeyboardNavigation enableAnimatedImages = snapshot4.enableAnimatedImages collapseBotComments = snapshot4.collapseBotComments } if let snapshot3 { if let instance1Snapshot = snapshot3.site { instance = api.caches.instance.getModel(api: api, from: .instance1(instance1Snapshot)) } moderatedCommunities = api.caches.community.getModels(api: api, from: snapshot3.moderatedCommunities.map { .community1($0) }) } if let snapshot2 { isAdmin = snapshot2.isAdmin postCount = snapshot2.postCount commentCount = snapshot2.commentCount } actorId = snapshot1.actorId id = snapshot1.id name = snapshot1.name created = snapshot1.created instanceId = snapshot1.instanceId displayName = snapshot1.displayName avatar = snapshot1.avatar note = snapshot1.note updated = snapshot1.updated matrixUserId = snapshot1.matrixUserId isBot = snapshot1.isBot instanceBan = snapshot1.instanceBan deleted = snapshot1.deleted if snapshot1.allPropertiesPresent { description = snapshot1.description banner = snapshot1.banner } } public mutating func merge(_ other: PersonProperties) { // tier 1 properties: simple assignment self.displayName = other.displayName self.avatar = other.avatar self.note = other.note self.updated = other.updated self.matrixUserId = other.matrixUserId self.isBot = other.isBot self.instanceBan = other.instanceBan self.deleted = other.deleted // tier 2, 3, 4 properties: only assign if incoming non-nil self.description = other.description ?? self.description self.banner = other.banner ?? self.banner isAdmin = other.isAdmin ?? self.isAdmin postCount = other.postCount ?? self.postCount commentCount = other.commentCount ?? self.commentCount instance = other.instance ?? self.instance moderatedCommunities = other.moderatedCommunities ?? self.moderatedCommunities email = other.email ?? self.email showNsfw = other.showNsfw ?? self.showNsfw theme = other.theme ?? self.theme defaultListingType = other.defaultListingType ?? self.defaultListingType interfaceLanguage = other.interfaceLanguage ?? self.interfaceLanguage showAvatars = other.showAvatars ?? self.showAvatars sendNotificationsToEmail = other.sendNotificationsToEmail ?? self.sendNotificationsToEmail showScores = other.showScores ?? self.showScores showBotAccounts = other.showBotAccounts ?? self.showBotAccounts showReadPosts = other.showReadPosts ?? self.showReadPosts discussionLanguageIds = other.discussionLanguageIds ?? self.discussionLanguageIds emailVerified = other.emailVerified ?? self.emailVerified acceptedApplication = other.acceptedApplication ?? self.acceptedApplication openLinksInNewTab = other.openLinksInNewTab ?? self.openLinksInNewTab blurNsfw = other.blurNsfw ?? self.blurNsfw autoExpandImages = other.autoExpandImages ?? self.autoExpandImages infiniteScrollEnabled = other.infiniteScrollEnabled ?? self.infiniteScrollEnabled postListingMode = other.postListingMode ?? self.postListingMode totp2faEnabled = other.totp2faEnabled ?? self.totp2faEnabled enableKeyboardNavigation = other.enableKeyboardNavigation ?? self.enableKeyboardNavigation enableAnimatedImages = other.enableAnimatedImages ?? self.enableAnimatedImages collapseBotComments = other.collapseBotComments ?? self.collapseBotComments } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Person/PersonStub.swift ================================================ // // Account.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation import Observation public struct PersonStub: Hashable { public var api: ApiClient public let url: URL public init(api: ApiClient, url: URL) { self.api = api self.url = url } public func asLocal() -> Self { .init(api: .getApiClient(url: url, username: nil), url: url) } public func hash(into hasher: inout Hasher) { hasher.combine(url) } public static func == (lhs: PersonStub, rhs: PersonStub) -> Bool { lhs.url == rhs.url } public func getPerson() async throws -> Person { try await api.getPerson(url: url) } } // Resolvable conformance public extension PersonStub { var resolvableUrl: URL { url } @inlinable var allResolvableUrls: [URL] { [resolvableUrl] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PersonVote/PersonVote+CacheExtensions.swift ================================================ // // PersonVote+CacheExtensions.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-18. // import Foundation extension PersonVote: CacheIdentifiable { public var cacheId: Int { var hasher = Hasher() hasher.combine(target) hasher.combine(creator.id) return hasher.finalize() } @MainActor func update(with snapshot: PersonVoteSnapshot, semaphore: UInt? = nil) { setIfChanged(\.vote, ScoringOperation(rawValue: snapshot.score) ?? .none) if let creatorBannedFromCommunity = snapshot.creatorBannedFromCommunity { creator.updateKnownCommunityBanState(id: communityId, banned: creatorBannedFromCommunity) } Task { await creator.updateQueue.attemptDirectUpdate(with: .init(api: api, snapshot: .person1(snapshot.creator))) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PersonVote/PersonVote.swift ================================================ // // PersonVote.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-18. // import Foundation import Observation @Observable public class PersonVote: ContentModel { public enum Target: Hashable { case post(id: Int) case comment(id: Int) } public let api: ApiClient public let target: Target public let communityId: Int public let creator: Person public var vote: ScoringOperation public var bannedFromCommunity: Bool { guard let state = creator.isBannedFromCommunity(id: communityId) else { assertionFailure("Ban status should be present at this point") return false } return state } init( api: ApiClient, target: Target, communityId: Int, creator: Person, vote: ScoringOperation, creatorBannedFromCommunity: Bool? ) { self.api = api self.target = target self.communityId = communityId self.creator = creator self.vote = vote if let creatorBannedFromCommunity { creator.updateKnownCommunityBanState(id: communityId, banned: creatorBannedFromCommunity) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PersonalUnreadCountSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public struct PersonalUnreadCountSnapshot { let replies: Int let mentions: Int let messages: Int var unreadCountDictionary: [InboxItemType: Int] { [ .reply: replies, .mention: mentions, .message: messages ] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/BlockListSnapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-25. // import Foundation public extension BlockListSnapshot { init(from myUserInfo: PieFedMyUserInfo) { self.people = myUserInfo.personBlocks.reduce(into: [:]) { $0[$1.target.actorId] = $1.target.id } self.communities = myUserInfo.communityBlocks.reduce(into: [:]) { $0[$1.community.actorId] = $1.community.id } self.instances = myUserInfo.instanceBlocks.reduce(into: [:]) { let actorId: ActorIdentifier = .instance(host: $1.instance.domain) $0[actorId] = actorId.url.absoluteString.hashValue } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Comment1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Comment1Snapshot { init(from comment: PieFedComment) throws(ApiClientError) { let parentCommentIds = comment.path .split(separator: ".") .dropFirst() .dropLast() .compactMap { Int($0) } self.init( actorId: comment.apId, id: comment.id, creatorId: comment.userId, postId: comment.postId, parentCommentIds: parentCommentIds, created: comment.published, content: comment.body, updated: comment.updated, distinguished: comment.distinguished ?? false, languageId: comment.languageId, // If a post is removed, deleted is true for some reason deleted: comment.removed ? false : comment.deleted, removed: comment.removed ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Comment2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Comment2Snapshot { init(from comment: PieFedCommentView) throws(ApiClientError) { let votes: VotesModel = .init( upvotes: comment.counts.upvotes, downvotes: comment.counts.downvotes, myVote: .guaranteedInit(from: comment.myVote) ) try self.init( comment: .init(from: comment.comment), creator: .init(from: comment.creator), post: .init(from: comment.post), community: .init(from: comment.community), commentCount: comment.counts.childCount, creatorIsModerator: comment.creatorIsModerator, creatorIsAdmin: comment.creatorIsAdmin, creatorBannedFromCommunity: comment.creatorBannedFromCommunity, votes: votes, saved: comment.saved ) } init(from report: PieFedCommentReportView) throws(ApiClientError) { let votes: VotesModel = .init( from: report.counts, myVote: .guaranteedInit(from: report.myVote) ) try self.init( comment: .init(from: report.comment), creator: .init(from: report.commentCreator), post: .init(from: report.post), community: .init(from: report.community), commentCount: report.counts.childCount, creatorIsModerator: report.creatorIsModerator, creatorIsAdmin: report.creatorIsAdmin, creatorBannedFromCommunity: report.creatorBannedFromCommunity, votes: votes, saved: report.saved ) } init(from reply: PieFedCommentReplyView) throws(ApiClientError) { let votes: VotesModel = .init( from: reply.counts, myVote: .guaranteedInit(from: reply.myVote) ) try self.init( comment: .init(from: reply.comment), creator: .init(from: reply.creator), post: .init(from: reply.post), community: .init(from: reply.community), commentCount: reply.counts.childCount, creatorIsModerator: reply.creatorIsModerator, creatorIsAdmin: reply.creatorIsAdmin, creatorBannedFromCommunity: reply.creatorBannedFromCommunity, votes: votes, saved: reply.saved ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/CommentSortType+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension CommentSortType { var piefedSortType: PieFedSortType? { switch self { case .new: .new case .old: .old case .hot: .hot case .controversial: nil case .top(.allTime): .top case .top: nil } } var piefedCommentSortType: PieFedCommentSortType? { switch self { case .new: .new case .old: .old case .hot: .hot case .controversial: .controversial case .top(.allTime): .top case .top: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Community1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Community1Snapshot { init(from community: PieFedCommunity, allPropertiesPresent: Bool = false) throws(ApiClientError) { self.init( actorId: community.actorId, id: community.id, name: community.name, created: community.published, instanceId: community.instanceId, updated: community.updated, displayName: community.title, description: community.description, deleted: community.deleted, removed: community.removed, nsfw: community.nsfw, avatar: community.icon, banner: community.banner, hidden: community.hidden, onlyModeratorsCanPost: community.restrictedToMods, allPropertiesPresent: allPropertiesPresent ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Community2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Community2Snapshot { init(from community: PieFedCommunityView, allPropertiesPresent: Bool = false) throws(ApiClientError) { let subscription: SubscriptionModel = .init( total: community.counts.totalSubscriptionsCount, local: community.counts.subscriptionsCount, subscribed: community.subscribed.isSubscribed, pending: community.subscribed == .pending ) let activeUserCount: ActiveUserCount = .init( sixMonths: community.counts.active6monthly ?? 0, month: community.counts.activeMonthly ?? 0, week: community.counts.activeWeekly ?? 0, day: community.counts.activeDaily ?? 0 ) try self.init( community: .init(from: community.community, allPropertiesPresent: allPropertiesPresent), subscription: subscription, postCount: community.counts.postCount, commentCount: community.counts.postReplyCount, activeUserCount: activeUserCount, bannedFromCommunity: false ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Community3Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Community3Snapshot { init(from community: PieFedGetCommunityResponse) throws(ApiClientError) { var moderators = [Person1Snapshot]() for moderator in community.moderators { try moderators.append(.init(from: moderator.moderator)) } self.init( community: try .init(from: community.communityView, allPropertiesPresent: true), instance: try community.site.map {site throws(ApiClientError) in try .init(from: site) }, moderators: moderators, discussionLanguageIds: .init(community.discussionLanguages) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/ImageUpload1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-05. // import Foundation public extension ImageUpload1Snapshot { init(from response: PieFedImageUploadResponse) { self.init( url: response.url, alias: nil, deleteToken: nil ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/InboxNotificationSnapshot+PieFed.swift ================================================ // // InboxNotificationSnapshot+PieFed.swift // MlemMiddleware // // Created by Sjmarf on 2025-12-22. // import Foundation extension InboxNotificationSnapshot { init(from replyView: PieFedCommentReplyView, isMention: Bool) throws(ApiClientError) { try self.init( id: LegacyNotificationIdWrapper(type: isMention ? .mention : .reply, id: replyView.commentReply.id).hashValue, contentId: replyView.commentReply.id, read: replyView.commentReply.read, content: .reply(.init(from: replyView)) ) } init(from messageView: PieFedPrivateMessageView) throws(ApiClientError) { try self.init( id: LegacyNotificationIdWrapper(type: .message, id: messageView.privateMessage.id).hashValue, contentId: messageView.privateMessage.id, read: messageView.privateMessage.read, content: .message(.init(from: messageView)) ) } } // This can be removed once we drop support for < Lemmy 1.0 private struct LegacyNotificationIdWrapper: Hashable { let type: InboxNotificationContentType let id: Int } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Instance1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation public extension Instance1Snapshot { init(from site: PieFedSite) throws(ApiClientError) { self.init( actorId: site.actorId, // This is kinda dodgy id: site.actorId.hashValue, instanceId: site.actorId.hashValue, created: Date(timeIntervalSince1970: 0), updated: nil, publicKey: "", displayName: site.name, description: site.sidebarMd ?? site.sidebar, shortDescription: site.description, avatar: site.icon, banner: nil, lastRefresh: Date(timeIntervalSince1970: 0), contentWarning: nil ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Instance2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation public extension Instance2Snapshot { init(pieFed: PieFedSite, lemmy: PieFedLemmyCompatibleSiteView) throws(ApiClientError) { // I suspect these can only be `nil` when the `PieFedSite` is used in a request body guard let enableDownvotes = pieFed.enableDownvotes else { throw ApiClientError.responseMissingRequiredData("PieFedSite downvotesEnabled") } guard let userCount = pieFed.userCount else { throw ApiClientError.responseMissingRequiredData("PieFedSite userCount") } guard let registrationMode = pieFed.registrationMode else { throw ApiClientError.responseMissingRequiredData("PieFedSite registrationMode") } let counts = lemmy.counts let activeUserCount: ActiveUserCount = .init( sixMonths: counts.usersActiveHalfYear, month: counts.usersActiveMonth, week: counts.usersActiveWeek, day: counts.usersActiveDay ) try self.init( instance: .init(from: pieFed), setup: true, voteFederationMode: enableDownvotes ? .all : .downvotesDisabled, nsfwContentEnabled: false, communityCreationRestrictedToAdmins: false, emailVerificationRequired: true, applicationQuestion: nil, isPrivate: false, defaultTheme: "browser", defaultFeed: .all, legalInformation: nil, hideModlogNames: true, emailApplicationsToAdmins: true, emailReportsToAdmins: false, slurFilterRegex: nil, actorNameMaxLength: 20, federationEnabled: true, captchaEnabled: false, captchaDifficulty: nil, registrationMode: .init(from: registrationMode), federationSignedFetch: nil, defaultPostListingMode: .list, defaultPostSortType: .hot, userCount: userCount, postCount: counts.posts, commentCount: counts.comments, communityCount: counts.communities, activeUserCount: activeUserCount ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Instance3Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation public extension Instance3Snapshot { init(pieFed: PieFedGetSiteResponse, lemmy: PieFedLemmyCompatibleSiteResponse) throws(ApiClientError) { // In addition to having their own site request, PieFed also impersonates // Lemmy's site request at "api/v3/site". We also use that response here, // because the response contains some data that is missing from PieFed's // own site request. // The source code for this is here (function name: `lemmy_site_data`) // https://codeberg.org/rimu/pyfedi/src/commit/75c48f6d22ec831e05bc54852f514caf34a60d0a/app/activitypub/util.py guard let allLanguages = pieFed.site.allLanguages else { throw ApiClientError.responseMissingRequiredData("PieFedSite allLanguages") } var administrators: [Person2Snapshot] = [] administrators.reserveCapacity(pieFed.admins.count) for admin in pieFed.admins { try administrators.append(.init(from: admin)) } try self.init( instance: .init(pieFed: pieFed.site, lemmy: lemmy.siteView), allLanguages: allLanguages.compactMap { .init($0) }, software: .init(type: .pieFed, version: .init(pieFed.version)), allowedLanguageIds: .init(0 ... allLanguages.count - 1), blockedUrls: [], administrators: administrators ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/LegacySortTimeRangeLimit+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension LegacySortTimeRangeLimit { var pieFedSortType: PieFedSortType { switch self { case .hour: .topHour case .sixHour: .topSixHour case .twelveHour: .topTwelveHour case .day: .topDay case .week: .topWeek case .month: .topMonth case .threeMonth: .topThreeMonths case .sixMonth: .topSixMonths case .nineMonth: .topNineMonths case .year: .topYear } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/ListingType+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension ListingType { init?(from listingType: PieFedListingType) { let value: Self? = switch listingType { case .all: .all case .local: .local case .subscribed: .subscribed case .moderatorView, .moderating: .moderated case .popular: .popular } if let value { self = value } else { return nil } } var pieFedListingType: PieFedListingType? { switch self { case .all: .all case .local: .local case .subscribed: .subscribed case .moderated: .moderatorView case .popular: .popular case .suggested: nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Locale.Language+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation extension Locale.Language { init?(_ language: PieFedLanguageView) { if let code = language.code { self = .init(identifier: code) } else { return nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Message1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-25. // import Foundation public extension Message1Snapshot { init(from message: PieFedPrivateMessage) throws(ApiClientError) { var deleted = message.deleted // This is required on PieFed 1.1. It *should* no longer be necessary // from PieFed 1.2 onwards. Check to be sure though if message.content == "Message Deleted" { deleted = true } self.init( actorId: message.apId, id: message.id, creatorId: message.creatorId, recipientId: message.recipientId, created: message.published, content: message.content, updated: message.updated, read: message.read, deleted: deleted ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Message2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-25. // import Foundation public extension Message2Snapshot { init(from message: PieFedPrivateMessageView) throws(ApiClientError) { try self.init( message: .init(from: message.privateMessage), creator: .init(from: message.creator), recipient: .init(from: message.recipient) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Person1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Person1Snapshot { init(from person: PieFedPerson, allPropertiesPresent: Bool = false) throws(ApiClientError) { self.init( actorId: person.actorId, id: person.id, name: person.userName, created: person.published, instanceId: person.instanceId, displayName: person.title ?? person.userName, avatar: person.avatar, banner: person.banner, note: person.note, updated: nil, description: person.about, matrixUserId: nil, isBot: person.bot, // Does PieFed not have bans with expiry times, or did they just not put it in the API yet? instanceBan: person.banned ? .permanentlyBanned : .notBanned, deleted: person.deleted, allPropertiesPresent: allPropertiesPresent ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Person2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Person2Snapshot { init(from person: PieFedPersonView, allPropertiesPresent: Bool = false) throws(ApiClientError) { try self.init( person: .init(from: person.person, allPropertiesPresent: allPropertiesPresent), isAdmin: person.isAdmin, postCount: person.counts.postCount, commentCount: person.counts.commentCount ) } init(from localUser: PieFedLocalUserView) throws(ApiClientError) { try self.init( person: .init(from: localUser.person, allPropertiesPresent: true), isAdmin: false, postCount: localUser.counts.postCount, commentCount: localUser.counts.commentCount ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Person3Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Person3Snapshot { init(from userInfo: PieFedMyUserInfo) throws(ApiClientError) { var moderatedCommunities: [Community1Snapshot] = [] moderatedCommunities.reserveCapacity(userInfo.moderates.count) for moderate in userInfo.moderates { try moderatedCommunities.append(.init(from: moderate.community)) } try self.init( person: .init(from: userInfo.localUserView), site: nil, moderatedCommunities: moderatedCommunities ) } init(from personDetails: PieFedGetUserResponse) throws(ApiClientError) { var moderatedCommunities: [Community1Snapshot] = [] moderatedCommunities.reserveCapacity(personDetails.moderates.count) for moderate in personDetails.moderates { try moderatedCommunities.append(.init(from: moderate.community)) } try self.init( person: .init(from: personDetails.personView, allPropertiesPresent: true), site: personDetails.site.map { site throws(ApiClientError) in try .init(from: site) }, moderatedCommunities: moderatedCommunities ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Person4Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-25. // import Foundation public extension Person4Snapshot { init(from userInfo: PieFedMyUserInfo) throws(ApiClientError) { let user = userInfo.localUserView.localUser try self.init( person: .init(from: userInfo), email: nil, showNsfw: user.showNsfw, theme: "", defaultListingType: .init(from: user.defaultListingType) ?? .all, interfaceLanguage: "en", showAvatars: true, sendNotificationsToEmail: false, showScores: user.showScores, showBotAccounts: user.showBotAccounts, showReadPosts: user.showReadPosts, discussionLanguageIds: Set(userInfo.discussionLanguages.compactMap(\.id)), emailVerified: true, acceptedApplication: true, openLinksInNewTab: nil, blurNsfw: nil, autoExpandImages: nil, infiniteScrollEnabled: nil, postListingMode: nil, totp2faEnabled: false, enableKeyboardNavigation: true, enableAnimatedImages: nil, collapseBotComments: false ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/PersonVoteSnapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-18. // import Foundation extension PersonVoteSnapshot { init(from vote: PieFedPostLikeView) throws(ApiClientError) { try self.init( creator: .init(from: vote.creator), score: vote.score, creatorBannedFromCommunity: vote.creatorBannedFromCommunity ) } init(from vote: PieFedCommentLikeView) throws(ApiClientError) { try self.init( creator: .init(from: vote.creator), score: vote.score, creatorBannedFromCommunity: vote.creatorBannedFromCommunity ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/PersonalUnreadCountSnapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-04. // import Foundation public extension PersonalUnreadCountSnapshot { init(from response: PieFedUserUnreadCountsResponse) throws(ApiClientError) { self.replies = response.replies self.mentions = response.mentions self.messages = response.privateMessages } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Post1Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Post1Snapshot { init(from post: PieFedPost) throws(ApiClientError) { self.init( actorId: post.apId, id: post.id, creatorId: post.userId, communityId: post.communityId, created: post.published, title: post.title, content: post.body, linkUrl: post.url, embed: nil, poll: post.poll.map { .init(from: $0) }, nsfw: post.nsfw, thumbnailUrl: post.thumbnailUrl, updated: post.updated, languageId: post.languageId, altText: post.altText, // If a post is removed, deleted is true for some reason deleted: post.removed ? false : post.deleted, removed: post.removed, pinnedCommunity: post.sticky, pinnedInstance: false, locked: post.locked ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Post2Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Post2Snapshot { init(from post: PieFedPostView, overrideRead: Bool? = nil) throws(ApiClientError) { let votes = VotesModel( upvotes: post.counts.upvotes, downvotes: post.counts.downvotes, myVote: .guaranteedInit(from: post.myVote) ) try self.init( post: .init(from: post.post), creator: .init(from: post.creator), community: .init(from: post.community), commentCount: post.counts.comments, unreadCommentCount: post.unreadComments, creatorIsModerator: post.creatorIsModerator, creatorIsAdmin: post.creatorIsAdmin, creatorBannedFromCommunity: post.creatorBannedFromCommunity, creatorBlocked: false, votes: votes, saved: post.saved, read: overrideRead ?? post.read, hidden: post.hidden ) } init(from report: PieFedPostReportView) throws(ApiClientError) { let votes = VotesModel(from: report.counts, myVote: .guaranteedInit(from: report.myVote)) try self.init( post: .init(from: report.post), creator: .init(from: report.postCreator), community: .init(from: report.community), commentCount: report.counts.comments, unreadCommentCount: 0, creatorIsModerator: report.creatorIsModerator, creatorIsAdmin: report.creatorIsAdmin, creatorBannedFromCommunity: report.creatorBannedFromCommunity, creatorBlocked: report.creatorBlocked, votes: votes, saved: report.saved, read: false, hidden: false ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/Post3Snapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension Post3Snapshot { init(from post: PieFedGetPostResponse) throws(ApiClientError) { var crossPosts: [Post2Snapshot] = [] for crossPost in post.crossPosts { try crossPosts.append(.init(from: crossPost)) } try self.init( post: .init(from: post.postView), community: .init(from: post.communityView, allPropertiesPresent: false), crossPosts: crossPosts ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/PostFeatureType+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-20. // import Foundation extension PostFeatureType { var piefedPostFeatureType: PieFedPostFeatureType { switch self { case .community: .community case .instance: .local } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/PostPoll+PieFed.swift ================================================ // // PostPoll+PieFed.swift // Mlem // // Created by Sjmarf on 2026-01-26. // extension PostPoll { init(from poll: PieFedPostPoll) { self.endDate = poll.endPoll self.latestVote = poll.latestVote self.localOnly = poll.localOnly self.type = .init(from: poll.mode) let myVotes = poll.myVotes ?? [] self.choices = poll.choices.map { .init(from: $0, selected: myVotes.contains($0.id)) } } } extension PostPollType { init(from type: PieFedPostPollMode) { self = switch type { case .single: .single case .multiple: .multiple } } } extension PostPollChoice { init(from choice: PieFedPollChoice, selected: Bool) { self.id = choice.id self.label = choice.choiceText self.voteCount = choice.numVotes self.selected = selected } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/PostSortType+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension PostSortType { var pieFedSortType: PieFedSortType? { switch self { case .active: nil case .hot: .hot case .new: .new case .old: .old case .mostComments: nil case .newComments: .active // This is intentional case .controversial: nil case .scaled: .scaled case let .top(range): range.pieFedSortType } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/RegistrationMode+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation public extension RegistrationMode { init(from mode: PieFedRegistrationMode) { self = switch mode { case .closed: .closed case .open: .open case .requireApplication: .requiresApplication } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/ReportSnapshot+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-25. // import Foundation extension ReportSnapshot { init(from report: PieFedCommentReportView) throws(ApiClientError) { try self.init( creator: .init(from: report.creator), id: report.commentReport.id, created: report.commentReport.published, resolver: report.resolver.map { resolver throws(ApiClientError) in try .init(from: resolver) }, updated: report.commentReport.updated, resolved: report.commentReport.resolved, reason: report.commentReport.reason ?? "", target: .comment(.init(from: report)) ) } init(from report: PieFedPostReportView) throws(ApiClientError) { try self.init( creator: .init(from: report.creator), id: report.postReport.id, created: report.postReport.published, resolver: report.resolver.map { resolver throws(ApiClientError) in try .init(from: resolver) }, updated: report.postReport.updated, resolved: report.postReport.resolved, reason: report.postReport.reason, target: .post(.init(from: report)) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/ResolvedContent+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-25. // import Foundation public extension ResolvedContent { init(from response: PieFedResolveObjectResponse) throws { if let comment = response.comment { self = try .comment(.init(from: comment)) } else if let post = response.post { self = try .post(.init(from: post)) } else if let community = response.community { self = try .community(.init(from: community)) } else if let person = response.person { self = try .person(.init(from: person)) } else { throw ApiClientError.noEntityFound } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/SearchSortType+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension SearchSortType { var pieFedSortType: PieFedSortType? { switch self { case .new: .new case .old: nil case let .top(range): range.pieFedSortType } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PieFedExtensions/SortTimeRange+PieFed.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension SortTimeRange { var pieFedSortType: PieFedSortType? { switch self { case .allTime: .topAll case let .limited(timeInterval): LegacySortTimeRangeLimit(timeInterval)?.pieFedSortType } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/Post+Conformance.swift ================================================ // // Post+Conformance.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-07. // import Foundation import Nuke import Rest // MARK: CacheIdentifiable public extension Post { var cacheId: Int { id } } // MARK: FeedLoadable public extension Post { typealias FilterType = PostFilterType static func == (lhs: Post, rhs: Post) -> Bool { lhs.actorId == rhs.actorId } func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: return .new(created) } } } // MARK: ImagePrefetchProviding extension Post: ImagePrefetchProviding { public var type: PostType { if let poll { return .poll(poll) } // post with URL: image, embedded, or link if let linkUrl { if let embeddedMediaUrl { return .embedded(embeddedMediaUrl, originalLink: linkUrl) } // if image, return image link, otherwise return thumbnail if linkUrl.isMedia { return .media(linkUrl) } return .link(.init(content: linkUrl, thumbnail: thumbnailUrl, label: embed?.title ?? title)) } // otherwise text, but post.body needs to be present, even if it's an empty string if let postBody = content { return .text(postBody) } return .titleOnly } func parseLoopEmbeds() async { if let loopsUrl = await linkUrl?.parseEmbeddedLoops() { _ = await Task { @MainActor in embeddedMediaUrl = loopsUrl }.result } } public func imageRequests(configuration config: PrefetchingConfiguration) async -> [ImageRequest] { var ret: [ImageRequest] = .init() // handle loops.video embedding if config.embedLoops { await parseLoopEmbeds() } switch type { case let .media(url), let .embedded(url, _): // media/embedded media: only load the media var urlRequest: URLRequest switch config.imageSize { case .unlimited: urlRequest = mlemUrlRequest(url: url) case let .limited(size): urlRequest = mlemUrlRequest(url: url.withIconSize(size)) } ret.append(ImageRequest(urlRequest: urlRequest, priority: .high)) case let .link(link): // websites: load image and favicon if config.fetchFavicons, let url = link.favicon { let urlRequest = mlemUrlRequest(url: url) ret.append(ImageRequest(urlRequest: urlRequest)) } if let url = link.thumbnail { var urlRequest: URLRequest switch config.imageSize { case .unlimited: urlRequest = mlemUrlRequest(url: url) case let .limited(size): urlRequest = mlemUrlRequest(url: url.withIconSize(size)) } ret.append(ImageRequest(urlRequest: urlRequest, priority: .high)) } default: break } // preload user and community avatars--fetching both because we don't know which we'll need, but these are super tiny // so it's probably not an API crime, right? if let avatarSize = config.avatarSize { if let communityAvatarLink = community.value_?.avatar { ret.append(ImageRequest(urlRequest: mlemUrlRequest(url: communityAvatarLink.withIconSize(avatarSize)))) } if let userAvatarLink = creator.value_?.avatar { ret.append(ImageRequest(urlRequest: mlemUrlRequest(url: userAvatarLink.withIconSize(avatarSize)))) } } return ret } } // MARK: SelectableContentProviding public extension Post { var selectableContent: String? { if let content { "\(title)\n\n\(content)" } else { title } } } // MARK: ContentIdentifiable public extension Post { static var modelTypeId: ContentType { .post } } // MARK: Resolvable public extension Post { /// Returns a `URL` that can be resolved by another `ApiClient`. func resolvableUrl(from instance: ContentModelUrlType) -> URL { switch instance { case .host: actorId.url case .provider: .post(host: api.host, id: id) } } @inlinable var allResolvableUrls: [URL] { ContentModelUrlType.allCases.map { resolvableUrl(from: $0) } } } // MARK: Sharable public extension Post { func url() -> URL { api.baseUrl.appending(path: "post/\(id)") } } // MARK: InteractableProviding public extension Post { var downvotesEnabled: Bool { api.voteFederationMode.postDownvote != .disable } } // MARK: PersonContentProviding public extension Post { var userContent: PersonContent { .init(wrappedValue: .post(self)) } } // MARK: CanModerateProviding public extension Post { var canModerate: Bool { guard let myPersonModerates = api.myPerson?.moderates else { return false } return myPersonModerates(.id(communityId)) || api.isAdmin } } // MARK: ReportableProviding public extension Post { func report(reason: String) async throws { try await api.reportPost(id: id, reason: reason) } } // MARK: OwnershipProviding public extension Post { func isOwnContent(myPersonId: Int) -> Bool { creatorId == myPersonId } } // MARK: Snapshots public extension Post { func takeSnapshot1() -> Post1Snapshot { .init(actorId: actorId, id: id, creatorId: creatorId, communityId: communityId, created: created, title: title, content: content, linkUrl: linkUrl, embed: embed, poll: poll, nsfw: nsfw, thumbnailUrl: thumbnailUrl, updated: updated, languageId: languageId, altText: altText, deleted: deleted, removed: removed, pinnedCommunity: pinnedCommunity, pinnedInstance: pinnedInstance, locked: locked ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/Post+Mock.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-02. // import Foundation // TODO: updated mocks //#if DEBUG // public extension Post1 { // static func mock( // api: MockApiClient = .mock, // actorId: ActorIdentifier? = nil, // id: Int, // creatorId: Int, // communityId: Int, // created: Date, // title: String, // content: String?, // linkUrl: URL?, // deleted: Bool, // embed: PostEmbed?, // pinnedCommunity: Bool, // pinnedInstance: Bool, // locked: Bool, // nsfw: Bool, // removed: Bool, // thumbnailUrl: URL?, // updated: Date?, // languageId: Int, // altText: String? // ) -> Post1 { // .init( // api: api, // actorId: actorId ?? .init(url: URL(string: "https://\(api.host)/post/\(id)")!)!, // id: id, // creatorId: creatorId, // communityId: communityId, // created: created, // title: title, // content: content, // linkUrl: linkUrl, // deleted: deleted, // embed: embed, // pinnedCommunity: pinnedCommunity, // pinnedInstance: pinnedInstance, // locked: locked, // nsfw: nsfw, // removed: removed, // thumbnailUrl: thumbnailUrl, // updated: updated, // languageId: languageId, // altText: altText // ) // } // } //#endif //#if DEBUG // public extension Post2 { // static func mock( // api: ApiClient = .mock, // post1: Post1, // creator: Person1, // community: Community1, // votes: VotesModel, // creatorIsModerator: Bool, // creatorIsAdmin: Bool, // creatorBannedFromCommunity: Bool, // commentCount: Int, // unreadCommentCount: Int, // saved: Bool, // read: Bool, // hidden: Bool // ) -> Post2 { // assert(api === post1.api) // assert(api === creator.api) // assert(api === community.api) // return .init( // api: api, // post1: post1, // creator: creator, // community: community, // votes: votes, // creatorIsModerator: creatorIsModerator, // creatorIsAdmin: creatorIsAdmin, // creatorBannedFromCommunity: creatorBannedFromCommunity, // commentCount: commentCount, // unreadCommentCount: unreadCommentCount, // saved: saved, // read: read, // hidden: hidden // ) // } // } //#endif ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/Post.swift ================================================ // // Post.swift // MlemMiddleware // // Created by Eric Andrews on 2025-12-18. // import Observation import Foundation import Nuke import Rest public struct PostEmbed: Equatable { public let title: String? public let description: String? public let videoUrl: URL? } @Observable public class Post: UnifiedModelProviding, FeedLoadable, SelectableContentProviding, ContentIdentifiable, Resolvable, Sharable, UnifiedReadableProviding, InteractableProviding, PersonContentProviding, DeletableProviding, ReportableProviding, RemovableProviding, PurgableProviding { public typealias Properties = PostProperties public var api: ApiClient private let properties: PostProperties @ObservationIgnored lazy var updateQueue: UnifiedUpdateQueue = .init(parent: self, properties: properties) // MARK: Custom Properties // Mlem-specific properties that are not reflected in the API public var readQueued: Bool = false public var pinnedCommunityPending: Bool = false public var pinnedInstancePending: Bool = false public var lockedPending: Bool = false public var nsfwPending: Bool = false public var removedPending: Bool = false public var purged: Bool = false public var embeddedMediaUrl: URL? // MARK: API Properties // Properties that are provided by the API public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let communityId: Int public let created: Date public var title: String public var content: String? public var linkUrl: URL? public var embed: PostEmbed? public var poll: PostPoll? public var nsfw: Bool public var thumbnailUrl: URL? public var updated: Date? public var languageId: Int public var altText: String? public var deleted: Bool public var removed: Bool public var pinnedCommunity: Bool public var pinnedInstance: Bool public var locked: Bool public var creator: ExpectedValue public var community: ExpectedValue public var commentCount: ExpectedValue public var unreadCommentCount: ExpectedValue public var creatorIsModerator: ExpectedValue public var creatorIsAdmin: ExpectedValue public var creatorBannedFromCommunity: ExpectedValue public var creatorBlocked: ExpectedValue public var votes: ExpectedValue public var saved: ExpectedValue public var readStatus: ExpectedValue public var read: ExpectedValue { .init( value: readStatus.value?.or(readQueued), provideValue: { try await self.upgrade() }) } public var hidden: ExpectedValue public var crossPosts: ExpectedValue<[Post]> // MARK: Initializers and Updates public init(api: ApiClient, properties: PostProperties) { self.api = api self.properties = properties self.actorId = properties.actorId self.id = properties.id self.creatorId = properties.creatorId self.communityId = properties.communityId self.created = properties.created self.title = properties.title self.content = properties.content self.linkUrl = properties.linkUrl self.embed = properties.embed self.poll = properties.poll self.nsfw = properties.nsfw self.thumbnailUrl = properties.thumbnailUrl self.updated = properties.updated self.languageId = properties.languageId self.altText = properties.altText self.deleted = properties.deleted self.removed = properties.removed self.pinnedCommunity = properties.pinnedCommunity self.pinnedInstance = properties.pinnedInstance self.locked = properties.locked // because upgrade() is not available until all properties are initialized, first populate all properties // with ExpectedValues that don't actually do anything, then reassign them properly at the end of the init // this is somewhat cumbersome but avoids lazy vars, which are very awkward in Observables self.creator = dummyExpectedValue(properties.creator) self.community = dummyExpectedValue(properties.community) self.commentCount = dummyExpectedValue(properties.commentCount) self.unreadCommentCount = dummyExpectedValue(properties.unreadCommentCount) self.creatorIsModerator = dummyExpectedValue(properties.creatorIsModerator) self.creatorIsAdmin = dummyExpectedValue(properties.creatorIsAdmin) self.creatorBannedFromCommunity = dummyExpectedValue(properties.creatorBannedFromCommunity) self.creatorBlocked = dummyExpectedValue(properties.creatorBlocked) self.votes = dummyExpectedValue(properties.votes) self.saved = dummyExpectedValue(properties.saved) self.readStatus = dummyExpectedValue(properties.read) self.hidden = dummyExpectedValue(properties.hidden) self.crossPosts = dummyExpectedValue(properties.crossPosts) func expectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { try await self.upgrade() }) } self.creator = expectedValue(properties.creator) self.community = expectedValue(properties.community) self.commentCount = expectedValue(properties.commentCount) self.unreadCommentCount = expectedValue(properties.unreadCommentCount) self.creatorIsModerator = expectedValue(properties.creatorIsModerator) self.creatorIsAdmin = expectedValue(properties.creatorIsAdmin) self.creatorBannedFromCommunity = expectedValue(properties.creatorBannedFromCommunity) self.creatorBlocked = expectedValue(properties.creatorBlocked) self.votes = expectedValue(properties.votes) self.saved = expectedValue(properties.saved) self.readStatus = expectedValue(properties.read) self.hidden = expectedValue(properties.hidden) self.crossPosts = expectedValue(properties.crossPosts) } @MainActor public func update(with properties: PostProperties) { setIfChanged(\.title, properties.title) setIfChanged(\.content, properties.content) setIfChanged(\.linkUrl, properties.linkUrl) setIfChanged(\.embed, properties.embed) // PieFed has a bug where sometimes doesn't it return the poll object. // This check prevents the poll from disappearing if we get a response // that doesn't include it. if properties.poll != nil { setIfChanged(\.poll, properties.poll) } setIfChanged(\.nsfw, properties.nsfw) setIfChanged(\.thumbnailUrl, properties.thumbnailUrl) setIfChanged(\.updated, properties.updated) setIfChanged(\.languageId, properties.languageId) setIfChanged(\.altText, properties.altText) setIfChanged(\.deleted, properties.deleted) setIfChanged(\.removed, properties.removed) setIfChanged(\.pinnedCommunity, properties.pinnedCommunity) setIfChanged(\.pinnedInstance, properties.pinnedInstance) setIfChanged(\.locked, properties.locked) // creator and community are not expected to change value, but need to be assigned if absent setIfNil(\.creator.value_, properties.creator ?? creator.value_) setIfNil(\.community.value_, properties.community ?? community.value_) updateIfChanged(\.commentCount.value_, properties.commentCount ?? commentCount.value_) updateIfChanged(\.unreadCommentCount.value_, properties.unreadCommentCount ?? unreadCommentCount.value_) updateIfChanged(\.creatorIsModerator.value_, properties.creatorIsModerator ?? creatorIsModerator.value_) updateIfChanged(\.creatorIsAdmin.value_, properties.creatorIsAdmin ?? creatorIsAdmin.value_) updateIfChanged(\.creatorBannedFromCommunity.value_ , properties.creatorBannedFromCommunity ?? creatorBannedFromCommunity.value_) updateIfChanged(\.creatorBlocked.value_, properties.creatorBlocked ?? creatorBlocked.value_) updateIfChanged(\.votes.value_, properties.votes ?? votes.value_) updateIfChanged(\.saved.value_, properties.saved ?? saved.value_) updateIfChanged(\.readStatus.value_, properties.read ?? readStatus.value_) updateIfChanged(\.hidden.value_, properties.hidden ?? hidden.value_) updateIfChanged(\.crossPosts.value_, properties.crossPosts ?? crossPosts.value_) } @MainActor public func softUpdate(with properties: PostProperties) { setIfNil(\.creator.value_, properties.creator) setIfNil(\.community.value_, properties.community) setIfNil(\.commentCount.value_, properties.commentCount) setIfNil(\.unreadCommentCount.value_, properties.unreadCommentCount) setIfNil(\.creatorIsModerator.value_, properties.creatorIsModerator) setIfNil(\.creatorIsAdmin.value_, properties.creatorIsAdmin) setIfNil(\.creatorBannedFromCommunity.value_ , properties.creatorBannedFromCommunity) setIfNil(\.creatorBlocked.value_, properties.creatorBlocked) setIfNil(\.votes.value_, properties.votes) setIfNil(\.saved.value_, properties.saved) setIfNil(\.readStatus.value_, properties.read) setIfNil(\.hidden.value_, properties.hidden) setIfNil(\.crossPosts.value_, properties.crossPosts ?? crossPosts.value_) } // MARK: Upgrades public func upgrade() async throws { try await updateQueue.upgrade() } public func refresh() async throws { try await updateQueue.refresh() } public func fetchUpgraded() async throws -> PostProperties { let snapshot = try await api.repository.getPost(id: id) return await .init(api: api, snapshot: .post3(snapshot)) } public func resolve(with api: ApiClient) async throws -> Self { let stub = PostStub(api: api, url: allResolvableUrls[0]) return try await stub.getPost() as! Self } } // MARK: - Computed public extension Post { var linkHost: String? { if case let .link(link) = type { return link.host } return nil } var isOwnPost: Bool { creatorId == api.myPerson?.id } } // MARK: - Interactions public extension Post { // Vote var updateVote: ((ScoringOperation) -> Void)? { if let votes = votes.value { return { self.updateVote($0, votes: votes) } } return nil } private func updateVote(_ newValue: ScoringOperation, votes: VotesModel) { self.votes.value_ = votes.applyScoringOperation(operation: newValue) readStatus.value_ = true Task { await updateQueue.addItem { try await .init( api: self.api, snapshot: .post2(self.api.repository.voteOnPost(id: self.id, score: newValue))) } } } // Save func updateSaved(_ newValue: Bool) { saved.value_ = newValue readStatus.value_ = true Task { await updateQueue.addItem { try await .init( api: self.api, snapshot: .post2(self.api.repository.savePost(id: self.id, save: newValue))) } } } // Reply func reply(content: String, languageId: Int?) async throws -> Comment { try await self.api.replyToPost(id: id, content: content, languageId: languageId) } // Hide func updateHidden(_ newValue: Bool) { hidden.value_ = newValue readStatus.value_ = true Task { await updateQueue.addItem { properties in try await self.api.repository.hidePost(id: self.id, hide: newValue) var properties = properties properties.hidden = newValue return properties } } } // Read func updateRead(_ newValue: Bool, shouldQueue: Bool = false) { if shouldQueue { readQueued = newValue Task { if newValue { await api.markReadQueue.add(id) } else { await api.markReadQueue.remove(id) } } } else { readStatus.value_ = newValue Task { await updateQueue.addItem { properties in try await self.api.repository.markPostAsRead(id: self.id, read: newValue) var properties = properties properties.read = newValue return properties } } } } /// Update the post when its queued mark read operation completes. func queuedMarkReadCompleted() { // sending this through the updateQueue ensures queue.lastVerifiedSnapshot receives the correct read value Task { await updateQueue.addItem { properties in var properties = properties properties.read = true return properties } readQueued = false } } // Vote on Poll func voteInPoll(_ choiceIds: Set) { guard let poll = self.poll else { return } self.poll = poll.applyVoteChoices(choiceIds: choiceIds) Task { await updateQueue.addItem { try await .init( api: self.api, snapshot: .post2(self.api.repository.voteInPoll(postId: self.id, choiceIds: choiceIds))) } } } // Pin /// Pins or unpins this post to the community according to newValue /// - Parameters: /// - newValue: true to pin post, false to unpin /// - callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func updatePinnedCommunity(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { pinnedCommunity = newValue pinnedCommunityPending = true Task { await updateQueue.addItem { do { let ret = try await self.api.repository.pinPost(id: self.id, pin: newValue, to: .community) callback?(.success) return await .init(api: self.api, snapshot: .post2(ret)) } catch { callback?(.failure(error)) throw error } } } } /// Pins or unpins this post to the instance according to newValue /// - Parameters: /// - newValue: true to pin post, false to unpin /// - callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func updatePinnedInstance(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { pinnedInstance = newValue pinnedInstancePending = true Task { await updateQueue.addItem { do { let ret = try await self.api.repository.pinPost(id: self.id, pin: newValue, to: .instance) callback?(.success) return await .init(api: self.api, snapshot: .post2(ret)) } catch { callback?(.failure(error)) throw error } } } } // Lock /// Locks or unlocks this post according to newValue /// - Parameters: /// - newValue: true to lock post, false to unlock /// - callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func updateLocked(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { locked = newValue lockedPending = true Task { await updateQueue.addItem { do { let ret = try await self.api.repository.lockPost(id: self.id, lock: newValue) callback?(.success) return await .init(api: self.api, snapshot: .post2(ret)) } catch { callback?(.failure(error)) throw (error) } } } } // Get Comments func getComments( sort: CommentSortType = .hot, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment] { try await api.getComments( postId: id, sort: sort, page: page, maxDepth: maxDepth, limit: limit, filter: filter ) } // Edit func edit( title: String, content: String?, linkUrl: URL?, altText: String?, thumbnail: URL?, nsfw: Bool, languageId: Int? ) throws { self.title = title self.content = content self.linkUrl = linkUrl self.altText = altText self.thumbnailUrl = thumbnail self.nsfw = nsfw self.languageId = languageId ?? self.languageId Task { await updateQueue.addItem { await .init(api: self.api, snapshot: .post2(try await self.api.repository.editPost( id: self.id, title: title, content: content, linkUrl: linkUrl, altText: altText, thumbnail: thumbnail, nsfw: nsfw, languageId: languageId ))) } } } // Get Votes func getVotes(page: Int, limit: Int) async throws -> [PersonVote] { try await api.getPostVotes(id: id, communityId: communityId, page: page, limit: limit) } // Deleted func updateDeleted(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { deleted = newValue Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.deletePost(id: self.id, delete: newValue) callback?(.success) return await .init(api: self.api, snapshot: .post2(snapshot)) } catch { callback?(.failure(error)) throw error } } } } // NSFW func updateNsfw(_ newValue: Bool, callback: ((UpdateStatus) -> Void)?) { nsfw = newValue nsfwPending = true Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.setPostNsfw(id: self.id, nsfw: newValue) callback?(.success) return await .init(api: self.api, snapshot: .post1(snapshot)) } catch { callback?(.failure(error)) throw (error) } } } } // Remove func updateRemoved(_ newValue: Bool, reason: String?, callback: ((UpdateStatus) -> Void)?) { removed = newValue removedPending = true Task { await updateQueue.addItem { do { let snapshot = try await self.api.repository.removePost(id: self.id, remove: newValue, reason: reason) callback?(.success) return await .init(api: self.api, snapshot: .post2(snapshot)) } catch { callback?(.failure(error)) throw (error) } } } } // Purge func purge(reason: String?) async throws { try await api.purgePost(id: id, reason: reason) purged = true } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/PostPoll.swift ================================================ // // PostPoll.swift // Mlem // // Created by Sjmarf on 2026-01-26. // import Foundation public struct PostPoll: Hashable { public let endDate: Date? public let localOnly: Bool? public let latestVote: Date? public let type: PostPollType public var choices: [PostPollChoice] public var hasEnded: Bool { if let endDate { endDate < .now } else { false } } // For multi-choice polls, this will be greater than the // number of users who have voted in the poll public var totalVotes: Int { choices.compactMap(\.voteCount).reduce(0, +) } public var hasVoted: Bool { choices.contains { $0.selected } } func applyVoteChoices(choiceIds: Set) -> PostPoll { var new = self new.choices = [] for choice in self.choices { var choice = choice let newSelected = choiceIds.contains(choice.id) if choice.selected { choice.voteCount = (choice.voteCount ?? 0) - 1 } if newSelected { choice.voteCount = (choice.voteCount ?? 0) + 1 } choice.selected = newSelected new.choices.append(choice) } return new } } public enum PostPollType { case single, multiple } public struct PostPollChoice: Hashable { public let id: Int public let label: String public var voteCount: Int? public var selected: Bool public func percentage(poll: PostPoll) -> Int { if poll.totalVotes == 0 { 0 } else { Int(100 * Double(voteCount ?? 0) / Double(poll.totalVotes)) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/PostProperties.swift ================================================ // // PostProperties.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-07. // import Foundation public struct PostProperties: UnifiedPropertiesProviding { // From Post1Snapshot, guaranteed to always be present let actorId: ActorIdentifier let id: Int let creatorId: Int let communityId: Int let created: Date var title: String var content: String? var linkUrl: URL? var embed: PostEmbed? var poll: PostPoll? var nsfw: Bool var thumbnailUrl: URL? var updated: Date? var languageId: Int var altText: String? var deleted: Bool var removed: Bool var pinnedCommunity: Bool var pinnedInstance: Bool var locked: Bool // From Post2Snapshot var creator: Person? var community: Community? var commentCount: Int? var unreadCommentCount: Int? var creatorIsModerator: Bool? var creatorIsAdmin: Bool? var creatorBannedFromCommunity: Bool? var creatorBlocked: Bool? var votes: VotesModel? var saved: Bool? var read: Bool? var hidden: Bool? // From Post3Snapshot var crossPosts: [Post]? /// Constructs a PostProperties from a given snapshot @MainActor public init(api: ApiClient, snapshot: AnyPostSnapshot) { let snapshot1: Post1Snapshot let snapshot2: Post2Snapshot? let snapshot3: Post3Snapshot? switch snapshot { case let .post1(post1Snapshot): snapshot1 = post1Snapshot snapshot2 = nil snapshot3 = nil case let .post2(post2Snapshot): snapshot1 = post2Snapshot.post snapshot2 = post2Snapshot snapshot3 = nil case let .post3(post3Snapshot): snapshot1 = post3Snapshot.post.post snapshot2 = post3Snapshot.post snapshot3 = post3Snapshot } if let snapshot3 { crossPosts = api.caches.post.getModels(api: api, from: snapshot3.crossPosts.map { .post2($0) }) } if let snapshot2 { let newCreator = api.caches.person.getModel(api: api, from: .person1(snapshot2.creator)) newCreator.updateKnownCommunityBanState(id: snapshot1.communityId, banned: snapshot2.creatorBannedFromCommunity) creator = newCreator community = api.caches.community.getModel(api: api, from: .community1(snapshot2.community)) commentCount = snapshot2.commentCount unreadCommentCount = snapshot2.unreadCommentCount creatorIsModerator = snapshot2.creatorIsModerator creatorIsAdmin = snapshot2.creatorIsAdmin creatorBannedFromCommunity = snapshot2.creatorBannedFromCommunity creatorBlocked = snapshot2.creatorBlocked votes = snapshot2.votes saved = snapshot2.saved read = snapshot2.read hidden = snapshot2.hidden } actorId = snapshot1.actorId id = snapshot1.id creatorId = snapshot1.creatorId communityId = snapshot1.communityId created = snapshot1.created title = snapshot1.title content = snapshot1.content linkUrl = snapshot1.linkUrl embed = snapshot1.embed poll = snapshot1.poll nsfw = snapshot1.nsfw thumbnailUrl = snapshot1.thumbnailUrl updated = snapshot1.updated languageId = snapshot1.languageId altText = snapshot1.altText deleted = snapshot1.deleted removed = snapshot1.removed pinnedCommunity = snapshot1.pinnedCommunity pinnedInstance = snapshot1.pinnedInstance locked = snapshot1.locked } public mutating func merge(_ other: PostProperties) { // tier 1 properties: simple assignment self.title = other.title self.content = other.content self.linkUrl = other.linkUrl self.embed = other.embed self.poll = other.poll self.nsfw = other.nsfw self.thumbnailUrl = other.thumbnailUrl self.updated = other.updated self.languageId = other.languageId self.altText = other.altText self.deleted = other.deleted self.removed = other.removed self.pinnedCommunity = other.pinnedCommunity self.pinnedInstance = other.pinnedInstance self.locked = other.locked // tier 2, 3 properties: only assign if incoming non-nil self.creator = other.creator ?? self.creator self.community = other.community ?? self.community self.commentCount = other.commentCount ?? self.commentCount self.unreadCommentCount = other.unreadCommentCount ?? self.unreadCommentCount self.creatorIsModerator = other.creatorIsModerator ?? self.creatorIsModerator self.creatorIsAdmin = other.creatorIsAdmin ?? self.creatorIsAdmin self.creatorBannedFromCommunity = other.creatorBannedFromCommunity ?? self.creatorBannedFromCommunity self.creatorBlocked = other.creatorBlocked ?? self.creatorBlocked self.votes = other.votes ?? self.votes self.saved = other.saved ?? self.saved self.read = other.read ?? self.read self.hidden = other.hidden ?? self.hidden self.crossPosts = other.crossPosts ?? self.crossPosts } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Post/PostStub.swift ================================================ // // PostStub.swift // Mlem // // Created by Sjmarf on 16/02/2024. // import Foundation public struct PostStub: Hashable { public var api: ApiClient public var url: URL public init(api: ApiClient, url: URL) { self.api = api self.url = url } public func asLocal() -> Self { .init(api: .getApiClient(url: url.removingPathComponents(), username: nil), url: url) } public func hash(into hasher: inout Hasher) { hasher.combine(url) } public static func == (lhs: PostStub, rhs: PostStub) -> Bool { lhs.url == rhs.url } public func getPost() async throws -> Post { try await api.getPost(url: resolvableUrl) } } // Resolvable conformance public extension PostStub { var resolvableUrl: URL { url } @inlinable var allResolvableUrls: [URL] { [resolvableUrl] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PostFeatureType.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum PostFeatureType { case community, instance init(from featureType: LemmyPostFeatureType) { self = switch featureType { case .community: .community case .local: .instance } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PostFeedViewMode.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum PostFeedViewMode { case list, card, smallCard init(from listingMode: LemmyPostListingMode) { self = switch listingMode { case .list: .list case .card: .card case .smallCard: .smallCard } } var apiType: LemmyPostListingMode { switch self { case .list: .list case .card: .card case .smallCard: .smallCard } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PostLink.swift ================================================ // // PostLink.swift // // // Created by Eric Andrews on 2024-07-30. // import Foundation public struct PostLink: Hashable { public let content: URL public let thumbnail: URL? public let label: String public var favicon: URL? { if let baseUrl = content.host { return URL(string: "https://www.google.com/s2/favicons?sz=64&domain=\(baseUrl)") } return nil } public var host: String { if var host = content.host() { host.trimPrefix("www.") return host } return "website" } public var effectiveThumbnail: URL? { thumbnail ?? content.youTubeThumbnailUrl } public init(content: URL, thumbnail: URL?, label: String) { self.content = content self.thumbnail = thumbnail self.label = label } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PostType.swift ================================================ // // PostType.swift // Mlem // // Created by Eric Andrews on 2023-06-14. // import Foundation public enum PostType: Hashable { /// Post containing both a title and text case text(String) /// Post containing only media case media(URL) /// Link post with embedded media content case embedded(URL, originalLink: URL) /// Link post case link(PostLink) /// Poll post case poll(PostPoll) /// Post containing only a title case titleOnly public var isText: Bool { if case .text = self { return true } return false } public var isMedia: Bool { switch self { case .media, .embedded: return true default: return false } } public var isLink: Bool { if case .link = self { return true } return false } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Profile/ProfileProviding.swift ================================================ // // ProfileProviding.swift // // // Created by Sjmarf on 20/05/2024. // import Foundation public protocol ProfileProviding: ActorIdentifiable { var name: String { get } var avatar: URL? { get } var blocked: any RealizedValueProviding { get } var displayName: String { get } var description: String? { get } var banner: URL? { get } var profileCreated: Date? { get } var updated: Date? { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/PurgableProviding.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2024-10-27. // import Foundation public protocol PurgableProviding: ContentIdentifiable { var purged: Bool { get } func purge(reason: String?) async throws } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ReadableProviding.swift ================================================ // // ReadableProviding.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-04. // public protocol UnifiedReadableProviding { var read: ExpectedValue { get } } // TODO: Full unified models remove public protocol ReadableProviding { var read: Bool { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/RegistrationApplication/RegistrationApplication.swift ================================================ // // RegistrationApplication1.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-12. // import Foundation // This could be two-tiered but doing so is tricky because whether the application has // been approved or denied is unknown at tier 1, which would make the tier 1 model pretty // useless. At this time, only tier 2 applications are returned anyways. @Observable public final class RegistrationApplication: ContentIdentifiable, FeedLoadable { public typealias FilterType = ModMailItemFilterType public static let modelTypeId: ContentType = .registrationApplication public let api: ApiClient public let id: Int public internal(set) var questionResponse: String public let creator: Person public internal(set) var resolver: Person? public internal(set) var email: String? public internal(set) var emailVerified: Bool public internal(set) var showNsfw: Bool public let created: Date var resolutionManager: StateManager public var resolution: ResolutionState { resolutionManager.displayedValue } init( api: ApiClient, id: Int, questionResponse: String, creator: Person, resolver: Person?, email: String?, emailVerified: Bool, showNsfw: Bool, created: Date, resolution: ResolutionState ) { self.api = api self.id = id self.questionResponse = questionResponse self.creator = creator self.resolver = resolver self.email = email self.emailVerified = emailVerified self.showNsfw = showNsfw self.created = created self.resolutionManager = .init(wrappedValue: resolution) resolutionManager.onSet = { newValue, type, _ in if type == .begin || type == .rollback { api.unreadCount?.updateUnverifiedItem(itemType: .registrationApplication, isRead: newValue != .unresolved) } } resolutionManager.onVerify = { newValue, _ in api.unreadCount?.verifyItem(itemType: .registrationApplication, isRead: newValue != .unresolved) } } var modMailId: Int { var hasher: Hasher = .init() hasher.combine("application") hasher.combine(id) return hasher.finalize() } public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: .new(created) } } @discardableResult public func approve() -> Task { resolutionManager.performRequest(expectedResult: .approved) { semaphore in try await self.api.approveRegistrationApplication(id: self.id, semaphore: semaphore) } } @discardableResult public func deny(reason: String?) -> Task { resolutionManager.performRequest(expectedResult: .denied(reason: reason)) { semaphore in try await self.api.denyRegistrationApplication(id: self.id, reason: reason, semaphore: semaphore) } } } public extension RegistrationApplication { enum ResolutionState: Equatable { case unresolved, approved, denied(reason: String?) public var reason: String? { switch self { case .unresolved: nil case .approved: nil case let .denied(reason): reason } } public var isDenied: Bool { switch self { case .denied: true default: false } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/RegistrationMode.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum RegistrationMode { case closed, open, requiresApplication } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/RemovableProviding.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-15. // import Foundation // TODO: it should be possible to move some of the queueing logic into RemovableProviding and just have the model // provide the repository call public protocol RemovableProviding: ContentIdentifiable, CanModerateProviding { var removed: Bool { get } var removedPending: Bool { get } func updateRemoved(_ newValue: Bool, reason: String?, callback: ((UpdateStatus) -> Void)?) } public extension RemovableProviding { /// Toggles the removed status of this item /// - Parameters: callback: if present, when the repository call completes, is called with `.success` if the operation succeeded and `.failure` otherwise. func toggleRemoved(reason: String?, callback: ((UpdateStatus) -> Void)? = nil) { updateRemoved(!removed, reason: reason, callback: callback) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Report/Report.swift ================================================ // // Report.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-15. // import Foundation import Observation @Observable public class Report: CacheIdentifiable, ContentModel, FeedLoadable { public typealias FilterType = ModMailItemFilterType public var api: ApiClient // Keep this internal - a post report and a comment report can have the same ID, so it's not a true identifier. var id: Int public let created: Date public internal(set) var updated: Date? public let creator: Person public let target: ReportTarget public internal(set) var resolver: Person? public internal(set) var reason: String var resolvedManager: StateManager public var resolved: Bool { resolvedManager.displayedValue } init( api: ApiClient, id: Int, creator: Person, resolver: Person?, target: ReportTarget, resolved: Bool, reason: String, created: Date, updated: Date? ) { self.api = api self.id = id self.creator = creator self.resolver = resolver self.target = target self.reason = reason self.created = created self.updated = updated self.resolvedManager = .init(wrappedValue: resolved) resolvedManager.onSet = { newValue, type, _ in if type == .begin || type == .rollback { api.unreadCount?.updateUnverifiedItem(itemType: target.type.inboxItemType, isRead: newValue) } } resolvedManager.onVerify = { newValue, _ in api.unreadCount?.verifyItem(itemType: target.type.inboxItemType, isRead: newValue) } } public var modMailId: Int { var hasher: Hasher = .init() hasher.combine("report") hasher.combine(target.case) hasher.combine(id) return hasher.finalize() } @discardableResult public func updateResolved(_ newValue: Bool) -> Task { resolvedManager.performRequest(expectedResult: newValue) { semaphore in switch self.target.type { case .post: try await self.api.resolvePostReport(id: self.id, resolved: newValue, semaphore: semaphore) case .comment: try await self.api.resolveCommentReport(id: self.id, resolved: newValue, semaphore: semaphore) case .message: try await self.api.resolveMessageReport(id: self.id, resolved: newValue, semaphore: semaphore) } } } @discardableResult public func toggleResolved() -> Task { updateResolved(!resolved) } public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch sortType { case .new: .new(created) } } public static func == (lhs: Report, rhs: Report) -> Bool { lhs.target.case == rhs.target.case && lhs.id == rhs.id } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Report/ReportTarget.swift ================================================ // // ReportTarget.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-16. // import Foundation public enum ReportTarget { enum Case { case post, comment, message } var `case`: Case { switch self { case .post: .post case .comment: .comment case .message: .message } } case post(Post) case comment(Comment) case message(Message2) var type: ReportType { switch self { case .post: .post case .comment: .comment case .message: .message } } public var community: Community? { switch self { case let .post(post): post.community.value_ case let .comment(comment): comment.community.value_ case .message: nil } } // TODO: UnifiedCommentModel, UnifiedMessageModel remove this shim public var creator: ExpectedValue { switch self { case let .post(post): post.creator case let .comment(comment): comment.creator case let .message(message): .init( value: message.creator, provideValue: { fatalError("This should not be called") } ) } } @MainActor init(from snapshot: ReportTargetSnapshot, api: ApiClient, myPersonId: Int) { switch snapshot { case let .post(post): self = .post(api.caches.post.getModel(api: api, from: .post2(post))) case let .comment(comment): self = .comment(api.caches.comment.getModel(api: api, from: .comment2(comment))) case let .message(message): self = .message(api.caches.message2.getModel(api: api, from: message, myPersonId: myPersonId)) } } @MainActor func update(with snapshot: ReportTargetSnapshot) { // TODO: UpdateQueue rework reports to integrate UpdateQueue switch (self, snapshot) { case (.post, .post), (.comment, .comment): break case let (.message(message), .message(updatedMessage)): message.update(with: updatedMessage) default: assertionFailure() } } } public enum ReportType: Hashable { case post, comment, message var inboxItemType: InboxItemType { switch self { case .post: .postReport case .comment: .commentReport case .message: .messageReport } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ReportUnreadCountSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public struct ReportUnreadCountSnapshot { let comments: Int let posts: Int let messages: Int init(from response: LemmyGetReportCountResponse) throws(ApiClientError) { self.comments = response.commentReports self.posts = response.postReports self.messages = response.privateMessageReports ?? 0 } var unreadCountDictionary: [InboxItemType: Int] { [ .postReport: posts, .commentReport: comments, .messageReport: messages ] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ReportableProviding.swift ================================================ // // ReportableProviding.swift // // // Created by Sjmarf on 23/07/2024. // import Foundation public protocol ReportableProviding: OwnershipProviding { func report(reason: String) async throws } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Resolvable.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-26. // import Foundation // TODO: UnifiedCommunity move resolve(with: ApiClient) into this protocol public protocol Resolvable { /// An array of available URLs for this entity that can be resolved by another `ApiClient`. var allResolvableUrls: [URL] { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/ResolvedContent.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-07. // import Foundation public enum ResolvedContent { case comment(Comment2Snapshot) case post(Post2Snapshot) case community(Community2Snapshot) case person(Person2Snapshot) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/SelectableContentProviding.swift ================================================ // // SelectableContentProviding.swift // // // Created by Sjmarf on 02/07/2024. // import Foundation public protocol SelectableContentProviding: ActorIdentifiable { var selectableContent: String? { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sharable.swift ================================================ // // Sharable.swift // MlemMiddleware // // Created by Sjmarf on 2025-03-09. // import Foundation public protocol Sharable: ActorIdentifiable, Hashable { func url() -> URL } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/UnifiedModelProviding.swift ================================================ // // UnifiedModelProviding.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-07. // public protocol UnifiedModelProviding: AnyObject, CacheIdentifiable, ContentModel { associatedtype Properties: UnifiedPropertiesProviding /// Updates with the values from the given Properties, preferring the incoming values. Should only be called /// from the `UpdateQueue` @MainActor func update(with properties: Properties) /// Updates only values that are currently nil with values from the given Properties. Safe to call outside the `UpdateQueue` @MainActor func softUpdate(with properties: Properties) /// Retrieves a fully populated Properties for this model func fetchUpgraded() async throws -> Properties func resolve(with api: ApiClient) async throws -> Self } extension UnifiedModelProviding { @MainActor func setIfChanged(_ keyPath: ReferenceWritableKeyPath, _ value: T) { if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value } } /// If the provided value is non-nil and different from the current value at the target key path, updates /// the target key path with the provided value @MainActor func updateIfChanged(_ keyPath: ReferenceWritableKeyPath, _ value: T?) { if let value, self[keyPath: keyPath] != value { self[keyPath: keyPath] = value } } /// If the current value at the target key path is nil, udpates it with the provided value @MainActor func setIfNil(_ keyPath: ReferenceWritableKeyPath, _ value: T?) { if self[keyPath: keyPath] == nil { self[keyPath: keyPath] = value } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/UnifiedPropertiesProviding.swift ================================================ // // UnifiedPropertiesProviding.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-07. // public protocol UnifiedPropertiesProviding { /// Merges the given properties into this one, preferring the incoming properties mutating func merge(_ other: Self) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/ExpectedValue.swift ================================================ // // ExpectedValue.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-07. // import Foundation /// Represents a value that a model can have, but might not currently be fetched if the model was instantiated /// with a low-tier snapshot. public struct ExpectedValue: ValueProviding { public var value_: T? /// Provides the value, or nil if the value is not present public var value: T? { if let ret = value_ { return ret } Task { do { try await provideValue() } catch { print(error) } } return nil } /// Callback expected to update value_ let provideValue: () async throws -> Void init(value: T?, provideValue: @escaping () async throws -> Void) { self.value_ = value self.provideValue = provideValue } } func dummyExpectedValue(_ value: T?) -> ExpectedValue { .init( value: value, provideValue: { assertionFailure("Dummy function! This should not be called") }) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/RealizedValue.swift ================================================ // // RealizedValue.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-25. // import Observation @Observable public class RealizedValue: RealizedValueProviding { private var value_: T public var value: T? { value_ } public var realizedValue: T { value_ } public init(_ value: T) { self.value_ = value } public func set(_ newValue: T) { value_ = newValue } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/SyntheticExpectedValue.swift ================================================ // // SyntheticExpectedValue.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-26. // import Observation @Observable public class SyntheticExpectedValue: ValueSynthesizer, ValueProviding { /// Callback expected to update value_ let provideValue: () async throws -> Void public var value: T? { if value_ == nil { Task { do { try await provideValue() } catch { print(error) } } } return synthesize() } init(value: T?, provideValue: @escaping () async throws -> Void, mergeType: ValueMergeType) { self.provideValue = provideValue super.init(value: value, mergeType: mergeType) } } func dummySyntheticExpectedValue(_ value: T?) -> SyntheticExpectedValue { .init( value: value, provideValue: { assertionFailure("Dummy function! This should not be called") }, mergeType: .disjunctive) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/SyntheticRealizedValue.swift ================================================ // // SyntheticRealizedValue.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-26. // import Observation @Observable public class SyntheticRealizedValue: ValueSynthesizer, RealizedValueProviding { public var value: T? { synthesize() } public var realizedValue: T { synthesize() } public func set(_ newValue: T) { value_ = newValue } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/ValueProviding.swift ================================================ // // Untitled.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-25. // /// Represents any member of the value providing family public protocol ValueProviding { associatedtype T var value: T? { get } } /// Represents a value that is guaranteed to be realized, but may need to be treated interchangeably with /// other `ValueProviding`s public protocol RealizedValueProviding: ValueProviding { var realizedValue: T { get } // NOTE: while value_ is currently always T (not T?), so could theoretically be directly exposed as `T { get set }`, // it is intentionally obscured behind this setter for extensibility func set(_ newValue: T) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Shared/ValueProviding/ValueSynthesizer.swift ================================================ // // ValueSynthesizer.swift // MlemMiddleware // // Created by Eric Andrews on 2026-02-25. // import Foundation public enum ValueMergeType { /// Indicates value should be true when any merged value is true case disjunctive /// Indicates value should be true only when all merged values are true case conjunctive } public protocol MergeableValue: Equatable { /// Merges self with other using the given merge type. /// - Returns: result of the merged value func merge(with other: Self, using mergeType: ValueMergeType) -> Self } // Allows optionals to be used as MergeableValue extension Optional: MergeableValue where Wrapped: MergeableValue & Equatable { /// If both self and other are present, returns the result of merging them; otherwise returns whichever value is present, /// and nil if both are nil. public func merge(with other: Optional, using mergeType: ValueMergeType) -> Optional { if let other { return self?.merge(with: other, using: mergeType) ?? other } return self } } /// Provides methods for tracking sibling `ValueSynthesizer`s. When `synthesize()` is called, all sibling values /// are accumulated into a single result according to the specified `mergeType` @Observable public class ValueSynthesizer { internal let uid: NSUUID = .init() internal let mergeType: ValueMergeType // using NSMapTable to store weak references internal var siblings: NSMapTable = .weakToWeakObjects() public var value_: T public init(value: T, mergeType: ValueMergeType) { self.value_ = value self.mergeType = mergeType } internal func synthesize() -> T { siblings.dictionaryRepresentation().values.reduce(value_) { result, sibling in result.merge(with: sibling.value_, using: mergeType) } } public func addSibling(_ sibling: ValueSynthesizer) { siblings.setObject(sibling, forKey: sibling.uid) } public func removeSibling(_ sibling: ValueSynthesizer) { siblings.removeObject(forKey: sibling.uid) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/SignUpResponse.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public enum SignUpResponse { public enum Reason: Hashable { case awaitingApproval, awaitingEmailVerification } case canLogIn(token: String) case cannotLogIn(reasons: Set) init(from loginResponse: LemmyLoginResponse) { if let token = loginResponse.jwt { self = .canLogIn(token: token) } var reasons: Set = [] if loginResponse.registrationCreated { reasons.insert(.awaitingApproval) } if loginResponse.verifyEmailSent { reasons.insert(.awaitingEmailVerification) } if !reasons.isEmpty { assertionFailure() } self = .cannotLogIn(reasons: reasons) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sort Types/CommentSortType.swift ================================================ // // CommentSortType.swift // MlemMiddleware // // Created by Sjmarf on 2025-03-04. // import SwiftUI public enum CommentSortType: Hashable, Sendable { case new case old case hot case controversial /// From 1.0.0 onwards, any time interval is supported. /// Before 1.0.0, only `.allTime` is supported. case top(SortTimeRange) public var isTop: Bool { switch self { case .top: true default: false } } public static var nonTopCases: [Self] = [ .hot, .new, .old, .controversial ] public static var legacyCases: [Self] = nonTopCases + [.top(.allTime)] public var timeRange: SortTimeRange? { switch self { case let .top(timeRange): timeRange default: nil } } // This should only be used internally within ApiClient var timeRangeSeconds: Int? { timeRange?.timeRangeSeconds } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sort Types/LegacySortTimeRange.swift ================================================ // // LegacySortTimeRange.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-25. // import Foundation /// Represents the available "top" sort time ranges available before Lemmy 1.0.0. /// After 1.0.0, the top sort time range can be any number of seconds. public enum LegacySortTimeRangeLimit: CaseIterable { case hour case sixHour case twelveHour case day case week case month case threeMonth case sixMonth case nineMonth case year } public extension LegacySortTimeRangeLimit { init?(_ timeInterval: TimeInterval?) { if let match = Self.allCases.first(where: { $0.timeInterval == timeInterval }) { self = match } else { return nil } } var timeInterval: TimeInterval { let hour = 3600.0 let day = hour * 24 let month = day * 30 return switch self { case .hour: hour case .sixHour: hour * 6 case .twelveHour: hour * 12 case .day: day case .week: day * 7 case .month: month case .threeMonth: month * 3 case .sixMonth: month * 6 case .nineMonth: month * 9 case .year: day * 365 } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sort Types/PostSortType.swift ================================================ // // PostSortTimeRange.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-25. // import Foundation public enum PostSortType: Hashable, Sendable { case active case hot case new case old case mostComments case newComments case controversial case scaled /// From 1.0.0 onwards, any time interval is supported. /// Before 1.0.0, there is a discrete list of supported time intervals, /// represented by the ``LegacySortTimeRange`` type. case top(SortTimeRange) public var isTop: Bool { switch self { case .top: true default: false } } public static var nonTopCases: [Self] = [ .hot, .scaled, .active, .new, .old, .controversial, .newComments, .mostComments ] public static var legacyTopCases: [Self] = SortTimeRange.legacyCases.map { .top($0) } public static var legacyCases: [Self] = nonTopCases + legacyTopCases public var timeRange: SortTimeRange? { switch self { case let .top(timeRange): timeRange default: nil } } // This should only be used internally within ApiClient var timeRangeSeconds: Int? { timeRange?.timeRangeSeconds } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sort Types/SearchSortType.swift ================================================ // // SearchSortTimeRange.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-28. // import Foundation // In the v3 API, it was possible to search for posts using any // of the regular post sorts ("Hot", "Active" etc). This was // intentionally removed in v4. // https://github.com/LemmyNet/lemmy/issues/5401 public enum SearchSortType: Hashable, Sendable { case new case old /// From 1.0.0 onwards, any time interval is supported. /// Before 1.0.0, there is a discrete list of supported time intervals, /// represented by the ``LegacySortTimeRange`` type. case top(SortTimeRange) public var isTop: Bool { switch self { case .top: true default: false } } public static var nonTopCases: [Self] = [.new, .old] public static var legacyTopCases: [Self] = SortTimeRange.legacyCases.map { .top($0) } public static var legacyCases: [Self] = nonTopCases + legacyTopCases public static var legacyPersonCases: [Self] = nonTopCases + [.top(.allTime)] public var timeRange: SortTimeRange? { switch self { case let .top(timeRange): timeRange default: nil } } // This should only be used internally within ApiClient var timeRangeSeconds: Int? { timeRange?.timeRangeSeconds } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/Sort Types/SortTimeRange.swift ================================================ // // SortTimeRange.swift // MlemMiddleware // // Created by Sjmarf on 2025-03-01. // import Foundation public enum SortTimeRange: Hashable, Sendable { case allTime case limited(TimeInterval) public static func limited(_ timeRangeLimit: LegacySortTimeRangeLimit) -> Self { .limited(timeRangeLimit.timeInterval) } init?(_ apiSortType: LemmySortType) { if apiSortType == .topAll { self = .allTime } else if let legacyTimeRange = LegacySortTimeRangeLimit(apiSortType) { self = .limited(legacyTimeRange) } else { return nil } } public static var legacyCases: [Self] = LegacySortTimeRangeLimit.allCases.map { .limited($0) } + [.allTime] // This should only be used internally within ApiClient var timeRangeSeconds: Int { switch self { case .allTime: Int(Int32.max) // Going higher than this value causes an error case let .limited(value): Int(value) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/StateManager.swift ================================================ // // StateManager.swift // Mlem // // Created by Eric Andrews on 2024-03-03. // import Foundation import os // TODO: Unified models remove // These can't go inside of StateManager because generic classes cannot store static properties class SemaphoreServer { static var value: UInt = 0 static func next() -> UInt { value += 1 return value } } enum StateManagerUpdateType: Equatable { case begin case rollback case receive } protocol StateManagerTickerProtocol { var valid: Bool { get } func begin(semaphore: UInt) func rollback(semaphore: UInt) } struct StateManagerTicket: StateManagerTickerProtocol { let manager: StateManager let expectedResult: Value func begin(semaphore: UInt) { manager.beginOperation(expectedResult: expectedResult, semaphore: semaphore) } func rollback(semaphore: UInt) { manager.rollback(semaphore: semaphore) } var valid: Bool { manager.wrappedValue != expectedResult } } /// This class provides logic to ensure proper handling of returned API responses so as to avoid flickering or the client falling out of sync. /// When you begin a task, call `.beginVotingOperation` with the result you expect to get. If no other operations are ongoing, /// `lastVerifiedValue` is updated to match; otherwise `lastVerifiedValue` is left untouched. The semaphore is incremented /// and its value returned. When a vote finishes successfully, call `finishVotingOperation` with the new state returned from the server. /// If the caller is the most recent one, then clean state is wiped and a `true` value is returned; this indicates that the caller is clear to /// update the post with the returned value. If the caller is not the most recent one (i.e., another vote is underway), then the clean state is /// updated but a `false` value is returned; this indicates that the caller should not update the post with the returned value. When a vote /// finishes unsuccessfully, call `rollback`. If the caller is the most recent one, then the `wrappedValue` will be reset to the /// `lastVerifiedValue`. @Observable public class StateManager { let log: Logger = .mlemLogger() /// Underlying state-faked wrapped value private(set) var wrappedValue: Value /// The state-faked value that should be shown to the user public var displayedValue: Value { wrappedValue } /// Called when `wrappedValue` is changed. var onSet: (Value, _ type: StateManagerUpdateType, _ semaphore: UInt?) -> Void /// Called whenever `wrappedValue` is verified. var onVerify: (Value, _ semaphore: UInt?) -> Void /// Responsible for tracking who the most recent caller is. Every time the state is changed, `lastSemaphore` is incremented by one. private var lastSemaphore: UInt = 0 /// Responsible for tracking the last verified value. If the current value is in sync with the server, this will be nil. private var lastVerifiedValue: Value? public var isInSync: Bool { lastVerifiedValue == nil } public var verifiedValue: Value { lastVerifiedValue ?? wrappedValue } init( wrappedValue: Value, onSet: @escaping (Value, _ type: StateManagerUpdateType, _ semaphore: UInt?) -> Void = { _, _, _ in }, onVerify: @escaping (Value, _ semaphore: UInt?) -> Void = { _, _ in } ) { self.wrappedValue = wrappedValue self.onSet = onSet self.onVerify = onVerify } /// Call at the start of a voting operation, BEFORE state faking is performed. Updates the clean state if nil and increments semaphore. /// - Returns: new sempaphore value @discardableResult func beginOperation(expectedResult: Value, semaphore: UInt? = nil) -> UInt { let semaphore = semaphore ?? SemaphoreServer.next() lastSemaphore = semaphore log.debug("[\(semaphore)] began operation.") if lastVerifiedValue == nil { log.debug("[\(semaphore)] Set lastVerifiedValue to \(String(describing: self.wrappedValue)).") lastVerifiedValue = wrappedValue } if wrappedValue != expectedResult { wrappedValue = expectedResult log.debug("[\(semaphore)] Set wrappedValue to \(String(describing: expectedResult)).") onSet(expectedResult, .begin, semaphore) } return lastSemaphore } /// Call when we receive a value from the ApiClient that we *know* to be up-to-date. Optionally pass a `sempahore` value. If the StateManager is awaiting the result of an operation, the `wrappedValue` will *only* be set if the passed semaphore matches the one that the `StateManager` is waiting for. Otherwise, the value is saved as the `lastVerifiedValue` such that the `StateManager` will rollback to it if the in-progress operation fails. @discardableResult func updateWithReceivedValue(_ newState: Value, semaphore: UInt?) -> Bool { if lastVerifiedValue == nil { if wrappedValue != newState { Task { @MainActor in self.wrappedValue = newState self.onSet(newState, .receive, semaphore) } } return false } if lastSemaphore == semaphore { log.debug("[\(semaphore?.description ?? "nil")] is the last caller! Resetting lastVerifiedValue.") onVerify(newState, semaphore) lastVerifiedValue = nil return true } if lastVerifiedValue != newState { lastVerifiedValue = newState if semaphore != nil { log.debug("[\(semaphore?.description ?? "nil")] is not the last caller! Updating lastVerifiedValue to \(String(describing: self.wrappedValue)).") } } return false } /// If the given semaphore is still the most recent operation, rollback `wrappedValue` to `cleanValue`. @discardableResult func rollback(semaphore: UInt) -> Value? { if lastSemaphore == semaphore, let lastVerifiedValue { log.debug("[\(semaphore)] is the most recent caller! Resetting lastVerifiedValue.") if wrappedValue != lastVerifiedValue { wrappedValue = lastVerifiedValue onSet(lastVerifiedValue, .rollback, semaphore) } defer { self.lastVerifiedValue = nil } return lastVerifiedValue } else { log.debug("[\(semaphore)] is not the most recent caller or vote state nil.") return nil } } func performRequest( expectedResult: Value, operation: @escaping (_ semaphore: UInt) async throws -> Void, onRollback: @escaping (_ value: Value) -> Void = { _ in } ) -> Task { Task(priority: .userInitiated) { @MainActor in guard wrappedValue != expectedResult else { return .ignored } let semaphore = beginOperation(expectedResult: expectedResult) do { try await operation(semaphore) return .succeeded } catch { log.error("Semaphore failed: \(error.localizedDescription)") if let newValue = self.rollback(semaphore: semaphore) { onRollback(newValue) } return .failed } } } func ticket(_ expectedResult: Value) -> StateManagerTicket { StateManagerTicket(manager: self, expectedResult: expectedResult) } } func groupStateRequest( _ tickets: [any StateManagerTickerProtocol], operation: @escaping (_ semaphore: UInt) async throws -> Void ) -> Task { let semaphore = SemaphoreServer.next() let tickets = tickets.filter(\.valid) for ticket in tickets { ticket.begin(semaphore: semaphore) } return Task(priority: .userInitiated) { do { try await operation(semaphore) return .succeeded } catch { Logger.universal.error("StateManager [\(semaphore)] failed: \(error.localizedDescription)") for ticket in tickets { ticket.rollback(semaphore: semaphore) } return .failed } } } func groupStateRequest( _ tickets: (any StateManagerTickerProtocol)..., operation: @escaping (_ semaphore: UInt) async throws -> Void ) -> Task { groupStateRequest(tickets, operation: operation) } public enum StateUpdateResult { case succeeded case failed /// Returned when the action is queued for later, e.g. when a post is marked as read. case deferred case ignored } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/SubscriptionList.swift ================================================ // // SubscriptionList.swift // // // Created by Sjmarf on 05/05/2024. // import Observation @Observable public class SubscriptionList { /// All subscribed-to communities, including favorited communities. public private(set) var communities: Set = .init() { didSet { communityIds = .init(communities.map(\.id)) } } public private(set) var communityIds: Set = .init() public private(set) var favorites: [Community] = .init() public private(set) var alphabeticSections: [String?: [Community]] = .init() public private(set) var instanceSections: [String?: [Community]] = .init() public internal(set) var hasLoaded: Bool = false var favoriteIDs: Set { get { getFavorites() } set { setFavorites(newValue) } } private var getFavorites: () -> Set private var setFavorites: (Set) -> Void private var api: ApiClient init( apiClient: ApiClient, getFavorites: @escaping () -> Set, setFavorites: @escaping (Set) -> Void ) { self.api = apiClient self.getFavorites = getFavorites self.setFavorites = setFavorites } public func refresh() async throws { _ = try await api.getSubscriptionList() } public func isFavorited(_ community: Community) -> Bool { favoriteIDs.contains(community.id) } private func alphabeticCategoryForCommunity(_ community: Community) -> String? { let first = String(community.name.first ?? "#").folding(options: .diacriticInsensitive, locale: .current) guard first.first?.isLetter ?? false else { return nil } return first.uppercased() } @MainActor func updateCommunities(with communities: Set) { self.communities = communities // Alphabetical var alphabeticSections: [String?: [Community]] = .init() let alphabeticSectionsGrouping: [String?: [Community]] = .init( grouping: communities, by: { alphabeticCategoryForCommunity($0) } ) for section in alphabeticSectionsGrouping { alphabeticSections[section.key] = section.value.sorted(by: self.sortPredicate) } self.alphabeticSections = alphabeticSections // Instance var otherSection = [Community]() let instanceSectionsGrouping: [String?: [Community]] = .init(grouping: communities, by: \.host) var instanceSections: [String?: [Community]] = .init() for section in instanceSectionsGrouping { if section.value.count == 1, let community = section.value.first { otherSection.append(community) } else { instanceSections[section.key] = section.value.sorted(by: self.sortPredicate) } } if !otherSection.isEmpty { instanceSections[nil] = otherSection.sorted(by: self.sortPredicate) } self.instanceSections = instanceSections favorites = communities.filter { favoriteIDs.contains($0.id) } } func updateCommunitySubscription(community: Community) { guard hasLoaded, let subscription = community.subscription.value else { return } if subscription.subscribed { if !communities.contains(community) { addCommunity(community: community) } if isFavorited(community) != community.shouldBeFavorited { if community.shouldBeFavorited { favoriteIDs.insert(community.id) favorites.sortedInsert(community, by: self.sortPredicate) } else { favoriteIDs.remove(community.id) favorites.removeFirst { $0 === community } } } } else if communities.contains(community) { removeCommunity(community: community) } } private func addCommunity(community: Community) { communities.insert(community) let alphabeticCategory = alphabeticCategoryForCommunity(community) if alphabeticSections.keys.contains(alphabeticCategory) { alphabeticSections[alphabeticCategory]?.sortedInsert(community, by: self.sortPredicate) } else { alphabeticSections[alphabeticCategory] = [community] } let hostCategoryExists = instanceSections.keys.contains(community.host) let hostExists: Bool = ( hostCategoryExists || instanceSections[nil, default: []].contains(where: { $0.host == community.host }) ) if hostExists { if hostCategoryExists { instanceSections[community.host]?.sortedInsert(community, by: self.sortPredicate) } else { if let otherCommunity = instanceSections[nil]?.removeFirst(where: { $0.host == community.host }) { instanceSections[community.host] = [community, otherCommunity].sorted(by: self.sortPredicate) } else { instanceSections[nil, default: []].append(community) } } } } private func removeCommunity(community: Community) { communities.remove(community) favoriteIDs.remove(community.id) favorites.removeFirst { $0 === community } let category = alphabeticCategoryForCommunity(community) alphabeticSections[category]?.removeFirst { $0 === community } if alphabeticSections[category]?.isEmpty ?? false { alphabeticSections.removeValue(forKey: category) } if var items = instanceSections[community.host] { switch items.count { case 1: instanceSections.removeValue(forKey: community.host) // Instance sections must contain at least two communities. If there is only one, it goes in // the // "other" section instead. If we're removing a community from an instance section of // size 2, we therefore need to move the remaining community to the "other" section. case 2: items.removeFirst { $0 === community } instanceSections[nil, default: []].sortedInsert(items[0], by: self.sortPredicate) instanceSections.removeValue(forKey: community.host) default: instanceSections[community.host]?.removeFirst { $0 === community } } } else { alphabeticSections[nil]?.removeFirst { $0 === community } } } private func sortPredicate(_ first: Community, _ second: Community) -> Bool { let result = first.name.localizedCompare(second.name) return switch result { case .orderedAscending: true case .orderedDescending: false case .orderedSame: first.host.localizedCompare(second.host) == .orderedAscending } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/SubscriptionModel.swift ================================================ // // SubscriptionModel.swift // MlemMiddleware // // Created by Sjmarf on 08/08/2024. // import Foundation public struct SubscriptionModel: Hashable, MergeableValue { // These are the values actually provided by the API. var actualTotal: Int var actualLocal: Int? public var subscribed: Bool // When you subscribe, your instance asks the community host to confirm the subscription. // Until a confirmation is received from the host, the subscription state is // `LemmySubscribedType.pending`. The subscription count of the community doesn't change // until the subscription status is confirmed by the community host. There also appears // to exist a "pending" state for unsubscribing, but the API doesn't tell us when it's // in this state. // // This property is "true" when the subscription is thought to be pending in **either** // direction. Because we don't actually know whether an unsubscription is pending, this // may not always be accurate. var pending: Bool // This accounts for the `actualTotal` not taking your own pending subscription into account. public var total: Int { actualTotal + pendingSubscriptionValue } // This accounts for the `actualLocal` not taking your own pending subscription into account. /// Added in 0.19.4. public var local: Int? { guard let actualLocal else { return nil } return actualLocal + pendingSubscriptionValue } public func merge(with other: SubscriptionModel, using mergeType: ValueMergeType) -> SubscriptionModel { switch mergeType { case .disjunctive: .init( total: max(total, other.total), local: nil, subscribed: subscribed || other.subscribed, pending: pending || other.pending ) case .conjunctive: .init( total: max(total, other.total), local: nil, subscribed: subscribed && other.subscribed, pending: pending && other.pending ) } } private var pendingSubscriptionValue: Int { switch (subscribed, pending) { case (true, true): return 1 case (false, true): return -1 case (_, false): return 0 } } init(total: Int, local: Int?, subscribed: Bool, pending: Bool) { self.actualTotal = total self.actualLocal = local self.subscribed = subscribed self.pending = pending } public func hash(into hasher: inout Hasher) { hasher.combine(actualTotal) hasher.combine(actualLocal) hasher.combine(subscribed) hasher.combine(pending) } public static func == (lhs: SubscriptionModel, rhs: SubscriptionModel) -> Bool { lhs.hashValue == rhs.hashValue } } extension SubscriptionModel { var subscribedType: LemmySubscribedType { if subscribed { pending ? .pending : .subscribed } else { .notSubscribed } } func withSubscriptionStatus(subscribed shouldSubscribe: Bool, isLocal: Bool) -> SubscriptionModel { guard shouldSubscribe != subscribed else { return self } let diff: Int if isLocal { diff = shouldSubscribe ? 1 : -1 } else { diff = 0 } let newLocal: Int? if let actualLocal { newLocal = actualLocal + diff } else { newLocal = nil } return SubscriptionModel( total: actualTotal + diff, local: newLocal, subscribed: shouldSubscribe, pending: !(pending || isLocal) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/UnreadCount.swift ================================================ // // UnreadCount.swift // // // Created by Sjmarf on 05/07/2024. // import Foundation import os @Observable public final class UnreadCount { let log: Logger = .mlemLogger() public let api: ApiClient var verifiedCount: [InboxItemType: Int] = .init() var unverifiedCount: [InboxItemType: Int] = .init() public var replies: Int { self[.reply] } public var mentions: Int { self[.mention] } public var messages: Int { self[.message] } public var postReports: Int { self[.postReport] } public var commentReports: Int { self[.commentReport] } public var messageReports: Int { self[.messageReport] } public var registrationApplications: Int { self[.registrationApplication] } /// This value is incremented whenever the inbox count changes due to an /// updated unread count being fetched from the API. It is not incremented when /// state-faking is performed. This can be used as a trigger to decide when to /// refresh the inbox. public private(set) var refreshNumber: UInt = 0 public var personalTotal: Int { replies + mentions + messages } public var reportTotal: Int { postReports + commentReports + messageReports } public var moderationTotal: Int { reportTotal + registrationApplications } public var total: Int { personalTotal + moderationTotal } init(api: ApiClient) { self.api = api } @MainActor func update(with newValues: [InboxItemType: Int]) { var shouldUpdate = false for (type, value) in newValues { if verifiedCount[type] != value { verifiedCount[type] = value shouldUpdate = true } } if shouldUpdate { refreshNumber += 1 } } @MainActor func update(with sources: [any DictionaryConvertible]) { update( with: sources.reduce(into: [InboxItemType: Int]()) { $0.merge($1.unreadCountDictionary) { $1 } } ) } func clear() { verifiedCount = .init() unverifiedCount = .init() } func clear(_ types: Set) { for type in types { verifiedCount[type] = 0 unverifiedCount[type] = 0 } } func updateUnverifiedItem(itemType: InboxItemType, isRead: Bool) { let diff = isRead ? -1 : 1 unverifiedCount[itemType, default: 0] += diff } func verifyItem(itemType: InboxItemType, isRead: Bool) { let diff = isRead ? -1 : 1 verifiedCount[itemType, default: 0] += diff unverifiedCount[itemType, default: 0] -= diff } public subscript(_ type: InboxItemType) -> Int { (verifiedCount[type] ?? 0) + (unverifiedCount[type] ?? 0) } // If `alwaysMakeCalls` is `false`, `UnreadCount` will avoid making calls it doesn't need to (e.g. checking for // moderation notifications if the user does not moderate any communities). You might want to set this to // `true` if you are using this function to measure the response time of the server. public func refresh(alwaysMakeCalls: Bool = false) async throws { let values: [InboxItemType: Int] = try await withThrowingTaskGroup( of: [InboxItemType: Int].self, returning: [InboxItemType: Int].self ) { taskGroup in taskGroup.addTask { try await self.api.repository.getPersonalUnreadCount().unreadCountDictionary } if !alwaysMakeCalls, self.api.username != nil, self.api.myPerson == nil || self.api.myInstance == nil { // The theoretical solution to this is to store the moderated // community IDs in `ApiClient.Context` and `await` them here. log.warning("ApiClient.myPerson or ApiClient.myInstance is nil at UnreadCount refresh - this may lead to unneeded API calls") } if try await self.api.supports(.viewReports) { if alwaysMakeCalls || !(self.api.myPerson?.moderatedCommunities.value_?.isEmpty ?? false) || self.api.isAdmin { taskGroup.addTask { do { return try await self.api.repository.getReportCount(communityId: nil).unreadCountDictionary } catch let ApiClientError.response(response, _) where response.notModOrAdmin { return [:] } } } // Don't use `api.isAdmin` here; it falls back to `false` and we need to fallback to `true` if alwaysMakeCalls || api.myInstance?.administrators.value?.contains(where: { $0.id == api.myPerson?.id }) ?? true { taskGroup.addTask { do { return try await [.registrationApplication: self.api.getRegistrationApplicationCount()] } catch let ApiClientError.response(response, _) where response.notAdmin { return [:] } } } } return try await taskGroup.reduce(into: [:]) { $0.merge($1) { $1 } } } await update(with: values) } } public enum InboxItemType: Codable { case reply, mention, message case postReport, commentReport, messageReport, registrationApplication } public extension Set { static var all: Set { [.reply, .mention, .message, .postReport, .commentReport, .messageReport, .registrationApplication] } static var personal: Set { [.reply, .mention, .message] } static var reports: Set { [.postReport, .commentReport, .messageReport] } static var moderatorAndAdmin: Set { reports.union([.registrationApplication]) } } extension UnreadCount { protocol DictionaryConvertible { var unreadCountDictionary: [InboxItemType: Int] { get } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/UpdateQueues/InboxNotificationUpdateQueue.swift ================================================ // // InboxNotificationUpdateQueue.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-04. // import os import Semaphore /// This actor synchronizes state updates for a particular inbox notification. /// /// Calls are queued using `addItem`, and each call must return an `InboxNotificationSnapshot`. When each call returns, `lastVerifiedSnapshot` is updated /// with the returned snapshot. Each call will only execute when the previous one finishes, ensuring that `lastVerifiedSnapshot` always accurately reflects /// the most recently queried server state. /// /// When the queue finishes executing altogether, it updates its parent model with the most recent snapshot. State faking is performed by the model /// before the work item is queued. /// public actor InboxNotificationUpdateQueue { let log: Logger = .mlemLogger() weak var parent: InboxNotification? private var lastVerifiedSnapshot: InboxNotificationSnapshot? private var semaphore: AsyncSemaphore = .init(value: 1) private var queue: Queue = .init() func setParent(_ newParent: InboxNotification) { parent = newParent lastVerifiedSnapshot = newParent.takeSnapshot() } /// Add a task to the queue for a repository call that returns a complete snapshot. /// - Note: prefer this method over the snapshot-modifying variant below func addItem(item: @escaping () async throws -> InboxNotificationSnapshot) { addItem(.createsSnapshot(item)) } /// Add a task to the queue for a repository call that **does not** return a complete snapshot. The queue will provide the latest verified /// snapshot to the task, which should then modify and return the snapshot according to the repository call result. /// - Note: **only** use this method when absolutely necessary; if the repository returns a complete snapshot, use the variant above. func addItem(item: @escaping (InboxNotificationSnapshot) async throws -> InboxNotificationSnapshot) { addItem(.modifiesSnapshot(item)) } private func addItem(_ item: InboxNotificationUpdateTask) { queue.enqueue(item) if queue.numItems == 1 { Task { await executeQueue() } } } /// Attempts to update the notification with the given snapshot. If any tasks are queued, no action will be taken. /// This method should be called when new snapshots are received by actions in a foreign object's queue or by headless calls func attemptDirectUpdate(with snapshot: InboxNotificationSnapshot) async { guard queue.numItems == 0, let parent else { return } await updateParent(parent, with: snapshot, isResultOfTask: false) } private func executeQueue() async { await semaphore.wait() defer { semaphore.signal() log.debug("Finished executing queue") } log.info("Executing queue") guard let parent else { assertionFailure("Cannot execute queue with no parent!") return } // this shouldn't be possible, since lastVerifiedSnapshot is set when the parent is set guard var lastVerifiedSnapshot else { assertionFailure("Cannot execute queue with no lastVerifiedSnapshot!") return } while let task = queue.next() { log.debug("Found next task") do { let snapshot: InboxNotificationSnapshot switch task { case let .createsSnapshot(callback): snapshot = try await callback() case let .modifiesSnapshot(callback): snapshot = try await callback(lastVerifiedSnapshot) } self.lastVerifiedSnapshot = snapshot lastVerifiedSnapshot = snapshot // also need to update scoped lastVerifiedSnapshot so updateParent gets the correct value\ } catch { log.error("\(error.localizedDescription)") } queue.dequeue() } await updateParent(parent, with: lastVerifiedSnapshot, isResultOfTask: true) } private func updateParent( _ parent: InboxNotification, with snapshot: InboxNotificationSnapshot, isResultOfTask: Bool ) async { await parent.snapshotUpdate(with: snapshot, isResultOfTask: isResultOfTask) } } enum InboxNotificationUpdateTask { case createsSnapshot(() async throws -> InboxNotificationSnapshot) case modifiesSnapshot((InboxNotificationSnapshot) async throws -> InboxNotificationSnapshot) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/UpdateQueues/Queue.swift ================================================ // // Queue.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-04. // public class Queue { private var items: [T] = .init() var numItems: Int { items.count } func enqueue(_ item: T) { items.append(item) } @discardableResult func dequeue() -> T? { guard !items.isEmpty else { return nil } return items.removeFirst() } func next() -> T? { items.first } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/UpdateQueues/UnifiedUpdateQueue.swift ================================================ // // UnifiedUpdateQueue.swift // MlemMiddleware // // Created by Eric Andrews on 2025-12-27. // import os import Semaphore /// This actor synchronizes state updates for a particular post. /// /// Calls are queued using `addItem`, and each call must return a `PostSnapshotProviding`. When each call returns, `lastVerifiedSnapshot` is updated /// with the returned snapshot. Each call will only execute when the previous one finishes, ensuring that `lastVerifiedSnapshot` always accurately reflects /// the most recently queried server state. /// /// When the queue finishes executing altogether, it updates its parent model with the most recent snapshot. State faking is performed by the model /// before the work item is queued. /// /// - Note: some care must be taken to ensure that `parent` always points to a valid model. When a model is initialized, it updates `parent` to be itself; /// likewise, when a model is deinitialized, it updates `parent` to be the next lower-tier model contained within itself (e.g., `Post3`'s deinit updates parent /// to be `post2`). If this update is not performed, `parent` may become nil and the queue will refuse to execute. In debug mode this will throw an error, /// while in production the queue will simply not run until an item is added when the parent is present. public actor UnifiedUpdateQueue { let log: Logger = .mlemLogger() let parent: Model private var lastVerifiedProperties: Model.Properties private var upgraded: Bool = false private var semaphore: AsyncSemaphore = .init(value: 1) private var queue: Queue = .init() init(parent: Model, properties: Model.Properties) { self.parent = parent self.lastVerifiedProperties = properties } /// Updates parent with its highest tier information. /// - Note: this function simply queues the upgrade and returns very quickly. For use with interactive spinners, use `refresh()` func upgrade() async throws { // this method is a unique case because upgrade will be called at every property access on the parent model until // the required properties are provided. Therefore we only allow a single call guard !upgraded else { return } upgraded = true addItem { return try await self.parent.fetchUpgraded() } } /// Updates parent with its highest tier information. Only returns when the upgrade is complete. func refresh() async throws { await semaphore.wait() defer { semaphore.signal() log.debug("Finished refresh") } let newProperties = try await self.parent.fetchUpgraded() self.lastVerifiedProperties.merge(newProperties) await updateParent(parent, with: lastVerifiedProperties) } /// Add a task to the queue for a repository call that returns a complete snapshot. /// - Note: prefer this method over the snapshot-modifying variant below func addItem(item: @escaping () async throws -> Model.Properties) { addItem(.createsProperties(item)) } /// Add a task to the queue for a repository call that **does not** return a complete snapshot. The queue will provide the latest verified /// snapshot to the task, which should then modify and return the snapshot according to the repository call result. /// - Note: **only** use this method when absolutely necessary; if the repository returns a complete snapshot, use the variant above. func addItem(item: @escaping (Model.Properties) async throws -> Model.Properties) { addItem(.modifiesProperties(item)) } /// Attempts to update the post with the given snapshot. If any tasks are queued, no action will be taken. /// This method should be called when new snapshots are received by actions in a foreign object's queue or by headless calls func attemptDirectUpdate(with properties: Model.Properties) async { guard queue.numItems == 0 else { return } await updateParent(parent, with: properties) } private func addItem(_ item: UpdateTask) { queue.enqueue(item) if queue.numItems == 1 { Task { await executeQueue() } } } private func executeQueue() async { await semaphore.wait() defer { semaphore.signal() log.debug("Finished executing queue") } log.info("Executing queue") while let task = queue.next() { log.debug("Found next task") do { let newProperties: Model.Properties switch task { case let .createsProperties(callback): newProperties = try await callback() case let .modifiesProperties(callback): newProperties = try await callback(lastVerifiedProperties) } self.lastVerifiedProperties.merge(newProperties) } catch { log.error("\(error.localizedDescription)") } queue.dequeue() } await updateParent(parent, with: lastVerifiedProperties) } @MainActor private func updateParent(_ parent: Model, with properties: Model.Properties) { // parent must be passed in rather than accessed directly due to actor access constraints parent.update(with: properties) } enum UpdateTask { case createsProperties(() async throws -> Model.Properties) case modifiesProperties((Model.Properties) async throws -> Model.Properties) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/UpdateQueues/UpdateStatus.swift ================================================ // // UpdateStatus.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-25. // public enum UpdateStatus { case success case failure(Error) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Content Models/VotesModel.swift ================================================ // // VotesModel.swift // Mlem // // Created by Eric Andrews on 2023-08-26. // import Foundation public struct VotesModel: Hashable, Equatable { public var total: Int { upvotes - downvotes } public var upvotes: Int public var downvotes: Int public var myVote: ScoringOperation // init from API type public init(from voteCount: any ApiContentAggregatesProtocol, myVote: ScoringOperation?) { self.upvotes = voteCount.upvotes self.downvotes = voteCount.downvotes self.myVote = myVote ?? .none } // raw init public init(upvotes: Int, downvotes: Int, myVote: ScoringOperation) { self.upvotes = upvotes self.downvotes = downvotes self.myVote = myVote } public func hash(into hasher: inout Hasher) { hasher.combine(upvotes) hasher.combine(downvotes) hasher.combine(myVote) } public static func == (lhs: VotesModel, rhs: VotesModel) -> Bool { lhs.hashValue == rhs.hashValue } } extension VotesModel { /// Returns the result of applying the given scoring operation. Assumes that it is a valid operation (i.e., not upvoting an upvoted post or downvoting a downvoted one) /// - Parameter operation: operation to apply /// - Returns: VotesModel representing the result of applying the given operation func applyScoringOperation(operation: ScoringOperation) -> VotesModel { assert(!(operation == .upvote && myVote == .upvote), "Cannot apply upvote to upvoted score") assert(!(operation == .downvote && myVote == .downvote), "Cannot apply downvote to downvoted score") var upvoteDelta: Int var downvoteDelta: Int switch myVote { case .upvote: // no matter what, removing 1 upvote; if downvoting, adding 1 downvote upvoteDelta = -1 downvoteDelta = operation == .downvote ? 1 : 0 case .none: // adding 1 to whichever operation we get as long as it's not resetVote upvoteDelta = operation == .upvote ? 1 : 0 downvoteDelta = operation == .downvote ? 1 : 0 case .downvote: // no matter what, removing 1 downvote; if upvoting, adding 1 upvote upvoteDelta = operation == .upvote ? 1 : 0 downvoteDelta = -1 } return VotesModel( upvotes: upvotes + upvoteDelta, downvotes: downvotes + downvoteDelta, myVote: operation ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/ApiPictrsFile.swift ================================================ // // LemmyPictrsFile.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation public struct LemmyPictrsFile: Codable, Equatable { public let file: String public let deleteToken: String } public extension LemmyPictrsFile { enum CodingKeys: String, CodingKey { case file case deleteToken = "delete_token" } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/ApiPictrsUploadResponse.swift ================================================ // // LemmyPictrsUploadResponse.swift // // // Created by Sjmarf on 26/08/2024. // import Foundation public struct LemmyPictrsUploadResponse: Codable { public let msg: String? public let files: [LemmyPictrsFile]? } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/APIComment+Extensions.swift ================================================ // // LemmyComment+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-02-19. // import Foundation extension LemmyComment: CacheIdentifiable { public var cacheId: Int { id } public var parentId: Int? { let components = path.components(separatedBy: ".") guard path != "0", components.count != 2 else { return nil } guard let id = components.dropLast(1).last else { return nil } return Int(id) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/APIPost+Extensions.swift ================================================ // // LemmyPost+ActorIdentifiable.swift // Mlem // // Created by Eric Andrews on 2024-02-19. // import Foundation extension LemmyPost { var linkUrl: URL? { LemmyURL(string: url?.absoluteString ?? "")?.url } // var thumbnailImageUrl: URL? { LemmyURL(string: thumbnail_url)?.url } var thumbnailImageUrl: URL? { thumbnailUrl } var embed: PostEmbed? { if embedTitle != nil || embedDescription != nil || embedVideoUrl != nil { return .init( title: embedTitle, description: embedDescription, videoUrl: embedVideoUrl ) } return nil } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/APISubscribedType+Extensions.swift ================================================ // // LemmySubscribedType+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-02-19. // import Foundation public extension LemmySubscribedType { var isSubscribed: Bool { self != .notSubscribed } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiCommentAggregates+Extensions.swift ================================================ // // LemmyCommentAggregates.swift // // // Created by Sjmarf on 24/06/2024. // import Foundation extension LemmyCommentAggregates: ApiContentAggregatesProtocol { public var comments: Int { childCount } static var zero: Self { .init(commentId: 0, score: 0, upvotes: 0, downvotes: 0, published: .distantPast, childCount: 0) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiCommunityAggregates+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-22. // import Foundation extension LemmyCommunityAggregates { static var zero: Self { .init( communityId: 0, subscribers: 0, posts: 0, comments: 0, published: .distantPast, usersActiveDay: 0, usersActiveWeek: 0, usersActiveMonth: 0, usersActiveHalfYear: 0, subscribersLocal: 0 ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiCommunityFollowerState+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-05. // import Foundation public extension LemmyCommunityFollowerState { var isSubscribed: Bool { self == .accepted } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiGetModlogResponse+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-17. // import Foundation public extension LemmyGetModlogResponse { func toSnapshots() throws(ApiClientError) -> [ModlogEntrySnapshot] { var result = try (removedPosts).map(ModlogEntrySnapshot.init) result += try (lockedPosts).map(ModlogEntrySnapshot.init) result += try (featuredPosts).map(ModlogEntrySnapshot.init) result += try (removedComments).map(ModlogEntrySnapshot.init) result += try (removedCommunities).map(ModlogEntrySnapshot.init) result += try (bannedFromCommunity).map(ModlogEntrySnapshot.init) result += try (banned).map(ModlogEntrySnapshot.init) result += try (addedToCommunity).map(ModlogEntrySnapshot.init) result += try (transferredToCommunity).map(ModlogEntrySnapshot.init) result += try (added).map(ModlogEntrySnapshot.init) result += try (adminPurgedPersons).map(ModlogEntrySnapshot.init) result += try (adminPurgedCommunities).map(ModlogEntrySnapshot.init) result += try (adminPurgedPosts).map(ModlogEntrySnapshot.init) result += try (adminPurgedComments).map(ModlogEntrySnapshot.init) result += try (hiddenCommunities).map(ModlogEntrySnapshot.init) return result } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiPersonAggregates+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-22. // import Foundation extension LemmyPersonAggregates { static var zero: Self { .init(personId: 0, postCount: 0, commentCount: 0) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiPostAggregates+Extensions.swift ================================================ // // LemmyPostAggregates+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-03-03. // import Foundation extension LemmyPostAggregates: ApiContentAggregatesProtocol { static var zero: Self { .init( postId: 0, comments: 0, score: 0, upvotes: 0, downvotes: 0, published: .distantPast, newestCommentTime: nil ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiPrivateMessageReportView+Extensions.swift ================================================ // // LemmyPrivateMessageReportView+Extensions.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-16. // import Foundation extension LemmyPrivateMessageReportView { func toPrivateMessageView() -> LemmyPrivateMessageView { .init( privateMessage: privateMessage, creator: privateMessageCreator, recipient: creator // Only the recipient of the message can report it. ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/ApiSiteAggregates+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-22. // import Foundation extension LemmySiteAggregates { static var zero: Self { .init( siteId: 0, users: 0, posts: 0, comments: 0, communities: 0, usersActiveDay: 0, usersActiveWeek: 0, usersActiveMonth: 0, usersActiveHalfYear: 0 ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/PieFedCommentAggregates+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-25. // import Foundation extension PieFedCommentAggregates: ApiContentAggregatesProtocol { public var comments: Int { childCount } static var zero: Self { .init(commentId: 0, score: 0, upvotes: 0, downvotes: 0, published: .distantPast, childCount: 0) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/PieFedPostAggregates+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-25. // import Foundation extension PieFedPostAggregates: ApiContentAggregatesProtocol { static var zero: Self { .init( postId: 0, comments: 0, score: 0, upvotes: 0, downvotes: 0, published: .distantPast, newestCommentTime: .distantPast, crossPosts: 0 ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/Extensions/PieFedSubscribedType+Extensions.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-19. // import Foundation public extension PieFedSubscribedType { var isSubscribed: Bool { self != .notSubscribed } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Custom API Models/LemmyPagedResponse.swift ================================================ // // LemmyPagedResponse.swift // MlemMiddleware // // Created by Sjmarf on 2025-12-05. // import Foundation public struct LemmyPagedResponse: Codable { public let items: [Value] public let nextPage: String? public let prevPage: String? } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/Array+Extensions.swift ================================================ // // Array+Prepend.swift // Mlem // // Created by David Bureš on 07.05.2023. // import Foundation public extension Array { mutating func prepend(_ newElement: Element) { insert(newElement, at: 0) } mutating func sortedInsert(_ newElement: Element, by predicate: (Element, Element) -> Bool) { insert(newElement, at: insertionIndex(for: { predicate($0, newElement) })) } subscript(safeIndex index: Int) -> Element? { guard index >= 0, index < endIndex else { return nil } return self[index] } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/Bool+Extensions.swift ================================================ // // Bool+Extensions.swift // MlemMiddleware // // Created by Eric Andrews on 2025-05-05. // extension Bool { public func or(_ other: Bool) -> Bool { self || other } } extension Bool: MergeableValue { public func merge(with other: Bool, using mergeType: ValueMergeType) -> Bool { switch mergeType { case .disjunctive: self || other case .conjunctive: self && other } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/InstanceSummarySoftware+Extensions.swift ================================================ // // InstanceSummarySoftware+Extensions.swift // Mlem // // Created by Eric Andrews on 2026-03-27. // import MlemBackend extension InstanceSummarySoftware { init(from software: SiteSoftware) { let type: InstanceSummarySoftwareType = switch software.type { case .lemmy: .lemmy case .pieFed: .pieFed } self.init( type: type, version: software.version.description ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/RandomAccessCollection+Extensions.swift ================================================ // // RandomAccessCollection+Extensions.swift // // // Created by Sjmarf on 05/05/2024. // import Foundation extension RandomAccessCollection { func insertionIndex(for predicate: (Element) -> Bool) -> Index { var slice: SubSequence = self[...] while !slice.isEmpty { let middle = slice.index(slice.startIndex, offsetBy: slice.count / 2) if predicate(slice[middle]) { slice = slice[index(after: middle)...] } else { slice = slice[.. Bool) rethrows -> Element? { guard let index = try firstIndex(where: predicate) else { return nil } return remove(at: index) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/String+Extensions.swift ================================================ // // String+Extensions.swift // Mlem // // Created by Eric Andrews on 2023-12-10. // import Foundation public extension String { /// Returns true if the given array of strings contains any word which appears as a substring of this string func isContainedIn(_ strings: [String]) -> Bool { strings.contains { contains($0) } } /// Returns true if the given set of strings contains any word which appears as a substring of this string func isContainedIn(_ strings: Set) -> Bool { strings.contains { contains($0) } } /// Returns true if this string contains: /// - Any single word that matches a single word in filteredKeywords /// - Any sequence of words that precisely matches a multi-word phrase in filteredKeywords func failsKeywordFilter(keywords: Set, phrases: Set<[String]>) -> Bool { // split on any non-letter/number characters so "keyword's" is filtered as "keyword" "s" let words = split(separator: /[^[:alnum:]]/) .map { $0.lowercased() } var partialMatches: [PartialMatch] = .init() for word in words { // check single keywords if keywords.contains(word) { return true } // check if any partial matches succeed var matchedPhrase: Bool = false partialMatches = partialMatches.filter { partial in switch partial.matchNextWord(word) { case .failed: return false case .partial: return true case .matched: matchedPhrase = true return true } } if matchedPhrase { return true } // check if this word starts a new partial match for phrase in phrases { guard let firstWord = phrase.first else { assertionFailure("Invalid phrase (no first element)") continue } if word == firstWord { partialMatches.append(.init(phrase: phrase)) } } } return false } // Returns true if this string contains any literals in the given set func failsLiteralFilter(literals: Set) -> Bool { return literals.contains(where: { self.contains($0) }) } } private enum MatchState { case partial, failed, matched } private class PartialMatch { private let phrase: [String] private var index: Int = 1 // starts at 1 because only initialized if first word matches init(phrase: [String]) { assert(phrase.count > 0, "Invalid phrase") self.phrase = phrase } func matchNextWord(_ word: String) -> MatchState { guard let nextWord = phrase[safeIndex: index] else { assertionFailure("No next word!") return .failed } if word == nextWord { if index == phrase.count - 1 { return .matched } else { index += 1 return .partial } } return .failed } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Extensions/URL+Extensions.swift ================================================ // // URL+Identifiable.swift // Mlem // // Created by Nicholas Lawson on 04/06/2023. // import Foundation import os extension URL: @retroactive Identifiable { public var id: URL { absoluteURL } } public extension URL { static func post(host: String, id: Int) -> Self { var components = URLComponents() components.scheme = "https" components.host = host components.path = "/post/\(id)" return components.url! // This will always succeed } static func comment(host: String, id: Int) -> Self { var components = URLComponents() components.scheme = "https" components.host = host components.path = "/comment/\(id)" return components.url! // This will always succeed } static func community(host: String, name: String) -> Self { var components = URLComponents() components.scheme = "https" components.host = host components.path = "/c/\(name)" return components.url! // This will always succeed } static func person(host: String, name: String) -> Self { var components = URLComponents() components.scheme = "https" components.host = host components.path = "/u/\(name)" return components.url! // This will always succeed } } public extension URL { // Spec described here: https://join-lemmy.org/docs/contributors/04-api.html#images func withIconSize(_ size: Int?) -> URL { guard scheme == "http" || scheme == "https" else { return self } guard let size else { return self } guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { Logger.universal.warning("Failed to create URLComponents") return appending(queryItems: [.init(name: "thumbnail", value: String(size))]) } var queryItems = components.queryItems ?? [] queryItems.removeFirst(where: { $0.name == "thumbnail" }) queryItems.append(.init(name: "thumbnail", value: String(size))) components.queryItems = queryItems return components.url ?? self } func removingPathComponents() -> URL { var components = URLComponents() components.scheme = scheme components.host = host components.port = port return components.url! } private struct LoopsVideoResponse: Codable { let data: Body internal struct Body: Codable { let media: Media internal struct Media: Codable { let src_url: URL } } } /// Attempts to extract the underlying loops.video media URL from this URL /// - Returns: loops.video media URL if this is a loops.video url and the underlying URL was successfully parsed, nil otherwise func parseEmbeddedLoops() async -> URL? { // TODO: Pending loops.video maturation // - More reliable way of determining if this is a Loops server // - More robust way of extracting media URL (preferably API support) guard host() == "loops.video" else { return nil } do { let (websiteContent, _) = try await URLSession.shared.data(from: self) // parse video API ID from website content let apiIdRegex = / 1 { return pathComponents[1] } } else if host == "youtube.com" || host == "www.youtube.com" || host == "m.youtube.com" { guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), let queryItems = components.queryItems, let videoId = queryItems.first(where: { $0.name == "v" })?.value else { let pathComponents = pathComponents if pathComponents.count > 2, pathComponents[1] == "embed" { return pathComponents[2] } return nil } return videoId } return nil } var youTubeThumbnailUrl: URL? { guard let videoId = youTubeVideoId else { return nil } return URL(string: "https://img.youtube.com/vi/\(videoId)/mqdefault.jpg") } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Comment/SearchCommentFeedLoader.swift ================================================ // // SearchCommentFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 2025-01-18. // import Foundation @Observable public class SearchCommentFetcher: Fetcher { public enum SortType { case v4(SearchSortType) case v3(CommentSortType) } public var query: String public var listing: ListingType public var sort: SortType public var communityId: Int? public var creatorId: Int? init( api: ApiClient, query: String, communityId: Int?, creatorId: Int?, pageSize: Int, listing: ListingType, sort: SortType ) { self.query = query self.communityId = communityId self.creatorId = creatorId self.listing = listing self.sort = sort super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let comments: [Comment] switch sort { case let .v4(searchSortType): comments = try await api.searchComments( query: query, page: page, limit: pageSize, communityId: communityId, creatorId: creatorId, filter: listing, sort: searchSortType ) case let .v3(commentSortType): comments = try await api.searchComments( query: query, page: page, limit: pageSize, communityId: communityId, creatorId: creatorId, filter: listing, sort: commentSortType ) } return .init( items: comments, prevCursor: nil, nextCursor: nil ) } public func changeApi(to newApi: ApiClient) async { await super.changeApi(to: newApi, context: .none()) } } @Observable public class SearchCommentFeedLoader: StandardFeedLoader { public var api: ApiClient // force unwrap because this should ALWAYS be a SearchCommentFetcher public var searchCommentFetcher: SearchCommentFetcher { fetcher as! SearchCommentFetcher } public init( api: ApiClient, query: String = "", communityId: Int? = nil, creatorId: Int? = nil, pageSize: Int = 20, listing: ListingType = .all, sort: SearchCommentFetcher.SortType = .v4(.top(.allTime)) ) { self.api = api super.init( filter: .init(), fetcher: SearchCommentFetcher( api: api, query: query, communityId: communityId, creatorId: creatorId, pageSize: pageSize, listing: listing, sort: sort ) ) } public func refresh( query: String? = nil, listing: ListingType? = nil, sort: SearchCommentFetcher.SortType? = nil, clearBeforeRefresh: Bool = false ) async throws { searchCommentFetcher.query = query ?? searchCommentFetcher.query searchCommentFetcher.listing = listing ?? searchCommentFetcher.listing searchCommentFetcher.sort = sort ?? searchCommentFetcher.sort try await refresh(clearBeforeRefresh: clearBeforeRefresh) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Community/CommunityFeedLoader.swift ================================================ // // CommunityFeedLoader.swift // // // Created by Sjmarf on 08/09/2024. // import Foundation @Observable class CommunityFetcher: Fetcher { var query: String var listing: ListingType var sort: SearchSortType var hostApi: ApiClient? init( api: ApiClient, query: String, pageSize: Int, listing: ListingType, sort: SearchSortType, hostApi: ApiClient? ) { self.query = query self.listing = listing self.sort = sort self.hostApi = hostApi super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let communities = try await api.searchCommunities( query: query, page: page, limit: pageSize, filter: listing, sort: sort, hostApi: hostApi ) return .init( items: communities, prevCursor: nil, nextCursor: nil ) } } @Observable public class CommunityFeedLoader: StandardFeedLoader { public var api: ApiClient // force unwrap because this should ALWAYS be a CommunityFetcher var communityFetcher: CommunityFetcher { fetcher as! CommunityFetcher } public init( api: ApiClient, query: String = "", pageSize: Int = 20, listing: ListingType = .all, sort: SearchSortType = .top(.allTime), hostApi: ApiClient? = nil ) { self.api = api super.init( filter: .init(), fetcher: CommunityFetcher( api: api, query: query, pageSize: pageSize, listing: listing, sort: sort, hostApi: hostApi ) ) } public func changeApi(to client: ApiClient, context: FilterContext, hostApi: ApiClient?) async { await super.changeApi(to: client, context: context) communityFetcher.hostApi = hostApi } public func refresh( query: String? = nil, listing: ListingType? = nil, sort: SearchSortType? = nil, clearBeforeRefresh: Bool = false ) async throws { communityFetcher.query = query ?? communityFetcher.query communityFetcher.listing = listing ?? communityFetcher.listing communityFetcher.sort = sort ?? communityFetcher.sort try await refresh(clearBeforeRefresh: clearBeforeRefresh) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/DualSourceMixed/CommentChildFeedLoader.swift ================================================ // // CommentChildFeedLoader.swift // MlemMiddleware // // Created by sjmarf on 2025-10-29. // public class CommentChildFeedLoader: ChildFeedLoader { class Fetcher: MlemMiddleware.Fetcher { let filter: GetContentFilter init( api: ApiClient, pageSize: Int, page: Int = 0, filter: GetContentFilter ) { self.filter = filter super.init(api: api, pageSize: pageSize, page: page) } override func fetchPage(_ page: Int) async throws -> FetchResponse { try await internalFetchCursor(page: page, cursor: nil) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { try await internalFetchCursor(page: nil, cursor: cursor) } private func internalFetchCursor(page: Int?, cursor: String?) async throws -> FetchResponse { let response = try await api.getCommentHistory( type: self.filter, page: page, cursor: cursor, limit: pageSize ) return .init( items: response.comments.map { PersonContent(wrappedValue: .comment($0)) }, prevCursor: cursor, nextCursor: response.cursor ) } } public init( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, filter: GetContentFilter ) { super.init( filter: MultiFilter(), fetcher: Fetcher(api: api, pageSize: pageSize, filter: filter), sortType: sortType ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/DualSourceMixed/DualSourceMixedFeedLoader.swift ================================================ // // DualSourceMixedFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 2025-10-29. // import Foundation // A feed loader that loads both posts and comments, with two child feed loaders that fetch from different sources. public class DualSourceMixedFeedLoader: StandardFeedLoader { public init( api: ApiClient, pageSize: Int, sources: [ChildFeedLoader], sortType: FeedLoaderSort.SortType ) { super.init( filter: MultiFilter(), fetcher: MultiFetcher( api: api, pageSize: pageSize, sources: sources, sortType: sortType ) ) for source in sources { source.setParent(parent: self) } } public static func setup( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, filter: GetContentFilter ) -> ( postFeedLoader: PostChildFeedLoader, commentFeedLoader: CommentChildFeedLoader, savedFeedLoader: DualSourceMixedFeedLoader ) { let postFeedLoader: PostChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, filter: filter ) let commentFeedLoader: CommentChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, filter: filter ) let savedFeedLoader: DualSourceMixedFeedLoader = .init( api: api, pageSize: pageSize, sources: [postFeedLoader, commentFeedLoader], sortType: sortType ) return (postFeedLoader, commentFeedLoader, savedFeedLoader) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/DualSourceMixed/PostChildFeedLoader.swift ================================================ // // PostChildFeedLoader.swift // MlemMiddleware // // Created by sjmarf on 2025-10-29. // public class PostChildFeedLoader: ChildFeedLoader { class Fetcher: MlemMiddleware.Fetcher { let filter: GetContentFilter init( api: ApiClient, pageSize: Int, page: Int = 0, filter: GetContentFilter ) { self.filter = filter super.init(api: api, pageSize: pageSize, page: page) } override func fetchPage(_ page: Int) async throws -> FetchResponse { try await internalFetchCursor(page: page, cursor: nil) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { try await internalFetchCursor(page: nil, cursor: cursor) } private func internalFetchCursor(page: Int?, cursor: String?) async throws -> FetchResponse { let response = try await api.getPostHistory( type: self.filter, page: page, cursor: cursor, limit: pageSize ) return .init( items: response.posts.map { PersonContent(wrappedValue: .post($0)) }, prevCursor: cursor, nextCursor: response.cursor ) } } public init( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, filter: GetContentFilter ) { super.init( filter: MultiFilter(), fetcher: Fetcher(api: api, pageSize: pageSize, filter: filter), sortType: sortType ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/FilterContext.swift ================================================ // // FilterContext.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-22. // import Foundation /// Information required to perform filtering public struct FilterContext { public let isAdmin: Bool public let moderatedCommunityActorIds: Set public let filteredKeywords: Set public let filteredPhrases: Set<[String]> public let filteredLiterals: Set public init( isAdmin: Bool, moderatedCommunityActorIds: Set, filteredKeywords: Set, filteredPhrases: Set<[String]>, filteredLiterals: Set ) { self.isAdmin = isAdmin self.moderatedCommunityActorIds = moderatedCommunityActorIds self.filteredKeywords = filteredKeywords self.filteredPhrases = filteredPhrases self.filteredLiterals = filteredLiterals } static func none() -> FilterContext { .init(isAdmin: true, moderatedCommunityActorIds: [], filteredKeywords: [], filteredPhrases: [], filteredLiterals: []) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/FilterProviding.swift ================================================ // // FilterProviding.swift // // // Created by Eric Andrews on 2024-05-31. // import Foundation class FilterProviding { var context: FilterContext /// How many items this filter has caught var numFiltered: Int = 0 var active: Bool = true init(context: FilterContext) { self.context = context } /// Given a list of `FilterTarget`s, returns all members that pass the filter and tracks how many members do not /// - Parameter targets: list of `FilterTarget`s to filter func filter(_ targets: [FilterTarget]) -> [FilterTarget] { let ret = targets.filter(shouldPassFilter) numFiltered += targets.count - ret.count return ret } /// Clears the filter and processes all provided targets /// - Parameter targets: optional list of `FilterTarget`s; if present, these will be filtered and the results returned func reset(with targets: [FilterTarget]?) -> [FilterTarget] { numFiltered = 0 if let targets { return filter(targets) } return .init() } /// Returns true if the given post should pass the filter, false otherwise public func shouldPassFilter(_ item: FilterTarget) -> Bool { preconditionFailure("This method must be implemented by the inheriting class") } func updateFilterContext(to context: FilterContext) { self.context = context } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filterable.swift ================================================ // // Filterable.swift // // // Created by Eric Andrews on 2024-06-03. // import Foundation public protocol Filterable { associatedtype FilterType } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/CommentFilter.swift ================================================ // // CommentFilter.swift // // // Created by Eric Andrews on 2024-07-10. // import Foundation public enum CommentFilterType { case todo } public enum CommunityFilterType { case todo } public enum PersonFilterType { case todo } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/DedupeFilter.swift ================================================ // // PostDedupeFilter.swift // // // Created by Eric Andrews on 2024-05-31. // import Foundation /// Filter that dedupes ActorIdentifiable items by actorId class DedupeFilter: FilterProviding { private var seen: Set = .init() override func reset(with targets: [FilterTarget]?) -> [FilterTarget] { numFiltered = 0 seen = .init() if let targets { return filter(targets) } return .init() } override public func shouldPassFilter(_ item: FilterTarget) -> Bool { seen.insert(item.actorId).inserted } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/InboxDedupeFilter.swift ================================================ // // InboxDedupeFilter.swift // MlemMiddleware // // Created by Eric Andrews on 2025-02-01. // /// Filter that dedupes InboxIdentifiable items by inboxId class InboxDedupeFilter: FilterProviding { private var seen: Set = .init() override func reset(with targets: [FilterTarget]?) -> [FilterTarget] { numFiltered = 0 seen = .init() if let targets { return filter(targets) } return .init() } override public func shouldPassFilter(_ item: FilterTarget) -> Bool { seen.insert(item.inboxId).inserted } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/Post/PostKeywordFilter.swift ================================================ // // PostKeywordFilter.swift // // // Created by Eric Andrews on 2024-06-02. // import Foundation class PostKeywordFilter: FilterProviding { override public func shouldPassFilter(_ post: Post) -> Bool { // community should always exist for posts going through the feed loader guard let community = post.community.value_ else { assertionFailure("No community found in filter-eligible post") return true } // bypass filter for moderated/administrated posts if context.isAdmin || context.moderatedCommunityActorIds.contains(community.actorId) { return true } return !post.title.failsKeywordFilter(keywords: context.filteredKeywords, phrases: context.filteredPhrases) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/Post/PostLiteralFilter.swift ================================================ // // PostLiteralFilter.swift // MlemMiddleware // // Created by Eric Andrews on 2025-11-18. // import Foundation class PostLiteralFilter: FilterProviding { override public func shouldPassFilter(_ post: Post) -> Bool { // community should always exist for posts going through the feed loader guard let community = post.community.value_ else { assertionFailure("No community found in filter-eligible post") return true } // bypass filter for moderated/administrated posts if context.isAdmin || context.moderatedCommunityActorIds.contains(community.actorId) { return true } return !post.title.failsLiteralFilter(literals: context.filteredLiterals) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/ReadFilter.swift ================================================ // // ReadFilter.swift // // // Created by Eric Andrews on 2024-05-31. // import Foundation class ReadFilter: FilterProviding { override public func shouldPassFilter(_ item: FilterTarget) -> Bool { return !item.read } } class UnifiedReadFilter: FilterProviding { override public func shouldPassFilter(_ item: FilterTarget) -> Bool { return !(item.read.value_ ?? false) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/Filters/UserContentFilter.swift ================================================ // // PersonContentFilterType.swift // // // Created by Eric Andrews on 2024-07-18. // import Foundation public enum PersonContentFilterType { case todo } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/InboxItemFilter.swift ================================================ // // InboxItemFilter.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-04. // public enum InboxItemFilterType { case read, dedupe } class InboxItemFilter: MultiFilter { private var readFilter: ReadFilter private var dedupeFilter: InboxDedupeFilter = .init(context: .none()) init(showRead: Bool) { self.readFilter = .init(context: .none()) if showRead { readFilter.active = false } } override func allFilters() -> [FilterProviding] { [ readFilter, dedupeFilter ] } override func getFilter(_ toGet: InboxItemFilterType) -> FilterProviding { switch toGet { case .read: readFilter case .dedupe: dedupeFilter } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/ModMailItemFilter.swift ================================================ // // ModMailItemFilter.swift // MlemMiddleware // // Created by Eric Andrews on 2025-01-31. // public enum ModMailItemFilterType { case read, dedupe } class ModMailItemFilter: MultiFilter { private var readFilter: ReadFilter private var dedupeFilter: InboxDedupeFilter = .init(context: .none()) init(showRead: Bool) { self.readFilter = .init(context: .none()) if showRead { readFilter.active = false } } override func allFilters() -> [FilterProviding] { [ readFilter, dedupeFilter ] } override func getFilter(_ toGet: ModMailItemFilterType) -> FilterProviding { switch toGet { case .read: readFilter case .dedupe: dedupeFilter } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/ModlogItemFilter.swift ================================================ // // ModlogItemFilter.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-28. // import Foundation public enum ModlogEntryFilterType {} class ModlogEntryFilter: MultiFilter { override func allFilters() -> [FilterProviding] { [] } override func getFilter(_ toGet: ModlogEntryFilterType) -> FilterProviding {} } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/MultiFilter.swift ================================================ // // MultiFilter.swift // // // Created by Eric Andrews on 2024-06-03. // import Foundation class MultiFilter { var numFiltered: Int { allFilters().reduce(0) { $0 + $1.numFiltered } } /// Lists all filters in this MultiFilter. Used internally to iterate over filters and perform filtering logic. This function bridges the gap between the generic behavior, which wants a list of `[any FilterProviding]` to use in filtering, and the instantiating class, which is far more ergonomic if filters can be declared as simple member variables. /// - Returns: list of all filters in this MultiFilter func allFilters() -> [FilterProviding] { [] } /// Gets a particular optional filter. Used internally to back the `activate`, `deactivate`, and `filteredCount` methods; as with `allFilters`, used to bridge generic and concrete behavior. /// - Parameter toGet: `OptionalFilters` describing the filter to get /// - Returns: filter corresponding to `toGet` func getFilter(_ toGet: FilterTarget.FilterType) -> FilterProviding { preconditionFailure("This method must be implemented by the instantiating class") } func filter(_ targets: [FilterTarget]) -> [FilterTarget] { var ret: [FilterTarget] = targets for filter in allFilters() where filter.active { ret = filter.filter(ret) } return ret } /// Resets this filter and all its children /// - Parameter targets optional; if present, will immediately re-filter all targets /// - Returns result of filtering targets, if present, otherwise an empty array @discardableResult func reset(with targets: [FilterTarget] = .init()) -> [FilterTarget] { var ret = targets for filter in allFilters() { if filter.active { ret = filter.reset(with: ret) } else { _ = filter.reset(with: nil) } } return ret } /// Activates the given filter /// - Parameter filter: filter to activate /// - Returns: true if the filter was successfully activated, false if it was already active func activate(_ toActivate: FilterTarget.FilterType) -> Bool { let filter = getFilter(toActivate) let ret = !filter.active filter.active = true return ret } /// Deactivates the given filter /// - Parameter filter: filter to deactivate /// - Returns: true if the filter was successfully deactivated, false if it was already inactive func deactivate(_ toDeactivate: FilterTarget.FilterType) -> Bool { let filter = getFilter(toDeactivate) let ret = filter.active filter.active = false return ret } func numFiltered(for filter: FilterTarget.FilterType) -> Int { getFilter(filter).numFiltered } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Filtering/PostFilter.swift ================================================ // // PostFilter.swift // // // Created by Eric Andrews on 2024-05-31. // import Foundation public enum PostFilterType { case read, dedupe, keyword, literal } class PostFilter: MultiFilter { private var readFilter: UnifiedReadFilter private var dedupeFilter: DedupeFilter = .init(context: .none()) private var keywordFilter: PostKeywordFilter private var literalFilter: PostLiteralFilter init(showRead: Bool, context: FilterContext) { self.keywordFilter = .init(context: context) self.literalFilter = .init(context: context) self.readFilter = .init(context: .none()) if showRead { readFilter.active = false } } override func allFilters() -> [FilterProviding] { [ readFilter, dedupeFilter, keywordFilter, literalFilter ] } override func getFilter(_ toGet: PostFilterType) -> FilterProviding { switch toGet { case .read: readFilter case .dedupe: dedupeFilter case .keyword: keywordFilter case .literal: literalFilter } } // MARK: Custom Behavior func updateContext(to context: FilterContext) { keywordFilter.updateFilterContext(to: context) literalFilter.updateFilterContext(to: context) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/ChildFeedLoader.swift ================================================ // // ChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-24. // /// Helper class bundling a parent feed loader and a position in a ChildFeedLoader's item list class FeedLoaderStream { weak var parent: (any FeedLoading)? var cursor: Int init(parent: (any FeedLoading)? = nil) { self.parent = parent self.cursor = 0 } } public class ChildFeedLoader: StandardFeedLoader { var stream: FeedLoaderStream? var sortType: FeedLoaderSort.SortType init(filter: MultiFilter, fetcher: Fetcher, sortType: FeedLoaderSort.SortType) { self.sortType = sortType super.init(filter: filter, fetcher: fetcher) } public func setParent(parent: any FeedLoading) { stream = .init(parent: parent) } public func nextItemSortVal(sortType: FeedLoaderSort.SortType) async throws -> FeedLoaderSort? { assert(sortType == self.sortType, "Conflicting types for sortType! This will lead to unexpected sorting behavior.") guard let stream, stream.parent != nil else { log.info("[\(Self.self)] could not find stream or parent") return nil } if stream.cursor < items.count { return items[safeIndex: stream.cursor]?.sortVal(sortType: sortType) } else { if loadingState == .done { log.debug("[\(Self.self)] done loading") return nil } log.debug("[\(Self.self)] out of items (\(self.items.count)), loading more") try await loadMoreItems() if stream.cursor >= items.count { // NOTE: this assertion can sometimes be tripped by spamming the filter button assert(loadingState == .done, "[\(Item.self) ChildFeedLoader] Invalid loading state \(self.loadingState)") return nil } log.debug("[\(Self.self)] fetched more items (\(self.items.count))") return items[stream.cursor].sortVal(sortType: sortType) } } public func consumeNextItem() -> Item? { guard let stream, stream.parent != nil else { assertionFailure("[\(Item.self)] could not find stream or parent") return nil } stream.cursor += 1 return items[safeIndex: stream.cursor - 1] } public func clear(clearParent: Bool) async { if clearParent { await stream?.parent?.clear() } stream?.cursor = 0 await super.clear() } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/FeedLoaderItem.swift ================================================ // // FeedLoadable.swift // Mlem // // Created by Eric Andrews on 2023-10-15. // import Foundation public protocol FeedLoadable: Filterable, Equatable { associatedtype FilterType var api: ApiClient { get } func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/FeedLoaderSort.swift ================================================ // // FeedLoaderSort.swift // Mlem // // Created by Eric Andrews on 2023-10-15. // import Foundation public enum FeedLoaderSort: Comparable { case new(Date) public enum SortType { case new } } public extension FeedLoaderSort { var sortType: FeedLoaderSort.SortType { switch self { case .new: .new } } var apiType: LemmySortType { switch self { case .new: .new } } func typeEquals(lhs: FeedLoaderSort, rhs: FeedLoaderSort) -> Bool { lhs.sortType == rhs.sortType } /// Compares two FeedLoaderSorts. Returns true if rhs should be sorted after lhs. Assumes that higher items should be sorted first; thus for some sorts (e.g., "old"), the result will be "flipped," since _lower_ dates should be sorted ahead of higher ones. static func < (lhs: FeedLoaderSort, rhs: FeedLoaderSort) -> Bool { guard lhs.sortType == rhs.sortType else { assertionFailure("Compare called on TrackerSorts with different types") return true } switch lhs { case let .new(lhsDate): switch rhs { case let .new(rhsDate): return lhsDate < rhsDate } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/FeedLoading.swift ================================================ // // FeedLoading.swift // // // Created by Eric Andrews on 2024-07-05. // import Foundation public protocol FeedLoading: AnyObject { associatedtype Item: FeedLoadable var items: [Item] { get } var loadingState: FeedLoadingState { get } func loadMoreItems() async throws func loadIfThreshold(_ item: Item) throws func refresh(clearBeforeRefresh: Bool) async throws func clear() async func changeApi(to newApi: ApiClient, context: FilterContext) async /// Adds the given item to the beginning of the items array, regardless of whether it should be filtered /// - Warning: when using this method with multi-feed loaders, you must call this on both the parent loader and the relevant child loader! func prependItem(_ newItem: Item) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/FeedLoadingState.swift ================================================ // // FeedLoadingState.swift // Mlem // // Created by Eric Andrews on 2023-10-12. // import Foundation /// Enum of possible loading states that a loader can be in. /// - idle: not currently loading, but more items available to load /// - loading: currently loading more items /// - done: no more items available to load public enum LoadingState: Hashable { case idle, loading, done } public enum FeedLoadingState: Hashable { case initial, idle, loading, done public init(from loadingState: LoadingState) { self = switch loadingState { case .idle: .idle case .loading: .loading case .done: .done } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/Fetcher.swift ================================================ // // Fetcher.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-17. // import Observation import os enum LoadingResponse { /// Indicates a successful load with more items available to fetch case success([Item]) /// Indicates a successful load with no more items available to fetch case done([Item]) /// Indicates the load was ignored due to an existing ongoing load case ignored /// Indicates the load was cancelled case cancelled var description: String { switch self { case let .success(items): "success (\(items.count))" case let .done(items): "done (\(items.count))" case .ignored: "ignored" case .cancelled: "cancelled" } } } @Observable public class Fetcher { let log: Logger = .mlemLogger() var api: ApiClient var pageSize: Int var page: Int private var cursor: String? init(api: ApiClient, pageSize: Int, page: Int = 0) { self.api = api self.pageSize = pageSize self.page = page } /// Helper struct bundling the response from a fetchPage or fetchCursor call struct FetchResponse { /// Items returned let items: [Item] /// Cursor used to fetch this response, if applicable let prevCursor: String? /// New cursor, if applicable let nextCursor: String? } /// Fetches the next page of items func fetch() async throws -> LoadingResponse { do { if let cursor, page > 0 { log.debug("[\(Item.self) Fetcher] loading cursor \(cursor)") let response = try await fetchCursor(cursor) // if same cursor returned, loading is finished. On Lemmy 1.0, if no cursor is returned, loading is finished. if response.nextCursor == self.cursor || (self.cursor != nil && response.nextCursor == nil) { return .done(response.items) } self.cursor = response.nextCursor return .success(response.items) } else { page += 1 log.debug("[\(Item.self) Fetcher] loading page \(self.page)") let response = try await fetchPage(page) // if nothing returned, loading is finished if response.items.count < pageSize { log.debug("[\(Item.self) Fetcher] received undersized page (\(response.items.count)/\(self.pageSize))") return .done(response.items) } cursor = response.nextCursor return .success(response.items) } } catch is CancellationError { return .cancelled } } /// Fetches the given page of items. /// - Parameters: /// - page: page number to fetch /// - Returns: tuple of the requested page of items, the cursor returned by the API call (if present), and the number of items that were filtered out. func fetchPage(_ page: Int) async throws -> FetchResponse { preconditionFailure("This method must be implemented by the inheriting class") } /// Fetches items from the given cursor. /// - Parameters: /// - cursor: cursor to fetch /// - Returns: tuple of the requested page of items, the cursor returned by the API call (if present), and the number of items that were filtered out. func fetchCursor(_ cursor: String) async throws -> FetchResponse { preconditionFailure("This method must be implemented by the inheriting class") } /// Resets the fetcher's page and cursor tracking. This method should only be overridden to handle abnormal pagination behavior (e.g., SingleSourceMixedFetcher); it should NOT change loading parameters such as query or sort. func reset() async { page = 0 cursor = nil } func changeApi(to newApi: ApiClient, context: FilterContext) async { api = newApi } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/LoadingActor.swift ================================================ // // LoadingActor.swift // MlemMiddleware // // Created by Eric Andrews on 2024-10-27. // import Foundation import os enum LoadingError: Error { case noTask } actor LoadingActor { internal let log: Logger = .mlemLogger() private var done: Bool = false private var loadingTask: Task? var filter: MultiFilter private var fetcher: Fetcher public init(fetcher: Fetcher, filter: MultiFilter) { self.fetcher = fetcher self.filter = filter } /// Cancels any ongoing loading and resets the page/cursor to 0 func reset() async { loadingTask?.cancel() loadingTask = nil filter.reset() await fetcher.reset() done = false } /// Loads the next page of items. /// - Returns: on success, .success with FetchResponse containing loaded items; if another load is underway, .ignored; if the load is cancelled, .cancelled func load(_ callback: @escaping (LoadingResponse) async -> Void) async throws { guard !done else { log.debug("[\(Self.self)] ignoring request, finished loading") return } // if already loading something, ignore the request if let loadingTask { log.debug("[\(Self.self)] ignoring request, load underway") // return .ignored _ = try await loadingTask.result.get() log.debug("[\(Self.self)] preexisting load finished, returning") return } // upon completion of load, remove loading task defer { loadingTask = nil } loadingTask = Task { let response = try await fetchMoreItems() await callback(response) } guard let loadingTask else { assertionFailure("loadingTask is nil!") throw LoadingError.noTask } _ = try await loadingTask.result.get() log.info("[\(Self.self)] finished loading") } @discardableResult func filterItem(_ target: Item) -> Item? { let filtered = filter.filter([target]) return filtered.first } func activateFilter(_ target: Item.FilterType, callback: () async throws -> Void) async throws { loadingTask?.cancel() loadingTask = nil if filter.activate(target) { try await callback() } } func deactivateFilter(_ target: Item.FilterType, callback: () async throws -> Void) async throws { loadingTask?.cancel() loadingTask = nil if filter.deactivate(target) { try await callback() } } // MARK: Helpers private func fetchMoreItems() async throws -> LoadingResponse { var newItems: [Item] = .init() fetchLoop: repeat { let response = try await fetcher.fetch() switch response { case let .success(items): log.debug("[\(Self.self)] received success (\(items.count))") newItems.append(contentsOf: filter.filter(items)) case let .done(items): log.debug("[\(Self.self)] received finished (\(items.count))") newItems.append(contentsOf: filter.filter(items)) return .done(newItems) case .cancelled, .ignored: log.info("[\(Self.self)] load did not complete (\(response.description))") break fetchLoop } } while newItems.count < MiddlewareConstants.infiniteLoadThresholdOffset return .success(newItems) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/MultiFetcher.swift ================================================ // // MultiFetcher.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-24. // import Observation @Observable class MultiFetcher: Fetcher { var sources: [ChildFeedLoader] var sortType: FeedLoaderSort.SortType init(api: ApiClient, pageSize: Int, sources: [ChildFeedLoader], sortType: FeedLoaderSort.SortType) { self.sources = sources self.sortType = sortType super.init(api: api, pageSize: pageSize) } override func fetch() async throws -> LoadingResponse { var newItems: [Item] = .init() while newItems.count < pageSize { if let nextItem = try await computeNextItem() { newItems.append(nextItem) } else { log.debug("[\(Self.self)] no next item found") return .done(newItems) } } return .success(newItems) } override func reset() async { for source in sources { await source.clear(clearParent: false) log.debug("[\(Self.self)] source cleared (\(String(describing: source.loadingState)))") } await super.reset() } /// Computes and returns the highest sorted item from the tops of all sources private func computeNextItem() async throws -> Item? { var sortVal: FeedLoaderSort? var sourceToConsume: ChildFeedLoader? // find the highest-sorted item from the tops of all sources for source in sources { (sortVal, sourceToConsume) = try await compareNextItem(lhsVal: sortVal, lhsSource: sourceToConsume, rhsSource: source) } return sourceToConsume?.consumeNextItem() } private func compareNextItem( lhsVal: FeedLoaderSort?, lhsSource: ChildFeedLoader?, rhsSource: ChildFeedLoader ) async throws -> (FeedLoaderSort?, ChildFeedLoader?) { // if no next item on rhs, return lhs (even if null) guard let rhsVal = try await rhsSource.nextItemSortVal(sortType: sortType) else { return (lhsVal, lhsSource) } // if no lhsVal, rhs next by default guard let lhsVal else { return (rhsVal, rhsSource) } return lhsVal > rhsVal ? (lhsVal, lhsSource) : (rhsVal, rhsSource) } override func changeApi(to newApi: ApiClient, context: FilterContext) async { for source in sources { await source.changeApi(to: newApi, context: context) } await super.changeApi(to: newApi, context: context) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/PrefetchingFeedLoader.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-25. // import Foundation import Nuke import Observation @Observable public class PrefetchingFeedLoader: StandardFeedLoader { public private(set) var prefetchingConfiguration: PrefetchingConfiguration init( filter: MultiFilter, prefetchingConfiguration: PrefetchingConfiguration, fetcher: Fetcher ) { self.prefetchingConfiguration = prefetchingConfiguration super.init( filter: filter, fetcher: fetcher ) } override func processNewItems(_ items: [Item]) { prefetchImages(items) } private func prefetchImages(_ items: [Item]) { Task { await prefetchingConfiguration.prefetcher.startPrefetching(with: items.concurrentFlatMap { item -> [ImageRequest] in await item.imageRequests(configuration: self.prefetchingConfiguration) }) } } public func setPrefetchingConfiguration(_ config: PrefetchingConfiguration) { prefetchingConfiguration = config prefetchImages(items) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Generics/StandardFeedLoader.swift ================================================ // // StandardFeedLoader.swift // Mlem // // Created by Eric Andrews on 2023-10-15. // import Foundation import Observation import os import Semaphore @Observable public class StandardFeedLoader: FeedLoading { let log: Logger = .mlemLogger() public internal(set) var items: [Item] = .init() public internal(set) var loadingState: FeedLoadingState = .initial private(set) var thresholds: Thresholds = .init() let fetcher: Fetcher var loadingActor: LoadingActor init(filter: MultiFilter, fetcher: Fetcher) { self.fetcher = fetcher self.loadingActor = .init(fetcher: fetcher, filter: filter) } // MARK: - State Modification Methods /// Updates the loading state @MainActor func setLoading(_ newState: FeedLoadingState) { loadingState = newState log.debug("[\(Self.self)] set loading state to \(String(describing: newState))") } /// Sets the items to a new array @MainActor func setItems(_ newItems: [Item]) { processNewItems(newItems) items = newItems thresholds.update(with: newItems) } /// Adds the given items to the items array /// - Parameter toAdd: items to add @MainActor func addItems(_ newItems: [Item]) async { processNewItems(newItems) items.append(contentsOf: newItems) thresholds.update(with: newItems) } @MainActor public func prependItem(_ newItem: Item) { items.prepend(newItem) // filter the item on a background thread for deduping Task { await loadingActor.filterItem(newItem) } } // MARK: - External methods /// If the given item is the loading threshold item, loads more content /// This should be called as an .onAppear of every item in a feed that should support infinite scrolling public func loadIfThreshold(_ item: Item) throws { if loadingState == .idle, thresholds.isThreshold(item) { // this is a synchronous function that wraps the loading as a task so that the task is attached to the loader itself, not the view that calls it, and is therefore safe from being cancelled by view redraws Task(priority: .userInitiated) { try await loadMoreItems() } } } /// Loads the next page of items. Returns when more items have been added to the items array or loading is complete, even /// if called while another load is underway public func loadMoreItems() async throws { try await loadMoreItems(overwriteExistingItems: false) } /// Internal loadMoreItems() that allows overwriting existing items, used to back refresh func loadMoreItems(overwriteExistingItems: Bool) async throws { await setLoading(.loading) try await loadingActor.load { response in var newItems: [Item]? var newState: FeedLoadingState switch response { case let .success(items): newItems = items newState = items.count > 0 ? .idle : .done case let .done(items): newItems = items newState = .done case .ignored, .cancelled: self.log.info("[\(Self.self)] load did not complete (\(response.description))") newState = .idle } if let newItems { if overwriteExistingItems { await self.setItems(newItems) } else { await self.addItems(newItems) } } await self.setLoading(newState) self.log.info("[\(Self.self)] loadMoreItems complete") } } public func refresh(clearBeforeRefresh: Bool) async throws { await setLoading(.loading) if clearBeforeRefresh { await setItems(.init()) } await loadingActor.reset() try await loadMoreItems(overwriteExistingItems: true) } public func clear() async { await loadingActor.reset() await setItems(.init()) await setLoading(.idle) } /// Helper function to perform custom post-fetch processing (e.g., prefetching). Override to implement desired behavior. func processNewItems(_ items: [Item]) {} /// Adds a filter to the tracker, removing all current items that do not pass the filter and filtering out all future items that do not pass the filter. /// Use in situations where filtering is handled client-side (e.g., keywords) /// - Parameter newFilter: Item.FilterType describing the filter to apply public func activateFilter(_ target: Item.FilterType) async throws { try await loadingActor.activateFilter(target) { await setItems(loadingActor.filter.reset(with: items)) if items.isEmpty { try await refresh(clearBeforeRefresh: false) } else if thresholds.fallback == nil { // if too few items are present after filtering to trigger threshold loading, initiate new load try await loadMoreItems() } } } public func deactivateFilter(_ target: Item.FilterType) async throws { try await loadingActor.deactivateFilter(target) { try await refresh(clearBeforeRefresh: true) } } public func getFilteredCount(for toCount: Item.FilterType) async -> Int { await loadingActor.filter.numFiltered(for: toCount) } public func changeApi(to newApi: ApiClient, context: FilterContext) async { await fetcher.changeApi(to: newApi, context: context) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Helpers/Thresholds.swift ================================================ // // Thresholds.swift // // // Created by Eric Andrews on 2024-07-22. // import Foundation struct Thresholds { var standard: Item? var fallback: Item? func isThreshold(_ item: Item) -> Bool { item == standard || item == fallback } mutating func update(with newItems: [Item]) { if newItems.count < MiddlewareConstants.infiniteLoadThresholdOffset { standard = nil fallback = nil } else { standard = newItems[newItems.count - MiddlewareConstants.infiniteLoadThresholdOffset] fallback = newItems.last } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/InboxChildFeedLoader.swift ================================================ // // InboxChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-04. // public class InboxChildFeedLoader: ChildFeedLoader { var inboxFetcher: InboxFetcher { fetcher as! InboxFetcher } public init(api: ApiClient, sortType: FeedLoaderSort.SortType, fetcher: InboxFetcher, showRead: Bool) { super.init(filter: InboxItemFilter(showRead: showRead), fetcher: fetcher, sortType: sortType) } func hideRead() async throws { try await loadingActor.activateFilter(.read) { await setItems(loadingActor.filter.reset(with: items)) inboxFetcher.hideRead(unreadCount: items.count) if items.isEmpty { try await refresh(clearBeforeRefresh: false) } } } func showRead() async throws { try await loadingActor.deactivateFilter(.read) { inboxFetcher.showRead() try await refresh(clearBeforeRefresh: true) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/InboxFeedLoader.swift ================================================ // // InboxFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-27. // import Foundation public class InboxFeedLoader: StandardFeedLoader { var inboxFetcher: MultiFetcher { fetcher as! MultiFetcher } public init(api: ApiClient, pageSize: Int, sources: [ChildFeedLoader], sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init(filter: InboxItemFilter(showRead: showRead), fetcher: MultiFetcher(api: api, pageSize: pageSize, sources: sources, sortType: sortType)) for source in sources { source.setParent(parent: self) } } public static func setup( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool ) -> ( replyFeedLoader: ReplyChildFeedLoader, mentionFeedLoader: MentionChildFeedLoader, messageFeedLoader: MessageChildFeedLoader, inboxFeedLoader: InboxFeedLoader ) { let replyFeedLoader: ReplyChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let mentionFeedLoader: MentionChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let messageFeedLoader: MessageChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let inboxFeedLoader: InboxFeedLoader = .init( api: api, pageSize: pageSize, sources: [replyFeedLoader, mentionFeedLoader, messageFeedLoader], sortType: sortType, showRead: showRead ) return ( replyFeedLoader, mentionFeedLoader, messageFeedLoader, inboxFeedLoader ) } public func hideRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in inboxFetcher.sources { group.addTask { guard let childSource = source as? InboxChildFeedLoader else { assertionFailure("Child is not InboxChildFeedLoader") return } try await childSource.hideRead() } } } try await activateFilter(.read) } public func showRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in inboxFetcher.sources { group.addTask { guard let childSource = source as? InboxChildFeedLoader else { assertionFailure("Child is not InboxChildFeedLoader") return } try await childSource.showRead() } } } try await deactivateFilter(.read) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/InboxFetcher.swift ================================================ // // InboxFetcher.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-24. // import Foundation @Observable public class InboxFetcher: Fetcher { var unreadOnly: Bool init(api: ApiClient, pageSize: Int, unreadOnly: Bool) { self.unreadOnly = unreadOnly super.init(api: api, pageSize: pageSize) } /// Updates fetching behavior to hide read items. Assumes items will NOT be cleared from the associated FeedLoader and that deduping will be handled by that FeedLoader. /// - Parameter unreadCount: number of unread items still present after client-side filtering func hideRead(unreadCount: Int) { guard !unreadOnly else { assertionFailure("Cannot hide read (unreadOnly already true)") return } unreadOnly = true page = Int(floor(Double(unreadCount / pageSize))) } /// Updates fetching behavior to show read posts. Assumes associated FeedLoader will immediately perform a refresh. func showRead() { guard unreadOnly else { assertionFailure("Cannot show read (unreadOnly already false)") return } unreadOnly = false } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/MentionChildFeedLoader.swift ================================================ // // MentionChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-02. // public class MentionChildFeedLoader: InboxChildFeedLoader { class Fetcher: InboxFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { let response = try await api.getMentionNotifications( page: page, cursor: nil, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { let response = try await api.getMentionNotifications( page: nil, cursor: cursor, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/MessageChildFeedLoader.swift ================================================ // // MessageChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-26. // public class MessageChildFeedLoader: InboxChildFeedLoader { class Fetcher: InboxFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { let response = try await api.getMessageNotifications( page: page, cursor: nil, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { let response = try await api.getMessageNotifications( page: nil, cursor: cursor, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/MessageFeedLoader.swift ================================================ // // MessageFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-22. // import Foundation @Observable class MessageFetcher: Fetcher { var personId: Int? init(api: ApiClient, personId: Int?, pageSize: Int) { self.personId = personId super.init(api: api, pageSize: pageSize) } convenience init(person: Person, pageSize: Int) { self.init(api: person.api, personId: person.id, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let messages = try await api.getMessages( creatorId: personId, page: page, limit: pageSize ) return .init( items: messages, prevCursor: nil, nextCursor: nil ) } } @Observable public class MessageFeedLoader: StandardFeedLoader { public var api: ApiClient // force unwrap because this should ALWAYS be a MessageFetcher var messageFetcher: MessageFetcher { fetcher as! MessageFetcher } public init( api: ApiClient, personId: Int?, pageSize: Int = 20 ) { self.api = api super.init( filter: .init(), fetcher: MessageFetcher( api: api, personId: personId, pageSize: pageSize ) ) } public init( person: Person, pageSize: Int = 20 ) { self.api = person.api super.init( filter: .init(), fetcher: MessageFetcher( person: person, pageSize: pageSize ) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoader/ReplyChildFeedLoader.swift ================================================ // // ReplyChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-27. // public class ReplyChildFeedLoader: InboxChildFeedLoader { class Fetcher: InboxFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { let response = try await api.getReplyNotifications( page: page, cursor: nil, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { let response = try await api.getReplyNotifications( page: nil, cursor: cursor, limit: pageSize, unreadOnly: unreadOnly ) return .init( items: response.notifications, prevCursor: nil, nextCursor: response.cursor ) } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxFeedLoading.swift ================================================ // // InboxFeedLoading.swift // MlemMiddleware // // Created by Eric Andrews on 2025-02-01. // protocol InboxFeedLoading: FeedLoading { func showRead() async throws func hideRead() async throws } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/InboxIdentifiable.swift ================================================ // // InboxIdentifiable.swift // MlemMiddleware // // Created by Eric Andrews on 2025-02-01. // public protocol InboxIdentifiable: Equatable { /// Identifier suitable for uniquely distinguishing inbox items from each other var inboxId: Int { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ApplicationChildFeedLoader.swift ================================================ // // ApplicationChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-02. // public class ApplicationChildFeedLoader: ModMailChildFeedLoader { class Fetcher: ModMailFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { guard api.isAdmin else { return .init(items: [], prevCursor: nil, nextCursor: nil) } do { let response = try await api.getRegistrationApplications(page: page, limit: pageSize, unreadOnly: unreadOnly) return .init( items: response.map { .application($0) }, prevCursor: nil, nextCursor: nil ) } catch let ApiClientError.response(response, _) where response.notAdmin { return .init(items: .init(), prevCursor: nil, nextCursor: nil) } } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/CommentReportChildFeedLoader.swift ================================================ // // CommentReportChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-26. // public class CommentReportChildFeedLoader: ModMailChildFeedLoader { class Fetcher: ModMailFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { do { let response = try await api.getCommentReports(page: page, limit: pageSize, unresolvedOnly: unreadOnly) return .init( items: response.map { .report($0) }, prevCursor: nil, nextCursor: nil ) } catch let ApiClientError.response(response, _) where response.notModOrAdmin { return .init(items: .init(), prevCursor: nil, nextCursor: nil) } } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/MessageReportChildFeedLoader.swift ================================================ // // MessageReportChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-27. // public class MessageReportChildFeedLoader: ModMailChildFeedLoader { class Fetcher: ModMailFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { guard api.isAdmin else { return .init(items: [], prevCursor: nil, nextCursor: nil) } do { let response = try await api.getMessageReports(page: page, limit: pageSize, unresolvedOnly: unreadOnly) return .init( items: response.map { .report($0) }, prevCursor: nil, nextCursor: nil ) } catch let ApiClientError.response(response, _) where response.notAdmin { return .init(items: .init(), prevCursor: nil, nextCursor: nil) } } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ModMailChildFeedLoader.swift ================================================ // // ModMailChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-04. // public class ModMailChildFeedLoader: ChildFeedLoader, InboxFeedLoading { var modMailFetcher: ModMailFetcher { fetcher as! ModMailFetcher } public init(api: ApiClient, sortType: FeedLoaderSort.SortType, fetcher: ModMailFetcher, showRead: Bool) { super.init(filter: ModMailItemFilter(showRead: showRead), fetcher: fetcher, sortType: sortType) } func hideRead() async throws { try await loadingActor.activateFilter(.read) { await setItems(loadingActor.filter.reset(with: items)) modMailFetcher.hideRead(unreadCount: items.count) if items.isEmpty { try await refresh(clearBeforeRefresh: false) } } } func showRead() async throws { try await loadingActor.deactivateFilter(.read) { modMailFetcher.showRead() try await refresh(clearBeforeRefresh: true) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ModMailFeedLoader.swift ================================================ // // ModMailFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-27. // import Foundation public class ModMailFeedLoader: StandardFeedLoader { var modMailFetcher: MultiFetcher { fetcher as! MultiFetcher } public init( api: ApiClient, pageSize: Int, sources: [ChildFeedLoader], sortType: FeedLoaderSort.SortType, showRead: Bool ) { super.init( filter: ModMailItemFilter(showRead: showRead), fetcher: MultiFetcher(api: api, pageSize: pageSize, sources: sources, sortType: sortType) ) for source in sources { source.setParent(parent: self) } } public static func setup( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool ) -> ( reportFeedLoader: ReportChildFeedLoader, applicationFeedLoader: ApplicationChildFeedLoader, modMailFeedLoader: ModMailFeedLoader ) { let postReportFeedLoader: PostReportChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let commentReportFeedLoader: CommentReportChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let messageReportFeedLoader: MessageReportChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let reportFeedLoader: ReportChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, sources: [postReportFeedLoader, commentReportFeedLoader, messageReportFeedLoader], showRead: showRead ) let applicationFeedLoader: ApplicationChildFeedLoader = .init( api: api, pageSize: pageSize, sortType: sortType, showRead: showRead ) let modMailFeedLoader: ModMailFeedLoader = .init( api: api, pageSize: pageSize, sources: [reportFeedLoader, applicationFeedLoader], sortType: sortType, showRead: showRead ) return (reportFeedLoader, applicationFeedLoader, modMailFeedLoader) } public func hideRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in modMailFetcher.sources { group.addTask { guard let childSource = source as? any InboxFeedLoading else { assertionFailure("Child is not ModMailChildFeedLoader") return } try await childSource.hideRead() } } } try await activateFilter(.read) } public func showRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in modMailFetcher.sources { group.addTask { guard let childSource = source as? any InboxFeedLoading else { assertionFailure("Child is not ModMailChildFeedLoader") return } try await childSource.showRead() } } } try await deactivateFilter(.read) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ModMailFetcher.swift ================================================ // // ModMailFetcher.swift // MlemMiddleware // // Created by Eric Andrews on 2024-11-24. // import Foundation @Observable public class ModMailFetcher: Fetcher { var unreadOnly: Bool init(api: ApiClient, pageSize: Int, unreadOnly: Bool) { self.unreadOnly = unreadOnly super.init(api: api, pageSize: pageSize) } /// Updates fetching behavior to hide read items. Assumes items will NOT be cleared from the associated FeedLoader and that deduping will be handled by that FeedLoader. /// - Parameter unreadCount: number of unread items still present after client-side filtering func hideRead(unreadCount: Int) { guard !unreadOnly else { assertionFailure("Cannot hide read (unreadOnly already true)") return } unreadOnly = true page = Int(floor(Double(unreadCount / pageSize))) } /// Updates fetching behavior to show read posts. Assumes associated FeedLoader will immediately perform a refresh. func showRead() { guard unreadOnly else { assertionFailure("Cannot show read (unreadOnly already false)") return } unreadOnly = false } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ModMailItem.swift ================================================ // // ModMailItem.swift // MlemMiddleware // // Created by Eric Andrews on 2025-02-01. // public enum ModMailItem: FeedLoadable, ReadableProviding, InboxIdentifiable { public typealias FilterType = ModMailItemFilterType case report(Report) case application(RegistrationApplication) var baseValue: any FeedLoadable { switch self { case let .report(report): report case let .application(application): application } } public var read: Bool { switch self { case let .report(report): report.resolved case let .application(application): application.resolution != .unresolved } } public var inboxId: Int { switch self { case let .report(report): report.modMailId case let .application(application): application.modMailId } } public var api: ApiClient { baseValue.api } public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { baseValue.sortVal(sortType: sortType) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/PostReportChildFeedLoader.swift ================================================ // // PostReportChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2024-12-02. // public class PostReportChildFeedLoader: ModMailChildFeedLoader { class Fetcher: ModMailFetcher { override func fetchPage(_ page: Int) async throws -> FetchResponse { do { let response = try await api.getPostReports(page: page, limit: pageSize) return .init( items: response.map { .report($0) }, prevCursor: nil, nextCursor: nil ) } catch let ApiClientError.response(response, _) where response.notModOrAdmin { return .init(items: .init(), prevCursor: nil, nextCursor: nil) } } } public init(api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, showRead: Bool) { super.init( api: api, sortType: sortType, fetcher: Fetcher( api: api, pageSize: pageSize, unreadOnly: !showRead ), showRead: showRead ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Inbox/ModMailFeedLoader/ReportChildFeedLoader.swift ================================================ // // ReportChildFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2025-02-01. // class ReportFetcher: MultiFetcher {} public class ReportChildFeedLoader: ChildFeedLoader, InboxFeedLoading { var reportFetcher: MultiFetcher { fetcher as! ReportFetcher } public init( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, sources: [ModMailChildFeedLoader], showRead: Bool ) { let fetcher: ReportFetcher = .init( api: api, pageSize: pageSize, sources: sources, sortType: sortType ) super.init(filter: ModMailItemFilter(showRead: showRead), fetcher: fetcher, sortType: sortType) for source in sources { source.setParent(parent: self) } } public func hideRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in reportFetcher.sources { group.addTask { guard let childSource = source as? ModMailChildFeedLoader else { assertionFailure("Child is not ModMailChildFeedLoader") return } try await childSource.hideRead() } } } try await activateFilter(.read) } public func showRead() async throws { await withThrowingTaskGroup(of: Void.self) { group in for source in reportFetcher.sources { group.addTask { guard let childSource = source as? ModMailChildFeedLoader else { assertionFailure("Child is not ModMailChildFeedLoader") return } try await childSource.showRead() } } } try await deactivateFilter(.read) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Modlog/ModlogChildFeedLoader.swift ================================================ // // ModlogChildFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-28. // import Foundation public class ModlogChildFeedLoader: ChildFeedLoader { var modlogFetcher: ModlogChildFetcher { fetcher as! ModlogChildFetcher } public init(api: ApiClient, sortType: FeedLoaderSort.SortType, fetcher: ModlogChildFetcher) { super.init(filter: ModlogEntryFilter(), fetcher: fetcher, sortType: sortType) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Modlog/ModlogChildFetcher+SharedCache.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-29. // import Foundation extension ModlogChildFetcher { class SharedCache { typealias TaskResponse = [ModlogEntryType: [ModlogEntry]] var api: ApiClient let pageSize: Int var communityId: Int? var targetPersonId: Int? var moderatorPersonId: Int? var ongoingTask: Task? init(api: ApiClient, pageSize: Int, communityId: Int?) { self.api = api self.pageSize = pageSize self.communityId = communityId } private func fetchItems() async throws -> TaskResponse { let response = try await api.getModlog( page: 1, limit: pageSize, communityId: communityId, moderatorId: moderatorPersonId, subjectPersonId: targetPersonId ) return .init(grouping: response, by: { $0.type.type }) } @MainActor func get(type: ModlogEntryType) async throws -> [ModlogEntry] { let task: Task if let ongoingTask { task = ongoingTask } else { task = Task { try await fetchItems() } ongoingTask = task } let response = try await task.result.get() return response[type] ?? [] } @MainActor func reset() { ongoingTask = nil } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Modlog/ModlogChildFetcher.swift ================================================ // // ModlogChildFetcher.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-28. // import Foundation @Observable public class ModlogChildFetcher: Fetcher { let sharedCache: SharedCache var communityId: Int? var targetPersonId: Int? var moderatorPersonId: Int? var type: ModlogEntryType init( api: ApiClient, pageSize: Int, sharedCache: SharedCache, communityId: Int?, targetPersonId: Int?, moderatorPersonId: Int?, type: ModlogEntryType ) { self.communityId = communityId self.targetPersonId = targetPersonId self.moderatorPersonId = moderatorPersonId self.type = type self.sharedCache = sharedCache super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let items: [ModlogEntry] if page == 1 { items = try await sharedCache.get(type: type) } else { items = try await api.getModlog( page: page, limit: pageSize, communityId: communityId, moderatorId: moderatorPersonId, subjectPersonId: targetPersonId, type: type ) } return .init( items: items, prevCursor: nil, nextCursor: nil ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Modlog/ModlogFeedLoader.swift ================================================ // // ModlogFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 2024-12-28. // import Foundation public class ModlogFeedLoader: StandardFeedLoader { var modlogFetcher: MultiFetcher { fetcher as! MultiFetcher } var modlogSources: [ModlogChildFeedLoader] { (modlogFetcher.sources as? [ModlogChildFeedLoader])! } private var sharedCache: ModlogChildFetcher.SharedCache public init( api: ApiClient, pageSize: Int, communityId: Int?, targetPersonId: Int?, moderatorPersonId: Int?, sortType: FeedLoaderSort.SortType ) { let sharedCache: ModlogChildFetcher.SharedCache = .init(api: api, pageSize: pageSize, communityId: communityId) self.sharedCache = sharedCache let sources: [ModlogChildFeedLoader] = ModlogEntryType.allCases.map { type in .init( api: api, sortType: sortType, fetcher: .init( api: api, pageSize: pageSize, sharedCache: sharedCache, communityId: communityId, targetPersonId: targetPersonId, moderatorPersonId: moderatorPersonId, type: type ) ) } super.init( filter: ModlogEntryFilter(), fetcher: MultiFetcher(api: api, pageSize: pageSize, sources: sources, sortType: sortType) ) for source in sources { source.setParent(parent: self) } } public func items(ofType type: ModlogEntryType?) -> [ModlogEntry] { if let type { modlogSources.first { $0.modlogFetcher.type == type }?.items ?? [] } else { items } } public func childLoader(ofType type: ModlogEntryType) -> ModlogChildFeedLoader { modlogSources.first(where: { $0.modlogFetcher.type == type })! } public func refresh( api: ApiClient? = nil, communityId: Int? = nil, targetPersonId: Int? = nil, moderatorPersonId: Int? = nil, clearBeforeRefresh: Bool = false ) async throws { sharedCache.api = api ?? sharedCache.api sharedCache.communityId = communityId sharedCache.targetPersonId = targetPersonId sharedCache.moderatorPersonId = moderatorPersonId for source in modlogSources { await source.changeApi(to: api ?? sharedCache.api, context: .none()) source.modlogFetcher.communityId = communityId ?? source.modlogFetcher.communityId source.modlogFetcher.targetPersonId = targetPersonId ?? source.modlogFetcher.targetPersonId source.modlogFetcher.moderatorPersonId = moderatorPersonId ?? source.modlogFetcher.moderatorPersonId } try await refresh(clearBeforeRefresh: clearBeforeRefresh) } override public func refresh(clearBeforeRefresh: Bool) async throws { await sharedCache.reset() try await super.refresh(clearBeforeRefresh: clearBeforeRefresh) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Person/PersonFeedLoader.swift ================================================ // // PersonFeedLoader.swift // // // Created by Sjmarf on 08/09/2024. // import Foundation @Observable class PersonFetcher: Fetcher { var query: String /// `listing` can be set to `.local` from 0.19.4 onwards. var listing: ListingType var sort: SearchSortType init(api: ApiClient, pageSize: Int, query: String, listing: ListingType, sort: SearchSortType) { self.query = query self.listing = listing self.sort = sort super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let communities = try await api.searchPeople( query: query, page: page, limit: pageSize, filter: listing, sort: sort ) return .init( items: communities, prevCursor: nil, nextCursor: nil ) } } @Observable public class PersonFeedLoader: StandardFeedLoader { public var api: ApiClient // force unwrap because this should ALWAYS be a PersonFetcher var personFetcher: PersonFetcher { fetcher as! PersonFetcher } public init( api: ApiClient, query: String = "", pageSize: Int = 20, listing: ListingType = .all, sort: SearchSortType = .top(.allTime) ) { self.api = api super.init( filter: .init(), fetcher: PersonFetcher(api: api, pageSize: pageSize, query: query, listing: listing, sort: sort) ) } public func refresh( query: String? = nil, listing: ListingType? = nil, sort: SearchSortType? = nil, clearBeforeRefresh: Bool = false ) async throws { personFetcher.query = query ?? personFetcher.query personFetcher.listing = listing ?? personFetcher.listing personFetcher.sort = sort ?? personFetcher.sort try await super.refresh(clearBeforeRefresh: clearBeforeRefresh) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Post Feed Loaders/AggregatePostFeedLoader.swift ================================================ // // AggregatePostFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-05. // import Foundation @Observable class AggregatePostFetcher: PostFetcher { var feedType: ListingType let contentFilter: GetContentFilter? init(api: ApiClient, feedType: ListingType, sortType: PostSortType, pageSize: Int, contentFilter: GetContentFilter?) { self.feedType = feedType self.contentFilter = contentFilter super.init(api: api, sortType: sortType, pageSize: pageSize) } override func getPosts(page: Int, cursor: String?) async throws -> (posts: [Post], cursor: String?) { try await api.getPosts( feed: feedType, sort: sortType, page: page, cursor: cursor, limit: pageSize, filter: contentFilter, showHidden: false // TODO: ) } } public class AggregatePostFeedLoader: CorePostFeedLoader { // force unwrap because this should ALWAYS be an AggregatePostFetcher var aggregatePostFetcher: AggregatePostFetcher { fetcher as! AggregatePostFetcher } // force unwrap because this should ALWAYS be a PostFetcher private var postFetcher: PostFetcher { fetcher as! PostFetcher } public var feedType: ListingType { aggregatePostFetcher.feedType } public var sortType: PostSortType { postFetcher.sortType } public init( pageSize: Int, sortType: PostSortType, showReadPosts: Bool, filterContext: FilterContext, prefetchingConfiguration: PrefetchingConfiguration, urlCache: URLCache, api: ApiClient, feedType: ListingType, contentFilter: GetContentFilter? = nil ) { super.init( showReadPosts: showReadPosts, filterContext: filterContext, prefetchingConfiguration: prefetchingConfiguration, fetcher: AggregatePostFetcher( api: api, feedType: feedType, sortType: sortType, pageSize: pageSize, contentFilter: contentFilter ) ) } @MainActor public func changeFeedType(to newFeedType: ListingType) async throws { let shouldRefresh = items.isEmpty || aggregatePostFetcher.feedType != newFeedType // always perform assignment--if account changed, feed type will look unchanged but API will be different aggregatePostFetcher.feedType = newFeedType // only refresh if nominal feed type changed if shouldRefresh { try await refresh(clearBeforeRefresh: true) } } /// Changes the post sort type to the specified value and reloads the feed public func changeSortType(to newSortType: PostSortType, forceRefresh: Bool = false) async throws { // don't do anything if sort type not changed guard postFetcher.sortType != newSortType || forceRefresh else { return } postFetcher.sortType = newSortType try await refresh(clearBeforeRefresh: true) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Post Feed Loaders/CommunityPostFeedLoader.swift ================================================ // // CommunityPostFeedLoader.swift // // // Created by Eric Andrews on 2024-07-07. // import Foundation @Observable class CommunityPostFetcher: PostFetcher { var community: Community init(sortType: PostSortType, pageSize: Int, community: Community) { self.community = community super.init(api: community.api, sortType: sortType, pageSize: pageSize) } override func getPosts(page: Int, cursor: String?) async throws -> (posts: [Post], cursor: String?) { try await community.getPosts( sort: sortType, page: page, cursor: cursor, limit: pageSize, filter: nil, // TODO: showHidden: false // TODO: ) } } public class CommunityPostFeedLoader: CorePostFeedLoader { public var community: Community var communityPostFetcher: CommunityPostFetcher { fetcher as! CommunityPostFetcher } // force unwrap because this should ALWAYS be a PostFetcher private var postFetcher: PostFetcher { fetcher as! PostFetcher } public var sortType: PostSortType { postFetcher.sortType } public init( pageSize: Int, sortType: PostSortType, showReadPosts: Bool, filterContext: FilterContext, prefetchingConfiguration: PrefetchingConfiguration, urlCache: URLCache, community: Community ) { self.community = community super.init( showReadPosts: showReadPosts, filterContext: filterContext, prefetchingConfiguration: prefetchingConfiguration, fetcher: CommunityPostFetcher(sortType: sortType, pageSize: pageSize, community: community) ) } override public func changeApi(to newApi: ApiClient, context: FilterContext) async { do { let resolvedCommunity = try await newApi.resolve(url: community.actorId.url) guard let newCommunity = resolvedCommunity as? Community else { assertionFailure("Did not get community back") return } filter.updateContext(to: context) communityPostFetcher.community = newCommunity } catch { assertionFailure("Couldn't change API") } } /// Changes the post sort type to the specified value and reloads the feed public func changeSortType(to newSortType: PostSortType, forceRefresh: Bool = false) async throws { // don't do anything if sort type not changed guard postFetcher.sortType != newSortType || forceRefresh else { return } postFetcher.sortType = newSortType try await refresh(clearBeforeRefresh: true) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Post Feed Loaders/CorePostFeedLoader.swift ================================================ // // CorePostFeedLoader.swift // MlemMiddleware // // Created by Eric Andrews on 2026-01-05. // import Foundation import Nuke import Observation @Observable public class PostFetcher: Fetcher { var sortType: PostSortType init(api: ApiClient, sortType: PostSortType, pageSize: Int) { self.sortType = sortType super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> FetchResponse { let result = try await getPosts(page: page, cursor: nil) return .init( items: result.posts, prevCursor: nil, nextCursor: result.cursor ) } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { let result = try await getPosts(page: 1, cursor: cursor) return .init( items: result.posts, prevCursor: cursor, nextCursor: result.cursor ) } func getPosts(page: Int, cursor: String?) async throws -> (posts: [Post], cursor: String?) { preconditionFailure("This method must be implemented by the inheriting class") } } /// Post tracker for use with single feeds. Can easily be extended to load any pure post feed by creating an inheriting class that overrides getPosts(). @Observable public class CorePostFeedLoader: PrefetchingFeedLoader { // store reference to the filter used by the LoadingActor so we can modify its filterContext from changeApi var filter: PostFilter init( showReadPosts: Bool, filterContext: FilterContext, prefetchingConfiguration: PrefetchingConfiguration, fetcher: Fetcher ) { let filter: PostFilter = .init(showRead: showReadPosts, context: filterContext) self.filter = filter super.init( filter: filter, prefetchingConfiguration: prefetchingConfiguration, fetcher: fetcher ) } // MARK: Custom Behavior override public func changeApi(to newApi: ApiClient, context: FilterContext) async { filter.updateContext(to: context) await fetcher.changeApi(to: newApi, context: context) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Post Feed Loaders/SearchPostFeedLoader.swift ================================================ // // SearchPostFeedLoader.swift // MlemMiddleware // // Created by Sjmarf on 04/10/2024. // import Foundation @Observable public class SearchPostFetcher: Fetcher { public enum SortType { case v4(SearchSortType) case v3(PostSortType) } public var query: String public var communityId: Int? public var creatorId: Int? public var listing: ListingType public var sortType: SortType // setters to allow manual overriding of these for search use cases override public func changeApi(to newApi: ApiClient, context: FilterContext) async { await super.changeApi(to: newApi, context: context) } public func setSortType(_ sortType: SortType) { self.sortType = sortType } init( api: ApiClient, sortType: SortType, pageSize: Int, query: String, communityId: Int?, creatorId: Int?, listing: ListingType ) { self.query = query self.communityId = communityId self.creatorId = creatorId self.listing = listing self.sortType = sortType super.init(api: api, pageSize: pageSize) } override func fetchPage(_ page: Int) async throws -> Fetcher.FetchResponse { let response: [Post] switch sortType { case let .v4(searchSortType): response = try await api.searchPosts( query: query, page: page, limit: pageSize, communityId: communityId, creatorId: creatorId, filter: listing, sort: searchSortType ) case let .v3(postSortType): response = try await api.searchPosts( query: query, page: page, limit: pageSize, communityId: communityId, creatorId: creatorId, filter: listing, sort: postSortType ) } return .init(items: response, prevCursor: nil, nextCursor: nil) } } public class SearchPostFeedLoader: CorePostFeedLoader { // force unwrap because this should ALWAYS be a SearchPostFetcher public var searchPostFetcher: SearchPostFetcher { fetcher as! SearchPostFetcher } public init( api: ApiClient, query: String = "", pageSize: Int = 20, sortType: SearchPostFetcher.SortType, creatorId: Int? = nil, communityId: Int? = nil, prefetchingConfiguration: PrefetchingConfiguration, urlCache: URLCache, listing: ListingType = .all ) { super.init( showReadPosts: true, filterContext: .none(), // search doesn't filter, only obscures on the frontend prefetchingConfiguration: prefetchingConfiguration, fetcher: SearchPostFetcher( api: api, sortType: sortType, pageSize: pageSize, query: query, communityId: communityId, creatorId: creatorId, listing: listing ) ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Prefetching/ImagePrefetchProviding.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-25. // import Foundation import Nuke public protocol ImagePrefetchProviding { func imageRequests(configuration config: PrefetchingConfiguration) async -> [ImageRequest] } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/Prefetching/PrefetchingConfiguration.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 10/08/2024. // import Foundation import Nuke public struct PrefetchingConfiguration { public enum ImageResolution { case unlimited, limited(Int) } public var prefetcher: ImagePrefetcher public var imageSize: ImageResolution /// If `nil`, does not fetch avatars. public var avatarSize: Int? let fetchFavicons: Bool let embedLoops: Bool public init( prefetcher: ImagePrefetcher, imageSize: ImageResolution, fetchFavicons: Bool, embedLoops: Bool, avatarSize: Int? = nil ) { self.prefetcher = prefetcher self.imageSize = imageSize self.avatarSize = avatarSize self.fetchFavicons = fetchFavicons self.embedLoops = embedLoops } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/SingleSourceMixedFeedLoader/PersonContent.swift ================================================ // // PersonContent.swift // // // Created by Eric Andrews on 2024-07-21. // import Foundation public class PersonContent: Hashable, Equatable, FeedLoadable, ActorIdentifiable { public typealias FilterType = PersonContentFilterType public let wrappedValue: Value public enum Value { // This always comes from GetPersonDetailsRequest, so we can know we're getting Post2 and Comment2 case post(Post) case comment(Comment) } public init(wrappedValue: PersonContent.Value) { self.wrappedValue = wrappedValue } public func sortVal(sortType: FeedLoaderSort.SortType) -> FeedLoaderSort { switch wrappedValue { case let .post(post): post.sortVal(sortType: sortType) case let .comment(comment): comment.sortVal(sortType: sortType) } } public var actorId: ActorIdentifier { switch wrappedValue { case let .post(post): post.actorId case let .comment(comment2): comment2.actorId } } public var api: ApiClient { switch wrappedValue { case let .post(post): post.api case let .comment(comment2): comment2.api } } public func hash(into hasher: inout Hasher) { switch wrappedValue { case let .post(post): hasher.combine(post) hasher.combine(ContentType.post) case let .comment(comment2): hasher.combine(comment2) hasher.combine(ContentType.comment) } } public static func == (lhs: PersonContent, rhs: PersonContent) -> Bool { lhs.actorId == rhs.actorId } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/SingleSourceMixedFeedLoader/PersonContentProviding.swift ================================================ // // File.swift // // // Created by Eric Andrews on 2024-07-23. // import Foundation /// Protocol for items that can be converted into a generic PersonContent public protocol PersonContentProviding: FeedLoadable { var userContent: PersonContent { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/SingleSourceMixedFeedLoader/PersonContentStream.swift ================================================ // // PersonContentStream.swift // // // Created by Eric Andrews on 2024-07-21. // import CollectionConcurrencyKit import Foundation import Nuke // This struct is just a convenience wrapper to handle stream state--all loading operations happen at the FeedLoader level to // avoid parent/child concurrency control hell public class PersonContentStream { // From the frontend it is more ergonomic to have these be PersonContent. These are guaranteed to all be of type Item by // guarding assignment behind `init` and `addItems`, which can only take Item. private(set) var items: [PersonContent] var cursor: Int = 0 var doneLoading: Bool = false var thresholds: Thresholds var prefetchingConfiguration: PrefetchingConfiguration init(items: [Item]? = nil, prefetchingConfiguration: PrefetchingConfiguration) { self.prefetchingConfiguration = prefetchingConfiguration self.thresholds = .init() if let items { let personContentItems: [PersonContent] = items.map(\.userContent) self.items = personContentItems thresholds.update(with: personContentItems) } else { self.items = .init() } } var needsMoreItems: Bool { !doneLoading && cursor >= items.count } func reset() { items = .init() cursor = 0 doneLoading = false thresholds = .init() } func addItems(_ newItems: [Item]) { let personContentItems: [PersonContent] = newItems.map(\.userContent) preloadImages(personContentItems) items.append(contentsOf: personContentItems) thresholds.update(with: personContentItems) // since the API returns posts and comments together, .success/.done isn't a reliable way to determine whether a particular stream // has finished loading. This will solve that problem in almost every case; however, if the user's filters remove enough items // that the page drops below the threshold, it will erroneously flag the load as done. This would require filtering out 40/50 items, // so it's very unlikely to actually occur. // TODO: 0.19 deprecation rewrite this whole thing with a standard parent/child feed loader setup using the type filtering in /personT if newItems.count < MiddlewareConstants.infiniteLoadThresholdOffset { doneLoading = true } } /// Gets the sort value of the next item in stream for a given sort type without affecting the cursor. Assumes loading has been handled by the FeedLoader. /// - Returns: sorting value of the next tracker item corresponding to the given sort type /// - Warning: This is NOT a thread-safe function! Only one thread at a time per stream may call this function! func nextItemSortVal(sortType: FeedLoaderSort.SortType) async throws -> FeedLoaderSort? { guard cursor < items.count else { return nil } return items[safeIndex: cursor]?.sortVal(sortType: sortType) } /// Gets the next item in the stream and increments the cursor /// - Returns: next item in the feed stream /// - Warning: This is NOT a thread-safe function! Only one thread at a time per stream may call this function! func consumeNextItem() -> PersonContent? { guard cursor < items.count else { return nil } cursor += 1 return items[cursor - 1] } /// Preloads images for the given PersonContent items func preloadImages(_ items: [PersonContent]) { Task { // TODO: prefetch comment images let posts = items.compactMap { item in switch item.wrappedValue { case let .post(post): return post default: return nil } } await prefetchingConfiguration.prefetcher.startPrefetching(with: posts.concurrentFlatMap { post -> [ImageRequest] in await post.imageRequests(configuration: self.prefetchingConfiguration) }) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/FeedLoaders/SingleSourceMixedFeedLoader/SingleSourceMixedFeedLoader.swift ================================================ // // SingleSourceMixedFeedLoader.swift // // // Created by Eric Andrews on 2024-07-09. // import Foundation import Nuke import Semaphore /// This is a special type of FeedLoader built for user content, which is uniquely challenging because you cannot load /// just posts or just comments, and thus the standard Parent/Child FeedLoader construction does not work without /// severe API waste. This solution is a simplified variant of that architecture. /// /// The SingleSourceMixedFeedLoader is the parent loader. It is responsible for all data fetching, and keeps track of two /// PersonContentStreams, one for Posts and one for Comments. To load a page of items, it consumes and merges the child streams, just as /// in the standard Parent/Child FeedLoader; if either stream reaches the end of its items, it triggers a new load, the response from /// which is then incorporated into both child streams. @Observable class SingleSourceMixedFetcher: Fetcher { var sortType: FeedLoaderSort.SortType var userId: Int var savedOnly: Bool var postStream: PersonContentStream var commentStream: PersonContentStream init( api: ApiClient, pageSize: Int, sortType: FeedLoaderSort.SortType, userId: Int, savedOnly: Bool, withContent: (posts: [Post], comments: [Comment])?, prefetchingConfiguration: PrefetchingConfiguration ) { self.sortType = sortType self.userId = userId self.savedOnly = savedOnly self.postStream = .init(items: withContent?.posts, prefetchingConfiguration: prefetchingConfiguration) self.commentStream = .init(items: withContent?.comments, prefetchingConfiguration: prefetchingConfiguration) super.init(api: api, pageSize: pageSize, page: withContent == nil ? 0 : 1) } override func reset() async { postStream.reset() commentStream.reset() await super.reset() } override func fetch() async throws -> LoadingResponse { var newItems: [PersonContent] = .init() while newItems.count < pageSize { if let nextItem = try await computeNextItem() { newItems.append(nextItem) } else { return .done(newItems) } } return .success(newItems) } override func fetchPage(_ page: Int) async throws -> FetchResponse { fatalError("Unsupported loading operation") } override func fetchCursor(_ cursor: String) async throws -> FetchResponse { fatalError("Unsupported loading operation") } /// Returns the next post or comment, depending on which is sorted first private func computeNextItem() async throws -> PersonContent? { // if either postStream or commentStream needs items, load the next page from the API if postStream.needsMoreItems || commentStream.needsMoreItems { page += 1 let response = try await api.getContent(authorId: userId, sort: .new, page: page, limit: pageSize, savedOnly: savedOnly) postStream.addItems(response.posts) commentStream.addItems(response.comments) } let nextPost = try await postStream.nextItemSortVal(sortType: sortType) let nextComment = try await commentStream.nextItemSortVal(sortType: sortType) if let nextPost { if let nextComment { // if both next post and next comment, return higher sort return nextPost > nextComment ? postStream.consumeNextItem() : commentStream.consumeNextItem() } else { // if next post but no next comment, return next post return postStream.consumeNextItem() } } // if no next post, always return next comment (this returns nil if no next comment) return commentStream.consumeNextItem() } } public class SingleSourceMixedFeedLoader: StandardFeedLoader { // force unwrap because this should ALWAYS be a SingleSourceMixedFetcher var singleSourceMixedFetcher: SingleSourceMixedFetcher { fetcher as! SingleSourceMixedFetcher } public var api: ApiClient { singleSourceMixedFetcher.api } public var userId: Int { singleSourceMixedFetcher.userId } // MARK: Custom Behavior // This FeedLoader is slightly awkward because it functions like a multi-loader but draws its posts and comments from a single API call. The streams act essentially like child loaders, but are populated using custom behavior in the fetcher. This FeedLoader is best understood as a multi-loader with the streams as child loaders. private var postStream: PersonContentStream { singleSourceMixedFetcher.postStream } private var commentStream: PersonContentStream { singleSourceMixedFetcher.commentStream } // these are used to allow refresh without clear private var tempPostStream: PersonContentStream? private var tempCommentStream: PersonContentStream? // convenience accessors for child types public var posts: [PersonContent] { tempPostStream?.items ?? postStream.items } public var postLoadingState: FeedLoadingState { postStream.doneLoading ? .done : loadingState } public var comments: [PersonContent] { tempCommentStream?.items ?? commentStream.items } public var commentLoadingState: FeedLoadingState { commentStream.doneLoading ? .done : loadingState } public init( api: ApiClient, pageSize: Int, userId: Int, sortType: FeedLoaderSort.SortType, savedOnly: Bool, prefetchingConfiguration: PrefetchingConfiguration, withContent: (posts: [Post], comments: [Comment])? = nil ) { super.init(filter: MultiFilter(), fetcher: SingleSourceMixedFetcher( api: api, pageSize: pageSize, sortType: sortType, userId: userId, savedOnly: savedOnly, withContent: withContent, prefetchingConfiguration: prefetchingConfiguration )) } // MARK: Custom Behavior override public func refresh(clearBeforeRefresh: Bool) async throws { if !clearBeforeRefresh { tempPostStream = postStream tempCommentStream = commentStream } try await super.refresh(clearBeforeRefresh: clearBeforeRefresh) tempPostStream = nil tempCommentStream = nil } public func changeUser(api: ApiClient, context: FilterContext, userId: Int) async { tempPostStream = postStream tempCommentStream = commentStream await singleSourceMixedFetcher.changeApi(to: api, context: context) singleSourceMixedFetcher.userId = userId await loadingActor.reset() await setLoading(.done) // prevent loading more items until refreshed } public func loadIfThreshold(_ item: PersonContent, asChild: Bool) throws { let shouldLoad: Bool if asChild { shouldLoad = switch item.wrappedValue { case .post: postStream.thresholds.isThreshold(item) case .comment: commentStream.thresholds.isThreshold(item) } } else { shouldLoad = thresholds.isThreshold(item) } // regardless of which threshold triggers this, always call loadMoreItems() because there's no item-specific endpoint if shouldLoad { Task(priority: .userInitiated) { try await loadMoreItems() } } } public func setPrefetchingConfiguration(_ config: PrefetchingConfiguration) { postStream.prefetchingConfiguration = config commentStream.prefetchingConfiguration = config postStream.preloadImages(items) // note that this currently doesn't do anything because comments don't support prefetching yet [Eric 2024.11.13] commentStream.preloadImages(items) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/InstanceConnection.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-05. // import Foundation public protocol InstanceConnection { static var softwareType: SiteSoftwareType { get } init(baseUrl: URL, token: String?) func updateToken(_ newToken: String) var contextIsFetched: Bool { get } func supports(_ feature: Feature) async throws -> Bool func supports(_ feature: Feature, defaultValue: Bool) -> Bool var fetchedVersion: SiteVersion? { get } var version: SiteVersion { get async throws } var myPersonId: Int? { get async throws } func ensureContextPresence() async throws // MARK: - Post func getPosts( communityId: Int, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter?, showHidden: Bool ) async throws -> (posts: [Post2Snapshot], cursor: String?) func getPosts( feed: ListingType, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter?, showHidden: Bool ) async throws -> (posts: [Post2Snapshot], cursor: String?) func getPosts( personId: Int, communityId: Int?, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot]) func getPostHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (posts: [Post2Snapshot], cursor: String?) func getPost(id: Int) async throws -> Post3Snapshot func getPost(url: URL) async throws -> Post2Snapshot // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchPosts( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, sort: PostSortType ) async throws -> [Post2Snapshot] func searchPosts( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, sort: SearchSortType ) async throws -> [Post2Snapshot] func markPostsAsRead(ids: Set, read: Bool) async throws func markPostAsRead(id: Int, read: Bool) async throws @discardableResult func voteOnPost(id: Int, score: ScoringOperation) async throws -> Post2Snapshot @discardableResult func savePost(id: Int, save: Bool) async throws -> Post2Snapshot @discardableResult func deletePost(id: Int, delete: Bool) async throws -> Post2Snapshot func hidePost(id: Int, hide: Bool) async throws func createPost( communityId: Int, title: String, content: String?, linkUrl: URL?, altText: String?, thumbnail: URL?, nsfw: Bool, languageId: Int? ) async throws -> Post2Snapshot @discardableResult func editPost( id: Int, title: String, content: String?, linkUrl: URL?, altText: String?, thumbnail: URL?, nsfw: Bool, languageId: Int? ) async throws -> Post2Snapshot func replyToPost( id: Int, content: String, languageId: Int? ) async throws -> Comment2Snapshot @discardableResult func reportPost(id: Int, reason: String) async throws -> ReportSnapshot func purgePost(id: Int, reason: String?) async throws @discardableResult func removePost( id: Int, remove: Bool, reason: String? ) async throws -> Post2Snapshot @discardableResult func pinPost( id: Int, pin: Bool, to target: PostFeatureType ) async throws -> Post2Snapshot @discardableResult func lockPost(id: Int, lock: Bool) async throws -> Post2Snapshot @discardableResult func setPostNsfw(id: Int, nsfw: Bool) async throws -> Post1Snapshot @discardableResult func getPostVotes( id: Int, page: Int, limit: Int ) async throws -> [PersonVoteSnapshot] @discardableResult func voteInPoll(postId: Int, choiceIds: Set) async throws -> Post2Snapshot // MARK: - Comment func getComment(id: Int) async throws -> Comment2Snapshot func getComment(url: URL) async throws -> Comment2Snapshot func getComments( sort: CommentSortType, page: Int, maxDepth: Int?, limit: Int, filter: GetContentFilter? ) async throws -> [Comment2Snapshot] func getComments( postId: Int, sort: CommentSortType, page: Int, maxDepth: Int?, limit: Int, filter: GetContentFilter? ) async throws -> [Comment2Snapshot] func getComments( parentId: Int, sort: CommentSortType, page: Int, maxDepth: Int?, limit: Int, filter: GetContentFilter? ) async throws -> [Comment2Snapshot] func getCommentHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (comments: [Comment2Snapshot], cursor: String?) // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchComments( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, sort: CommentSortType ) async throws -> [Comment2Snapshot] func searchComments( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, sort: SearchSortType ) async throws -> [Comment2Snapshot] @discardableResult func voteOnComment(id: Int, score: ScoringOperation) async throws -> Comment2Snapshot @discardableResult func saveComment(id: Int, save: Bool) async throws -> Comment2Snapshot @discardableResult func deleteComment(id: Int, delete: Bool) async throws -> Comment2Snapshot @discardableResult func editComment( id: Int, content: String, languageId: Int? ) async throws -> Comment2Snapshot func replyToComment(postId: Int, parentId: Int?, content: String, languageId: Int?) async throws -> Comment2Snapshot @discardableResult func reportComment(id: Int, reason: String) async throws -> ReportSnapshot func purgeComment(id: Int, reason: String?) async throws @discardableResult func removeComment( id: Int, remove: Bool, reason: String? ) async throws -> Comment2Snapshot @discardableResult func getCommentVotes( id: Int, page: Int, limit: Int ) async throws -> [PersonVoteSnapshot] // MARK: - Person func getPerson(id: Int) async throws -> Person3Snapshot func getPerson(url: URL) async throws -> Person2Snapshot func getPerson(username: String) async throws -> Person3Snapshot func searchPeople( query: String, page: Int, limit: Int, filter: ListingType, sort: SearchSortType ) async throws -> [Person2Snapshot] @discardableResult func blockPerson(id: Int, block: Bool) async throws -> Person2Snapshot @discardableResult func banPersonFromCommunity( personId: Int, communityId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? ) async throws -> Person1Snapshot @discardableResult func banPersonFromInstance( personId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? ) async throws -> Person2Snapshot func purgePerson(id: Int, reason: String?) async throws func getContent( authorId id: Int, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool?, communityId: Int? ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot], comments: [Comment2Snapshot]) func getMyPerson() async throws -> (person: Person4Snapshot?, instance: Instance3Snapshot, blocks: BlockListSnapshot?) func deleteAccount(password: String, deleteContent: Bool) async throws func editNote(id: Int, content: String?) async throws func editProfile(details: ProfileDetails) async throws func editAccountSettings( showNsfw: Bool?, showScores: Bool?, theme: String?, defaultListingType: ListingType?, interfaceLanguage: String?, avatar: String?, banner: String?, displayName: String?, email: String?, bio: String?, matrixUserId: String?, showAvatars: Bool?, sendNotificationsToEmail: Bool?, botAccount: Bool?, showBotAccounts: Bool?, showReadPosts: Bool?, discussionLanguages: [Int]?, openLinksInNewTab: Bool?, blurNsfw: Bool?, autoExpand: Bool?, infiniteScrollEnabled: Bool?, postListingMode: PostFeedViewMode?, enableKeyboardNavigation: Bool?, enableAnimatedImages: Bool?, collapseBotComments: Bool?, showUpvotes: Bool?, showDownvotes: Bool?, showUpvotePercentage: Bool? ) async throws // MARK: - Community func getCommunity(id: Int) async throws -> Community3Snapshot func getCommunity(url: URL) async throws -> Community2Snapshot func editCommunityDescription(id: Int, newValue: String?) async throws -> Community2Snapshot func searchCommunities( query: String, page: Int, limit: Int, filter: ListingType, sort: SearchSortType ) async throws -> [Community2Snapshot] @discardableResult func getSubscriptionList(page: Int, limit: Int) async throws -> [Community2Snapshot] @discardableResult func subscribeToCommunity(id: Int, subscribe: Bool) async throws -> Community2Snapshot @discardableResult func blockCommunity(id: Int, block: Bool) async throws -> Community2Snapshot @discardableResult func removeCommunity( id: Int, remove: Bool, reason: String? ) async throws -> Community2Snapshot func purgeCommunity(id: Int, reason: String?) async throws @discardableResult func addModerator( communityId: Int, personId: Int, added: Bool ) async throws -> (moderators: [Person1Snapshot], community: Community1Snapshot) // MARK: - General func getAccountToken(usernameOrEmail: String, password: String, totpToken: String?) async throws -> String func getUsernameFromToken(token: String) async throws -> String func signUp( username: String, password: String, confirmPassword: String, showNsfw: Bool, email: String?, captcha: Captcha?, captchaAnswer: String?, applicationQuestionResponse: String? ) async throws -> SignUpResponse @discardableResult func changePassword( newPassword: String, confirmNewPassword: String, oldPassword: String ) async throws -> String func getCaptcha() async throws -> Captcha func resolve(url: URL) async throws -> ResolvedContent func getBlocked() async throws -> (people: [Person1Snapshot], communities: [Community1Snapshot], instances: [Instance1Snapshot]) func getModlog( page: Int, limit: Int, communityId: Int?, moderatorId: Int?, subjectPersonId: Int?, postId: Int?, commentId: Int?, type: ModlogEntryType? ) async throws -> [ModlogEntrySnapshot] func getPostLink(url: URL) async throws -> PostLink // MARK: - Inbox func getMessages( creatorId: Int?, page: Int, limit: Int, unreadOnly: Bool ) async throws -> [Message2Snapshot] func getReplyNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) func getMentionNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) func getMessageNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) func markNotificationAsRead( type: InboxNotificationContentType, id: Int, contentId: Int, read: Bool ) async throws func markAllAsRead() async throws func getPersonalUnreadCount() async throws -> PersonalUnreadCountSnapshot func createMessage(personId: Int, content: String) async throws -> Message2Snapshot @discardableResult func editMessage(id: Int, content: String) async throws -> Message2Snapshot @discardableResult func reportMessage(id: Int, reason: String) async throws -> ReportSnapshot @discardableResult func deleteMessage(id: Int, delete: Bool) async throws -> Message2Snapshot // MARK: - Instance func getMyInstance() async throws -> Instance3Snapshot func getFederatedInstances() async throws -> FederationPolicy func blockInstance(instanceId: Int, block: Bool) async throws @discardableResult func addAdmin(personId: Int, added: Bool) async throws -> [Person2Snapshot] // MARK: - RegistrationApplication func getRegistrationApplicationCount() async throws -> Int func getRegistrationApplications( page: Int, limit: Int, unreadOnly: Bool ) async throws -> [RegistrationApplicationSnapshot] @discardableResult func approveRegistrationApplication(id: Int) async throws -> RegistrationApplicationSnapshot @discardableResult func denyRegistrationApplication(id: Int, reason: String?) async throws -> RegistrationApplicationSnapshot // MARK: - Report func getReportCount(communityId: Int?) async throws -> ReportUnreadCountSnapshot func getPostReports( page: Int, limit: Int, unresolvedOnly: Bool, communityId: Int?, postId: Int? ) async throws -> [ReportSnapshot] func getCommentReports( page: Int, limit: Int, unresolvedOnly: Bool, communityId: Int?, commentId: Int? ) async throws -> [ReportSnapshot] func getMessageReports( page: Int, limit: Int, unresolvedOnly: Bool ) async throws -> [ReportSnapshot] @discardableResult func resolvePostReport(id: Int, resolved: Bool) async throws -> ReportSnapshot @discardableResult func resolveCommentReport(id: Int, resolved: Bool) async throws -> ReportSnapshot @discardableResult func resolveMessageReport(id: Int, resolved: Bool) async throws -> ReportSnapshot // MARK: - Image func uploadImage( _ imageData: Data, fileExtension: String, onProgress progressCallback: @escaping (_ progress: Double) -> Void ) async throws -> ImageUpload1Snapshot func deleteImage(alias: String, deleteToken: String) async throws } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Comment.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-06. // import Foundation public extension LemmyConnection { func getComment(id: Int) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyGetCommentRequest(endpoint: endpoint, id: id) } return try .init(from: response.commentView) } func getComment(url: URL) async throws -> Comment2Snapshot { do { let result = try await resolve(url: url) switch result { case let .comment(comment): return comment default: throw ApiClientError.noEntityFound } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } func getComments( sort: CommentSortType, page: Int, maxDepth: Int?, limit: Int, filter: GetContentFilter? ) async throws -> [Comment2Snapshot] { let response = try await performingForEndpoint { endpoint in LemmyListCommentsRequest( endpoint: endpoint, type_: .all, sort: sort.v3CommentApiType, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, communityName: nil, postId: nil, parentId: nil, savedOnly: filter == .saved, likedOnly: filter == .upvoted, dislikedOnly: filter == .downvoted, timeRangeSeconds: sort.timeRangeSeconds, pageCursor: nil, searchTerm: nil ) } return try response.items.map { try .init(from: $0) } } func getComments( postId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { let response = try await performingForEndpoint { endpoint in LemmyListCommentsRequest( endpoint: endpoint, type_: .all, sort: sort.v3CommentApiType, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, communityName: nil, postId: postId, parentId: nil, savedOnly: filter == .saved, likedOnly: filter == .upvoted, dislikedOnly: filter == .downvoted, timeRangeSeconds: sort.timeRangeSeconds, pageCursor: nil, searchTerm: nil ) } return try response.items.map { try .init(from: $0) } } func getComments( parentId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { let response = try await performingForEndpoint { endpoint in LemmyListCommentsRequest( endpoint: endpoint, type_: .all, sort: sort.v3CommentApiType, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, communityName: nil, postId: nil, parentId: parentId, savedOnly: filter == .saved, likedOnly: filter == .upvoted, dislikedOnly: filter == .downvoted, timeRangeSeconds: sort.timeRangeSeconds, pageCursor: nil, searchTerm: nil ) } return try response.items.map { try .init(from: $0) } } func getCommentHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (comments: [Comment2Snapshot], cursor: String?) { try await processingForEndpoint { endpoint in switch endpoint { case .v3: guard let page else { throw ApiClientError.featureUnsupported } let request = LemmyListCommentsRequest( endpoint: .v3, type_: .all, sort: .new, maxDepth: nil, page: page, limit: limit, communityId: nil, communityName: nil, postId: nil, parentId: nil, savedOnly: type == .saved, likedOnly: type == .upvoted, dislikedOnly: type == .downvoted, timeRangeSeconds: nil, pageCursor: nil, searchTerm: nil ) let response = try await self.perform(request, endpoint: .v3) return try ( comments: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) case .v4: switch type { case .saved: let request = LemmyListPersonSavedRequest( type_: .comments, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( comments: response.items.compactMap(\.commentValue).map { try .init(from: $0) }, cursor: response.nextPage ) default: let request = LemmyListPersonLikedRequest( type_: .comments, likeType: type == .upvoted ? .likedOnly : .dislikedOnly, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( comments: response.items.compactMap(\.commentValue).map { try .init(from: $0) }, cursor: response.nextPage ) } } } } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: CommentSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { try await searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, createSortType: { try sort.apiType(for: $0) }, timeRangeSeconds: sort.timeRangeSeconds ) } func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { try await searchComments( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, createSortType: { try sort.apiType(for: $0) }, timeRangeSeconds: sort.timeRangeSeconds ) } private func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int?, creatorId: Int?, filter: ListingType, createSortType: @escaping (LemmyEndpointVersion) throws -> LemmySearchSortTypeBridge, timeRangeSeconds: Int? ) async throws -> [Comment2Snapshot] { let response = try await performingForEndpoint { endpoint in try LemmySearchRequest( endpoint: endpoint, q: query, communityId: communityId, communityName: nil, creatorId: creatorId, type_: .comments, sort: createSortType(endpoint), listingType: filter.apiType, page: page, limit: limit, postTitleOnly: false, searchTerm: query, searchTitleOnly: false ) } return try response.comments.map { try .init(from: $0) } } @discardableResult func voteOnComment(id: Int, score: ScoringOperation) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyLikeCommentRequest( endpoint: endpoint, commentId: id, score: score.rawValue, isUpvote: score.booleanValue ) } return try .init(from: response.commentView) } @discardableResult func saveComment(id: Int, save: Bool) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmySaveCommentRequest(endpoint: endpoint, commentId: id, save: save) } return try .init(from: response.commentView) } @discardableResult func deleteComment(id: Int, delete: Bool) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyDeleteCommentRequest(endpoint: endpoint, commentId: id, deleted: delete) } return try .init(from: response.commentView) } @discardableResult func editComment( id: Int, content: String, languageId: Int? ) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyEditCommentRequest( endpoint: endpoint, commentId: id, content: content, languageId: languageId ) } return try .init(from: response.commentView) } func replyToComment(postId: Int, parentId: Int?, content: String, languageId: Int? = nil) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyCreateCommentRequest( endpoint: endpoint, content: content, postId: postId, parentId: parentId, languageId: languageId ) } return try .init(from: response.commentView) } @discardableResult func reportComment(id: Int, reason: String) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyCreateCommentReportRequest( endpoint: endpoint, commentId: id, reason: reason, violatesInstanceRules: nil ) } return try .init(from: response.commentReportView) } func purgeComment(id: Int, reason: String?) async throws { let response = try await performingForEndpoint { endpoint in LemmyPurgeCommentRequest(endpoint: endpoint, commentId: id, reason: reason) } guard response.success else { throw ApiClientError.unsuccessful } } @discardableResult func removeComment( id: Int, remove: Bool, reason: String? ) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyRemoveCommentRequest( endpoint: endpoint, commentId: id, removed: remove, reason: reason, removeChildren: nil ) } return try .init(from: response.commentView) } @discardableResult func getCommentVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { let response = try await performingForEndpoint { endpoint in LemmyListCommentLikesRequest( endpoint: endpoint, commentId: id, page: page, limit: limit, pageCursor: nil ) } return try response.items.map { try .init(from: $0) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Community.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-07. // import Foundation public extension LemmyConnection { func getCommunity(id: Int) async throws -> Community3Snapshot { let response = try await performingForEndpoint { endpoint in LemmyGetCommunityRequest(endpoint: endpoint, id: id, name: nil) } return try .init(from: response) } func getCommunity(url: URL) async throws -> Community2Snapshot { do { let result = try await resolve(url: url) switch result { case let .community(community): return community default: throw ApiClientError.noEntityFound } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } func searchCommunities( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Community2Snapshot] { let response = try await performingForEndpoint { endpoint in try LemmySearchRequest( endpoint: endpoint, q: query, communityId: nil, communityName: nil, creatorId: nil, type_: .communities, sort: sort.apiType(for: endpoint), listingType: filter.apiType, page: page, limit: limit, postTitleOnly: false, searchTerm: query, searchTitleOnly: false ) } return try response.communities.map { try .init(from: $0) } } func editCommunityDescription(id: Int, newValue: String?) async throws -> Community2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyEditCommunityRequest( endpoint: endpoint, communityId: id, title: nil, // In the v4 API, the `description` field is for the short description description: endpoint == .v3 ? newValue : nil, icon: nil, banner: nil, nsfw: nil, postingRestrictedToMods: nil, discussionLanguages: nil, visibility: nil, sidebar: newValue, summary: nil ) } return try .init(from: response.communityView) } @discardableResult func getSubscriptionList(page: Int, limit: Int) async throws -> [Community2Snapshot] { let response = try await performingForEndpoint { endpoint in LemmyListCommunitiesRequest( endpoint: endpoint, type_: .subscribed, sort: endpoint == .v4 ? .new(.nameAsc) : .old(.new), showNsfw: true, page: page, limit: limit, timeRangeSeconds: nil, multiCommunityId: nil, searchTerm: nil, searchTitleOnly: nil, pageCursor: nil ) } return try response.items.map { try .init(from: $0) } } @discardableResult func subscribeToCommunity(id: Int, subscribe: Bool) async throws -> Community2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyFollowCommunityRequest(endpoint: endpoint, communityId: id, follow: subscribe) } return try .init(from: response.communityView) } @discardableResult func blockCommunity(id: Int, block: Bool) async throws -> Community2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyUserBlockCommunityRequest(endpoint: endpoint, communityId: id, block: block) } switch response { case let .lemmyBlockCommunityResponse(response): return try .init(from: response.communityView) case let .lemmyCommunityResponse(response): return try .init(from: response.communityView) } } @discardableResult func removeCommunity( id: Int, remove: Bool, reason: String? ) async throws -> Community2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyRemoveCommunityRequest(endpoint: endpoint, communityId: id, removed: remove, reason: reason) } return try .init(from: response.communityView) } func purgeCommunity(id: Int, reason: String?) async throws { _ = try await performingForEndpoint { endpoint in LemmyPurgeCommunityRequest(endpoint: endpoint, communityId: id, reason: reason) } } @discardableResult func addModerator( communityId: Int, personId: Int, added: Bool ) async throws -> (moderators: [Person1Snapshot], community: Community1Snapshot) { let response = try await performingForEndpoint { endpoint in LemmyAddModToCommunityRequest( endpoint: endpoint, communityId: communityId, personId: personId, added: added ) } let moderators: [Person1Snapshot] = try response.moderators.map { try .init(from: $0.moderator) } guard let first = response.moderators.first else { throw ApiClientError.unsuccessful } let community: Community1Snapshot = try .init(from: first.community) return ( moderators: moderators, community: community ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Context.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-08-10. // import Foundation extension LemmyConnection { // For use inside LemmyConnection only func getRawContext() async throws -> RawContext { // Inconveniently, PieFed offers the `api/v3/site` endpoint in an attempt to look like a Lemmy instance. // We need to check that this *isn't* a PieFed instance, which we can do by making a second request. // The type of request doesn't matter - we're using `UnreadCountRequest` here. let response = try await processingForEndpoint { endpoint in switch endpoint { case .v3: async let site = await self.perform(LemmyGetSiteRequest(endpoint: .v3), endpoint: .v3) async let other = await self.perform(LemmyUnreadCountRequest(), endpoint: .v3) do { _ = try await other } catch ApiClientError.notLoggedIn { // no-op } let response = try await site return RawContext(site: response, myUser: response.myUser) case .v4: async let site = await self.perform(LemmyGetSiteRequest(endpoint: .v4), endpoint: .v4) var myUser: LemmyMyUserInfo? if self.token != nil { myUser = try await self.perform(LemmyGetMyUserRequest(), endpoint: .v4) } return try await .init(site: site, myUser: myUser) } } return response } // Calls getRawContext, but if there's already a task running in the `contextDataManager` uses that instead. func getRawContextWithCaching() async throws -> RawContext { if let ongoingTask = contextDataManager.ongoingTask { return try await ongoingTask.result.get() } else { let task = Task(operation: getRawContext) Task.detached { _ = try await self.contextDataManager.getValue(task: task) } return try await task.result.get() } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Feature.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public extension LemmyConnection { func supports(_ feature: Feature) async throws -> Bool { try await Self.supports(feature, version: version) } func supports(_ feature: Feature, defaultValue: Bool) -> Bool { if let fetchedVersion { return Self.supports(feature, version: fetchedVersion) } else { return defaultValue } } static func supports( _ feature: Feature, version: SiteVersion ) -> Bool { switch feature { case let .postSortType(sort): version >= sort.minimumVersion case let .commentSortType(sort): version >= sort.minimumVersion case let .searchSortType(sort): version >= sort.minimumVersion case let .sortTimeRange(timeRange): version >= timeRange.minimumVersion case let .listingType(listingType): version >= listingType.minimumVersion case .searchLocalCommunities, .viewInstanceSettings, .viewInstanceCreationDate, .modlog, .logIn, .signUp, .viewCommunityActiveUsers, .uploadImages, .editAccountSettings, .viewMentionsAndPrivateMessages, .viewReports, .editAndDeletePrivateMessages, .reportPrivateMessages, .viewVotes, .purgeContent, .removeCommunity, .banFromInstance, .banFromCommunity, .editModeratorList, .commentSearch, .undeletePrivateMessages, .searchLocalPeople, .hidePosts, .editDisplayName, .editProfile, .autoMarkPostReadOnInteract, .blockInstances, .fetchLinkMetadata, .unbanWithReason, .customPostThumbnail, .banFromNonLocalCommunity, .editCommunityDescription, .searchLocalComments, .viewInstanceBlockList: true case .moderatorSetNsfw, .userNotes: false } } } private extension SiteVersion { static let v0_19_0: Self = .init("0.19.0") static let v0_19_1: Self = .init("0.19.1") static let v0_19_2: Self = .init("0.19.2") static let v0_19_3: Self = .init("0.19.3") static let v0_19_4: Self = .init("0.19.4") static let v0_19_5: Self = .init("0.19.5") static let v0_19_6: Self = .init("0.19.6") static let v0_19_7: Self = .init("0.19.7") static let v0_19_8: Self = .init("0.19.8") static let v0_19_9: Self = .init("0.19.9") static let v0_19_10: Self = .init("0.19.10") static let v0_19_11: Self = .init("0.19.11") static let v0_19_12: Self = .init("0.19.12") static let v1_0_0: Self = .init("1.0.0") } private extension PostSortType { var minimumVersion: SiteVersion { switch self { case let .top(timeRange): timeRange.minimumVersion default: .zero } } } private extension CommentSortType { var minimumVersion: SiteVersion { switch self { case let .top(timeRange): timeRange == .allTime ? .zero : .v1_0_0 default: .zero } } } private extension SearchSortType { var minimumVersion: SiteVersion { switch self { case let .top(timeRange): timeRange.minimumVersion default: .zero } } } private extension SortTimeRange { var minimumVersion: SiteVersion { switch self { case .allTime: .zero case let .limited(timeInterval): LegacySortTimeRangeLimit(timeInterval)?.minimumVersion ?? .v1_0_0 } } } private extension LegacySortTimeRangeLimit { var minimumVersion: SiteVersion { .zero } } private extension ListingType { var minimumVersion: SiteVersion { switch self { case .suggested: .v1_0_0 case .popular: .infinity default: .zero } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+General.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-07. // import Foundation public extension LemmyConnection { func getAccountToken( usernameOrEmail: String, password: String, totpToken: String? ) async throws -> String { let response = try await performingForEndpoint { endpoint in LemmyLoginRequest( endpoint: endpoint, usernameOrEmail: usernameOrEmail, password: password, totp2faToken: totpToken, stayLoggedIn: true ) } // I actually don't think this is necessary - the login endpoint seems to throw these errors itself. // I suspect that `registrationCreated` and `verifyEmailSent` can only be true for the `LemmyLoginResponse` // that is returned when signing in. Nevertheless, I've included this just in case. if response.registrationCreated { throw ApiClientError.response(.init(error: "registration_application_is_pending"), 200) } if response.verifyEmailSent { throw ApiClientError.response(.init(error: "email_not_verified"), 200) } guard let jwt = response.jwt else { assertionFailure() throw ApiClientError.responseMissingRequiredData("getAccountToken jwt") } return jwt } func getUsernameFromToken(token: String) async throws -> String { let username = try await processingForEndpoint { endpoint in switch endpoint { case .v3: let request = LemmyGetSiteRequest(endpoint: endpoint) let response = try await self.perform(request, tokenOverride: token, endpoint: .v3) return response.myUser?.localUserView.person.name case .v4: let request = LemmyGetMyUserRequest() let response = try await self.perform(request, tokenOverride: token, endpoint: .v4) return response.localUserView.person.name } } if let username { return username } throw ApiClientError.notLoggedIn } func signUp( username: String, password: String, confirmPassword: String, showNsfw: Bool, email: String?, captcha: Captcha?, captchaAnswer: String?, applicationQuestionResponse: String? ) async throws -> SignUpResponse { let response = try await performingForEndpoint { endpoint in LemmyRegisterRequest( endpoint: endpoint, username: username, password: password, passwordVerify: confirmPassword, showNsfw: showNsfw, email: email, captchaUuid: captcha?.id.uuidString, captchaAnswer: captchaAnswer, honeypot: nil, answer: applicationQuestionResponse, stayLoggedIn: true ) } return .init(from: response) } @discardableResult func changePassword( newPassword: String, confirmNewPassword: String, oldPassword: String ) async throws -> String { let response = try await performingForEndpoint { endpoint in LemmyChangePasswordRequest( endpoint: endpoint, newPassword: newPassword, newPasswordVerify: confirmNewPassword, oldPassword: oldPassword, stayLoggedIn: true ) } guard let token = response.jwt else { assertionFailure() throw ApiClientError.responseMissingRequiredData("changePassword jwt") } return token } func getCaptcha() async throws -> Captcha { let response = try await performingForEndpoint { endpoint in LemmyGetCaptchaRequest(endpoint: endpoint) } guard let info = response.ok, let uuid = UUID(uuidString: info.uuid), let data = Data(base64Encoded: info.png) else { throw ApiClientError.unsuccessful } return .init(id: uuid, imageData: data) } func resolve(url: URL) async throws -> ResolvedContent { do { // Fix for https://github.com/mlemgroup/mlem/issues/2341 let components = url.pathComponents if url.host == baseUrl.host(), components.count > 2 { switch components[1] { case "c": let response = try await performingForEndpoint { endpoint in LemmyGetCommunityRequest(endpoint: endpoint, id: nil, name: components[2]) } return try .community(.init(from: response.communityView)) case "u": let response = try await performingForEndpoint { endpoint in LemmyReadPersonRequest( endpoint: endpoint, personId: nil, username: components[2], sort: nil, page: 1, limit: 1, communityId: nil, savedOnly: nil ) } return try .person(.init(from: response.personView)) default: break } } let response = try await performingForEndpoint { endpoint in LemmyResolveObjectRequest(endpoint: endpoint, q: url.absoluteString) } return try .init(from: response) } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } func getBlocked() async throws -> (people: [Person1Snapshot], communities: [Community1Snapshot], instances: [Instance1Snapshot]) { let response = try await performingForEndpoint { endpoint in LemmyGetSiteRequest(endpoint: endpoint) } guard let myUser = response.myUser else { return ([], [], []) } return try ( people: myUser.personBlocks.map { try .init(from: $0.person) }, communities: myUser.communityBlocks.map { try .init(from: $0.community) }, instances: myUser.instanceBlocks?.compactMap(\.site).map { try .init(from: $0) } ?? [] // TODO: Lemmy 1.0 ) } func getModlog( page: Int = 1, limit: Int = 20, communityId: Int? = nil, moderatorId: Int? = nil, subjectPersonId: Int? = nil, postId: Int? = nil, commentId: Int? = nil, type: ModlogEntryType? = nil ) async throws -> [ModlogEntrySnapshot] { let response = try await performingForEndpoint { endpoint in LemmyGetModLogRequest( endpoint: endpoint, modPersonId: moderatorId, communityId: communityId, page: page, limit: limit, type_: type?.apiType, otherPersonId: subjectPersonId, postId: postId, commentId: commentId, listingType: .all, showBulk: nil, bulkActionParentId: nil, pageCursor: nil ) } switch response { case let .lemmyGetModlogResponse(response): return try response.toSnapshots() case let .lemmyPagedResponse(response): return try response.items.compactMap { try .init(from: $0) } } } func getPostLink(url: URL) async throws -> PostLink { let response = try await performingForEndpoint { endpoint in LemmyGetLinkMetadataRequest(endpoint: endpoint, url: url) } return .init( content: url, thumbnail: response.metadata.image, label: response.metadata.title ?? url.absoluteString ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Image.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-05. // import Foundation import Rest public extension LemmyConnection { func uploadImage( _ imageData: Data, fileExtension: String, onProgress progressCallback: @escaping (_ progress: Double) -> Void = { _ in } ) async throws -> ImageUpload1Snapshot { guard let token else { throw ApiClientError.notLoggedIn } var request = mlemUrlRequest(url: baseUrl.appending(path: "pictrs/image")) request.httpMethod = "POST" let boundary = UUID().uuidString request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let encodedData = createMultiPartForm( boundary: boundary, contentType: "image/png", name: "images[]", fileName: "image.\(fileExtension)", imageData: imageData, auth: token ) let (data, _) = try await restClient.urlSession.upload( for: request, from: encodedData, delegate: ImageUploadDelegate(callback: progressCallback) ) do { let response = try JSONDecoder.defaultDecoder.decode(LemmyPictrsUploadResponse.self, from: data) guard let file = response.files?.first else { throw ApiClientError.noEntityFound } return .init(from: file, baseUrl: baseUrl) } catch DecodingError.dataCorrupted { let text = String(decoding: data, as: UTF8.self) if text.contains("413 Request Entity Too Large") { throw ApiClientError.imageTooLarge } throw ApiClientError.decoding(data, nil) } } func deleteImage(alias: String, deleteToken: String) async throws { guard let token else { throw ApiClientError.notLoggedIn } var request = mlemUrlRequest(url: baseUrl.appending(path: "pictrs/image/delete/\(deleteToken)/\(alias)")) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let response = try await restClient.execute(request) if let response = response.1 as? HTTPURLResponse { if response.statusCode != 204 { throw ApiClientError.response(.init(error: "Unexpected status code"), response.statusCode) } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Inbox.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension LemmyConnection { func getMessages( creatorId: Int? = nil, page: Int, limit: Int, unreadOnly: Bool = false ) async throws -> [Message2Snapshot] { let response = try await performingForEndpoint { _ in LemmyGetPrivateMessageRequest( unreadOnly: unreadOnly, page: page, limit: limit, creatorId: creatorId ) } return try response.privateMessages.map { try .init(from: $0) } } func getReplyNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await processingForEndpoint { endpoint in switch endpoint { case .v3: guard let page else { throw ApiClientError.featureUnsupported } let request = LemmyListRepliesRequest( sort: .new, page: page, limit: limit, unreadOnly: unreadOnly ) let response = try await self.perform(request, endpoint: .v3) return try (notifications: response.replies.map { try .init(from: $0) }, cursor: nil) case .v4: let request = LemmyListNotificationsRequest( type_: .reply, unreadOnly: unreadOnly, creatorId: nil, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( notifications: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) } } } func getMentionNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await processingForEndpoint { endpoint in switch endpoint { case .v3: guard let page else { throw ApiClientError.featureUnsupported } let request = LemmyListMentionsRequest( sort: .new, page: page, limit: limit, unreadOnly: unreadOnly ) let response = try await self.perform(request, endpoint: .v3) return try (notifications: response.mentions.map { try .init(from: $0) }, cursor: nil) case .v4: let request = LemmyListNotificationsRequest( type_: .mention, unreadOnly: unreadOnly, creatorId: nil, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( notifications: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) } } } func getMessageNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { try await processingForEndpoint { endpoint in switch endpoint { case .v3: guard let page else { throw ApiClientError.featureUnsupported } let request = LemmyGetPrivateMessageRequest( unreadOnly: unreadOnly, page: page, limit: limit, creatorId: nil ) let response = try await self.perform(request, endpoint: .v3) return try (notifications: response.privateMessages.map { try .init(from: $0) }, cursor: nil) case .v4: let request = LemmyListNotificationsRequest( type_: .privateMessage, unreadOnly: unreadOnly, creatorId: nil, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( notifications: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) } } } func markNotificationAsRead( type: InboxNotificationContentType, id: Int, contentId: Int, read: Bool = true ) async throws { try await processingForEndpoint { endpoint in switch endpoint { case .v3: try await self.markNotificationAsReadV3(type: type, contentId: contentId, read: read) case .v4: let request = LemmyMarkNotificationAsReadRequest(notificationId: id, read: read) try await self.perform(request, endpoint: .v4) } } } private func markNotificationAsReadV3( type: InboxNotificationContentType, contentId: Int, read: Bool ) async throws { switch type { case .reply: try await self.perform(LemmyMarkReplyAsReadRequest(commentReplyId: contentId, read: read), endpoint: .v3) case .mention: try await self.perform(LemmyMarkPersonMentionAsReadRequest(personMentionId: contentId, read: read), endpoint: .v3) case .message: try await self.perform(LemmyMarkPmAsReadRequest(privateMessageId: contentId, read: read), endpoint: .v3) } } func markAllAsRead() async throws { _ = try await performingForEndpoint { endpoint in LemmyMarkAllNotificationsReadRequest(endpoint: endpoint) } } func getPersonalUnreadCount() async throws -> PersonalUnreadCountSnapshot { let response = try await performingForEndpoint { endpoint in LemmyUnreadCountRequest() } return try .init(from: response) } func createMessage(personId: Int, content: String) async throws -> Message2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyCreatePrivateMessageRequest( endpoint: endpoint, content: content, recipientId: personId ) } return try .init(from: response.privateMessageView) } @discardableResult func editMessage(id: Int, content: String) async throws -> Message2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyEditPrivateMessageRequest( endpoint: endpoint, privateMessageId: id, content: content ) } return try .init(from: response.privateMessageView) } @discardableResult func reportMessage(id: Int, reason: String) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyCreatePmReportRequest(endpoint: endpoint, privateMessageId: id, reason: reason) } return try .init(from: response.privateMessageReportView) } @discardableResult func deleteMessage(id: Int, delete: Bool) async throws -> Message2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyDeletePrivateMessageRequest(endpoint: endpoint, privateMessageId: id, deleted: delete) } return try .init(from: response.privateMessageView) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Instance.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension LemmyConnection { func getMyInstance() async throws -> Instance3Snapshot { let rawContext = try await getRawContextWithCaching() return try .init(from: rawContext.site) } func getFederatedInstances() async throws -> FederationPolicy { let response = try await performingForEndpoint { endpoint in LemmyGetFederatedInstancesRequest(endpoint: endpoint) } switch response { case let .lemmyLegacyGetFederatedInstancesResponse(response): if let federatedInstances = response.federatedInstances { return .init(from: federatedInstances) } throw ApiClientError.noEntityFound case let .lemmyPagedResponse(response): return .init(from: response.items) } } func blockInstance(instanceId: Int, block: Bool) async throws { _ = try await performingForEndpoint { endpoint in LemmyUserBlockInstanceCommunitiesRequest(endpoint: endpoint, instanceId: instanceId, block: block) } } @discardableResult func addAdmin(personId: Int, added: Bool) async throws -> [Person2Snapshot] { let response = try await performingForEndpoint { endpoint in LemmyAddAdminRequest(endpoint: endpoint, personId: personId, added: added) } return try response.admins.map { try .init(from: $0) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Person.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-06. // import Foundation public extension LemmyConnection { func getPerson(id: Int) async throws -> Person3Snapshot { let response = try await performingForEndpoint { endpoint in LemmyReadPersonRequest( endpoint: endpoint, personId: id, username: nil, sort: .new, page: 1, limit: 1, communityId: nil, savedOnly: nil ) } return try .init(from: response) } func getPerson(url: URL) async throws -> Person2Snapshot { do { let result = try await resolve(url: url) switch result { case let .person(person): return person default: throw ApiClientError.noEntityFound } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } func getPerson(username: String) async throws -> Person3Snapshot { do { let response = try await performingForEndpoint { endpoint in LemmyReadPersonRequest( endpoint: endpoint, personId: nil, username: username, sort: nil, page: nil, limit: nil, communityId: nil, savedOnly: nil ) } return try .init(from: response) } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } /// `filter` can be set to `.local` from 0.19.4 onwards. func searchPeople( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Person2Snapshot] { let response = try await performingForEndpoint { endpoint in try LemmySearchRequest( endpoint: endpoint, q: query, communityId: nil, communityName: nil, creatorId: nil, type_: .users, sort: sort.apiType(for: endpoint), listingType: filter.apiType, page: page, limit: limit, postTitleOnly: false, searchTerm: query, searchTitleOnly: false ) } return try response.users?.map { try .init(from: $0) } ?? [] } @discardableResult func blockPerson(id: Int, block: Bool) async throws -> Person2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyUserBlockPersonRequest(endpoint: endpoint, personId: id, block: block) } switch response { case let .lemmyBlockPersonResponse(response): return try .init(from: response.personView) case let .lemmyPersonResponse(response): return try .init(from: response.personView) } } @discardableResult func banPersonFromCommunity( personId: Int, communityId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person1Snapshot { let expiryTimestamp: Int? if let expires { expiryTimestamp = Int(expires.timeIntervalSince1970) } else { expiryTimestamp = nil } let response = try await performingForEndpoint { endpoint in LemmyBanFromCommunityRequest( endpoint: endpoint, communityId: communityId, personId: personId, ban: ban, removeData: removeContent, reason: reason, expires: expiryTimestamp, removeOrRestoreData: removeContent, expiresAt: expiryTimestamp ) } switch response { case let .lemmyBanFromCommunityResponse(response): guard response.banned == ban else { throw ApiClientError.unsuccessful } return try .init(from: response.personView.person) case let .lemmyPersonResponse(response): return try .init(from: response.personView.person) } } @discardableResult func banPersonFromInstance( personId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person2Snapshot { let expiryTimestamp: Int? if let expires { expiryTimestamp = Int(expires.timeIntervalSince1970) } else { expiryTimestamp = nil } let response = try await performingForEndpoint { endpoint in LemmyBanFromSiteRequest( endpoint: endpoint, personId: personId, ban: ban, removeData: removeContent, reason: reason, expires: expiryTimestamp, removeOrRestoreData: removeContent, expiresAt: expiryTimestamp ) } return try .init(from: response.personView) } func purgePerson(id: Int, reason: String?) async throws { let response = try await performingForEndpoint { endpoint in LemmyPurgePersonRequest(endpoint: endpoint, personId: id, reason: reason) } guard response.success else { throw ApiClientError.unsuccessful } } func getContent( authorId id: Int, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool? = nil, communityId: Int? = nil ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot], comments: [Comment2Snapshot]) { let response = try await performingForEndpoint { endpoint in if endpoint == .v4 { // TODO: Use LemmyListPersonContentRequest here throw ApiClientError.featureUnsupported } return LemmyReadPersonRequest( endpoint: endpoint, personId: id, username: nil, sort: sort.v3ApiType, page: page, limit: limit, communityId: nil, savedOnly: savedOnly ) } return try ( person: .init(from: response), posts: response.posts?.map { try .init(from: $0) } ?? [], comments: response.comments?.map { try .init(from: $0) } ?? [] ) } func getMyPerson() async throws -> (person: Person4Snapshot?, instance: Instance3Snapshot, blocks: BlockListSnapshot?) { let rawContext = try await getRawContextWithCaching() var person: Person4Snapshot? var blocks: BlockListSnapshot? if let myUser = rawContext.myUser { person = try .init(from: myUser) blocks = try .init(from: myUser) } return try ( person: person, instance: .init(from: rawContext.site), blocks: blocks ) } func deleteAccount(password: String, deleteContent: Bool) async throws { let response = try await performingForEndpoint { endpoint in LemmyDeleteAccountRequest( endpoint: endpoint, password: password, deleteContent: deleteContent ) } guard response.success else { throw ApiClientError.unsuccessful } } func editNote(id: Int, content: String?) async throws { throw ApiClientError.featureUnsupported } func editProfile(details: ProfileDetails) async throws { let response = try await performingForEndpoint { endpoint in LemmySaveUserSettingsRequest( endpoint: endpoint, showNsfw: nil, blurNsfw: nil, autoExpand: nil, showScores: nil, theme: nil, defaultSortType: nil, defaultListingType: nil, interfaceLanguage: nil, avatar: details.avatar?.absoluteString ?? "", banner: details.banner?.absoluteString ?? "", displayName: details.displayName, email: nil, bio: details.description, matrixUserId: details.matrixUserId, showAvatars: nil, sendNotificationsToEmail: nil, botAccount: nil, showBotAccounts: nil, showReadPosts: nil, discussionLanguages: nil, openLinksInNewTab: nil, infiniteScrollEnabled: nil, postListingMode: nil, enableKeyboardNavigation: nil, enableAnimatedImages: nil, collapseBotComments: nil, showUpvotes: nil, showDownvotes: nil, showUpvotePercentage: nil, defaultPostSortType: nil, defaultPostTimeRangeSeconds: nil, defaultItemsPerPage: nil, defaultCommentSortType: nil, blockingKeywords: nil, animatedImagesEnabled: nil, privateMessagesEnabled: nil, showScore: nil, autoMarkFetchedPostsAsRead: nil, hideMedia: nil, showPersonVotes: nil ) } guard response.success else { throw ApiClientError.unsuccessful } } func editAccountSettings( showNsfw: Bool?, showScores: Bool?, theme: String?, defaultListingType: ListingType?, interfaceLanguage: String?, avatar: String?, banner: String?, displayName: String?, email: String?, bio: String?, matrixUserId: String?, showAvatars: Bool?, sendNotificationsToEmail: Bool?, botAccount: Bool?, showBotAccounts: Bool?, showReadPosts: Bool?, discussionLanguages: [Int]?, openLinksInNewTab: Bool?, blurNsfw: Bool?, autoExpand: Bool?, infiniteScrollEnabled: Bool?, postListingMode: PostFeedViewMode?, enableKeyboardNavigation: Bool?, enableAnimatedImages: Bool?, collapseBotComments: Bool?, showUpvotes: Bool?, showDownvotes: Bool?, showUpvotePercentage: Bool? ) async throws { let response = try await performingForEndpoint { endpoint in LemmySaveUserSettingsRequest( endpoint: endpoint, showNsfw: showNsfw, blurNsfw: blurNsfw, autoExpand: autoExpand, showScores: showScores, theme: theme, defaultSortType: nil, defaultListingType: defaultListingType?.apiType, interfaceLanguage: interfaceLanguage, avatar: avatar, banner: banner, displayName: displayName, email: email, bio: bio, matrixUserId: matrixUserId, showAvatars: showAvatars, sendNotificationsToEmail: sendNotificationsToEmail, botAccount: botAccount, showBotAccounts: showBotAccounts, showReadPosts: showReadPosts, discussionLanguages: discussionLanguages, openLinksInNewTab: openLinksInNewTab, infiniteScrollEnabled: infiniteScrollEnabled, postListingMode: postListingMode?.apiType, enableKeyboardNavigation: enableKeyboardNavigation, enableAnimatedImages: enableAnimatedImages, collapseBotComments: collapseBotComments, showUpvotes: showUpvotes, showDownvotes: showDownvotes.map { .init(showVotes: $0) }, showUpvotePercentage: showUpvotePercentage, defaultPostSortType: nil, defaultPostTimeRangeSeconds: nil, defaultItemsPerPage: nil, defaultCommentSortType: nil, blockingKeywords: nil, animatedImagesEnabled: nil, privateMessagesEnabled: nil, showScore: nil, autoMarkFetchedPostsAsRead: nil, hideMedia: nil, showPersonVotes: nil ) } guard response.success else { throw ApiClientError.unsuccessful } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Post.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-05. // import Foundation public extension LemmyConnection { func getPosts( communityId: Int, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { let response = try await performingForEndpoint { endpoint in try LemmyListPostsRequest( endpoint: endpoint, type_: .all, sort: sort.apiType(for: endpoint), page: cursor == nil ? page : nil, limit: limit, communityId: communityId, communityName: nil, savedOnly: filter == .saved, likedOnly: filter == .upvoted, dislikedOnly: filter == .downvoted, pageCursor: cursor, showHidden: showHidden, showRead: nil, showNsfw: nil, timeRangeSeconds: sort.timeRangeSeconds, multiCommunityId: nil, multiCommunityName: nil, hideMedia: nil, markAsRead: nil, noCommentsOnly: nil, searchTerm: nil, searchTitleOnly: nil, searchUrlOnly: nil ) } return try ( posts: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) } func getPosts( feed: ListingType, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { let response = try await performingForEndpoint { endpoint in try LemmyListPostsRequest( endpoint: endpoint, type_: feed.apiType, sort: sort.apiType(for: endpoint), page: cursor == nil ? page : nil, limit: limit, communityId: nil, communityName: nil, savedOnly: filter == .saved, likedOnly: filter == .upvoted, dislikedOnly: filter == .downvoted, pageCursor: cursor, showHidden: showHidden, showRead: nil, showNsfw: nil, timeRangeSeconds: sort.timeRangeSeconds, multiCommunityId: nil, multiCommunityName: nil, hideMedia: nil, markAsRead: nil, noCommentsOnly: nil, searchTerm: nil, searchTitleOnly: nil, searchUrlOnly: nil ) } return try ( posts: response.items.map { try .init(from: $0) }, cursor: response.nextPage ) } func getPosts( personId: Int, communityId: Int? = nil, sort: PostSortType = .new, page: Int, limit: Int, savedOnly: Bool = false ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot]) { let response = try await performingForEndpoint { endpoint in LemmyReadPersonRequest( endpoint: endpoint, personId: personId, username: nil, sort: sort.v3ApiType, page: page, limit: limit, communityId: communityId, savedOnly: savedOnly ) } return try ( person: .init(from: response), posts: response.posts?.map { try .init(from: $0) } ?? [] ) } func getPostHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (posts: [Post2Snapshot], cursor: String?) { try await processingForEndpoint { endpoint in switch endpoint { case .v3: // Cursors are supported on v3, but are super slow when // querying saved posts. For that reason, we're considering them // unsupported and requiring a page number instead. // See LemmyNet/lemmy#6171 guard let page else { throw ApiClientError.featureUnsupported } let request = LemmyListPostsRequest( endpoint: .v3, type_: .all, sort: .old(.new), page: page, limit: limit, communityId: nil, communityName: nil, savedOnly: type == .saved, likedOnly: type == .upvoted, dislikedOnly: type == .downvoted, pageCursor: nil, showHidden: false, showRead: nil, showNsfw: nil, timeRangeSeconds: nil, multiCommunityId: nil, multiCommunityName: nil, hideMedia: nil, markAsRead: nil, noCommentsOnly: nil, searchTerm: nil, searchTitleOnly: nil, searchUrlOnly: nil ) let response = try await self.perform(request, endpoint: .v3) return try ( posts: response.items.map { try .init(from: $0) }, // Cursor intentionally omitted here. See Comment above cursor: nil ) case .v4: if let page, page != 1 { throw ApiClientError.featureUnsupported } switch type { case .saved: let request = LemmyListPersonSavedRequest( type_: .all, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( posts: response.items.compactMap(\.postValue).map { try .init(from: $0) }, cursor: response.nextPage ) default: let request = LemmyListPersonLikedRequest( type_: .all, likeType: type == .upvoted ? .likedOnly : .dislikedOnly, pageCursor: cursor, limit: limit ) let response = try await self.perform(request, endpoint: .v4) return try ( posts: response.items.compactMap(\.postValue).map { try .init(from: $0) }, cursor: response.nextPage ) } } } } func getPost(id: Int) async throws -> Post3Snapshot { let response = try await performingForEndpoint { endpoint in LemmyGetPostRequest( endpoint: endpoint, id: id, commentId: nil ) } return try .init(from: response) } func getPost(url: URL) async throws -> Post2Snapshot { do { let result = try await resolve(url: url) switch result { case let .post(post): return post default: throw ApiClientError.noEntityFound } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: PostSortType ) async throws -> [Post2Snapshot] { try await searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, createSortType: { try sort.apiType(for: $0) }, timeRangeSeconds: sort.timeRangeSeconds ) } func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType ) async throws -> [Post2Snapshot] { try await searchPosts( query: query, page: page, limit: limit, communityId: communityId, creatorId: creatorId, filter: filter, createSortType: { try sort.apiType(for: $0) }, timeRangeSeconds: sort.timeRangeSeconds ) } private func searchPosts( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, createSortType: @escaping (LemmyEndpointVersion) throws -> LemmySearchSortTypeBridge, timeRangeSeconds: Int? ) async throws -> [Post2Snapshot] { let response = try await performingForEndpoint { endpoint in try LemmySearchRequest( endpoint: endpoint, q: query, communityId: communityId, communityName: nil, creatorId: creatorId, type_: .posts, sort: createSortType(endpoint), listingType: filter.apiType, page: page, limit: limit, postTitleOnly: false, searchTerm: query, searchTitleOnly: false ) } return try response.posts.map { try .init(from: $0) } } func markPostsAsRead(ids: Set, read: Bool) async throws { guard !ids.isEmpty else { return } try await processingForEndpoint { endpoint in switch endpoint { case .v3: let request = LemmyMarkPostAsReadRequest(endpoint: .v3, postId: nil, postIds: Array(ids), read: read) try await self.perform(request, endpoint: .v3) case .v4: let request = LemmyMarkPostsAsReadRequest(postIds: Array(ids), read: read) try await self.perform(request, endpoint: .v4) } } } func markPostAsRead(id: Int, read: Bool) async throws { // Could we do something with the response here? _ = try await performingForEndpoint { endpoint in LemmyMarkPostAsReadRequest( endpoint: endpoint, postId: id, postIds: [id], read: read ) } } @discardableResult func voteOnPost(id: Int, score: ScoringOperation) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyLikePostRequest( endpoint: endpoint, postId: id, score: score.rawValue, isUpvote: score.booleanValue ) } return try .init(from: response.postView) } @discardableResult func savePost(id: Int, save: Bool) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmySavePostRequest( endpoint: endpoint, postId: id, save: save ) } return try .init(from: response.postView, overrideRead: true) } @discardableResult func deletePost(id: Int, delete: Bool) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyDeletePostRequest( endpoint: endpoint, postId: id, deleted: delete ) } return try .init(from: response.postView) } // Marking many posts as hidden was possible in 0.19.0, but this was removed in 1.0.0 func hidePost(id: Int, hide: Bool) async throws { // Could we do something with the response here? _ = try await performingForEndpoint { endpoint in LemmyHidePostRequest( endpoint: endpoint, postIds: [id], hide: hide, postId: id ) } } func createPost( communityId: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyCreatePostRequest( endpoint: endpoint, name: title, communityId: communityId, url: linkUrl, body: content, honeypot: nil, nsfw: nsfw, languageId: languageId, altText: altText, customThumbnail: thumbnail?.absoluteString, tags: nil, scheduledPublishTimeAt: nil ) } return try .init(from: response.postView) } @discardableResult func editPost( id: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyEditPostRequest( endpoint: endpoint, postId: id, name: title, url: linkUrl, body: content, nsfw: nsfw, languageId: languageId, altText: altText, customThumbnail: thumbnail?.absoluteString, scheduledPublishTimeAt: nil, tags: nil ) } return try .init(from: response.postView) } func replyToPost( id: Int, content: String, languageId: Int? = nil ) async throws -> Comment2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyCreateCommentRequest( endpoint: endpoint, content: content, postId: id, parentId: nil, languageId: languageId ) } return try .init(from: response.commentView) } @discardableResult func reportPost(id: Int, reason: String) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyCreatePostReportRequest( endpoint: endpoint, postId: id, reason: reason, violatesInstanceRules: nil ) } return try .init(from: response.postReportView) } func purgePost(id: Int, reason: String?) async throws { let response = try await performingForEndpoint { endpoint in LemmyPurgePostRequest(endpoint: endpoint, postId: id, reason: reason) } guard response.success else { throw ApiClientError.unsuccessful } } @discardableResult func removePost( id: Int, remove: Bool, reason: String? ) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyRemovePostRequest( endpoint: endpoint, postId: id, removed: remove, reason: reason, removeChildren: nil ) } return try .init(from: response.postView) } @discardableResult func pinPost( id: Int, pin: Bool, to target: PostFeatureType ) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyFeaturePostRequest( endpoint: endpoint, postId: id, featured: pin, featureType: target.apiType ) } return try .init(from: response.postView) } @discardableResult func lockPost(id: Int, lock: Bool) async throws -> Post2Snapshot { let response = try await performingForEndpoint { endpoint in LemmyLockPostRequest( endpoint: endpoint, postId: id, locked: lock, reason: nil ) } return try .init(from: response.postView) } @discardableResult func setPostNsfw(id: Int, nsfw: Bool) async throws -> Post1Snapshot { let response = try await performingForEndpoint { endpoint in switch endpoint { case .v3: throw ApiClientError.featureUnsupported case .v4: return LemmyModEditPostRequest(postId: id, nsfw: nsfw, tags: nil) } } return try .init(from: response.postView.post) } @discardableResult func getPostVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { let response = try await performingForEndpoint { endpoint in LemmyListPostLikesRequest( endpoint: endpoint, postId: id, page: page, limit: limit, pageCursor: nil ) } return try response.items.map { try .init(from: $0) } } @discardableResult func voteInPoll(postId: Int, choiceIds: Set) async throws -> Post2Snapshot { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+RegistrationApplication.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension LemmyConnection { func getRegistrationApplicationCount() async throws -> Int { let response = try await performingForEndpoint { endpoint in switch endpoint { case .v3: LemmyGetUnreadRegistrationApplicationCountRequest() case .v4: throw ApiClientError.featureUnsupported } } return response.registrationApplications } func getRegistrationApplications( page: Int = 1, limit: Int = 20, unreadOnly: Bool = false ) async throws -> [RegistrationApplicationSnapshot] { let response = try await performingForEndpoint { endpoint in LemmyListRegistrationApplicationsRequest( endpoint: endpoint, unreadOnly: unreadOnly, page: page, limit: limit, pageCursor: nil ) } return try response.items.map { try .init(from: $0) } } @discardableResult func approveRegistrationApplication(id: Int) async throws -> RegistrationApplicationSnapshot { let response = try await performingForEndpoint { endpoint in LemmyApproveRegistrationApplicationRequest( endpoint: endpoint, id: id, approve: true, denyReason: nil ) } return try .init(from: response.registrationApplication) } @discardableResult func denyRegistrationApplication(id: Int, reason: String?) async throws -> RegistrationApplicationSnapshot { let response = try await performingForEndpoint { endpoint in LemmyApproveRegistrationApplicationRequest( endpoint: endpoint, id: id, approve: false, denyReason: reason ) } return try .init(from: response.registrationApplication) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection+Report.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension LemmyConnection { func getReportCount(communityId: Int? = nil) async throws -> ReportUnreadCountSnapshot { let response = try await performingForEndpoint { endpoint in switch endpoint { case .v3: LemmyReportCountRequest(communityId: communityId) case .v4: throw ApiClientError.featureUnsupported } } return try .init(from: response) } func getPostReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, postId: Int? = nil ) async throws -> [ReportSnapshot] { let response = try await performingForEndpoint { _ in LemmyListPostReportsRequest( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, postId: postId ) } return try response.postReports.map { try .init(from: $0) } } func getCommentReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, commentId: Int? = nil ) async throws -> [ReportSnapshot] { let response = try await performingForEndpoint { _ in LemmyListCommentReportsRequest( page: page, limit: limit, unresolvedOnly: unresolvedOnly, communityId: communityId, commentId: commentId ) } return try response.commentReports.map { try .init(from: $0) } } func getMessageReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false ) async throws -> [ReportSnapshot] { let response = try await performingForEndpoint { _ in LemmyListPmReportsRequest( page: page, limit: limit, unresolvedOnly: unresolvedOnly ) } return try response.privateMessageReports.map { try .init(from: $0) } } @discardableResult func resolvePostReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyResolvePostReportRequest(endpoint: endpoint, reportId: id, resolved: resolved) } return try .init(from: response.postReportView) } @discardableResult func resolveCommentReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyResolveCommentReportRequest(endpoint: endpoint, reportId: id, resolved: resolved) } return try .init(from: response.commentReportView) } @discardableResult func resolveMessageReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { let response = try await performingForEndpoint { endpoint in LemmyResolvePmReportRequest(endpoint: endpoint, reportId: id, resolved: resolved) } return try .init(from: response.privateMessageReportView) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/LemmyConnection/LemmyConnection.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-05. // import Foundation import Rest public class LemmyConnection: InstanceConnection { public static let softwareType: SiteSoftwareType = .lemmy let restClient = RestClient(errorType: ApiErrorResponse.self) enum LemmyConnectionError: Error { case invalidSession } struct Context { let siteVersion: SiteVersion let myPersonId: Int? } struct RawContext { let site: LemmyGetSiteResponse let myUser: LemmyMyUserInfo? } public let baseUrl: URL public var token: String? private var endpointMultiplexer: ConnectionMultiplexer = .init { // The order here matters! Lemmy 1.0 supports both v3 and v4. // Putting v4 first in the array gives it priority. [.v4, .v3] } private(set) var contextDataManager: SharedTaskManager = .init() public var fetchedVersion: SiteVersion? { contextDataManager.fetchedValue?.siteVersion } /// Returns the `fetchedVersion` if the version has already been fetched. Otherwise, waits until the version has been fetched before returning the received value. public var version: SiteVersion { get async throws { try await contextDataManager.getValue().siteVersion } } public var myPersonId: Int? { get async throws { try await contextDataManager.getValue().myPersonId } } public var contextIsFetched: Bool { contextDataManager.fetchedValue != nil } public func ensureContextPresence() async throws { try await contextDataManager.getValue() } public required init(baseUrl: URL, token: String? = nil) { self.baseUrl = baseUrl self.token = token contextDataManager.fetchTask = { try await self.getRawContext() } contextDataManager.createValue = { response in .init(siteVersion: .init(response.site.version), myPersonId: response.myUser?.localUserView.person.id) } } public func updateToken(_ newToken: String) { token = newToken } @discardableResult func perform( _ request: Request, tokenOverride: String? = nil, endpoint: LemmyEndpointVersion ) async throws -> Request.Response { let token = tokenOverride ?? token do throws(RestError) { return try await restClient.perform( baseUrl: baseUrl, request, token: token, encoderUserInfo: [.endpointVersion: endpoint] ) } catch { switch error { case let RestError.response(response, statusCode: _): if ApiErrorResponse(error: response).isNotLoggedIn { if token == nil { throw ApiClientError.notLoggedIn } else { throw LemmyConnectionError.invalidSession } } else { throw ApiClientError(from: error) } default: throw ApiClientError(from: error) } } } // When this function is called, the `requestGenerator` will be called at least once, // but may be called more than once. func performingForEndpoint( _ requestGenerator: @escaping (LemmyEndpointVersion) async throws -> Request ) async throws -> Request.Response { do { return try await endpointMultiplexer.perform { endpoint in try await self.perform(requestGenerator(endpoint), endpoint: endpoint) } } catch ConnectionMultiplexerError.allConnectionsFailed { throw ApiClientError.serverError(statusCode: 404) } } // When this function is called, the `callback` will be called at least once, // but may be called more than once. func processingForEndpoint( _ callback: @escaping (LemmyEndpointVersion) async throws -> Response ) async throws -> Response { do { return try await endpointMultiplexer.perform { endpoint in try await callback(endpoint) } } catch ConnectionMultiplexerError.allConnectionsFailed { throw ApiClientError.serverError(statusCode: 404) } } #if DEBUG func setMockContext(_ context: Context) { contextDataManager.fetchedValue = context } #endif } public extension CodingUserInfoKey { static let endpointVersion = CodingUserInfoKey(rawValue: "com.hanners.Mlem.endpointVersion")! } enum LemmyEncodingError: Error { case noEndpointVersionInUserInfo case lemmyVoteShowBridge } extension Encoder { var endpointVersion: LemmyEndpointVersion { get throws { if let endpoint = userInfo[.endpointVersion] as? LemmyEndpointVersion { return endpoint } else { assertionFailure() throw LemmyEncodingError.noEndpointVersionInUserInfo } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Comment.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-06. // import Foundation public extension PieFedConnection { func getComment(id: Int) async throws -> Comment2Snapshot { let request = PieFedGetCommentRequest(id: id) let response = try await perform(request) return try .init(from: response.commentView) } func getComment(url: URL) async throws -> Comment2Snapshot { do { let request = PieFedResolveObjectRequest(q: url.absoluteString) let response = try await perform(request) if let comment = response.comment { return try .init(from: comment) } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } throw ApiClientError.noEntityFound } func getComments( sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { guard let sort = sort.piefedCommentSortType, filter != .downvoted else { throw ApiClientError.featureUnsupported } let request = PieFedGetCommentsRequest( type_: .all, sort: sort, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, postId: nil, parentId: nil, personId: nil, likedOnly: filter == .upvoted, savedOnly: filter == .saved, depthFirst: false ) let response = try await perform(request) return try response.comments.map { try .init(from: $0) } } func getComments( postId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { guard let sort = sort.piefedCommentSortType, filter != .downvoted else { throw ApiClientError.featureUnsupported } let request = PieFedGetCommentsRequest( type_: .all, sort: sort, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, postId: postId, parentId: nil, personId: nil, likedOnly: filter == .upvoted, savedOnly: filter == .saved, depthFirst: false ) let response = try await perform(request) return try response.comments.map { try .init(from: $0) } } func getComments( parentId: Int, sort: CommentSortType, page: Int, maxDepth: Int? = nil, limit: Int, filter: GetContentFilter? = nil ) async throws -> [Comment2Snapshot] { guard let sort = sort.piefedCommentSortType, filter != .downvoted else { throw ApiClientError.featureUnsupported } let request = PieFedGetCommentsRequest( type_: .all, sort: sort, maxDepth: maxDepth, page: page, limit: limit, communityId: nil, postId: nil, parentId: parentId, personId: nil, likedOnly: filter == .upvoted, savedOnly: filter == .saved, depthFirst: false ) let response = try await perform(request) return try response.comments.map { try .init(from: $0) } } func getCommentHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (comments: [Comment2Snapshot], cursor: String?) { guard type != .downvoted else { throw ApiClientError.featureUnsupported } let request = PieFedGetCommentsRequest( type_: .all, sort: nil, maxDepth: nil, page: page, limit: limit, communityId: nil, postId: nil, parentId: nil, personId: nil, likedOnly: type == .upvoted, savedOnly: type == .saved, depthFirst: false ) let response = try await perform(request) return try ( comments: response.comments.map { try .init(from: $0) }, cursor: nil ) } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: CommentSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { guard let sort = sort.piefedSortType else { throw ApiClientError.featureUnsupported } let request = PieFedSearchRequest( q: query, type_: .comments, sort: sort, listingType: filter.pieFedListingType, page: page, limit: limit, communityName: nil, communityId: communityId, minimumUpvotes: nil, nsfw: nil ) let response = try await perform(request) guard let comments = response.comments else { throw ApiClientError.featureUnsupported } return try comments.map { try .init(from: $0) } } func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Comment2Snapshot] { throw ApiClientError.featureUnsupported } private func searchComments( query: String, page: Int = 1, limit: Int = 20, communityId: Int?, creatorId: Int?, filter: ListingType, legacySort: LemmySortType?, sort: LemmySearchSortType?, timeRangeSeconds: Int? ) async throws -> [Comment2Snapshot] { throw ApiClientError.featureUnsupported } @discardableResult func voteOnComment(id: Int, score: ScoringOperation) async throws -> Comment2Snapshot { let request = PieFedLikeCommentRequest( commentId: id, score: score.rawValue, private: false, emoji: nil ) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func saveComment(id: Int, save: Bool) async throws -> Comment2Snapshot { let request = PieFedSaveCommentRequest(commentId: id, save: save) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func deleteComment(id: Int, delete: Bool) async throws -> Comment2Snapshot { let request = PieFedDeleteCommentRequest(commentId: id, deleted: delete) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func editComment( id: Int, content: String, languageId: Int? ) async throws -> Comment2Snapshot { let request = PieFedEditCommentRequest( commentId: id, body: content, languageId: languageId, distinguished: false ) let response = try await perform(request) return try .init(from: response.commentView) } func replyToComment( postId: Int, parentId: Int?, content: String, languageId: Int? = nil ) async throws -> Comment2Snapshot { let request = PieFedCreateCommentRequest( body: content, postId: postId, parentId: parentId, languageId: languageId ) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func reportComment(id: Int, reason: String) async throws -> ReportSnapshot { let request = PieFedCreateCommentReportRequest( commentId: id, reason: reason, description: nil, reportRemote: nil ) let response = try await perform(request) return try .init(from: response.commentReportView) } func purgeComment(id: Int, reason: String?) async throws { throw ApiClientError.featureUnsupported } @discardableResult func removeComment( id: Int, remove: Bool, reason: String? ) async throws -> Comment2Snapshot { let request = PieFedRemoveCommentRequest(commentId: id, removed: remove, reason: reason) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func getCommentVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { let request = PieFedListCommentLikesRequest(commentId: id, page: page, limit: limit) let response = try await perform(request) return try response.commentLikes.map { try .init(from: $0) } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Community.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-07. // import Foundation public extension PieFedConnection { func getCommunity(id: Int) async throws -> Community3Snapshot { let request = PieFedGetCommunityRequest(id: id, name: nil) let response = try await perform(request) return try .init(from: response) } func getCommunity(url: URL) async throws -> Community2Snapshot { do { let request = PieFedResolveObjectRequest(q: url.absoluteString) let response = try await perform(request) if let community = response.community { return try .init(from: community) } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } throw ApiClientError.noEntityFound } func searchCommunities( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Community2Snapshot] { guard let sort = sort.pieFedSortType else { throw ApiClientError.featureUnsupported } let request = PieFedSearchRequest( q: query, type_: .communities, sort: sort, listingType: filter.pieFedListingType, page: page, limit: limit, communityName: nil, communityId: nil, minimumUpvotes: nil, nsfw: nil ) let response = try await perform(request) return try response.communities.map { try .init(from: $0) } } func editCommunityDescription(id: Int, newValue: String?) async throws -> Community2Snapshot { let request = PieFedEditCommunityRequest( id: id, title: nil, description: newValue, rules: nil, iconUrl: nil, bannerUrl: nil, nsfw: nil, restrictedToMods: nil, localOnly: nil, discussionLanguages: nil, communityId: id, questionAnswer: nil ) let response = try await perform(request) return try .init(from: response.communityView) } @discardableResult func getSubscriptionList(page: Int, limit: Int) async throws -> [Community2Snapshot] { let request = PieFedListCommunitiesRequest( type_: .subscribed, sort: nil, showNsfw: true, page: page, limit: limit ) let response = try await perform(request) return try response.communities.map { try .init(from: $0) } } @discardableResult func subscribeToCommunity(id: Int, subscribe: Bool) async throws -> Community2Snapshot { let request = PieFedFollowCommunityRequest(communityId: id, follow: subscribe) let response = try await perform(request) return try .init(from: response.communityView) } @discardableResult func blockCommunity(id: Int, block: Bool) async throws -> Community2Snapshot { let request = PieFedBlockCommunityRequest(communityId: id, block: block) let response = try await perform(request) return try .init(from: response.communityView) } @discardableResult func removeCommunity( id: Int, remove: Bool, reason: String? ) async throws -> Community2Snapshot { throw ApiClientError.featureUnsupported } func purgeCommunity(id: Int, reason: String?) async throws { throw ApiClientError.featureUnsupported } @discardableResult func addModerator( communityId: Int, personId: Int, added: Bool ) async throws -> (moderators: [Person1Snapshot], community: Community1Snapshot) { let request = PieFedAddModToCommunityRequest(communityId: communityId, personId: personId, added: added) let response = try await perform(request) let moderators: [Person1Snapshot] = try response.moderators.map { try .init(from: $0.moderator) } guard let first = response.moderators.first else { throw ApiClientError.unsuccessful } let community: Community1Snapshot = try .init(from: first.community) return ( moderators: moderators, community: community ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Feature.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-13. // import Foundation public extension PieFedConnection { func supports(_ feature: Feature) async throws -> Bool { try await Self.supports(feature, version: version) } func supports(_ feature: Feature, defaultValue: Bool) -> Bool { if let fetchedVersion { return Self.supports(feature, version: fetchedVersion) } else { return defaultValue } } static func supports( _ feature: Feature, version: SiteVersion ) -> Bool { switch feature { case let .postSortType(sort): version >= sort.minimumVersion case let .commentSortType(sort): version >= sort.minimumVersion case let .searchSortType(sort): version >= sort.minimumVersion case let .sortTimeRange(timeRange): version >= timeRange.minimumVersion case let .listingType(listingType): listingType.pieFedListingType != nil case .viewCommunityActiveUsers, .viewMentionsAndPrivateMessages, .editAndDeletePrivateMessages, .autoMarkPostReadOnInteract: version >= .v1_1_0 case .editProfile, .viewVotes, .undeletePrivateMessages: version >= .v1_2_0 case .banFromCommunity, .editCommunityDescription: version >= .v1_3_0 case .searchLocalPeople, .searchLocalCommunities, .blockInstances: // These features were not necessarily added in 1.3. // Rather, we have only tested them on 1.3 and so are // restricting them to that version. version >= .v1_3_0 case .commentSearch: version >= .v1_3_0 case .userNotes, .searchLocalComments, .fetchLinkMetadata: version >= .v1_4_0 case .moderatorSetNsfw: true default: false } } } private extension SiteVersion { static let v1_0_0: Self = .init("1.0.0") static let v1_1_0: Self = .init("1.1.0") static let v1_2_0: Self = .init("1.2.0") static let v1_3_0: Self = .init("1.3.0") static let v1_4_0: Self = .init("1.4.0") } private extension PostSortType { var minimumVersion: SiteVersion { switch self { case .active: .infinity case .hot: .zero case .new: .zero case .old: .v1_3_0 case .mostComments: .infinity case .newComments: .zero case .controversial: .infinity case .scaled: .zero case let .top(timeRange): timeRange.minimumVersion } } } private extension CommentSortType { var minimumVersion: SiteVersion { switch self { case .new: .zero case .old: .zero case .hot: .zero case .controversial: .v1_4_0 case let .top(timeRange): timeRange == .allTime ? .zero : .infinity } } } private extension SearchSortType { var minimumVersion: SiteVersion { switch self { case .new: .zero case .old: .infinity case let .top(timeRange): timeRange.minimumVersion } } } private extension SortTimeRange { var minimumVersion: SiteVersion { switch self { case .allTime: .v1_1_0 case let .limited(timeInterval): LegacySortTimeRangeLimit(timeInterval)?.minimumVersion ?? .infinity } } } private extension LegacySortTimeRangeLimit { var minimumVersion: SiteVersion { switch self { case .threeMonth, .sixMonth, .nineMonth, .year: .v1_1_0 default: .zero } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+General.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-07. // import Foundation public extension PieFedConnection { func getAccountToken( usernameOrEmail: String, password: String, totpToken: String? ) async throws -> String { if totpToken != nil { throw ApiClientError.featureUnsupported } let request = PieFedUserLoginRequest(username: usernameOrEmail, password: password) let response = try await perform(request) guard let jwt = response.jwt else { throw ApiClientError.notLoggedIn } return jwt } func getUsernameFromToken(token: String) async throws -> String { let request = PieFedGetSiteRequest() let response = try await perform(request, tokenOverride: token) if let name = response.myUser?.localUserView.person.userName { return name } throw ApiClientError.notLoggedIn } func signUp( username: String, password: String, confirmPassword: String, showNsfw: Bool, email: String?, captcha: Captcha?, captchaAnswer: String?, applicationQuestionResponse: String? ) async throws -> SignUpResponse { throw ApiClientError.featureUnsupported } @discardableResult func changePassword( newPassword: String, confirmNewPassword: String, oldPassword: String ) async throws -> String { throw ApiClientError.featureUnsupported } func getCaptcha() async throws -> Captcha { throw ApiClientError.featureUnsupported } func resolve(url: URL) async throws -> ResolvedContent { let request = PieFedResolveObjectRequest(q: url.absoluteString) let response = try await perform(request) return try .init(from: response) } func getBlocked() async throws -> (people: [Person1Snapshot], communities: [Community1Snapshot], instances: [Instance1Snapshot]) { let request = PieFedGetSiteRequest() let response = try await perform(request) guard let myUser = response.myUser else { return ([], [], []) } return try ( people: myUser.personBlocks.map { try .init(from: $0.target) }, communities: myUser.communityBlocks.map { try .init(from: $0.community) }, instances: myUser.instanceBlocks.compactMap(\.site).map { try .init(from: $0) } ) } func getModlog( page: Int = 1, limit: Int = 20, communityId: Int? = nil, moderatorId: Int? = nil, subjectPersonId: Int? = nil, postId: Int? = nil, commentId: Int? = nil, type: ModlogEntryType? = nil ) async throws -> [ModlogEntrySnapshot] { throw ApiClientError.featureUnsupported } func getPostLink(url: URL) async throws -> PostLink { guard try await self.supports(.fetchLinkMetadata) else { throw ApiClientError.featureUnsupported } let request = PieFedGetSiteMetadataRequest(url: url.absoluteString) let response = try await perform(request) guard let imageUrl = response.metadata.image.map(URL.init(string:)) else { throw ApiClientError.unsuccessful } return .init( content: url, thumbnail: imageUrl, label: response.metadata.title ?? url.absoluteString ) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Image.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-05. // import Foundation import Rest public extension PieFedConnection { func uploadImage( _ imageData: Data, fileExtension: String, onProgress progressCallback: @escaping (_ progress: Double) -> Void = { _ in } ) async throws -> ImageUpload1Snapshot { guard let token else { throw ApiClientError.notLoggedIn } var request = mlemUrlRequest(url: baseUrl.appending(path: "api/alpha/upload/image")) request.httpMethod = "POST" let boundary = UUID().uuidString request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let encodedData = createMultiPartForm( boundary: boundary, contentType: "application/octet-stream", name: "file", fileName: "image.\(fileExtension)", imageData: imageData, auth: token ) let (data, _) = try await restClient.urlSession.upload( for: request, from: encodedData, delegate: ImageUploadDelegate(callback: progressCallback) ) do { let response = try JSONDecoder.defaultDecoder.decode(PieFedImageUploadResponse.self, from: data) return .init(from: response) } catch DecodingError.dataCorrupted { let text = String(decoding: data, as: UTF8.self) if text.contains("413 Request Entity Too Large") { throw ApiClientError.imageTooLarge } throw ApiClientError.decoding(data, nil) } } func deleteImage(alias: String, deleteToken: String) async throws { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Inbox.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension PieFedConnection { func getMessages( creatorId: Int? = nil, page: Int, limit: Int, unreadOnly: Bool = false ) async throws -> [Message2Snapshot] { if let creatorId { if unreadOnly { throw ApiClientError.featureUnsupported } let request = PieFedGetPrivateMessagesConversationRequest( page: page, limit: limit, personId: creatorId, conversationId: nil ) let response = try await perform(request) return try response.privateMessages.map { try .init(from: $0) } } else { let request = PieFedListPrivateMessagesRequest( unreadOnly: unreadOnly, page: page, limit: limit, creatorId: nil ) let response = try await perform(request) return try response.privateMessages.map { try .init(from: $0) } } } func getReplyNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { let request = PieFedGetRepliesRequest( sort: .new, page: page, limit: limit, unreadOnly: unreadOnly ) let response = try await perform(request) return try (notifications: response.replies.map { try .init(from: $0, isMention: false) }, cursor: nil) } func getMentionNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { let request = PieFedGetMentionsRequest( sort: .new, page: page, limit: limit, unreadOnly: unreadOnly ) let response = try await perform(request) return try (notifications: response.replies.map { try .init(from: $0, isMention: true) }, cursor: nil) } func getMessageNotifications( page: Int?, cursor: String?, limit: Int, unreadOnly: Bool ) async throws -> (notifications: [InboxNotificationSnapshot], cursor: String?) { let request = PieFedListPrivateMessagesRequest( unreadOnly: unreadOnly, page: page, limit: limit, creatorId: nil ) let response = try await perform(request) return try (notifications: response.privateMessages.map { try .init(from: $0) }, cursor: nil) } func markNotificationAsRead( type: InboxNotificationContentType, id: Int, contentId: Int, read: Bool ) async throws { switch type { case .reply: try await self.markReplyAsRead(id: contentId, read: read) case .mention: try await self.markMentionAsRead(id: contentId, read: read) case .message: try await self.markMessageAsRead(id: contentId, read: read) } } private func markReplyAsRead(id: Int, read: Bool = true) async throws { let request = PieFedMarkReplyAsReadRequest(commentReplyId: id, read: read) try await perform(request) } private func markMentionAsRead(id: Int, read: Bool = true) async throws { let request = PieFedMarkReplyAsReadRequest(commentReplyId: id, read: read) try await perform(request) } private func markMessageAsRead(id: Int, read: Bool = true) async throws { let request = PieFedMarkPrivateMessageAsReadRequest(privateMessageId: id, read: read) try await perform(request) } func markAllAsRead() async throws { let request = PieFedMarkAllRepliesReadRequest() try await perform(request) } func getPersonalUnreadCount() async throws -> PersonalUnreadCountSnapshot { let request = PieFedGetUnreadCountRequest() let response = try await perform(request) return try .init(from: response) } func createMessage(personId: Int, content: String) async throws -> Message2Snapshot { let request = PieFedCreatePrivateMessageRequest(content: content, recipientId: personId) let response = try await perform(request) return try .init(from: response.privateMessageView) } @discardableResult func editMessage(id: Int, content: String) async throws -> Message2Snapshot { let request = PieFedEditPrivateMessageRequest(privateMessageId: id, content: content) let response = try await perform(request) return try .init(from: response.privateMessageView) } @discardableResult func reportMessage(id: Int, reason: String) async throws -> ReportSnapshot { throw ApiClientError.featureUnsupported } @discardableResult func deleteMessage(id: Int, delete: Bool) async throws -> Message2Snapshot { let request = PieFedDeletePrivateMessageRequest( messageId: id, deleted: delete, privateMessageId: id ) let response = try await perform(request) return try .init(from: response.privateMessageView) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Instance.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension PieFedConnection { func getMyInstance() async throws -> Instance3Snapshot { let response = try await rawGetMyPersonWithContext() return try .init(pieFed: response.0, lemmy: response.1) } func getFederatedInstances() async throws -> FederationPolicy { throw ApiClientError.featureUnsupported } func blockInstance(instanceId: Int, block: Bool) async throws { let request = PieFedBlockInstanceRequest( instanceId: instanceId, block: block ) try await perform(request) } @discardableResult func addAdmin(personId: Int, added: Bool) async throws -> [Person2Snapshot] { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Person.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-06. // import Foundation public extension PieFedConnection { func getPerson(id: Int) async throws -> Person3Snapshot { let request = PieFedGetPersonDetailsRequest( personId: id, username: nil, sort: .new, page: 1, limit: 1, communityId: nil, savedOnly: nil, includeContent: false ) let response = try await perform(request) return try .init(from: response) } func getPerson(url: URL) async throws -> Person2Snapshot { do { let request = PieFedResolveObjectRequest(q: url.absoluteString) let response = try await perform(request) if let person = response.person { return try .init(from: person) } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } throw ApiClientError.noEntityFound } func getPerson(username: String) async throws -> Person3Snapshot { let request = PieFedGetPersonDetailsRequest( personId: nil, username: username, sort: .new, page: 1, limit: 1, communityId: nil, savedOnly: nil, includeContent: false ) let response = try await perform(request) return try .init(from: response) } /// `filter` can be set to `.local` from 0.19.4 onwards. func searchPeople( query: String, page: Int = 1, limit: Int = 20, filter: ListingType = .all, sort: SearchSortType = .top(.allTime) ) async throws -> [Person2Snapshot] { guard let sort = sort.pieFedSortType else { throw ApiClientError.featureUnsupported } let request = PieFedSearchRequest( q: query, type_: .users, sort: sort, listingType: filter.pieFedListingType, page: page, limit: limit, communityName: nil, communityId: nil, minimumUpvotes: nil, nsfw: nil ) let response = try await perform(request) return try response.users.map { try .init(from: $0) } } @discardableResult func blockPerson(id: Int, block: Bool) async throws -> Person2Snapshot { let request = PieFedBlockPersonRequest(personId: id, block: block) let response = try await perform(request) return try .init(from: response.personView) } @discardableResult func banPersonFromCommunity( personId: Int, communityId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person1Snapshot { // Explicit check because the endpoint exists before 1.3, but the date // formats are different. Don't want to send a broken ban request. if try await !supports(.banFromCommunity) { throw ApiClientError.featureUnsupported } if ban { let request = PieFedModerateCommunityBanRequest( communityId: communityId, userId: personId, reason: reason ?? "", expiredAt: nil, expiresAt: expires, permanent: expires == nil ) let response = try await perform(request) return try .init(from: response.bannedUser) } else { let request = PieFedModerateCommunityUnBanRequest( communityId: communityId, userId: personId ) let response = try await perform(request) return try .init(from: response.bannedUser) } } @discardableResult func banPersonFromInstance( personId: Int, ban: Bool, removeContent: Bool, reason: String?, expires: Date? = nil ) async throws -> Person2Snapshot { throw ApiClientError.featureUnsupported } func purgePerson(id: Int, reason: String?) async throws { throw ApiClientError.featureUnsupported } func getContent( authorId id: Int, sort: PostSortType, page: Int, limit: Int, savedOnly: Bool? = nil, communityId: Int? = nil ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot], comments: [Comment2Snapshot]) { let request = PieFedGetPersonDetailsRequest( personId: id, username: nil, sort: .new, page: page, limit: limit, communityId: nil, savedOnly: nil, includeContent: true ) let response = try await perform(request) return try ( person: .init(from: response), posts: response.posts.map { try .init(from: $0) }, comments: response.comments.map { try .init(from: $0) } ) } // Returns a raw API type. For use inside PieFedConnection only internal func rawGetMyPerson() async throws -> (PieFedGetSiteResponse, PieFedLemmyCompatibleSiteResponse) { async let pieFedResponse = await perform(PieFedGetSiteRequest()) async let lemmyResponse = await perform(PieFedLemmyCompatibleGetSiteRequest()) return try await (pieFedResponse, lemmyResponse) } // Calls rawGetMyPerson, but if there's already a task running in the `contextDataManager` uses that instead. internal func rawGetMyPersonWithContext() async throws -> (PieFedGetSiteResponse, PieFedLemmyCompatibleSiteResponse) { if let ongoingTask = contextDataManager.ongoingTask { return try await ongoingTask.result.get() } else { let task = Task { try await rawGetMyPerson() } Task.detached { _ = try await self.contextDataManager.getValue(task: task) } return try await task.result.get() } } func getMyPerson() async throws -> (person: Person4Snapshot?, instance: Instance3Snapshot, blocks: BlockListSnapshot?) { let response = try await rawGetMyPersonWithContext() var person: Person4Snapshot? var blocks: BlockListSnapshot? if let myUser = response.0.myUser { person = try .init(from: myUser) blocks = .init(from: myUser) } return try ( person: person, instance: .init(pieFed: response.0, lemmy: response.1), blocks: blocks ) } func deleteAccount(password: String, deleteContent: Bool) async throws { throw ApiClientError.featureUnsupported } func editNote(id: Int, content: String?) async throws { let request = PieFedUserSetNoteRequest(personId: id, note: content ?? "") try await perform(request) } func editProfile(details: ProfileDetails) async throws { let request = PieFedSaveUserSettingsRequest( showNsfw: nil, showReadPosts: nil, bio: details.description, avatar: details.avatar?.absoluteString ?? "", cover: details.banner?.absoluteString ?? "", defaultCommentSortType: nil, defaultSortType: nil, showNsfl: nil, extraFields: nil, acceptPrivateMessages: nil, bot: nil, botVisibility: nil, communityKeywordFilter: nil, emailUnread: nil, federateVotes: nil, feedAutoFollow: nil, feedAutoLeave: nil, hideLowQuality: nil, indexable: nil, newsletter: nil, nsflVisibility: nil, nsfwVisibility: nil, genaiVisibility: nil, replyCollapseThreshold: nil, replyHideThreshold: nil, searchable: nil ) try await perform(request) } func editAccountSettings( showNsfw: Bool?, showScores: Bool?, theme: String?, defaultListingType: ListingType?, interfaceLanguage: String?, avatar: String?, banner: String?, displayName: String?, email: String?, bio: String?, matrixUserId: String?, showAvatars: Bool?, sendNotificationsToEmail: Bool?, botAccount: Bool?, showBotAccounts: Bool?, showReadPosts: Bool?, discussionLanguages: [Int]?, openLinksInNewTab: Bool?, blurNsfw: Bool?, autoExpand: Bool?, infiniteScrollEnabled: Bool?, postListingMode: PostFeedViewMode?, enableKeyboardNavigation: Bool?, enableAnimatedImages: Bool?, collapseBotComments: Bool?, showUpvotes: Bool?, showDownvotes: Bool?, showUpvotePercentage: Bool? ) async throws { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Post.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-05. // import Foundation public extension PieFedConnection { func getPosts( communityId: Int, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { if filter == .downvoted { throw ApiClientError.featureUnsupported } let request = PieFedListPostsRequest( type_: nil, sort: sort.pieFedSortType, pageCursor: page, limit: limit, communityId: communityId, personId: nil, communityName: nil, likedOnly: filter == .upvoted, savedOnly: filter == .saved, q: nil, page: page, feedId: nil, topicId: nil, ignoreSticky: nil ) let response = try await perform(request) let posts: [Post2Snapshot] = try response.posts.map { try .init(from: $0) } return (posts: posts, cursor: nil) } func getPosts( feed: ListingType, sort: PostSortType, page: Int, cursor: String?, limit: Int, filter: GetContentFilter? = nil, showHidden: Bool = false ) async throws -> (posts: [Post2Snapshot], cursor: String?) { if filter == .downvoted || showHidden { throw ApiClientError.featureUnsupported } let request = PieFedListPostsRequest( type_: feed.pieFedListingType, sort: sort.pieFedSortType, pageCursor: page, limit: limit, communityId: nil, personId: nil, communityName: nil, likedOnly: filter == .upvoted, savedOnly: filter == .saved, q: nil, page: page, feedId: nil, topicId: nil, ignoreSticky: nil ) let response = try await perform(request) let posts: [Post2Snapshot] = try response.posts.map { try .init(from: $0) } return (posts: posts, cursor: nil) } func getPosts( personId: Int, communityId: Int? = nil, sort: PostSortType = .new, page: Int, limit: Int, savedOnly: Bool = false ) async throws -> (person: Person3Snapshot, posts: [Post2Snapshot]) { throw ApiClientError.featureUnsupported } func getPostHistory( type: GetContentFilter, page: Int?, cursor: String?, limit: Int ) async throws -> (posts: [Post2Snapshot], cursor: String?) { guard type != .downvoted else { throw ApiClientError.featureUnsupported } // PieFed doesn't support cursors so we need to fake it here let pageNumber = (cursor.map(Int.init) ?? nil) ?? 1 let request = PieFedListPostsRequest( type_: nil, sort: .new, pageCursor: pageNumber, limit: limit, communityId: nil, personId: nil, communityName: nil, likedOnly: type == .upvoted, savedOnly: type == .saved, q: nil, page: pageNumber, feedId: nil, topicId: nil, ignoreSticky: nil ) let response = try await perform(request) let posts: [Post2Snapshot] = try response.posts.map { try .init(from: $0) } return (posts: posts, cursor: String(pageNumber+1)) } func getPost(id: Int) async throws -> Post3Snapshot { let request = PieFedGetPostRequest(id: id, commentId: nil) let response = try await perform(request) return try .init(from: response) } func getPost(url: URL) async throws -> Post2Snapshot { do { let request = PieFedResolveObjectRequest(q: url.absoluteString) let response = try await perform(request) if let post = response.post { return try .init(from: post) } } catch let ApiClientError.response(response, _) where response.couldntFindObject { throw ApiClientError.noEntityFound } throw ApiClientError.noEntityFound } // This method should be removed in favor of the below method once we drop support for versions before Lemmy 1.0 func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: PostSortType ) async throws -> [Post2Snapshot] { guard let sort = sort.pieFedSortType else { throw ApiClientError.featureUnsupported } if communityId != nil || creatorId != nil { throw ApiClientError.featureUnsupported } let request = PieFedSearchRequest( q: query, type_: .posts, sort: sort, listingType: filter.pieFedListingType, page: page, limit: limit, communityName: nil, communityId: communityId, minimumUpvotes: nil, nsfw: nil ) let response = try await perform(request) return try response.posts.map { try .init(from: $0) } } func searchPosts( query: String, page: Int = 1, limit: Int = 20, communityId: Int? = nil, creatorId: Int? = nil, filter: ListingType = .all, sort: SearchSortType ) async throws -> [Post2Snapshot] { throw ApiClientError.featureUnsupported } private func searchPosts( query: String, page: Int, limit: Int, communityId: Int?, creatorId: Int?, filter: ListingType, legacySort: LemmySortType?, sort: LemmySearchSortType?, timeRangeSeconds: Int? ) async throws -> [Post2Snapshot] { throw ApiClientError.featureUnsupported } func markPostsAsRead(ids: Set, read: Bool) async throws { let request = PieFedMarkPostAsReadRequest(postIds: Array(ids), postId: nil, read: read) try await perform(request) } func markPostAsRead(id: Int, read: Bool) async throws { let request = PieFedMarkPostAsReadRequest(postIds: nil, postId: id, read: read) try await perform(request) } @discardableResult func voteOnPost(id: Int, score: ScoringOperation) async throws -> Post2Snapshot { let request = PieFedLikePostRequest( postId: id, score: score.rawValue, private: nil, emoji: nil ) async let response = perform(request) if !supports(.autoMarkPostReadOnInteract, defaultValue: false) { try await markPostAsRead(id: id, read: true) return try await .init(from: response.postView, overrideRead: true) } return try await .init(from: response.postView) } @discardableResult func savePost(id: Int, save: Bool) async throws -> Post2Snapshot { let request = PieFedSavePostRequest(postId: id, save: save) async let response = try await perform(request) if !supports(.autoMarkPostReadOnInteract, defaultValue: false) { try await markPostAsRead(id: id, read: true) return try await .init(from: response.postView, overrideRead: true) } return try await .init(from: response.postView) } @discardableResult func deletePost(id: Int, delete: Bool) async throws -> Post2Snapshot { let request = PieFedDeletePostRequest(postId: id, deleted: delete) let response = try await perform(request) return try .init(from: response.postView) } func hidePost(id: Int, hide: Bool) async throws { throw ApiClientError.featureUnsupported } func createPost( communityId: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { if thumbnail != nil || altText != nil { throw ApiClientError.featureUnsupported } let request = PieFedCreatePostRequest( title: title, communityId: communityId, url: linkUrl, body: content, nsfw: nsfw, languageId: languageId, altText: altText, aiGenerated: nil, event: nil, poll: nil, ) let response = try await perform(request) return try .init(from: response.postView) } @discardableResult func editPost( id: Int, title: String, content: String? = nil, linkUrl: URL? = nil, altText: String? = nil, thumbnail: URL? = nil, nsfw: Bool, languageId: Int? = nil ) async throws -> Post2Snapshot { if thumbnail != nil || altText != nil { throw ApiClientError.featureUnsupported } let request = PieFedEditPostRequest( postId: id, title: title, url: linkUrl, body: content, nsfw: nsfw, languageId: languageId, altText: altText, event: nil, poll: nil, tags: nil, flair: nil ) let response = try await perform(request) return try .init(from: response.postView) } func replyToPost( id: Int, content: String, languageId: Int? = nil ) async throws -> Comment2Snapshot { let request = PieFedCreateCommentRequest( body: content, postId: id, parentId: nil, languageId: languageId ) let response = try await perform(request) return try .init(from: response.commentView) } @discardableResult func reportPost(id: Int, reason: String) async throws -> ReportSnapshot { let request = PieFedCreatePostReportRequest( postId: id, reason: reason, description: nil, reportRemote: true ) let response = try await perform(request) return try .init(from: response.postReportView) } func purgePost(id: Int, reason: String?) async throws { throw ApiClientError.featureUnsupported } @discardableResult func removePost( id: Int, remove: Bool, reason: String? ) async throws -> Post2Snapshot { let request = PieFedRemovePostRequest(postId: id, removed: remove, reason: reason) let response = try await perform(request) return try .init(from: response.postView) } @discardableResult func pinPost( id: Int, pin: Bool, to target: PostFeatureType ) async throws -> Post2Snapshot { let request = PieFedFeaturePostRequest( postId: id, featured: pin, featureType: target.piefedPostFeatureType ) let response = try await perform(request) return try .init(from: response.postView) } @discardableResult func lockPost(id: Int, lock: Bool) async throws -> Post2Snapshot { let request = PieFedLockPostRequest(postId: id, locked: lock) let response = try await perform(request) return try .init(from: response.postView) } @discardableResult func setPostNsfw(id: Int, nsfw: Bool) async throws -> Post1Snapshot { let request = PieFedModerateCommunityPostNsfwRequest(postId: id, nsfwStatus: nsfw) let response = try await perform(request) return try .init(from: response.post) } @discardableResult func getPostVotes( id: Int, page: Int = 1, limit: Int = 20 ) async throws -> [PersonVoteSnapshot] { let request = PieFedListPostLikesRequest(postId: id, page: page, limit: limit) let response = try await perform(request) return try response.postLikes.map { try .init(from: $0) } } @discardableResult func voteInPoll(postId: Int, choiceIds: Set) async throws -> Post2Snapshot { let request = PieFedPollVoteRequest(postId: postId, choiceId: Array(choiceIds)) let response = try await perform(request) return try .init(from: response.postView) } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+RegistrationApplication.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension PieFedConnection { func getRegistrationApplicationCount() async throws -> Int { throw ApiClientError.featureUnsupported } func getRegistrationApplications( page: Int = 1, limit: Int = 20, unreadOnly: Bool = false ) async throws -> [RegistrationApplicationSnapshot] { throw ApiClientError.featureUnsupported } @discardableResult func approveRegistrationApplication(id: Int) async throws -> RegistrationApplicationSnapshot { throw ApiClientError.featureUnsupported } @discardableResult func denyRegistrationApplication(id: Int, reason: String?) async throws -> RegistrationApplicationSnapshot { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection+Report.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-08. // import Foundation public extension PieFedConnection { func getReportCount(communityId: Int? = nil) async throws -> ReportUnreadCountSnapshot { throw ApiClientError.featureUnsupported } func getPostReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, postId: Int? = nil ) async throws -> [ReportSnapshot] { throw ApiClientError.featureUnsupported } func getCommentReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false, communityId: Int? = nil, commentId: Int? = nil ) async throws -> [ReportSnapshot] { throw ApiClientError.featureUnsupported } func getMessageReports( page: Int = 1, limit: Int = 20, unresolvedOnly: Bool = false ) async throws -> [ReportSnapshot] { throw ApiClientError.featureUnsupported } @discardableResult func resolvePostReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { throw ApiClientError.featureUnsupported } @discardableResult func resolveCommentReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { throw ApiClientError.featureUnsupported } @discardableResult func resolveMessageReport(id: Int, resolved: Bool) async throws -> ReportSnapshot { throw ApiClientError.featureUnsupported } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedConnection.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-05. // import Foundation import Rest public class PieFedConnection: InstanceConnection { public static let softwareType: SiteSoftwareType = .pieFed let restClient = RestClient(errorType: ApiErrorResponse.self) enum PieFedConnectionError: Error { case invalidSession } struct Context { let siteVersion: SiteVersion let myPersonId: Int? } public let baseUrl: URL public var token: String? private(set) var contextDataManager: SharedTaskManager = .init() public var fetchedVersion: SiteVersion? { contextDataManager.fetchedValue?.siteVersion } /// Returns the `fetchedVersion` if the version has already been fetched. Otherwise, waits until the version has been fetched before returning the received value. public var version: SiteVersion { get async throws { try await contextDataManager.getValue().siteVersion } } public var myPersonId: Int? { get async throws { try await contextDataManager.getValue().myPersonId } } public var contextIsFetched: Bool { contextDataManager.fetchedValue != nil } public func ensureContextPresence() async throws { try await contextDataManager.getValue() } public required init(baseUrl: URL, token: String? = nil) { self.baseUrl = baseUrl self.token = token contextDataManager.fetchTask = { try await self.rawGetMyPerson() } contextDataManager.createValue = { response in .init(siteVersion: .init(response.0.version), myPersonId: response.0.myUser?.localUserView.person.id) } } public func updateToken(_ newToken: String) { token = newToken } @discardableResult func perform(_ request: Request, tokenOverride: String? = nil) async throws -> Request.Response { let token = tokenOverride ?? token do throws(RestError) { return try await restClient.perform(baseUrl: baseUrl, request, token: token) } catch { switch error { case let RestError.response(response, statusCode: _): if ApiErrorResponse(error: response).isNotLoggedIn { if token == nil { throw ApiClientError.notLoggedIn } else { throw PieFedConnectionError.invalidSession } } else { throw ApiClientError(from: error) } default: throw ApiClientError(from: error) } } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/InstanceConnection/PiefedConnection/PieFedLemmyCompatible/PieFedLemmyCompatibleSite.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-14. // import Foundation import Rest // These schemas are defined by hand and only include the necessary data - parts are omitted. // In theory we could squeeze more data out of this by adding some of the other properties, // but I'd rather just wait for PieFed to implement actual support for those public struct PieFedLemmyCompatibleGetSiteRequest: GetRequest { public typealias Parameters = Int public typealias Response = PieFedLemmyCompatibleSiteResponse public let path: String public let parameters: Parameters? = nil init() { self.path = "api/v3/site" } } public struct PieFedLemmyCompatibleSiteResponse: Codable, Hashable, Sendable { public let siteView: PieFedLemmyCompatibleSiteView } public extension PieFedLemmyCompatibleSiteResponse { enum CodingKeys: String, CodingKey { case siteView = "site_view" } } public struct PieFedLemmyCompatibleSiteView: Codable, Hashable, Sendable { public let counts: LemmySiteAggregates } public extension PieFedLemmyCompatibleSiteView { enum CodingKeys: String, CodingKey { case counts } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Protocols/APIContentAggregatesProtocol.swift ================================================ // // ApiContentAggregatesProtocol.swift // Mlem // // Created by Sjmarf on 09/08/2023. // import Foundation public protocol ApiContentAggregatesProtocol { var score: Int { get } var upvotes: Int { get } var downvotes: Int { get } var published: Date { get } var comments: Int { get } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Protocols/UpgradableProtocol.swift ================================================ // // UpgradableProtocol.swift // // // Created by Eric Andrews on 2024-05-13. // import Foundation // TODO: Unified Community, Modlog remove this public protocol Upgradable: Observable { associatedtype Base associatedtype MinimumRenderable associatedtype Upgraded var wrappedValue: Base { get } func upgrade(api: ApiClient?, upgradeOperation: ((Base) async throws -> Base)?) async throws func refresh(upgradeOperation: ((Base) async throws -> Base)?) async throws init(_ wrappedValue: Base) } public extension Upgradable { var isRenderable: Bool { wrappedValue is MinimumRenderable } var isUpgraded: Bool { wrappedValue is Upgraded } func upgradeFromLocal() async throws { if let wrappedValue = wrappedValue as? any Resolvable { try await upgrade( api: .getApiClient(url: wrappedValue.allResolvableUrls[0].removingPathComponents(), username: nil), upgradeOperation: nil ) } else { assertionFailure() } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Comment/Comment1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-07. // import Foundation public struct Comment1Snapshot: CacheIdentifiable, CommentSnapshotProviding { // Won't change. public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let postId: Int public let parentCommentIds: [Int] public let created: Date // May change. If you add/remove items from this list, // remember to also amend the `update` method of Comment1! public let content: String public let updated: Date? public let distinguished: Bool public let languageId: Int public let deleted: Bool public let removed: Bool public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, creatorId: Int, postId: Int, parentCommentIds: [Int], created: Date, content: String, updated: Date?, distinguished: Bool, languageId: Int, deleted: Bool, removed: Bool ) { self.actorId = actorId self.id = id self.creatorId = creatorId self.postId = postId self.parentCommentIds = parentCommentIds self.created = created self.content = content self.updated = updated self.distinguished = distinguished self.languageId = languageId self.deleted = deleted self.removed = removed } public func merge(with snapshot: any CommentSnapshotProviding) -> any CommentSnapshotProviding { if snapshot is Comment1Snapshot { return self } if var snapshot2 = snapshot as? Comment2Snapshot { snapshot2.comment = self return snapshot2 } assertionFailure("Unrecognized snapshot") return self } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Comment/Comment2Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-07. // import Foundation public struct Comment2Snapshot: CacheIdentifiable, CommentSnapshotProviding { // Won't change, but the corresponding models need to // be updated within the `update` method of Post2. public var comment: Comment1Snapshot public let creator: Person1Snapshot public let post: Post1Snapshot public let community: Community1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Comment2! public let commentCount: Int public let creatorIsModerator: Bool public let creatorIsAdmin: Bool public let creatorBannedFromCommunity: Bool public let votes: VotesModel public let saved: Bool public var cacheId: Int { comment.cacheId } public init( comment: Comment1Snapshot, creator: Person1Snapshot, post: Post1Snapshot, community: Community1Snapshot, commentCount: Int, creatorIsModerator: Bool, creatorIsAdmin: Bool, creatorBannedFromCommunity: Bool, votes: VotesModel, saved: Bool ) { self.comment = comment self.creator = creator self.post = post self.community = community self.commentCount = commentCount self.creatorIsModerator = creatorIsModerator self.creatorIsAdmin = creatorIsAdmin self.creatorBannedFromCommunity = creatorBannedFromCommunity self.votes = votes self.saved = saved } public func merge(with snapshot: any CommentSnapshotProviding) -> any CommentSnapshotProviding { self } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Comment/CommentSnapshotProviding.swift ================================================ // // CommentSnapshotProviding.swift // MlemMiddleware // // Created by Eric Andrews on 2025-08-12. // public protocol CommentSnapshotProviding { /// Combines this snapshot with the given snapshot, returning the highest possible tier snapshot. Prefers this snapshot's values. func merge(with snapshot: any CommentSnapshotProviding) -> any CommentSnapshotProviding } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Community/Community1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-04. // import Foundation public struct Community1Snapshot: CacheIdentifiable { // Won't change. public let actorId: ActorIdentifier public let id: Int public let name: String public let created: Date public let instanceId: Int // May change. If you add/remove items from this list, // remember to also amend the `update` method of Community1! public let updated: Date? public let displayName: String public let description: String? public let deleted: Bool public let removed: Bool public let nsfw: Bool public let avatar: URL? public let banner: URL? public let hidden: Bool public let onlyModeratorsCanPost: Bool // This is a dodgy workaround for https://codeberg.org/rimu/pyfedi/issues/882 // TODO: If that issue gets fixed, we can remove this public let allPropertiesPresent: Bool public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, name: String, created: Date, instanceId: Int, updated: Date?, displayName: String, description: String?, deleted: Bool, removed: Bool, nsfw: Bool, avatar: URL?, banner: URL?, hidden: Bool, onlyModeratorsCanPost: Bool, allPropertiesPresent: Bool ) { self.actorId = actorId self.id = id self.name = name self.created = created self.instanceId = instanceId self.updated = updated self.displayName = displayName self.description = description self.deleted = deleted self.removed = removed self.nsfw = nsfw self.avatar = avatar self.banner = banner self.hidden = hidden self.onlyModeratorsCanPost = onlyModeratorsCanPost self.allPropertiesPresent = allPropertiesPresent } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Community/Community2Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-04. // import Foundation public struct Community2Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Community2. public let community: Community1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Community2! public let subscription: SubscriptionModel public let postCount: Int public let commentCount: Int public let activeUserCount: ActiveUserCount public let bannedFromCommunity: Bool? public var cacheId: Int { community.cacheId } public init( community: Community1Snapshot, subscription: SubscriptionModel, postCount: Int, commentCount: Int, activeUserCount: ActiveUserCount, bannedFromCommunity: Bool? ) { self.community = community self.subscription = subscription self.postCount = postCount self.commentCount = commentCount self.activeUserCount = activeUserCount self.bannedFromCommunity = bannedFromCommunity } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Community/Community3Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-05. // import Foundation public struct Community3Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Community3. public let community: Community2Snapshot public let instance: Instance1Snapshot? public let moderators: [Person1Snapshot] public let discussionLanguageIds: Set public var cacheId: Int { community.cacheId } public init( community: Community2Snapshot, instance: Instance1Snapshot?, moderators: [Person1Snapshot], discussionLanguageIds: Set ) { self.community = community self.instance = instance self.moderators = moderators self.discussionLanguageIds = discussionLanguageIds } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/ImageUpload/ImageUpload1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-07-05. // import Foundation public struct ImageUpload1Snapshot: CacheIdentifiable { public let url: URL public let alias: String? public let deleteToken: String? public init( url: URL, alias: String?, deleteToken: String? ) { self.url = url self.alias = alias self.deleteToken = deleteToken } public var cacheId: Int { url.hashValue } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Instance/Instance1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-11. // import Foundation public struct Instance1Snapshot: CacheIdentifiable { // Won't change. public let actorId: ActorIdentifier public let id: Int public let instanceId: Int public let created: Date // May change. If you add/remove items from this list, // remember to also amend the `update` method of Instance1! public let updated: Date? public let publicKey: String public var displayName: String public var description: String? public var shortDescription: String? public var avatar: URL? public var banner: URL? public var lastRefresh: Date public var contentWarning: String? public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, instanceId: Int, created: Date, updated: Date?, publicKey: String, displayName: String, description: String? = nil, shortDescription: String? = nil, avatar: URL? = nil, banner: URL? = nil, lastRefresh: Date, contentWarning: String? = nil ) { self.actorId = actorId self.id = id self.instanceId = instanceId self.created = created self.updated = updated self.publicKey = publicKey self.displayName = displayName self.description = description self.shortDescription = shortDescription self.avatar = avatar self.banner = banner self.lastRefresh = lastRefresh self.contentWarning = contentWarning } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Instance/Instance2Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-11. // import Foundation public struct Instance2Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Instance2. public let instance: Instance1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Post2! public var setup: Bool public var voteFederationMode: VoteFederationMode public var nsfwContentEnabled: Bool public var communityCreationRestrictedToAdmins: Bool public var emailVerificationRequired: Bool public var applicationQuestion: String? public var isPrivate: Bool public var defaultTheme: String public var defaultFeed: ListingType public var legalInformation: String? public var hideModlogNames: Bool public var emailApplicationsToAdmins: Bool public var emailReportsToAdmins: Bool public var slurFilterRegex: String? public var actorNameMaxLength: Int public var federationEnabled: Bool public var captchaEnabled: Bool public var captchaDifficulty: CaptchaDifficulty? public var registrationMode: RegistrationMode public var federationSignedFetch: Bool? public var defaultPostListingMode: PostFeedViewMode? public var defaultPostSortType: PostSortType? public var userCount: Int public var postCount: Int public var commentCount: Int public var communityCount: Int public var activeUserCount: ActiveUserCount public var cacheId: Int { instance.cacheId } public init( instance: Instance1Snapshot, setup: Bool, voteFederationMode: VoteFederationMode, nsfwContentEnabled: Bool, communityCreationRestrictedToAdmins: Bool, emailVerificationRequired: Bool, applicationQuestion: String? = nil, isPrivate: Bool, defaultTheme: String, defaultFeed: ListingType, legalInformation: String? = nil, hideModlogNames: Bool, emailApplicationsToAdmins: Bool, emailReportsToAdmins: Bool, slurFilterRegex: String? = nil, actorNameMaxLength: Int, federationEnabled: Bool, captchaEnabled: Bool, captchaDifficulty: CaptchaDifficulty? = nil, registrationMode: RegistrationMode, federationSignedFetch: Bool? = nil, defaultPostListingMode: PostFeedViewMode? = nil, defaultPostSortType: PostSortType? = nil, userCount: Int, postCount: Int, commentCount: Int, communityCount: Int, activeUserCount: ActiveUserCount ) { self.instance = instance self.setup = setup self.voteFederationMode = voteFederationMode self.nsfwContentEnabled = nsfwContentEnabled self.communityCreationRestrictedToAdmins = communityCreationRestrictedToAdmins self.emailVerificationRequired = emailVerificationRequired self.applicationQuestion = applicationQuestion self.isPrivate = isPrivate self.defaultTheme = defaultTheme self.defaultFeed = defaultFeed self.legalInformation = legalInformation self.hideModlogNames = hideModlogNames self.emailApplicationsToAdmins = emailApplicationsToAdmins self.emailReportsToAdmins = emailReportsToAdmins self.slurFilterRegex = slurFilterRegex self.actorNameMaxLength = actorNameMaxLength self.federationEnabled = federationEnabled self.captchaEnabled = captchaEnabled self.captchaDifficulty = captchaDifficulty self.registrationMode = registrationMode self.federationSignedFetch = federationSignedFetch self.defaultPostListingMode = defaultPostListingMode self.defaultPostSortType = defaultPostSortType self.userCount = userCount self.postCount = postCount self.commentCount = commentCount self.communityCount = communityCount self.activeUserCount = activeUserCount } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Instance/Instance3Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-11. // import Foundation public struct Instance3Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Instance3. public let instance: Instance2Snapshot // Won't Change. public let allLanguages: [Locale.Language] // May change. If you add/remove items from this list, // remember to also amend the `update` method of Instance3! public let software: SiteSoftware // This excludes the "undetermined" language identifier (which is 0), // because its presence or absence doesn't actually affect whether you're // able to create a post with "undetermined" as the language public var allowedLanguageIds: Set public let blockedUrls: [InstanceUrlBlockRecord]? public let administrators: [Person2Snapshot] public var cacheId: Int { instance.cacheId } public init( instance: Instance2Snapshot, allLanguages: [Locale.Language], software: SiteSoftware, allowedLanguageIds: Set, blockedUrls: [InstanceUrlBlockRecord]?, administrators: [Person2Snapshot] ) { self.instance = instance self.allLanguages = allLanguages self.software = software self.allowedLanguageIds = allowedLanguageIds self.blockedUrls = blockedUrls self.administrators = administrators } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Message/Message1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-10. // import Foundation public struct Message1Snapshot: CacheIdentifiable { // Won't change. public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let recipientId: Int public let created: Date // May change. If you add/remove items from this list, // remember to also amend the `update` method of Message1! public let content: String public let updated: Date? public let read: Bool public let deleted: Bool public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, creatorId: Int, recipientId: Int, created: Date, content: String, updated: Date?, read: Bool, deleted: Bool ) { self.actorId = actorId self.id = id self.creatorId = creatorId self.recipientId = recipientId self.created = created self.content = content self.updated = updated self.read = read self.deleted = deleted } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Message/Message2Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-10. // import Foundation public struct Message2Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Message2. public let message: Message1Snapshot public let creator: Person1Snapshot public let recipient: Person1Snapshot public var cacheId: Int { message.cacheId } public init( message: Message1Snapshot, creator: Person1Snapshot, recipient: Person1Snapshot ) { self.message = message self.creator = creator self.recipient = recipient } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/ModlogEntry/ModlogEntryContentSnapshot.swift ================================================ // // ModlogEntryContentSnapshot.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-13. // import Foundation public enum ModlogEntryContentSnapshot { case removePost( _ post: Post1Snapshot, community: Community1Snapshot, removed: Bool, reason: String? ) case lockPost( _ post: Post1Snapshot, community: Community1Snapshot, locked: Bool ) case pinPost( _ post: Post1Snapshot, community: Community1Snapshot, pinned: Bool, type: PostFeatureType ) case purgePost(reason: String?) case removeComment( _ comment: Comment1Snapshot, creator: Person1Snapshot, post: Post1Snapshot, community: Community1Snapshot, removed: Bool, reason: String? ) case purgeComment(reason: String?) case removeCommunity( _ community: Community1Snapshot, removed: Bool, reason: String? ) case purgeCommunity(reason: String?) case hideCommunity( _ community: Community1Snapshot, hidden: Bool, reason: String? ) case transferCommunityOwnership( person: Person1Snapshot, community: Community1Snapshot ) case updatePersonModeratorStatus( person: Person1Snapshot, community: Community1Snapshot, appointed: Bool ) case updatePersonAdminStatus( person: Person1Snapshot, appointed: Bool ) case banPersonFromCommunity( person: Person1Snapshot, community: Community1Snapshot, banned: Bool, reason: String?, expires: Date? ) case banPersonFromInstance( person: Person1Snapshot, banned: Bool, reason: String?, expires: Date? ) case purgePerson(reason: String?) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/ModlogEntry/ModlogEntrySnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-13. // import Foundation public struct ModlogEntrySnapshot { public let created: Date public let moderator: Person1Snapshot? public let type: ModlogEntryContentSnapshot public init(created: Date, moderator: Person1Snapshot?, type: ModlogEntryContentSnapshot) { self.created = created self.moderator = moderator self.type = type } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Notification/InboxNotificationSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-06. // import Foundation public struct InboxNotificationSnapshot: CacheIdentifiable { public let id: Int public let contentId: Int public var read: Bool public var content: InboxNotificationContentSnapshot public var cacheId: Int { id } public init( id: Int, contentId: Int, read: Bool, content: InboxNotificationContentSnapshot ) { self.id = id self.contentId = contentId self.read = read self.content = content } } public enum InboxNotificationContentSnapshot { case reply(Comment2Snapshot) case mention(Comment2Snapshot) case message(Message2Snapshot) } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Person/Person1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-04. // import Foundation public struct Person1Snapshot: CacheIdentifiable { // Won't change. public let actorId: ActorIdentifier public let id: Int public let name: String public let created: Date public let instanceId: Int // May change. If you add/remove items from this list, // remember to also amend the `update` method of Person1! public let displayName: String public let avatar: URL? public let banner: URL? public let note: String? public let updated: Date? public let description: String? public let matrixUserId: String? public let isBot: Bool public let instanceBan: InstanceBanType public let deleted: Bool // This is a dodgy workaround for https://codeberg.org/rimu/pyfedi/issues/882 // TODO: If that issue gets fixed, we can remove this public let allPropertiesPresent: Bool public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, name: String, created: Date, instanceId: Int, displayName: String, avatar: URL?, banner: URL?, note: String?, updated: Date?, description: String?, matrixUserId: String?, isBot: Bool, instanceBan: InstanceBanType, deleted: Bool, allPropertiesPresent: Bool ) { self.actorId = actorId self.id = id self.name = name self.created = created self.instanceId = instanceId self.displayName = displayName self.avatar = avatar self.banner = banner self.note = note self.updated = updated self.description = description self.matrixUserId = matrixUserId self.isBot = isBot self.instanceBan = instanceBan self.deleted = deleted self.allPropertiesPresent = allPropertiesPresent } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Person/Person2Snapshot.swift ================================================ // // Person2ApiBacker.swift // Mlem // // Created by Eric Andrews on 2024-02-19. // import Foundation public struct Person2Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Person2. public let person: Person1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Person2! public let isAdmin: Bool public let postCount: Int public let commentCount: Int public var cacheId: Int { person.cacheId } public init( person: Person1Snapshot, isAdmin: Bool, postCount: Int, commentCount: Int ) { self.person = person self.isAdmin = isAdmin self.postCount = postCount self.commentCount = commentCount } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Person/Person3Snapshot.swift ================================================ // // Person3ApiBacker.swift // Mlem // // Created by Eric Andrews on 2024-03-01. // import Foundation public struct Person3Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Person3. let person: Person2Snapshot let site: Instance1Snapshot? // May change. If you add/remove items from this list, // remember to also amend the `update` method of Person3! let moderatedCommunities: [Community1Snapshot] public var cacheId: Int { person.cacheId } public init( person: Person2Snapshot, site: Instance1Snapshot?, moderatedCommunities: [Community1Snapshot] ) { self.person = person self.site = site self.moderatedCommunities = moderatedCommunities } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Person/Person4Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-04. // import Foundation public struct Person4Snapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Person2. public let person: Person3Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Person3! public var email: String? public var showNsfw: Bool public var theme: String public var defaultListingType: ListingType public var interfaceLanguage: String public var showAvatars: Bool public var sendNotificationsToEmail: Bool public var showScores: Bool public var showBotAccounts: Bool public var showReadPosts: Bool public var discussionLanguageIds: Set public var emailVerified: Bool public var acceptedApplication: Bool public var openLinksInNewTab: Bool? public var blurNsfw: Bool? public var autoExpandImages: Bool? public var infiniteScrollEnabled: Bool? public var postListingMode: PostFeedViewMode? public var totp2faEnabled: Bool? public var enableKeyboardNavigation: Bool? public var enableAnimatedImages: Bool? public var collapseBotComments: Bool? public var cacheId: Int { person.cacheId } public init( person: Person3Snapshot, email: String? = nil, showNsfw: Bool, theme: String, defaultListingType: ListingType, interfaceLanguage: String, showAvatars: Bool, sendNotificationsToEmail: Bool, showScores: Bool, showBotAccounts: Bool, showReadPosts: Bool, discussionLanguageIds: Set, emailVerified: Bool, acceptedApplication: Bool, openLinksInNewTab: Bool? = nil, blurNsfw: Bool? = nil, autoExpandImages: Bool? = nil, infiniteScrollEnabled: Bool? = nil, postListingMode: PostFeedViewMode? = nil, totp2faEnabled: Bool? = nil, enableKeyboardNavigation: Bool? = nil, enableAnimatedImages: Bool? = nil, collapseBotComments: Bool? = nil ) { self.person = person self.email = email self.showNsfw = showNsfw self.theme = theme self.defaultListingType = defaultListingType self.interfaceLanguage = interfaceLanguage self.showAvatars = showAvatars self.sendNotificationsToEmail = sendNotificationsToEmail self.showScores = showScores self.showBotAccounts = showBotAccounts self.showReadPosts = showReadPosts self.discussionLanguageIds = discussionLanguageIds self.emailVerified = emailVerified self.acceptedApplication = acceptedApplication self.openLinksInNewTab = openLinksInNewTab self.blurNsfw = blurNsfw self.autoExpandImages = autoExpandImages self.infiniteScrollEnabled = infiniteScrollEnabled self.postListingMode = postListingMode self.totp2faEnabled = totp2faEnabled self.enableKeyboardNavigation = enableKeyboardNavigation self.enableAnimatedImages = enableAnimatedImages self.collapseBotComments = collapseBotComments } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/PersonVote/PersonVoteSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-10. // import Foundation public struct PersonVoteSnapshot: CacheIdentifiable { public let creator: Person1Snapshot public let score: Int public let creatorBannedFromCommunity: Bool? public var cacheId: Int { creator.id } public init( creator: Person1Snapshot, score: Int, creatorBannedFromCommunity: Bool? ) { self.creator = creator self.score = score self.creatorBannedFromCommunity = creatorBannedFromCommunity } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Post/Post1Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-06. // import Foundation public struct Post1Snapshot: CacheIdentifiable, PostSnapshotProviding { // Won't change. public let actorId: ActorIdentifier public let id: Int public let creatorId: Int public let communityId: Int public let created: Date // May change. If you add/remove items from this list, // remember to also amend the `update` method of Post1! public let title: String public let content: String? public let linkUrl: URL? public let embed: PostEmbed? public let poll: PostPoll? public let nsfw: Bool public let thumbnailUrl: URL? public let updated: Date? public let languageId: Int public let altText: String? public let deleted: Bool public let removed: Bool public let pinnedCommunity: Bool public let pinnedInstance: Bool public let locked: Bool public var cacheId: Int { id } public init( actorId: ActorIdentifier, id: Int, creatorId: Int, communityId: Int, created: Date, title: String, content: String?, linkUrl: URL?, embed: PostEmbed?, poll: PostPoll?, nsfw: Bool, thumbnailUrl: URL?, updated: Date?, languageId: Int, altText: String?, deleted: Bool, removed: Bool, pinnedCommunity: Bool, pinnedInstance: Bool, locked: Bool ) { self.actorId = actorId self.id = id self.creatorId = creatorId self.communityId = communityId self.created = created self.title = title self.content = content self.linkUrl = linkUrl self.embed = embed self.poll = poll self.nsfw = nsfw self.thumbnailUrl = thumbnailUrl self.updated = updated self.languageId = languageId self.altText = altText self.deleted = deleted self.removed = removed self.pinnedCommunity = pinnedCommunity self.pinnedInstance = pinnedInstance self.locked = locked } public func merge(with snapshot: any PostSnapshotProviding) -> any PostSnapshotProviding { if snapshot is Post1Snapshot { return self } if var snapshot2 = snapshot as? Post2Snapshot { snapshot2.post = self return snapshot2 } if var snapshot3 = snapshot as? Post3Snapshot { snapshot3.post.post = self return snapshot3 } assertionFailure("Unrecognized snapshot") return self } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Post/Post2Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-06. // import Foundation public struct Post2Snapshot: CacheIdentifiable, PostSnapshotProviding { // Won't change, but the corresponding models need to // be updated within the `update` method of Post2. public var post: Post1Snapshot public let creator: Person1Snapshot public let community: Community1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Post2! public let commentCount: Int public let unreadCommentCount: Int public let creatorIsModerator: Bool public let creatorIsAdmin: Bool public let creatorBannedFromCommunity: Bool public let creatorBlocked: Bool public let votes: VotesModel public let saved: Bool public var read: Bool public var hidden: Bool public var cacheId: Int { post.cacheId } public var actorId: ActorIdentifier { post.actorId } public init( post: Post1Snapshot, creator: Person1Snapshot, community: Community1Snapshot, commentCount: Int, unreadCommentCount: Int, creatorIsModerator: Bool, creatorIsAdmin: Bool, creatorBannedFromCommunity: Bool, creatorBlocked: Bool, votes: VotesModel, saved: Bool, read: Bool, hidden: Bool ) { self.post = post self.creator = creator self.community = community self.commentCount = commentCount self.unreadCommentCount = unreadCommentCount self.creatorIsModerator = creatorIsModerator self.creatorIsAdmin = creatorIsAdmin self.creatorBannedFromCommunity = creatorBannedFromCommunity self.creatorBlocked = creatorBlocked self.votes = votes self.saved = saved self.read = read self.hidden = hidden } public func merge(with snapshot: any PostSnapshotProviding) -> any PostSnapshotProviding { if snapshot is Post1Snapshot || snapshot is Post2Snapshot { return self } if var snapshot3 = snapshot as? Post3Snapshot { snapshot3.post = self return snapshot3 } assertionFailure("Unrecognized snapshot") return self } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Post/Post3Snapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-07. // import Foundation public struct Post3Snapshot: CacheIdentifiable, PostSnapshotProviding { // Won't change, but the corresponding models need to // be updated within the `update` method of Post2. public var post: Post2Snapshot public let community: Community2Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of Post3! public let crossPosts: [Post2Snapshot] public var cacheId: Int { post.cacheId } public var actorId: ActorIdentifier { post.actorId } public init( post: Post2Snapshot, community: Community2Snapshot, crossPosts: [Post2Snapshot] ) { self.post = post self.community = community self.crossPosts = crossPosts } public func merge(with snapshot: any PostSnapshotProviding) -> any PostSnapshotProviding { self } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Post/PostSnapshotProviding.swift ================================================ // // PostSnapshotProviding.swift // MlemMiddleware // // Created by Eric Andrews on 2025-07-04. // public protocol PostSnapshotProviding: CacheIdentifiable, ActorIdentifiable { /// Combines this snapshot with the given snapshot, returning the highest possible tier snapshot. Prefers this snapshot's values. func merge(with snapshot: any PostSnapshotProviding) -> any PostSnapshotProviding } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/ProfileDetails.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-09-07. // import Foundation public struct ProfileDetails: Hashable, Sendable { public var avatar: URL? public var banner: URL? public var displayName: String? public var description: String? public var matrixUserId: String? } public struct ProfileDetailsMutation { let originalDetails: ProfileDetails let newDetails: ProfileDetails func isValid(forSoftware software: SiteSoftware) -> Bool { if originalDetails.displayName != newDetails.displayName, !software.supports(.editDisplayName) { return false } return true } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/RegistrationApplication/RegistrationApplicationSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-10. // import Foundation public struct RegistrationApplicationSnapshot: CacheIdentifiable { // Won't change. public let id: Int public let created: Date // I don't *think* these can change, but I'm assuming they do // incase the ability to edit applications is added in future. // Update RegistrationApplication if you change these! public let questionResponse: String public let email: String? public let showNsfw: Bool public let creator: Person1Snapshot // May change. If you add/remove items from this list, // remember to also amend the `update` method of RegistrationApplication! public let emailVerified: Bool public let resolver: Person1Snapshot? public let resolution: RegistrationApplication.ResolutionState public var cacheId: Int { id } public init( id: Int, created: Date, questionResponse: String, email: String?, showNsfw: Bool, creator: Person1Snapshot, emailVerified: Bool, resolver: Person1Snapshot?, resolution: RegistrationApplication.ResolutionState ) { self.id = id self.created = created self.questionResponse = questionResponse self.email = email self.showNsfw = showNsfw self.creator = creator self.emailVerified = emailVerified self.resolver = resolver self.resolution = resolution } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/Snapshot/Report/ReportSnapshot.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-10. // import Foundation public struct ReportSnapshot: CacheIdentifiable { // Won't change, but the corresponding models need to // be updated within the `update` method of Report. public let creator: Person1Snapshot // Won't change. public let id: Int public let created: Date // May change. If you add/remove items from this list, // remember to also amend the `update` method of Report! public let resolver: Person1Snapshot? public let updated: Date? public let resolved: Bool public let reason: String public let target: ReportTargetSnapshot public var cacheId: Int { var hasher = Hasher() hasher.combine(target.type) hasher.combine(id) return hasher.finalize() } public init( creator: Person1Snapshot, id: Int, created: Date, resolver: Person1Snapshot?, updated: Date?, resolved: Bool, reason: String, target: ReportTargetSnapshot ) { self.creator = creator self.id = id self.created = created self.resolver = resolver self.updated = updated self.resolved = resolved self.reason = reason self.target = target } } public enum ReportTargetSnapshot { case post(Post2Snapshot) case comment(Comment2Snapshot) case message(Message2Snapshot) var type: ReportType { switch self { case .post: .post case .comment: .comment case .message: .message } } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Sources/MlemMiddleware/UsernameValidity.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-05-24. // import Foundation public enum UsernameValidity: Hashable, Sendable { case available case taken case invalid(InvalidityReason) public enum InvalidityReason: Hashable, Sendable { case tooShort(minLength: Int) case tooLong(maxLength: Int) case containsInvalidCharacters(_ characters: Set) case other } } ================================================ FILE: Mlem/Packages/MlemMiddleware/Tests/MlemMiddlewareTests/MlemMiddlewareTests.swift ================================================ @testable import MlemMiddleware import XCTest final class MlemMiddlewareTests: XCTestCase { func testExample() throws { // XCTest Documentation // https://developer.apple.com/documentation/xctest // Defining Test Cases and Test Methods // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods } } ================================================ FILE: Mlem/Packages/QuickSwipes/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/QuickSwipes/Package.resolved ================================================ { "originHash" : "433eb46e437fba071b47444bcf712c57872e1973de4812d146c7db18e50834e2", "pins" : [ { "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", "version" : "1.5.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke.git", "state" : { "revision" : "0ead44350d2737db384908569c012fe67c421e4d", "version" : "12.8.0" } }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", "version" : "5.21.0" } }, { "identity" : "sdwebimagewebpcoder", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", "state" : { "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", "version" : "0.14.6" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/QuickSwipes/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "QuickSwipes", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "QuickSwipes", targets: ["QuickSwipes"] ) ], dependencies: [ .package(path: "../Theming"), .package(path: "../ComponentViews"), .package(path: "../Icons"), .package(path: "../Haptics"), .package(path: "../MlemLogger") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "QuickSwipes", dependencies: [ .byName(name: "Theming"), .byName(name: "ComponentViews"), .byName(name: "Icons"), .byName(name: "Haptics"), .byName(name: "MlemLogger") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/Array+Extensions.swift ================================================ // // File.swift // QuickSwipes // // Created by Sjmarf on 2025-08-23. // import Foundation extension Array { subscript(safeIndex index: Int) -> Element? { guard index >= 0, index < endIndex else { return nil } return self[index] } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/EnvironmentValues+Extensions.swift ================================================ // // File.swift // QuickSwipes // // Created by Sjmarf on 2025-08-22. // import SwiftUI extension EnvironmentValues { @Entry var quickSwipeThresholdSet: QuickSwipeThresholdSet = .default @Entry var quickSwipeMinimumDrag: CGFloat = 20 @Entry var quickSwipeIconSize: CGFloat = 16 @Entry var quickSwipeCornerRadius: CGFloat = 28 @Entry var quickSwipesEnabled: Bool = true } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/PanGesture.swift ================================================ // // PanGesture.swift // Mlem // // Created by Sjmarf on 17/09/2024. // import SwiftUI struct PanGesture: UIGestureRecognizerRepresentable { /// If provided, the gesture will not register within `leadingBuffer` px of the leading edge let leadingBuffer: CGFloat? var handle: (UIPanGestureRecognizer) -> Void func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { .init(leadingBuffer: leadingBuffer) } func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { let gesture = UIPanGestureRecognizer() gesture.delegate = context.coordinator gesture.isEnabled = true return gesture } func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context: Context) { handle(recognizer) } class Coordinator: NSObject, UIGestureRecognizerDelegate { let leadingBuffer: CGFloat init(leadingBuffer: CGFloat?) { self.leadingBuffer = leadingBuffer ?? 0 } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { false } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let panRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { return false } // prevent swipe from interfering with interactive swipe back guard panRecognizer.location(in: gestureRecognizer.view).x >= leadingBuffer else { return false } let velocity = panRecognizer.velocity(in: gestureRecognizer.view) return abs(velocity.y) < abs(velocity.x) } } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/QuickSwipeAction.swift ================================================ // // File.swift // QuickSwipes // // Created by Sjmarf on 2025-08-22. // import Foundation import Icons import Theming public struct QuickSwipeAction { enum ActionType { case callback(@MainActor () -> Void, confirmationPrompt: String?) case choice(QuickSwipeChoiceGroup) } var enabled: Bool var perform: ActionType var color: ThemedColor var icon: Icon public init( icon: Icon, color: ThemedColor, enabled: Bool, confirmationPrompt: String?, callback: @MainActor @escaping () -> Void ) { self.icon = icon self.color = color self.enabled = enabled self.perform = .callback(callback, confirmationPrompt: confirmationPrompt) } public init( icon: Icon, color: ThemedColor, enabled: Bool, alertTitle: LocalizedStringResource, choices: [QuickSwipeChoice] ) { self.icon = icon self.color = color self.enabled = enabled self.perform = .choice(.init(title: .init(localized: alertTitle), items: choices)) } @_disfavoredOverload public init( icon: Icon, color: ThemedColor, enabled: Bool, alertTitle: String, choices: [QuickSwipeChoice] ) { self.icon = icon self.color = color self.enabled = enabled self.perform = .choice(.init(title: alertTitle, items: choices)) } } struct QuickSwipeChoiceGroup { let title: String let items: [QuickSwipeChoice] } public struct QuickSwipeChoice { let label: String let destructive: Bool let callback: () -> Void public init( label: LocalizedStringResource, destructive: Bool = false, callback: @escaping () -> Void ) { self.label = .init(localized: label) self.destructive = destructive self.callback = callback } @_disfavoredOverload public init( label: String, destructive: Bool = false, callback: @escaping () -> Void ) { self.label = label self.destructive = destructive self.callback = callback } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/QuickSwipeThresholdSet.swift ================================================ // // SwipeBehavior.swift // QuickSwipes // // Created by Sjmarf on 2025-08-22. // import Foundation public struct QuickSwipeThresholdSet { /// Minimum distance to trigger the primary action public let primary: CGFloat /// Minimum distance to trigger the secondary action public let secondary: CGFloat /// Minimum distance to trigger the tertiary action public let tertiary: CGFloat public var all: [CGFloat] { [primary, secondary, tertiary] } public init(primary: CGFloat, secondary: CGFloat, tertiary: CGFloat) { self.primary = primary self.secondary = secondary self.tertiary = tertiary } public static var `default`: QuickSwipeThresholdSet { .init(primary: 60, secondary: 150, tertiary: 240) } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/QuickSwipesViewModifier.swift ================================================ // // QuickSwipesViewModifier.swift // Mlem // // Created by Sjmarf on 2025-03-23. // import ComponentViews import Haptics import Icons import MlemLogger import os import SwiftUI import Theming // swiftlint:disable:next type_body_length struct QuickSwipeViewModifier: ViewModifier { let log: Logger = .mlemLogger() @Environment(HapticManager.self) var hapticManager @Environment(\.palette) var palette @Environment(\.quickSwipeThresholdSet) var thresholds @Environment(\.quickSwipeMinimumDrag) var minimumDrag @Environment(\.quickSwipeIconSize) var iconSize @Environment(\.quickSwipeCornerRadius) var cornerRadius @Environment(\.quickSwipesEnabled) var quickSwipesEnabled // state @GestureState var dragState: CGFloat = .zero @State var dragPosition: CGFloat = .zero @State var prevDragPosition: CGFloat = .zero @State var dragBackground: ThemedColor? = .themedBackground @State var leadingSwipeIcon: Icon? @State var trailingSwipeIcon: Icon? @State var iconIsActive: Bool = false @State var activeChoiceGroup: QuickSwipeChoiceGroup? let config: SwipeConfiguration private var primaryLeadingAction: QuickSwipeAction? { config.leadingActions.first } private var primaryTrailingAction: QuickSwipeAction? { config.trailingActions.first } init(config: SwipeConfiguration) { self.config = config _leadingSwipeIcon = State(initialValue: primaryLeadingAction?.icon) _trailingSwipeIcon = State(initialValue: primaryTrailingAction?.icon) } func body(content: Content) -> some View { if quickSwipesEnabled { innerBody(content: content) .clipShape(.rect(cornerRadius: cornerRadius)) // clip slidable card .background(shadowBackground) .geometryGroup() .offset(x: dragPosition) // using dragPosition so we can apply withAnimation() to it .background(iconBackground) // disables links from highlighting when tapped .buttonStyle(.empty) .clipShape(.rect(cornerRadius: cornerRadius)) // clip entire view .versionAwareDialog( activeChoiceGroup?.title ?? "", isPresented: .init(get: { activeChoiceGroup != nil }, set: { _ in activeChoiceGroup = nil }) ) { ForEach(Array((activeChoiceGroup?.items ?? []).enumerated()), id: \.offset) { _, item in Button(item.label, role: item.destructive ? .destructive : nil, action: item.callback) } Button("Cancel", role: .cancel) {} } } else { content .clipShape(.rect(cornerRadius: cornerRadius)) // clip entire view } } @ViewBuilder func innerBody(content: Content) -> some View { content .gesture( PanGesture(leadingBuffer: 70) { recognizer in if [.ended, .cancelled].contains(recognizer.state) { draggingUpdated(dragState: 0) } else { draggingUpdated(dragState: recognizer.translation(in: recognizer.view).x) } } ) } var shadowBackground: some View { // creates a shadow under the edge of the view Rectangle() .foregroundStyle(.clear) .border(width: 10, edges: [.leading, .trailing], color: .black) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .shadow(radius: 5) .opacity(dragPosition == .zero ? 0 : 1) // prevent this view from appearing in animations on parent view(s). } var iconBackground: some View { dragBackground?.resolve(with: palette) .overlay { HStack(spacing: 0) { if dragPosition > 0 { iconView(leadingSwipeIcon) } Spacer() if dragPosition < 0 { iconView(trailingSwipeIcon) } } .accessibilityHidden(true) // prevent these from popping up in VO .opacity(dragPosition == .zero ? 0 : 1) // prevent this view from appearing in animations on parent view(s). } } func iconView(_ icon: Icon?) -> some View { Image(icon: icon?.representingState(active: iconIsActive) ?? .general.warning) .font(.system(size: iconSize)) .foregroundStyle(.themedContrastingLabel) .frame(width: iconWidth) .padding(.horizontal, iconWidth) } private func draggingUpdated(dragState: CGFloat) { // if dragState changes and is now 0, gesture has ended; compute action based on last detected position if dragState == .zero { draggingDidEnd() } else { guard shouldRespondToDragPosition(dragState) else { // as swipe actions are optional we don't allow dragging without a primary action return } // update position dragPosition = dragState let edgeForActions = edgeForActions(at: dragPosition) let actionIndex = actionIndex(edge: edgeForActions, at: dragPosition) let action = action(edge: edgeForActions, index: actionIndex) let threshold = actionThreshold(edge: edgeForActions, index: actionIndex) // update color and symbol. If crossed an edge, play a gentle haptic switch edgeForActions { case .leading: if actionIndex == nil { iconIsActive = false leadingSwipeIcon = primaryLeadingAction?.icon dragBackground = primaryLeadingAction?.color.opacity(dragPosition / threshold) } else { iconIsActive = true leadingSwipeIcon = action?.icon dragBackground = action?.color.opacity(dragPosition / threshold) } case .trailing: if actionIndex == nil { iconIsActive = false trailingSwipeIcon = primaryTrailingAction?.icon dragBackground = primaryTrailingAction?.color.opacity(dragPosition / threshold) } else { iconIsActive = true trailingSwipeIcon = action?.icon dragBackground = action?.color.opacity(dragPosition / threshold) } } // If crossed an edge, play a gentle haptic let previousIndex = self.actionIndex(edge: edgeForActions, at: prevDragPosition) let currentIndex = self.actionIndex(edge: edgeForActions, at: dragPosition) if let hapticInfo = hapticInfo(transitioningFrom: previousIndex, to: currentIndex) { hapticManager.play(haptic: hapticInfo.0, tier: hapticInfo.1) } prevDragPosition = dragPosition } } private func draggingDidEnd() { let finalDragPosition = prevDragPosition reset() let action = swipeAction(at: finalDragPosition) switch action?.perform { case let .callback(callback, confirmationPrompt): if let confirmationPrompt { activeChoiceGroup = .init( title: confirmationPrompt, items: [.init(label: "Confirm", destructive: true, callback: callback)] ) } else { callback() } case let .choice(choiceGroup): activeChoiceGroup = choiceGroup case nil: break } } private func reset() { withAnimation(.spring(response: 0.25)) { dragPosition = .zero prevDragPosition = .zero leadingSwipeIcon = primaryLeadingAction?.icon trailingSwipeIcon = primaryTrailingAction?.icon dragBackground = .themedBackground } } private func shouldRespondToDragPosition(_ position: CGFloat) -> Bool { if position > 0, primaryLeadingAction == nil { return false } if position < 0, primaryTrailingAction == nil { return false } return true } // MARK: - /// Get the swipe action a specific drag position. /// - Parameter dragPosition: Along the x-axis. private func swipeAction(at dragPosition: CGFloat) -> (QuickSwipeAction)? { let edge = edgeForActions(at: dragPosition) let index = actionIndex(edge: edge, at: dragPosition) let action = action(edge: edge, index: index) return action } /// For a particular `dragPosition`, returns the relevant edge for which to show/perform actions. private func edgeForActions(at dragPosition: CGFloat) -> HorizontalEdge { dragPosition > 0 ? .leading : .trailing } /// Index of the action along the specified edge at the specified drag position. /// - Returns: A `nil` value denotes the state where swiping has begun, but not enough to trigger any actions. private func actionIndex(edge: HorizontalEdge, at dragPosition: CGFloat) -> Array.Index? { /// Map a `dragPosition` to a `dragThreshold`, which tells us what swipe action to perform, where `nil` is no action, `1` is primary, `2` is secondary, etc. let thresholdIndex = thresholds.all.lastIndex { switch edge { case .leading: return dragPosition > $0 case .trailing: return dragPosition < -$0 } } guard let thresholdIndex else { return nil } /// There may not be an associated action for a threshold. switch edge { case .leading: if thresholdIndex > (config.leadingActions.endIndex - 1) { log.debug("leading action not configured for this threshold") return config.leadingActions.endIndex - 1 } return thresholdIndex case .trailing: if thresholdIndex > (config.trailingActions.endIndex - 1) { log.debug("trailing action not configured for this threshold") return config.trailingActions.endIndex - 1 } return thresholdIndex } } /// Get the action associated with an edge at the specified index. private func action(edge: HorizontalEdge, index actionIndex: Array.Index?) -> (QuickSwipeAction)? { guard let actionIndex else { return nil } switch edge { case .leading: return config.leadingActions[safeIndex: actionIndex] case .trailing: return config.trailingActions[safeIndex: actionIndex] } } /// Maps swipe action transitions into an appropriate haptic info payload for haptic playback purposes. /// - No-op if both indexes are the same (i.e. transition isn't happening). private func hapticInfo( transitioningFrom previousIndex: Array.Index?, to currentIndex: Array.Index? ) -> (Haptic, HapticTier)? { guard previousIndex != currentIndex else { /// Same action, don't play haptic. return nil } // From nil -> 0 -> 1 -> 2, etc, where nil is no action, and 0 is the primary action. // Swiping towards to primary action. // Index values are always >= 0 for both leading/trailing edges. // Since nil indicates no action, we use -1 to represent nil instead (lol, yes). if (currentIndex ?? -1) < (previousIndex ?? -1) { return (.mushyInfo, .low) } else { if previousIndex == nil { return (.gentleInfo, .high) } else if previousIndex == 1 { return (.firmInfo, .high) } else { return (.firmInfo, .high) } } } /// Get the threshold (in screen points) required to trigger a particular action. /// - Parameter edge: Show actions on this edge. /// - Parameter index: Index of the action in question. /// - Returns: Negative values for trailing actions along the x-axis. private func actionThreshold( edge edgeForActions: HorizontalEdge, index actionIndex: Array.Index? ) -> CGFloat { guard let actionIndex else { switch edgeForActions { case .leading: return thresholds.primary case .trailing: return -thresholds.primary } } switch edgeForActions { case .leading: return thresholds.all[actionIndex] case .trailing: return -thresholds.all[actionIndex] } } private var iconWidth: CGFloat { // this sets the icon to always be centered between the edge of the background and the edge of the swipeable item, as this is // both the width of the icon's frame and its padding. the actual icon size is done using fonts. thresholds.primary / 3 } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/SwipeConfiguration.swift ================================================ // // SwipeConfiguration.swift // Mlem // // Created by Eric Andrews on 2024-06-11. // import Foundation public struct SwipeConfiguration { /// In ascending order of appearance. public let leadingActions: [QuickSwipeAction] /// In ascending order of appearance. public let trailingActions: [QuickSwipeAction] public init( leadingActions: [QuickSwipeAction] = [], trailingActions: [QuickSwipeAction] = [] ) { assert( leadingActions.count <= 3 && trailingActions.count <= 3, "Too many swipe actions!" ) self.leadingActions = leadingActions.filter(\.enabled) self.trailingActions = trailingActions.filter(\.enabled) } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/View+EdgeBorders.swift ================================================ // // View+EdgeBorders.swift // Mlem // // Created by Eric Andrews on 2024-06-10. // // source: https://stackoverflow.com/questions/58632188/swiftui-add-border-to-one-edge-of-an-image import Foundation import SwiftUI extension View { func border(width: CGFloat, edges: [Edge], color: Color) -> some View { overlay(EdgeBorder(width: width, edges: edges).foregroundColor(color)) } } struct EdgeBorder: Shape { var width: CGFloat var edges: [Edge] func path(in rect: CGRect) -> Path { edges.map { edge -> Path in switch edge { case .top: return Path(.init(x: rect.minX, y: rect.minY, width: rect.width, height: width)) case .bottom: return Path(.init(x: rect.minX, y: rect.maxY - width, width: rect.width, height: width)) case .leading: return Path(.init(x: rect.minX, y: rect.minY, width: width, height: rect.height)) case .trailing: return Path(.init(x: rect.maxX - width, y: rect.minY, width: width, height: rect.height)) } }.reduce(into: Path()) { $0.addPath($1) } } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/View+Extensions.swift ================================================ // // File.swift // QuickSwipes // // Created by Sjmarf on 2025-08-22. // import SwiftUI public extension View { func quickSwipeThresholds(_ thresholdSet: QuickSwipeThresholdSet) -> some View { environment(\.quickSwipeThresholdSet, thresholdSet) } func quickSwipeThresholds(primary: CGFloat, secondary: CGFloat, tertiary: CGFloat) -> some View { environment(\.quickSwipeThresholdSet, .init(primary: primary, secondary: secondary, tertiary: tertiary)) } func quickSwipeMinimumDrag(_ minimumDrag: CGFloat) -> some View { environment(\.quickSwipeMinimumDrag, minimumDrag) } func quickSwipeIconSize(_ iconSize: CGFloat) -> some View { environment(\.quickSwipeIconSize, iconSize) } func quickSwipeCornerRadius(_ cornerRadius: CGFloat) -> some View { environment(\.quickSwipeCornerRadius, cornerRadius) } func quickSwipesDisabled(_ disabled: Bool = true) -> some View { environment(\.quickSwipesEnabled, !disabled) } } ================================================ FILE: Mlem/Packages/QuickSwipes/Sources/QuickSwipes/View+QuickSwipes.swift ================================================ // // View+QuickSwipes.swift // Mlem // // Created by Eric Andrews on 2023-06-20. // import SwiftUI import Theming public extension View { /// Adds quick swipes to a view. /// /// NOTE: if the view you are attaching this to also has a context menu, add the context menu view modifier AFTER the quick swipes modifier! This will prevent the quick swipe from triggering and appearing bugged on an aborted context menu pop if the context menu animation initiates. /// - Parameters: /// - leading: leading edge quick swipes, ordered by ascending swipe distance from leading edge /// - trailing: trailing edge quick swipes, ordered by ascending swipe distance from leading edge @ViewBuilder func quickSwipes( leading: [QuickSwipeAction] = [], trailing: [QuickSwipeAction] = [] ) -> some View { modifier( QuickSwipeViewModifier( config: .init( leadingActions: leading, trailingActions: trailing ) ) ) } @ViewBuilder func quickSwipes(_ config: SwipeConfiguration) -> some View { modifier(QuickSwipeViewModifier(config: config)) } } ================================================ FILE: Mlem/Packages/Rest/.gitignore ================================================ .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Mlem/Packages/Rest/Package.resolved ================================================ { "originHash" : "433eb46e437fba071b47444bcf712c57872e1973de4812d146c7db18e50834e2", "pins" : [ { "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", "version" : "1.5.0" } }, { "identity" : "nuke", "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke.git", "state" : { "revision" : "0ead44350d2737db384908569c012fe67c421e4d", "version" : "12.8.0" } }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", "version" : "5.21.0" } }, { "identity" : "sdwebimagewebpcoder", "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", "state" : { "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", "version" : "0.14.6" } } ], "version" : 3 } ================================================ FILE: Mlem/Packages/Rest/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Rest", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Rest", targets: ["Rest", "URLEncoder"] ) ], dependencies: [ .package(path: "../MlemLogger") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Rest", dependencies: [ .byName(name: "MlemLogger"), .byName(name: "URLEncoder") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("FullTypedThrows"), .enableUpcomingFeature("BareSlashRegexLiterals") ] ), .target( name: "URLEncoder", dependencies: [ .byName(name: "MlemLogger") ], swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("FullTypedThrows"), .enableUpcomingFeature("BareSlashRegexLiterals") ] ) ] ) ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/ApiRequest.swift ================================================ // // ApiRequest.swift // Mlem // // Created by Nicholas Lawson on 06/06/2023. // import Foundation import URLEncoder // MARK: - RestRequest public protocol RestRequest { associatedtype Response: Decodable var path: String { get } var headers: [String: String] { get } func endpoint( base: URL, encoderUserInfo: [CodingUserInfoKey: any Sendable], convertParamsToSnakeCase: Bool ) throws(URLQueryItemEncoderError) -> URL } public extension RestRequest { var headers: [String: String] { defaultHeaders } var defaultHeaders: [String: String] { ["Content-Type": "application/json"] } } // MARK: - GetRequest public protocol GetRequest: RestRequest { associatedtype Parameters: Encodable var parameters: Parameters? { get } } public extension RestRequest { func endpoint( base: URL, encoderUserInfo: [CodingUserInfoKey: any Sendable], convertParamsToSnakeCase: Bool ) throws(URLQueryItemEncoderError) -> URL { base .appending(path: path) } } public extension GetRequest { func endpoint( base: URL, encoderUserInfo: [CodingUserInfoKey: any Sendable], convertParamsToSnakeCase: Bool ) throws(URLQueryItemEncoderError) -> URL { if let parameters { try base .appending(path: path) .appending(queryItems: URLQueryItemEncoder.encode( parameters, convertToSnakeCase: convertParamsToSnakeCase, userInfo: encoderUserInfo )) } else { base .appending(path: path) } } } // MARK: - RequestWithBody public enum RequestWithBodyMethod { case post, put, delete var stringValue: String { switch self { case .post: "POST" case .put: "PUT" case .delete: "DELETE" } } } public protocol RequestWithBody: RestRequest { associatedtype Body: Encodable var body: Body? { get } var method: RequestWithBodyMethod { get } } public protocol PostRequest: RequestWithBody { } public extension PostRequest { var method: RequestWithBodyMethod { .post } } public protocol PutRequest: RequestWithBody { } public extension PutRequest { var method: RequestWithBodyMethod { .put } } public protocol DeleteRequest: RequestWithBody { } public extension DeleteRequest { var method: RequestWithBodyMethod { .delete } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/ImageUploadDelegate.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-07-05. // import Foundation public class ImageUploadDelegate: NSObject, URLSessionTaskDelegate { let callback: (Double) -> Void public init(callback: @escaping (Double) -> Void) { self.callback = callback } public func urlSession( _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { callback(Double(totalBytesSent) / Double(totalBytesExpectedToSend)) } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/JSONDecoder+Extensions.swift ================================================ // // JSONDecoder+Extensions.swift // Mlem // // Created by Nicholas Lawson on 11/06/2023. // import Foundation public extension JSONDecoder { static var defaultDecoder: JSONDecoder { let decoder = JSONDecoder() let formats = [ "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ssZ", "yyyy-MM-dd'T'HH:mm:ss" ] let formatters = formats.map { format in let formatter = DateFormatter() formatter.timeZone = .gmt formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = format return formatter } decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let string = try container.decode(String.self) for formatter in formatters { if let date = formatter.date(from: string) { return date } } // after some discussion we've agreed to fail the modelling if the date // does match _any_ of the above, as based on the current API source code // it should be one of those throw Swift.DecodingError.dataCorrupted( .init( codingPath: container.codingPath, debugDescription: "Failed to parse date" ) ) } return decoder } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/JSONEncoder+Extensions.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-10-14. // import Foundation extension JSONEncoder.DateEncodingStrategy { static var iso8601WithMilliseconds: Self { .custom { date, encoder in let formatter = ISO8601DateFormatter() // `.withFractionalSeconds` is required for the PieFed banFromCommunity request formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var container = encoder.singleValueContainer() try container.encode(formatter.string(from: date)) } } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/MlemUrlRequest.swift ================================================ // // MlemUrlRequest.swift // MlemMiddleware // // Created by Eric Andrews on 2025-03-12. // import Foundation public func mlemUrlRequest(url: URL) -> URLRequest { var url = url // .gifv is secretly just mp4; replacing the extension here ensures it is picked up by the NukeVideo decoder if url.pathExtension == "gifv" { if let fixedUrl: URL = .init(string: url.absoluteString.replacingOccurrences(of: ".gifv", with: ".mp4")) { url = fixedUrl } else { assertionFailure("Could not create fixed URL for \(url)") } } var ret = URLRequest(url: url) ret.addValue("MlemUserAgent", forHTTPHeaderField: "User-Agent") return ret } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/MultiPartForm.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-07-05. // import Foundation // swiftlint:disable:next function_parameter_count public func createMultiPartForm( boundary: String, contentType: String, name: String, fileName: String, imageData: Data, auth: String ) -> Data { var data = Data() data.append(Data("--\(boundary)\r\n".utf8)) data.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(fileName)\"\r\n".utf8)) data.append(Data("Content-Type: \(contentType)\r\n\r\n".utf8)) data.append(imageData) data.append(Data("\r\n--\(boundary)--\r\n".utf8)) return data } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/RestClient.swift ================================================ // // RestClient // MlemMiddleware // // Created by Sjmarf on 2025-06-04. // import Foundation public class RestClient { public struct ErrorProcessorContext { let decoder: JSONDecoder let data: Data let response: HTTPURLResponse } public var decoder: JSONDecoder public var convertParamsToSnakeCase: Bool = true // This should really be internal, but for now the image upload system needs to access this public let urlSession: URLSession = .init(configuration: .default) public var errorProcessor: (ErrorProcessorContext) throws(RestError) -> Void public init( errorProcessor: @escaping (ErrorProcessorContext) throws(RestError) -> Void = { _ in }, convertParamsToSnakeCase: Bool = true, decoder: JSONDecoder = .defaultDecoder ) { self.errorProcessor = errorProcessor self.convertParamsToSnakeCase = convertParamsToSnakeCase self.decoder = decoder } public init( errorType: ErrorType.Type, convertParamsToSnakeCase: Bool = true, decoder: JSONDecoder = .defaultDecoder ) { self.errorProcessor = { context throws(RestError) in if let apiError = try? context.decoder.decode(ErrorType.self, from: context.data) { // at present we have a single error model which appears to be used throughout // the API, however we may way to consider adding the error model type as an // associated value in the same was as the response to allow requests to define // their own error models when necessary, or drop back to this as the default... throw .response(String(describing: apiError), statusCode: context.response.statusCode) } } self.convertParamsToSnakeCase = convertParamsToSnakeCase self.decoder = decoder } public func perform( baseUrl: URL, _ request: Request, token: String?, encoderUserInfo: [CodingUserInfoKey: any Sendable] = [:] ) async throws(RestError) -> Request.Response { let urlRequest = try urlRequest( baseUrl: baseUrl, request: request, token: token, encoderUserInfo: encoderUserInfo ) // this line intentionally left commented for convenient future debugging // urlRequest.debug() let (data, response) = try await execute(urlRequest) if let response = response as? HTTPURLResponse { if response.statusCode >= 500 || response.statusCode == 404 { throw .serverError(statusCode: response.statusCode) } try errorProcessor( .init( decoder: decoder, data: data, response: response ) ) } return try decode(Request.Response.self, from: data) } public func execute(_ urlRequest: URLRequest) async throws(RestError) -> (Data, URLResponse) { do { return try await urlSession.data(for: urlRequest) } catch { if case URLError.cancelled = error as NSError { throw .cancelled } else { throw .networking(error) } } } func urlRequest( baseUrl: URL, request: any RestRequest, token: String?, encoderUserInfo: [CodingUserInfoKey: any Sendable] = [:] ) throws(RestError) -> URLRequest { let url: URL do { url = try request.endpoint( base: baseUrl, encoderUserInfo: encoderUserInfo, convertParamsToSnakeCase: convertParamsToSnakeCase ) } catch { throw .parameterEncoding(error) } var urlRequest = mlemUrlRequest(url: url) urlRequest.cachePolicy = .reloadIgnoringLocalCacheData for header in request.headers { urlRequest.setValue(header.value, forHTTPHeaderField: header.key) } if request is any GetRequest { urlRequest.httpMethod = "GET" } else if let postDefinition = request as? any RequestWithBody { urlRequest.httpMethod = postDefinition.method.stringValue urlRequest.httpBody = try createBodyData(for: postDefinition, encoderUserInfo: encoderUserInfo) } if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } return urlRequest } func createBodyData( for defintion: any RequestWithBody, encoderUserInfo: [CodingUserInfoKey: any Sendable] = [:] ) throws(RestError) -> Data { do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601WithMilliseconds encoder.userInfo = encoderUserInfo let body = defintion.body ?? "" return try encoder.encode(body) } catch { throw .encoding(error) } } private func decode(_ model: T.Type, from data: Data) throws(RestError) -> T { do { return try decoder.decode(model, from: data) } catch { throw .decoding(data, error) } } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/RestError.swift ================================================ // // File.swift // MlemMiddleware // // Created by Sjmarf on 2025-06-04. // import Foundation import URLEncoder public enum RestError: Error { case serverError(statusCode: Int) // Should always be a 5xx status code case response(String, statusCode: Int) case encoding(Error) case parameterEncoding(URLQueryItemEncoderError) case decoding(Data, Error?) case networking(Error) case cancelled } extension RestError: CustomStringConvertible { public var description: String { switch self { case let .encoding(error): return "Unable to encode: \(error)" case let .networking(error): return "Networking error: \(error)" case let .response(errorResponse, status): return "Response error: \(errorResponse) with status \(status)" case let .serverError(statusCode): return "Server Error: \(statusCode)" case .cancelled: return "Cancelled" case let .decoding(data, error): guard let string = String(data: data, encoding: .utf8) else { return localizedDescription } if let error { return "Unable to decode: \(string)\nError: \(error)" } return "Unable to decode: \(string)" case let .parameterEncoding(error): return "Unable to encode request parameters: \(error)" } } } ================================================ FILE: Mlem/Packages/Rest/Sources/Rest/URLRequest+Extensions.swift ================================================ // // URLRequest+Extensions.swift // // // Created by Eric Andrews on 2024-07-03. // // https://stackoverflow.com/questions/34705449/how-to-print-http-request-to-console import Foundation import MlemLogger import os extension URLRequest { /// Prints this URLRequest in human-readable form func debug() { let statement = """ \(httpMethod!) \(url!) "Headers:" \(allHTTPHeaderFields ?? [:]) "Body:" \(String(data: httpBody ?? Data(), encoding: .utf8)!) """ Logger.universal.debug("\(statement)") } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/InternalQueryItemEncoder.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal class InternalURLQueryItemEncoder: Encoder { var queryParams: [URLQueryItem] = .init() // This is just for conformance to Encoder. This never gets modified because we // disallow nested containers let codingPath: [CodingKey] = [] let userInfo: [CodingUserInfoKey: Any] let settings: URLQueryItemEncoderSettings init(userInfo: [CodingUserInfoKey: Any], settings: URLQueryItemEncoderSettings) { self.userInfo = userInfo self.settings = settings } func singleValueContainer() -> SingleValueEncodingContainer { // This value throws an error as soon as you try to encode with it ThrowingSingleValueContainer(encoder: self) } func unkeyedContainer() -> UnkeyedEncodingContainer { // This value throws an error as soon as you try to encode with it ThrowingUnkeyedContainer(encoder: self) } func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { KeyedEncodingContainer(TopLevelKeyedContainer(encoder: self, settings: settings)) } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/RetrievalEncoder.swift ================================================ // // RetrievalEncoder.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal class RetrievalEncoder: Encoder { // This is just for conformance to Encoder. This never gets modified because we // disallow nested containers let codingPath: [CodingKey] = [] // Just for conformance; unused var userInfo: [CodingUserInfoKey: Any] var encodedValue: (any Encodable)? init(userInfo: [CodingUserInfoKey: Any]) { self.userInfo = userInfo } func singleValueContainer() -> SingleValueEncodingContainer { // This value throws an error as soon as you try to encode with it RetrievalSingleValueContainer(encoder: self) } func unkeyedContainer() -> UnkeyedEncodingContainer { // This value throws an error as soon as you try to encode with it ThrowingUnkeyedContainer(encoder: self) } func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { KeyedEncodingContainer(ThrowingKeyedContainer(encoder: self)) } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/RetrievalSingleValueContainer.swift ================================================ // // RetrievalSingleValueContainer.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal class RetrievalSingleValueContainer: SingleValueEncodingContainer { let encoder: RetrievalEncoder let codingPath: [CodingKey] = [] init(encoder: RetrievalEncoder) { self.encoder = encoder } func encodeNil() throws {} func encode(_ value: some Encodable) throws { encoder.encodedValue = value } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/String+Extensions.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal extension String { func camelToSnakeCase() -> String { replacing(/([a-z])([A-Z])/) { "\($0.output.1)_\($0.output.2)" }.lowercased() } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/ThrowingKeyedContainer.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal class ThrowingKeyedContainer: KeyedEncodingContainerProtocol { var encoder: any Encoder init(encoder: any Encoder) { self.encoder = encoder } var codingPath: [CodingKey] = [] func encodeNil(forKey key: K) throws {} func encode(_ value: some Encodable, forKey key: K) throws { throw URLQueryItemEncoderError.nestedContainersUnsupported } func nestedContainer( keyedBy type: NestedKey.Type, forKey key: K ) -> KeyedEncodingContainer where NestedKey: CodingKey { assertionFailure("We should throw an error *before* this gets called") return KeyedEncodingContainer(ThrowingKeyedContainer(encoder: encoder)) } func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer { assertionFailure("We should throw an error *before* this gets called") return ThrowingUnkeyedContainer(encoder: encoder) } func superEncoder() -> Encoder { encoder } func superEncoder(forKey key: K) -> Encoder { encoder } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/ThrowingSingleValueContainer.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation // This simply throws an error as soon as you try to encode with it. internal class ThrowingSingleValueContainer: SingleValueEncodingContainer { let encoder: InternalURLQueryItemEncoder let codingPath: [CodingKey] = [] init(encoder: InternalURLQueryItemEncoder) { self.encoder = encoder } func encodeNil() throws {} func encode(_ value: some Encodable) throws { throw URLQueryItemEncoderError.singleValueContainerUnsupported } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/ThrowingUnkeyedContainer.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation // This simply throws an error as soon as you try to encode with it. internal class ThrowingUnkeyedContainer: UnkeyedEncodingContainer { let encoder: any Encoder let codingPath: [any CodingKey] = [] let count: Int = 0 init(encoder: any Encoder) { self.encoder = encoder } func encodeNil() throws {} func encode(_ value: some Encodable) throws { throw URLQueryItemEncoderError.unkeyedContainerUnsupported } func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer where NestedKey: CodingKey { assertionFailure("We should throw an error *before* this gets called") return KeyedEncodingContainer(ThrowingKeyedContainer(encoder: encoder)) } func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { assertionFailure("We should throw an error *before* this gets called") return ThrowingUnkeyedContainer(encoder: encoder) } func superEncoder() -> any Encoder { encoder } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/TopLevelKeyedContainer.swift ================================================ // // File.swift // Rest // // Created by Sjmarf on 2025-11-14. // import Foundation internal class TopLevelKeyedContainer: KeyedEncodingContainerProtocol { var encoder: InternalURLQueryItemEncoder var settings: URLQueryItemEncoderSettings init(encoder: InternalURLQueryItemEncoder, settings: URLQueryItemEncoderSettings) { self.encoder = encoder self.settings = settings } var codingPath: [CodingKey] = [] func encodeNil(forKey key: K) throws {} func encode(_ value: some Encodable, forKey key: K) throws { let key = settings.convertToSnakeCase ? key.stringValue.camelToSnakeCase() : key.stringValue if let valueString = convertValueToString(value) { encoder.queryParams.append(.init(name: key, value: valueString)) } else { let encoder = RetrievalEncoder(userInfo: self.encoder.userInfo) try value.encode(to: encoder) if let wrappedValue = encoder.encodedValue, let valueString = convertValueToString(wrappedValue) { self.encoder.queryParams.append(.init(name: key, value: valueString)) } else { throw URLQueryItemEncoderError.nestedContainersUnsupported } } } func convertValueToString(_ value: any Encodable) -> String? { if let value = value as? String { value } else if let value = value as? Int { String(value) } else if let value = value as? Double { String(value) } else if let value = value as? Bool { value ? "true" : "false" } else if let value = value as? URL { value.absoluteString } else { nil } } func nestedContainer( keyedBy type: NestedKey.Type, forKey key: K ) -> KeyedEncodingContainer where NestedKey: CodingKey { assertionFailure("We should throw an error *before* this gets called") return KeyedEncodingContainer(ThrowingKeyedContainer(encoder: encoder)) } func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer { assertionFailure("We should throw an error *before* this gets called") return ThrowingUnkeyedContainer(encoder: encoder) } func superEncoder() -> Encoder { encoder } func superEncoder(forKey key: K) -> Encoder { encoder } } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/URLQueryItemEncoder.swift ================================================ // // URLQueryItemEncoder.swift // MlemMiddleware // // Created by Sjmarf on 2025-02-20. // import Foundation public enum URLQueryItemEncoder { public static func encode( _ value: some Encodable, convertToSnakeCase: Bool, userInfo: [CodingUserInfoKey: Any] = [:] ) throws(URLQueryItemEncoderError) -> [URLQueryItem] { let encoder = InternalURLQueryItemEncoder( userInfo: userInfo, settings: .init(convertToSnakeCase: convertToSnakeCase) ) do { try value.encode(to: encoder) } catch { if let error = error as? URLQueryItemEncoderError { throw error } assertionFailure() throw .unknown } return encoder.queryParams } } public enum URLQueryItemEncoderError: Error { case nestedContainersUnsupported case singleValueContainerUnsupported case unkeyedContainerUnsupported case unknown // Should never be thrown } ================================================ FILE: Mlem/Packages/Rest/Sources/URLEncoder/URLQueryItemEncoderSettings.swift ================================================ // // URLQueryItemEncoderSettings.swift // Mlem // // Created by Sjmarf on 2026-03-19. // struct URLQueryItemEncoderSettings { var convertToSnakeCase: Bool } ================================================ FILE: Mlem/Packages/Theming/Package.swift ================================================ // swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Theming", platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Theming", targets: ["Theming"] ) ], dependencies: [], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Theming", dependencies: [] ) ] ) ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/Color+Extensions.swift ================================================ // // Color+Extensions.swift // Mlem // // Created by Eric Andrews on 2024-08-29. // import Foundation import SwiftUI public extension Color { init(light: UIColor, dark: UIColor) { self.init(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }) } init(light: Color, dark: Color) { self.init(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) }) } } ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/EnvironmentValues+Extensions.swift ================================================ // // File.swift // Theming // // Created by Sjmarf on 2025-03-06. // import SwiftUI public extension EnvironmentValues { @Entry var palette: Palette = .default @Entry var tint: ThemedColor = .themedAccent } public extension View { func palette(_ palette: Palette) -> some View { environment(\.palette, palette) } } ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/Palette+Default.swift ================================================ // // File.swift // Theming // // Created by Sjmarf on 2025-03-06. // import SwiftUI public extension Palette { static let `default`: Self = .init( bordered: false, label: .init( primary: .primary, secondary: .secondary, tertiary: .init(uiColor: .tertiaryLabel) ), background: .init( primary: .init(uiColor: .systemBackground), secondary: .init(uiColor: .secondarySystemBackground), tertiary: .init(uiColor: .tertiarySystemBackground) ), groupedBackground: .init( primary: .init(uiColor: .systemGroupedBackground), secondary: .init(uiColor: .secondarySystemGroupedBackground), tertiary: .init(uiColor: .tertiarySystemGroupedBackground) ), thumbnailBackground: Color(UIColor.systemGray4), contrastingLabel: .white, accent: .blue, neutralAccent: .gray, colorfulAccents: [.orange, .pink, .blue, .green, .purple, .indigo, .mint, .teal, .yellow], commentIndentColors: [.red, .orange, .yellow, .green, .blue, .purple], accountAgeColors: [ .green, .init( // This is `.green.mix(with: .cyan, by: 0.333)` light: .init(red: 0.20605278, green: 0.7933883, blue: 0.53997606, alpha: 1.0), dark: .init(red: 0.23807898, green: 0.8318233, blue: 0.56663805, alpha: 1.0) ), .init( // This is `.green.mix(with: .cyan, by: 0.666)` light: .init(red: 0.2665288, green: 0.7745191, blue: 0.7497066, alpha: 1.0), dark: .init(red: 0.2917948, green: 0.8128531, blue: 0.7726594, alpha: 1.0) ), .cyan, .brown ], positive: .green, negative: .red, warning: .red, caution: .orange, upvote: .blue, downvote: .red, save: .green, read: .purple, favorite: .blue, administration: .teal, moderation: .cyan, federatedFeed: .blue, localFeed: .purple, subscribedFeed: .red, moderatedFeed: .cyan, savedFeed: .green, popularFeed: .indigo, suggestedFeed: .orange, inbox: .purple, fediseerEndorsement: .cyan ) } ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/Palette.swift ================================================ // // Palette.swift // Theming // // Created by Sjmarf on 2025-03-06. // import SwiftUI @preconcurrency public struct Palette { public var bordered: Bool public var label: ColorHierarchy public var background: ColorHierarchy public var groupedBackground: ColorHierarchy public var thumbnailBackground: Color public var contrastingLabel: Color public var accent: Color public var neutralAccent: Color public var colorfulAccents: [Color] public var commentIndentColors: [Color] public var accountAgeColors: [Color] public var positive: Color public var negative: Color public var warning: Color public var caution: Color public var upvote: Color public var downvote: Color public var save: Color public var read: Color public var favorite: Color public var administration: Color public var moderation: Color public var federatedFeed: Color public var localFeed: Color public var subscribedFeed: Color public var moderatedFeed: Color public var savedFeed: Color public var popularFeed: Color public var suggestedFeed: Color public var inbox: Color public var fediseerEndorsement: Color public var fediseerHesitation: Color { caution } public var fediseerCensure: Color { warning } public init( bordered: Bool, label: ColorHierarchy, background: ColorHierarchy, groupedBackground: ColorHierarchy, thumbnailBackground: Color, contrastingLabel: Color, accent: Color, neutralAccent: Color, colorfulAccents: [Color], commentIndentColors: [Color], accountAgeColors: [Color], positive: Color, negative: Color, warning: Color, caution: Color, upvote: Color, downvote: Color, save: Color, read: Color, favorite: Color, administration: Color, moderation: Color, federatedFeed: Color, localFeed: Color, subscribedFeed: Color, moderatedFeed: Color, savedFeed: Color, popularFeed: Color, suggestedFeed: Color, inbox: Color, fediseerEndorsement: Color ) { self.bordered = bordered self.label = label self.background = background self.groupedBackground = groupedBackground self.thumbnailBackground = thumbnailBackground self.contrastingLabel = contrastingLabel self.accent = accent self.neutralAccent = neutralAccent self.colorfulAccents = colorfulAccents self.commentIndentColors = commentIndentColors self.accountAgeColors = accountAgeColors self.positive = positive self.negative = negative self.warning = warning self.caution = caution self.upvote = upvote self.downvote = downvote self.save = save self.read = read self.favorite = favorite self.administration = administration self.moderation = moderation self.federatedFeed = federatedFeed self.localFeed = localFeed self.subscribedFeed = subscribedFeed self.moderatedFeed = moderatedFeed self.savedFeed = savedFeed self.popularFeed = popularFeed self.suggestedFeed = suggestedFeed self.inbox = inbox self.fediseerEndorsement = fediseerEndorsement } } public extension Palette { struct ColorHierarchy { public var primary: Color public var secondary: Color public var tertiary: Color public init(primary: Color, secondary: Color, tertiary: Color) { self.primary = primary self.secondary = secondary self.tertiary = tertiary } } } ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/ThemedColor.swift ================================================ // // ThemedColor.swift // Theming // // Created by Sjmarf on 2025-03-06. // import Foundation import SwiftUI public struct ThemedColor: ShapeStyle, Hashable, View, Sendable { fileprivate let hashString: String let getColor: @Sendable (Palette) -> Color var opacity: CGFloat = 1 public func body(palette: Palette) -> some View { resolve(with: palette) } public func gradient(palette: Palette) -> AnyGradient { resolve(with: palette).gradient } public nonisolated func resolve(in environment: EnvironmentValues) -> Color { resolve(with: environment.palette) } public func resolve(with palette: Palette) -> Color { getColor(palette).opacity(opacity) } public func opacity(_ newOpacity: CGFloat) -> ThemedColor { .init(hashString: hashString, getColor: getColor, opacity: newOpacity) } public nonisolated func hash(into hasher: inout Hasher) { hasher.combine(hashString) hasher.combine(opacity) } public nonisolated static func == (lhs: Self, rhs: Self) -> Bool { lhs.hashValue == rhs.hashValue } } public extension ShapeStyle where Self == ThemedColor { static var themedPrimary: ThemedColor { .init(hashString: "primary", getColor: \.label.primary) } static var themedSecondary: ThemedColor { .init(hashString: "secondary", getColor: \.label.secondary) } static var themedTertiary: ThemedColor { .init(hashString: "tertiary", getColor: \.label.tertiary) } static var themedBackground: ThemedColor { .init(hashString: "background", getColor: \.background.primary) } static var themedSecondaryBackground: ThemedColor { .init(hashString: "secondaryBackground", getColor: \.background.secondary) } static var themedTertiaryBackground: ThemedColor { .init(hashString: "tertiaryBackground", getColor: \.background.tertiary) } static var themedGroupedBackground: ThemedColor { .init(hashString: "groupedBackground", getColor: \.groupedBackground.primary) } static var themedSecondaryGroupedBackground: ThemedColor { .init(hashString: "secondaryGroupedBackground", getColor: \.groupedBackground.secondary) } static var themedTertiaryGroupedBackground: ThemedColor { .init(hashString: "tertiaryGroupedBackground", getColor: \.groupedBackground.tertiary) } static var themedContrastingLabel: ThemedColor { .init(hashString: "contrastingLabel", getColor: \.contrastingLabel) } static var themedThumbnailBackground: ThemedColor { .init(hashString: "thumbnailBackground", getColor: \.thumbnailBackground) } static var themedAccent: ThemedColor { .init(hashString: "accent", getColor: \.accent) } static var themedNeutralAccent: ThemedColor { .init(hashString: "neutralAccent", getColor: \.neutralAccent) } static func themedColorfulAccent(_ index: Int) -> ThemedColor { .init(hashString: "colorfulAccent\(index)") { $0.colorfulAccents[index % $0.colorfulAccents.count] } } static func themedCommentIndentColor(_ index: Int) -> ThemedColor { .init(hashString: "commentIndentColor\(index)") { $0.commentIndentColors[index % $0.commentIndentColors.count] } } static func themedAccountAgeColor(_ index: Int) -> ThemedColor { .init(hashString: "accountAgeColor\(index)") { $0.accountAgeColors[min(index, $0.accountAgeColors.count - 1)] } } static var themedPositive: ThemedColor { .init(hashString: "positive", getColor: \.positive) } static var themedNegative: ThemedColor { .init(hashString: "negative", getColor: \.negative) } static var themedWarning: ThemedColor { .init(hashString: "warning", getColor: \.warning) } static var themedCaution: ThemedColor { .init(hashString: "caution", getColor: \.caution) } static var themedUpvote: ThemedColor { .init(hashString: "upvote", getColor: \.upvote) } static var themedDownvote: ThemedColor { .init(hashString: "downvote", getColor: \.downvote) } static var themedSave: ThemedColor { .init(hashString: "save", getColor: \.save) } static var themedRead: ThemedColor { .init(hashString: "read", getColor: \.read) } static var themedFavorite: ThemedColor { .init(hashString: "favorite", getColor: \.favorite) } static var themedAdministration: ThemedColor { .init(hashString: "administration", getColor: \.administration) } static var themedModeration: ThemedColor { .init(hashString: "moderation", getColor: \.moderation) } static var themedFederatedFeed: ThemedColor { .init(hashString: "federatedFeed", getColor: \.federatedFeed) } static var themedLocalFeed: ThemedColor { .init(hashString: "localFeed", getColor: \.localFeed) } static var themedSubscribedFeed: ThemedColor { .init(hashString: "subscribedFeed", getColor: \.subscribedFeed) } static var themedModeratedFeed: ThemedColor { .init(hashString: "moderatedFeed", getColor: \.moderatedFeed) } static var themedSavedFeed: ThemedColor { .init(hashString: "savedFeed", getColor: \.savedFeed) } static var themedPopularFeed: ThemedColor { .init(hashString: "popularFeed", getColor: \.popularFeed) } static var themedSuggestedFeed: ThemedColor { .init(hashString: "suggestedFeed", getColor: \.suggestedFeed) } static var themedInbox: ThemedColor { .init(hashString: "inbox", getColor: \.inbox) } static var themedFediseerEndorsement: ThemedColor { .init(hashString: "fediseerEndorsement", getColor: \.fediseerEndorsement) } static var themedFediseerHesitation: ThemedColor { .init(hashString: "fediseerHesitation", getColor: \.fediseerHesitation) } static var themedFediseerCensure: ThemedColor { .init(hashString: "fediseerCensure", getColor: \.fediseerCensure) } static var themedCommentAccent: ThemedColor { themedColorfulAccent(0) } static var themedPostAccent: ThemedColor { themedColorfulAccent(1) } static var themedPersonAccent: ThemedColor { themedColorfulAccent(2) } static var themedCommunityAccent: ThemedColor { themedColorfulAccent(3) } static var themedLockAccent: ThemedColor { themedColorfulAccent(0) } static var themedDivider: ThemedColor { .init(hashString: "divider") { Color(light: $0.label.secondary.opacity(0.5), dark: $0.neutralAccent.opacity(0.35)) } } @_disfavoredOverload static var clear: ThemedColor { .init(hashString: "clear", getColor: { _ in .clear }) } } ================================================ FILE: Mlem/Packages/Theming/Sources/Theming/View+Tint.swift ================================================ // // File.swift // Theming // // Created by Sjmarf on 2025-03-07. // import SwiftUI private struct ThemedTintViewModifier: ViewModifier { @Environment(\.palette) private var palette let themedColor: ThemedColor func body(content: Content) -> some View { content .tint(themedColor.resolve(with: palette)) .environment(\.tint, themedColor) } } public extension View { @ViewBuilder func tint(_ themedColor: ThemedColor?) -> some View { if let themedColor { modifier(ThemedTintViewModifier(themedColor: themedColor)) } else { self } } } private struct ThemedGradientTintModifier: ViewModifier { @Environment(\.palette) private var palette let themedColor: ThemedColor func body(content: Content) -> some View { content .tint(themedColor.gradient(palette: palette)) } } public extension View { func gradientTint(_ themedColor: ThemedColor) -> some View { modifier(ThemedGradientTintModifier(themedColor: themedColor)) .environment(\.tint, themedColor) } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "generate-swift-asset-symbol-extensions" : "disabled" } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/image.droplets.imageset/Contents.json ================================================ { "images" : [ { "filename" : "kenrick-mills-uxk0JKMrZts-unsplash.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/image.meguro_river.imageset/Contents.json ================================================ { "images" : [ { "filename" : "photo-1524413840807-0c3cb6fa808d.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/image.yorkshire_dales.imageset/Contents.json ================================================ { "images" : [ { "filename" : "620px-2015_Swaledale_from_Kisdon_Hill.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.balloon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-04 at 20.39.06.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.circuit.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-04 at 20.56.12.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.firework.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-02 at 17.55.51.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.fish.imageset/Contents.json ================================================ { "images" : [ { "filename" : "fish-silly-fish.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.flowers.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-02 at 20.56.36.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.goose.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-02 at 21.16.20.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.lakeside.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Screenshot 2025-02-04 at 20.38.34.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.news.imageset/Contents.json ================================================ { "images" : [ { "filename" : "406d3ede-5b39-493d-98c0-6ddab03a59aa.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.person.imageset/Contents.json ================================================ { "images" : [ { "filename" : "icons8-person-64.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/Preview Assets.xcassets/pfp.shower.imageset/Contents.json ================================================ { "images" : [ { "filename" : "image_proxy.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Mlem/Preview Content/PreviewLocalizable.xcstrings ================================================ { "sourceLanguage" : "en", "strings" : { "community.1.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "World News" } } } }, "community.1.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "news" } } } }, "community.2.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Pics" } } } }, "community.2.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "pics" } } } }, "community.3.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "me_irl" } } } }, "community.3.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "me_irl" } } } }, "community.4.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Technology" } } } }, "community.4.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "technology" } } } }, "community.5.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Nature" } } } }, "community.5.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "nature" } } } }, "community.6.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Nature" } } } }, "community.6.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "showerthoughts" } } } }, "person.1.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Flowertail" } } } }, "person.1.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "flowertail" } } } }, "person.2.description" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "HONK" } } } }, "person.2.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Commander Goose" } } } }, "person.2.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "CommanderGoose" } } } }, "person.3.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "BillyDAFISH" } } } }, "person.3.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "BillyDAFISH" } } } }, "person.4.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Grt38" } } } }, "person.4.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Grt38" } } } }, "person.5.displayName" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "AnteSocial" } } } }, "person.5.name" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "ante_social_58" } } } }, "post.1.title" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "The Yorkshire Dales, England" } } } }, "post.2.title" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Meguro River, Matsuno, Japan" } } } }, "post.3.title" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "During a nuclear explosion, there is a certain distance of the radius where all the frozen supermarket pizzas are cooked to perfection." } } } } }, "version" : "1.0" } ================================================ FILE: Mlem/Settings.bundle/Root.plist ================================================ StringsTable Root ================================================ FILE: Mlem.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 70; objects = { /* Begin PBXBuildFile section */ 0300309D2C4163C9009A65FF /* CommentTreeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0300309C2C4163C9009A65FF /* CommentTreeTracker.swift */; }; 030030A12C416B0B009A65FF /* RefreshPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030030A02C416B0B009A65FF /* RefreshPopupView.swift */; }; 030050D32D109B7E002B1E99 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030050D22D109B7E002B1E99 /* ReportView.swift */; }; 030050D52D10AE30002B1E99 /* Report+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030050D42D10AE30002B1E99 /* Report+Extensions.swift */; }; 030056A42D7DB05A00EB0BA3 /* SharingLinksSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030056A32D7DB05A00EB0BA3 /* SharingLinksSettingsView.swift */; }; 030056A62D7DBD4F00EB0BA3 /* Sharable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030056A52D7DBD4F00EB0BA3 /* Sharable+Extensions.swift */; }; 030056BB2D7E137800EB0BA3 /* ShareInstancePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030056BA2D7E137800EB0BA3 /* ShareInstancePickerView.swift */; }; 0302A8802F9BC379000A127A /* SearchHomeCategoryLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0302A87F2F9BC379000A127A /* SearchHomeCategoryLabelStyle.swift */; }; 03036C742C71408700C6DA1D /* CounterAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03036C732C71408700C6DA1D /* CounterAppearance.swift */; }; 03036C832C727D0500C6DA1D /* InteractionBarEditorView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03036C822C727D0500C6DA1D /* InteractionBarEditorView+Logic.swift */; }; 03049A1A2C6502F300FF6889 /* FormSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03049A192C6502F300FF6889 /* FormSection.swift */; }; 03049A1C2C65039400FF6889 /* ActiveUserCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03049A1B2C65039400FF6889 /* ActiveUserCountView.swift */; }; 03049A1E2C6508F400FF6889 /* RegistrationMode+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03049A1D2C6508F400FF6889 /* RegistrationMode+Extensions.swift */; }; 03049A202C650A8100FF6889 /* FormReadout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03049A1F2C650A8100FF6889 /* FormReadout.swift */; }; 03049A222C650B2C00FF6889 /* CommunityDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03049A212C650B2C00FF6889 /* CommunityDetailsView.swift */; }; 0305EBAA2D32B3B80066E5AD /* ModlogView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0305EBA92D32B3B80066E5AD /* ModlogView+Logic.swift */; }; 0305EBAC2D32C9300066E5AD /* ModlogEntryType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0305EBAB2D32C9300066E5AD /* ModlogEntryType+Extensions.swift */; }; 0305EBB22D35C1B70066E5AD /* RegistrationApplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0305EBB12D35C1B70066E5AD /* RegistrationApplicationView.swift */; }; 030778EC2C52ED350018E61C /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 030778EB2C52ED350018E61C /* Localizable.xcstrings */; }; 030BCB1B2C3EA5FD0037680F /* InstanceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030BCB1A2C3EA5FD0037680F /* InstanceDetailsView.swift */; }; 030E95E72C80A20A0045BC2C /* View+NavigationTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E95E62C80A20A0045BC2C /* View+NavigationTransition.swift */; }; 030EE3042D651A4100D58C2C /* View+Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030EE3032D651A4100D58C2C /* View+Refreshable.swift */; }; 030FF6792BC84F7E00F6BFAC /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 030FF6782BC84F7E00F6BFAC /* SwiftUIIntrospect */; }; 030FF67B2BC8521600F6BFAC /* CustomTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF67A2BC8521600F6BFAC /* CustomTabView.swift */; }; 030FF67D2BC8524500F6BFAC /* CustomTabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF67C2BC8524500F6BFAC /* CustomTabItem.swift */; }; 030FF67F2BC8544700F6BFAC /* CustomTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF67E2BC8544600F6BFAC /* CustomTabBarController.swift */; }; 030FF6812BC859FD00F6BFAC /* CustomTabViewHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF6802BC859FD00F6BFAC /* CustomTabViewHostingController.swift */; }; 0311ADB72E4DF49800EC3120 /* SearchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311ADB62E4DF49800EC3120 /* SearchHomeView.swift */; }; 0311ADB92E4E0E0800EC3120 /* VisitAgainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311ADB82E4E0E0800EC3120 /* VisitAgainView.swift */; }; 0311ADBD2E4F668900EC3120 /* TopCommunitiesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311ADBC2E4F668900EC3120 /* TopCommunitiesListView.swift */; }; 0311ADBF2E4F68D000EC3120 /* TopPeopleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311ADBE2E4F68D000EC3120 /* TopPeopleListView.swift */; }; 0311ADC12E4F693B00EC3120 /* TopInstancesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311ADC02E4F693B00EC3120 /* TopInstancesListView.swift */; }; 03134A502BEAD245002662CC /* NavigationLink+NavigationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03134A4F2BEAD245002662CC /* NavigationLink+NavigationPage.swift */; }; 03134A522BEAD69F002662CC /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03134A512BEAD69F002662CC /* SettingsPage.swift */; }; 03134A582BEC1C46002662CC /* AccountListSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03134A572BEC1C46002662CC /* AccountListSettingsView.swift */; }; 03134A5A2BEC2253002662CC /* AvatarStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03134A592BEC2253002662CC /* AvatarStackView.swift */; }; 0315B1BE2C74C3D6006D4F82 /* CommentEditorView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315B1BD2C74C3D6006D4F82 /* CommentEditorView+Logic.swift */; }; 0315B1C12C74C71A006D4F82 /* CommentEditorView+Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315B1C02C74C71A006D4F82 /* CommentEditorView+Context.swift */; }; 0315B1C62C754802006D4F82 /* PostEditorView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315B1C52C754802006D4F82 /* PostEditorView+Logic.swift */; }; 0316CD642C382A6A009EA8EA /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0316CD632C382A6A009EA8EA /* MessageView.swift */; }; 0318BA9F2D72405F006CA71F /* PostSortType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0318BA9E2D72405F006CA71F /* PostSortType+Extensions.swift */; }; 031CA0D92E4FBFD800CF0C0F /* MarkAllAsReadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031CA0D82E4FBFD800CF0C0F /* MarkAllAsReadButton.swift */; }; 031CA4AE2E58A84E00CF0C0F /* View+WithSheetSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031CA4AD2E58A84E00CF0C0F /* View+WithSheetSearch.swift */; }; 031CA5752E5900C800CF0C0F /* SwipeConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031CA5742E5900C800CF0C0F /* SwipeConfiguration+Extensions.swift */; }; 031CA5772E5900E900CF0C0F /* QuickSwipes in Frameworks */ = {isa = PBXBuildFile; productRef = 031CA5762E5900E900CF0C0F /* QuickSwipes */; }; 031CA5B52E590B0D00CF0C0F /* QuickSwipeAction+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031CA5B42E590B0D00CF0C0F /* QuickSwipeAction+Extensions.swift */; }; 031CA5B72E599F7E00CF0C0F /* View+QuickSwipes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031CA5B62E599F7E00CF0C0F /* View+QuickSwipes.swift */; }; 031DBA682F9A65DC00B4BAE4 /* BackendClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031DBA672F9A65DC00B4BAE4 /* BackendClient+Extensions.swift */; }; 031DBA772F9A93AD00B4BAE4 /* EventRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031DBA762F9A93AD00B4BAE4 /* EventRowView.swift */; }; 031DBA792F9A95B200B4BAE4 /* SearchHomeLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031DBA782F9A95B200B4BAE4 /* SearchHomeLabelStyle.swift */; }; 031DBA7B2F9A993000B4BAE4 /* SearchHomeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031DBA7A2F9A993000B4BAE4 /* SearchHomeListView.swift */; }; 031E2D512BEF961D0003BC45 /* SubscriptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2D502BEF961D0003BC45 /* SubscriptionListView.swift */; }; 031E2D5B2BEFC9460003BC45 /* ThemeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2D5A2BEFC9460003BC45 /* ThemeSettingsView.swift */; }; 031E2D5D2BEFCC630003BC45 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031E2D5C2BEFCC630003BC45 /* SettingsView.swift */; }; 031EC5302E5F77D7003408B7 /* FeedContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031EC52F2E5F77D7003408B7 /* FeedContext.swift */; }; 0320B64F2C8A638A00D38548 /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B64E2C8A638A00D38548 /* SignUpView.swift */; }; 0320B6542C8B65EB00D38548 /* Captcha+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6532C8B65EB00D38548 /* Captcha+Extensions.swift */; }; 0320B6582C8BB3C400D38548 /* SignUpView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6572C8BB3C400D38548 /* SignUpView+Views.swift */; }; 0320B65A2C8CBB0D00D38548 /* SignUpView+EmailConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6592C8CBB0D00D38548 /* SignUpView+EmailConfirmationView.swift */; }; 0320B65C2C8CFBDC00D38548 /* ImageUploadHistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B65B2C8CFBDC00D38548 /* ImageUploadHistoryManager.swift */; }; 0320B6612C8DFCF100D38548 /* SearchView+FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6602C8DFCF100D38548 /* SearchView+FiltersView.swift */; }; 0320B6632C8F8D5A00D38548 /* InstanceSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6622C8F8D5A00D38548 /* InstanceSort.swift */; }; 0320B6652C91DBD500D38548 /* NavigationPage+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6642C91DBD500D38548 /* NavigationPage+View.swift */; }; 0320B6672C93504600D38548 /* SignUpView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6662C93504600D38548 /* SignUpView+Logic.swift */; }; 0320B6692C93506300D38548 /* SearchView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320B6682C93506300D38548 /* SearchView+Logic.swift */; }; 0324FA772C1F0AE100F6247D /* Readout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0324FA762C1F0AE100F6247D /* Readout.swift */; }; 0324FA7B2C1F2CD200F6247D /* InfoStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0324FA7A2C1F2CD200F6247D /* InfoStackView.swift */; }; 0325B93A2D3A9E8100E28B97 /* InboxBadgeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0325B9392D3A9E8100E28B97 /* InboxBadgeSettingsView.swift */; }; 0325B93C2D3AA62500E28B97 /* InboxItemType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0325B93B2D3AA62500E28B97 /* InboxItemType+Extensions.swift */; }; 0325B93E2D3AAE9E00E28B97 /* SettingsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0325B93D2D3AAE9E00E28B97 /* SettingsHeaderView.swift */; }; 03267D822BED489C009D6268 /* AvatarBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03267D812BED489C009D6268 /* AvatarBannerView.swift */; }; 03267D842BED49CE009D6268 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03267D832BED49CE009D6268 /* AccountSettingsView.swift */; }; 032A22012EB2A4D100E8B346 /* PersonContentFeedLoader+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032A22002EB2A4D100E8B346 /* PersonContentFeedLoader+Extensions.swift */; }; 032C32042C3439C600595286 /* ActorIdentifiable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C32032C3439C600595286 /* ActorIdentifiable+Extensions.swift */; }; 032C32082C34469900595286 /* SelectableContentProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C32072C34469900595286 /* SelectableContentProviding+Extensions.swift */; }; 032C320A2C34495D00595286 /* SelectTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C32092C34495D00595286 /* SelectTextView.swift */; }; 032C32162C36F65500595286 /* ReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C32152C36F65500595286 /* ReplyView.swift */; }; 032C32182C36F70300595286 /* ReplyBarConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C32172C36F70300595286 /* ReplyBarConfiguration.swift */; }; 0331715E2CCD6D95002DA370 /* ContentPurgeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0331715D2CCD6D95002DA370 /* ContentPurgeEditorView.swift */; }; 033171782CCE89E3002DA370 /* PurgableProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033171772CCE89E3002DA370 /* PurgableProviding+Extensions.swift */; }; 0335AE112D8991330094FFD9 /* View+HiddenNavigationTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0335AE102D8991330094FFD9 /* View+HiddenNavigationTitle.swift */; }; 033819282D4424D9000AFC55 /* SafetyWarningsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033819272D4424D9000AFC55 /* SafetyWarningsSettingsView.swift */; }; 033EF4102CB9AEF7004D8A3F /* ExpandedPostView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033EF40F2CB9AEF7004D8A3F /* ExpandedPostView+Views.swift */; }; 033F84492D18D1F400D87A9E /* MessageFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84482D18D1F400D87A9E /* MessageFeedView.swift */; }; 033F844D2D18D90900D87A9E /* MessageBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F844C2D18D90900D87A9E /* MessageBubbleView.swift */; }; 033F84512D196AFD00D87A9E /* MessageFeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84502D196AFD00D87A9E /* MessageFeedView+Logic.swift */; }; 033F84662D1C780900D87A9E /* ModlogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84652D1C780900D87A9E /* ModlogButtonView.swift */; }; 033F84732D1C784600D87A9E /* ModlogEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84702D1C784600D87A9E /* ModlogEntryView.swift */; }; 033F84742D1C784600D87A9E /* ModlogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84712D1C784600D87A9E /* ModlogView.swift */; }; 033F84782D1D68D200D87A9E /* ModlogEntryContent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84772D1D68D200D87A9E /* ModlogEntryContent+Extensions.swift */; }; 033F84AD2C298466002E3EDF /* SectionIndexTitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84AC2C298466002E3EDF /* SectionIndexTitles.swift */; }; 033F84B12C29907F002E3EDF /* FeedbackType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84B02C29907F002E3EDF /* FeedbackType.swift */; }; 033F84BB2C2ACB96002E3EDF /* CommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84BA2C2ACB96002E3EDF /* CommentView.swift */; }; 033F84BD2C2ACC5F002E3EDF /* CommentBarConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84BC2C2ACC5F002E3EDF /* CommentBarConfiguration.swift */; }; 033F84C12C2AD072002E3EDF /* CommentTreeNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84C02C2AD072002E3EDF /* CommentTreeNode.swift */; }; 033F84C32C2B12AA002E3EDF /* InstanceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84C22C2B12AA002E3EDF /* InstanceSummary.swift */; }; 033F84C82C2B193D002E3EDF /* MlemStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84C72C2B193D002E3EDF /* MlemStats.swift */; }; 033F84CC2C2B1E46002E3EDF /* HandleThreadiverseLinksModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033F84CB2C2B1E46002E3EDF /* HandleThreadiverseLinksModifier.swift */; }; 033F84D92C2B61FB002E3EDF /* ToastType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03500C212BF5594100CAA076 /* ToastType.swift */; }; 033FCAEC2C57DCCD007B7CD1 /* ListingType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCAEB2C57DCCD007B7CD1 /* ListingType+Extensions.swift */; }; 033FCAEE2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCAED2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift */; }; 033FCAF42C59843E007B7CD1 /* CommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCAF32C59843E007B7CD1 /* CommunityView.swift */; }; 033FCB272C5E3933007B7CD1 /* AlternateIconLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCB242C5E3933007B7CD1 /* AlternateIconLabel.swift */; }; 033FCB282C5E3933007B7CD1 /* AlternateIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCB222C5E3933007B7CD1 /* AlternateIcon.swift */; }; 033FCB292C5E3933007B7CD1 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCB252C5E3933007B7CD1 /* IconSettingsView.swift */; }; 033FCB2A2C5E3933007B7CD1 /* AlternateIconCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCB232C5E3933007B7CD1 /* AlternateIconCell.swift */; }; 033FCB3E2C5E7FA9007B7CD1 /* View+OutdatedFeedPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033FCB3D2C5E7FA9007B7CD1 /* View+OutdatedFeedPopup.swift */; }; 034065C32D83742900637308 /* View+NavigtionStackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034065C22D83742900637308 /* View+NavigtionStackPreview.swift */; }; 034147FD2D8F5844005503AF /* ExpandedPostHistoryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034147FC2D8F5844005503AF /* ExpandedPostHistoryTracker.swift */; }; 0341480D2D8F63A6005503AF /* MlemMiddleware in Frameworks */ = {isa = PBXBuildFile; productRef = 0341480C2D8F63A6005503AF /* MlemMiddleware */; }; 0343C0482D3AD6DB001CF709 /* Set+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0343C0472D3AD6DB001CF709 /* Set+Extensions.swift */; }; 034690932D0F4D720073E664 /* RemovableProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034690922D0F4D720073E664 /* RemovableProviding+Extensions.swift */; }; 034690992D105DFD0073E664 /* InboxView+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034690982D105DFD0073E664 /* InboxView+Types.swift */; }; 0347A6FB2F97F4CF00EFD670 /* FediverseEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 0347A6FA2F97F4CF00EFD670 /* FediverseEvents */; }; 0348F98D2DDBB526006639CD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0348F98C2DDBB526006639CD /* OnboardingView.swift */; }; 034A82032EBA688F00E5F904 /* LinkEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034A82022EBA688F00E5F904 /* LinkEditorView.swift */; }; 034A85712EC0A1FA00E5F904 /* InstanceCommunityListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034A85702EC0A1FA00E5F904 /* InstanceCommunityListView.swift */; }; 034B947F2C091EDD00039AF4 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034B947E2C091EDD00039AF4 /* ProfileHeaderView.swift */; }; 034B94812C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034B94802C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift */; }; 034B94832C09340A00039AF4 /* MarkdownConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034B94822C09340A00039AF4 /* MarkdownConfiguration+Extensions.swift */; }; 034B94892C09360A00039AF4 /* Int+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034B94882C09360A00039AF4 /* Int+Extensions.swift */; }; 034B948E2C0937BA00039AF4 /* FancyScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034B948D2C0937BA00039AF4 /* FancyScrollView.swift */; }; 034CC0302D22C5BE00C557D3 /* WarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034CC02F2D22C5BE00C557D3 /* WarningOverlayView.swift */; }; 03500C242BF55D0E00CAA076 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03500C232BF55D0E00CAA076 /* Toast.swift */; }; 03500C272BF69D1D00CAA076 /* ToastModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03500C262BF69D1D00CAA076 /* ToastModel.swift */; }; 03500C2B2BF7F1B100CAA076 /* ToastOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03500C2A2BF7F1B100CAA076 /* ToastOverlayView.swift */; }; 03500C2D2BF7FC2500CAA076 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03500C2C2BF7FC2500CAA076 /* ToastView.swift */; }; 03531EEC2C2D81DC004A3464 /* LinkSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03531EEB2C2D81DC004A3464 /* LinkSettingsView.swift */; }; 03531EEE2C2D9298004A3464 /* SearchSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03531EED2C2D9298004A3464 /* SearchSheetView.swift */; }; 03531EF12C2DA298004A3464 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03531EF02C2DA298004A3464 /* SearchResultsView.swift */; }; 03531EF52C2DA610004A3464 /* NavigationSearchType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03531EF42C2DA610004A3464 /* NavigationSearchType.swift */; }; 035394862C9CDC1100795AA5 /* SubscriptionListNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035394852C9CDC1100795AA5 /* SubscriptionListNavigationButton.swift */; }; 0353948B2CA076D000795AA5 /* InboxView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0353948A2CA076D000795AA5 /* InboxView+Views.swift */; }; 0353948D2CA080EB00795AA5 /* FeedWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0353948C2CA080EB00795AA5 /* FeedWelcomeView.swift */; }; 0353948F2CA088E600795AA5 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0353948E2CA088E600795AA5 /* DeveloperSettingsView.swift */; }; 035394932CA1AE2C00795AA5 /* UptimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035394922CA1AE2C00795AA5 /* UptimeData.swift */; }; 035394952CA1AE6300795AA5 /* InstanceUptimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035394942CA1AE6300795AA5 /* InstanceUptimeView.swift */; }; 035394992CA1B20B00795AA5 /* InstanceView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035394982CA1B20B00795AA5 /* InstanceView+Logic.swift */; }; 0353949C2CA4B3E800795AA5 /* CrossPostListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0353949B2CA4B3E800795AA5 /* CrossPostListView.swift */; }; 0355F9462C150B2300605248 /* ExternalApiInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0355F9452C150B2300605248 /* ExternalApiInfoView.swift */; }; 035BE0872BDD8DA000F77D73 /* NavigationRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE0862BDD8DA000F77D73 /* NavigationRootView.swift */; }; 035BE0892BDD901B00F77D73 /* NavigationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE0882BDD901B00F77D73 /* NavigationPage.swift */; }; 035BE08B2BDD903100F77D73 /* NavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE08A2BDD903100F77D73 /* NavigationModel.swift */; }; 035BE08D2BDE88EC00F77D73 /* NavigationLayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE08C2BDE88EC00F77D73 /* NavigationLayerView.swift */; }; 035BE08F2BDE911900F77D73 /* NavigationLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE08E2BDE911900F77D73 /* NavigationLayer.swift */; }; 035BE0912BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035BE0902BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift */; }; 035DF9112EB2AA160021DE8C /* PersonContentGridView+FeedLoaderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DF9102EB2AA160021DE8C /* PersonContentGridView+FeedLoaderType.swift */; }; 035DF9132EB2AF8E0021DE8C /* GetContentFilter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DF9122EB2AF8E0021DE8C /* GetContentFilter+Extensions.swift */; }; 035DFA232EB3F7240021DE8C /* CommunityAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DFA222EB3F7240021DE8C /* CommunityAboutView.swift */; }; 035DFA252EB3FB550021DE8C /* CommunityDescriptionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DFA242EB3FB550021DE8C /* CommunityDescriptionEditorView.swift */; }; 035EDEF12C2DE94B00F51144 /* DefaultTextInputType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEED2C2DE94B00F51144 /* DefaultTextInputType.swift */; }; 035EDEF22C2DE94B00F51144 /* _assignIfNotEqual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEEC2C2DE94B00F51144 /* _assignIfNotEqual.swift */; }; 035EDEF32C2DE94B00F51144 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEEE2C2DE94B00F51144 /* SearchBar.swift */; }; 035EDEF42C2DE94B00F51144 /* SearchBar+NavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEEF2C2DE94B00F51144 /* SearchBar+NavigationView.swift */; }; 035EDEF52C2DE94B00F51144 /* SearchBarExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEF02C2DE94B00F51144 /* SearchBarExtensions.swift */; }; 035EDEFB2C2DF98700F51144 /* CommunityListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDEFA2C2DF98700F51144 /* CommunityListRowBody.swift */; }; 035EDF012C2ECFE000F51144 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDF002C2ECFE000F51144 /* Searchable.swift */; }; 035EDF032C2ED0DE00F51144 /* PersonListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EDF022C2ED0DE00F51144 /* PersonListRowBody.swift */; }; 03600D932D4531DF00C704CB /* PrivacyBypassImageProxySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03600D922D4531DF00C704CB /* PrivacyBypassImageProxySettingsView.swift */; }; 0368799A2DA1320000E796EF /* ComponentViews in Frameworks */ = {isa = PBXBuildFile; productRef = 036879992DA1320000E796EF /* ComponentViews */; }; 0368F3432D72796B007DEB70 /* LanguagePickerSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0368F3422D72796B007DEB70 /* LanguagePickerSheetView.swift */; }; 0368F34D2D733215007DEB70 /* SortTimeRange+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0368F34C2D733215007DEB70 /* SortTimeRange+Extensions.swift */; }; 0368F34F2D734066007DEB70 /* SearchSortType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0368F34E2D734066007DEB70 /* SearchSortType+Extensions.swift */; }; 0368F3692D7349D8007DEB70 /* LanguageListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0368F3682D7349D8007DEB70 /* LanguageListRowBody.swift */; }; 0369B3532BFA514B001EFEDF /* ToastLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0369B3522BFA514B001EFEDF /* ToastLocation.swift */; }; 0369B3562BFA6824001EFEDF /* InboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0369B3552BFA6824001EFEDF /* InboxView.swift */; }; 0369B35D2BFB86E3001EFEDF /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0369B35A2BFB86E3001EFEDF /* Account.swift */; }; 036A84552D98253400E95D50 /* UpdateBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036A84542D98253400E95D50 /* UpdateBannerView.swift */; }; 036A84D82D99531400E95D50 /* View+ConditionalNavigationTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036A84D72D99531400E95D50 /* View+ConditionalNavigationTitle.swift */; }; 036CC3AF2B8145C30098B6A1 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036CC3AE2B8145C30098B6A1 /* AppState.swift */; }; 036ED6792D0AF3740018E5EA /* PinnedSortTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED6782D0AF3740018E5EA /* PinnedSortTracker.swift */; }; 036ED67B2D0B004D0018E5EA /* AdvancedSortView+SortButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED67A2D0B004D0018E5EA /* AdvancedSortView+SortButton.swift */; }; 036ED67D2D0B006C0018E5EA /* TopSortPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED67C2D0B006C0018E5EA /* TopSortPicker.swift */; }; 036ED67F2D0B9A520018E5EA /* CommunitySearchSortPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED67E2D0B9A520018E5EA /* CommunitySearchSortPicker.swift */; }; 036ED6832D0C483B0018E5EA /* ProfileProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED6822D0C483B0018E5EA /* ProfileProviding+Extensions.swift */; }; 036FFA2D2D45110C00998D8A /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036FFA2C2D45110C00998D8A /* ChangePasswordView.swift */; }; 036FFA2F2D45197300998D8A /* PrivacySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036FFA2E2D45197300998D8A /* PrivacySettingsView.swift */; }; 0370299D2D6B70F400B749DF /* MockApiClient+Realistic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0370299C2D6B70F400B749DF /* MockApiClient+Realistic.swift */; }; 0370299F2D6B743B00B749DF /* PostMockType+Realistic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0370299E2D6B743B00B749DF /* PostMockType+Realistic.swift */; }; 037029A12D6B9A3900B749DF /* View+TabBarPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037029A02D6B9A3900B749DF /* View+TabBarPreview.swift */; }; 037029A32D6B9B8400B749DF /* ContentView+Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037029A22D6B9B8400B749DF /* ContentView+Tab.swift */; }; 0372EBCE2D36FBCF00257095 /* RegistrationApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0372EBCD2D36FBCF00257095 /* RegistrationApplication+Extensions.swift */; }; 0372EC202D370F0200257095 /* RegistrationApplicationDenialEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0372EC1F2D370F0200257095 /* RegistrationApplicationDenialEditorView.swift */; }; 037331A42C9CB12D00C826E1 /* EnvironmentValues+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037331A32C9CB12D00C826E1 /* EnvironmentValues+Extensions.swift */; }; 037352332F27A83900341673 /* PostPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037352322F27A83900341673 /* PostPollView.swift */; }; 037386472BDAFE81007492B5 /* LemmyMarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 037386462BDAFE81007492B5 /* LemmyMarkdownUI */; }; 0377BD752DE219A400E38593 /* OnboardingUsernameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BD742DE219A400E38593 /* OnboardingUsernameView.swift */; }; 0377BD792DE22D4E00E38593 /* UsernameValidity+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BD782DE22D4E00E38593 /* UsernameValidity+Extensions.swift */; }; 0377BE072DE645E100E38593 /* OnboardingRecommendInstanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BE062DE645E100E38593 /* OnboardingRecommendInstanceView.swift */; }; 0377BE292DE7A2DE00E38593 /* Haptics in Frameworks */ = {isa = PBXBuildFile; productRef = 0377BE282DE7A2DE00E38593 /* Haptics */; }; 0377BE3B2DE8E70D00E38593 /* HapticLevel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BE3A2DE8E70D00E38593 /* HapticLevel+Extensions.swift */; }; 0377BE9B2DEA328900E38593 /* OnboardingEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BE9A2DEA328900E38593 /* OnboardingEmailView.swift */; }; 0377BE9F2DEA361600E38593 /* OnboardingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0377BE9E2DEA361600E38593 /* OnboardingModel.swift */; }; 0377BF9B2DF0E0E000E38593 /* Rest in Frameworks */ = {isa = PBXBuildFile; productRef = 0377BF9A2DF0E0E000E38593 /* Rest */; }; 037DE0752CE023E3007F7B92 /* BlockListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037DE0742CE023E3007F7B92 /* BlockListView.swift */; }; 037DE07A2CE108D9007F7B92 /* FooterLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037DE0792CE108D9007F7B92 /* FooterLinkView.swift */; }; 037F77ED2D3B064B00D4E180 /* SettingsDeviceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F77EC2D3B064B00D4E180 /* SettingsDeviceView.swift */; }; 037F783F2D3BD05500D4E180 /* PostSettingsView+PostSizePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F783E2D3BD05500D4E180 /* PostSettingsView+PostSizePicker.swift */; }; 037F78412D3C00E600D4E180 /* SettingsInteractionBarSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F78402D3C00E600D4E180 /* SettingsInteractionBarSummaryView.swift */; }; 037F78432D3C129B00D4E180 /* PostThumbnailSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F78422D3C129B00D4E180 /* PostThumbnailSettingsView.swift */; }; 037F78452D3C25AB00D4E180 /* PostSubscriptionIndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F78442D3C25AB00D4E180 /* PostSubscriptionIndicatorSettingsView.swift */; }; 037FC0702E4A6B16009E3E63 /* InstanceView+About.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037FC06F2E4A6B16009E3E63 /* InstanceView+About.swift */; }; 038028D32CAB3D2D0091A8A2 /* ShareActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028D22CAB3D2D0091A8A2 /* ShareActivity.swift */; }; 038028D52CAB479D0091A8A2 /* PostEditorView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028D42CAB479D0091A8A2 /* PostEditorView+Views.swift */; }; 038028D82CACAB960091A8A2 /* ModeratorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028D72CACAB960091A8A2 /* ModeratorSettingsView.swift */; }; 038028DA2CACACD30091A8A2 /* PostEllipsisMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028D92CACACD30091A8A2 /* PostEllipsisMenus.swift */; }; 038028F62CB096960091A8A2 /* SearchView+FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028F52CB096960091A8A2 /* SearchView+FilterModels.swift */; }; 038028F82CB097A10091A8A2 /* SearchView+InstancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028F72CB097A10091A8A2 /* SearchView+InstancePicker.swift */; }; 038028FA2CB097CB0091A8A2 /* SearchView+LocationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028F92CB097CB0091A8A2 /* SearchView+LocationPicker.swift */; }; 038028FD2CB72A2A0091A8A2 /* ContentRemovalEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028FC2CB72A2A0091A8A2 /* ContentRemovalEditorView.swift */; }; 038028FF2CB72AC90091A8A2 /* ReasonShortcutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038028FE2CB72AC90091A8A2 /* ReasonShortcutView.swift */; }; 0380965F2C10AA80003ED1D8 /* AppState+Transition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0380965E2C10AA80003ED1D8 /* AppState+Transition.swift */; }; 038096612C10AAD8003ED1D8 /* TransitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038096602C10AAD8003ED1D8 /* TransitionView.swift */; }; 038100512F6AE867008A7731 /* MlemBackend in Frameworks */ = {isa = PBXBuildFile; productRef = 038100502F6AE867008A7731 /* MlemBackend */; }; 038188992D43E0F30073E88D /* SafetySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038188982D43E0F30073E88D /* SafetySettingsView.swift */; }; 0381889B2D4412A40073E88D /* SafetyBlurNsfwSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381889A2D4412A40073E88D /* SafetyBlurNsfwSettingsView.swift */; }; 0381F7142F670F95008A7731 /* SwipeActionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381F7132F670F95008A7731 /* SwipeActionConfiguration.swift */; }; 0381F7162F671427008A7731 /* PostBarConfiguration+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381F7152F671427008A7731 /* PostBarConfiguration+Types.swift */; }; 0381F7242F672258008A7731 /* CommentBarConfiguration+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381F7232F672258008A7731 /* CommentBarConfiguration+Types.swift */; }; 0381F7282F6724D3008A7731 /* ReplyBarConfiguration+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381F7272F6724D3008A7731 /* ReplyBarConfiguration+Types.swift */; }; 0382A7F02C09F0F800C79DDA /* PersonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0382A7EF2C09F0F800C79DDA /* PersonView.swift */; }; 0382A7F22C0A758E00C79DDA /* ProfileDateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0382A7F12C0A758E00C79DDA /* ProfileDateView.swift */; }; 0382A7F42C0A76A900C79DDA /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0382A7F32C0A76A900C79DDA /* Date+Extensions.swift */; }; 0389DDC32C38907C0005B808 /* Message1Providing+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDC22C38907C0005B808 /* Message1Providing+Extensions.swift */; }; 0389DDC52C38917A0005B808 /* InboxItemProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDC42C38917A0005B808 /* InboxItemProviding+Extensions.swift */; }; 0389DDC72C389F840005B808 /* UnreadCount+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDC62C389F840005B808 /* UnreadCount+Extensions.swift */; }; 0389DDC92C39658E0005B808 /* Binding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDC82C39658E0005B808 /* Binding+Extensions.swift */; }; 0389DDCF2C39CB0E0005B808 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDCE2C39CB0E0005B808 /* SearchView.swift */; }; 0389DDD12C39E1030005B808 /* InstanceListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDD02C39E1030005B808 /* InstanceListRowBody.swift */; }; 0389DDD32C39E4D40005B808 /* PasteLinkButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDD22C39E4D40005B808 /* PasteLinkButtonView.swift */; }; 0389DDD52C39F1290005B808 /* CommunityListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDD42C39F1290005B808 /* CommunityListRow.swift */; }; 0389DDDB2C3AB6340005B808 /* ActionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0389DDDA2C3AB6340005B808 /* ActionBuilder.swift */; }; 038C85692D861A2100543F70 /* Comment+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038C85682D861A2100543F70 /* Comment+Mock.swift */; }; 038C85D82D87696F00543F70 /* FeedToolbarOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038C85D72D87696F00543F70 /* FeedToolbarOptions.swift */; }; 038C85E62D88337100543F70 /* CommentMockType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038C85E52D88337100543F70 /* CommentMockType.swift */; }; 038C86572D888EC100543F70 /* CommentSortType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038C86562D888EC100543F70 /* CommentSortType+Extensions.swift */; }; 038E1ABC2F58B9EF00D30F01 /* CommunityActionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E1ABB2F58B9EF00D30F01 /* CommunityActionConfiguration.swift */; }; 038E1AC02F58C76A00D30F01 /* SwipeActionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E1ABF2F58C76A00D30F01 /* SwipeActionEditorView.swift */; }; 038E1ACA2F59C18100D30F01 /* CommunitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E1AC92F59C18100D30F01 /* CommunitySettingsView.swift */; }; 038E5C132F6D617C00C54DEB /* ImageViewerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E5C122F6D617C00C54DEB /* ImageViewerSettingsView.swift */; }; 038E5E892F6D694500C54DEB /* ImageViewerShowControlsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E5E882F6D694500C54DEB /* ImageViewerShowControlsSettingsView.swift */; }; 038E5E8B2F6D6C3800C54DEB /* ImageViewerDismissSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E5E8A2F6D6C3800C54DEB /* ImageViewerDismissSettingsView.swift */; }; 038E62E02F6F0FC600C54DEB /* ContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038E62DF2F6F0FC600C54DEB /* ContextMenuConfiguration.swift */; }; 0391E0F92CFF17AE0040CCA8 /* ProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0391E0F82CFF17AE0040CCA8 /* ProfileSettingsView.swift */; }; 0391E0FB2D0066240040CCA8 /* ImageUploadMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0391E0FA2D0066240040CCA8 /* ImageUploadMenu.swift */; }; 0391E0FE2D05B2DF0040CCA8 /* AdvancedSortView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0391E0FD2D05B2DF0040CCA8 /* AdvancedSortView.swift */; }; 0395BCF82D9C57DE00865B33 /* View+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0395BCF72D9C57DE00865B33 /* View+Background.swift */; }; 0397D4602C66113F002C6CDC /* CommentBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D45F2C66113F002C6CDC /* CommentBodyView.swift */; }; 0397D4642C676CA8002C6CDC /* FeedSortPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4632C676CA8002C6CDC /* FeedSortPicker.swift */; }; 0397D46C2C67E583002C6CDC /* SortingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D46B2C67E583002C6CDC /* SortingSettingsView.swift */; }; 0397D4722C68078F002C6CDC /* PrefetchingConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4712C68078F002C6CDC /* PrefetchingConfiguration+Extensions.swift */; }; 0397D47A2C693444002C6CDC /* ReportEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4792C693444002C6CDC /* ReportEditorView.swift */; }; 0397D4802C693A88002C6CDC /* [BlockNode]+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D47F2C693A88002C6CDC /* [BlockNode]+Extensions.swift */; }; 0397D4862C6A24D2002C6CDC /* ReportableProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4852C6A24D2002C6CDC /* ReportableProviding+Extensions.swift */; }; 0397D48C2C6BE9A2002C6CDC /* CollapsibleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D48B2C6BE9A2002C6CDC /* CollapsibleSection.swift */; }; 0397D4912C6CE871002C6CDC /* PostEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D48F2C6CE871002C6CDC /* PostEditorView.swift */; }; 0397D4932C6CE87E002C6CDC /* PostEditorTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4922C6CE87E002C6CDC /* PostEditorTargetView.swift */; }; 0397D49A2C6EA6EE002C6CDC /* InteractionBarEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4992C6EA6EE002C6CDC /* InteractionBarEditorView.swift */; }; 0397D49C2C6EA73C002C6CDC /* InteractionBarConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D49B2C6EA73C002C6CDC /* InteractionBarConfiguration.swift */; }; 0397D4A22C6EB035002C6CDC /* ActionAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4A12C6EB035002C6CDC /* ActionAppearance.swift */; }; 0397D4A42C6FBC04002C6CDC /* ActionAppearance+StaticValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0397D4A32C6FBC04002C6CDC /* ActionAppearance+StaticValues.swift */; }; 039D75642C4EEE69004F24C2 /* DeletableProviding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039D75632C4EEE69004F24C2 /* DeletableProviding+Extensions.swift */; }; 039EFEC32BEEBEE0003AC372 /* LoginInstancePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039EFEC22BEEBEE0003AC372 /* LoginInstancePickerView.swift */; }; 039F58812C7A7E5900C61658 /* JumpButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58802C7A7E5900C61658 /* JumpButtonView.swift */; }; 039F58822C7A7EF300C61658 /* ToolbarEllipsisMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0E07002C12707700445849 /* ToolbarEllipsisMenu.swift */; }; 039F58842C7A7F2C00C61658 /* CommentJumpButtonLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58832C7A7F2C00C61658 /* CommentJumpButtonLocation.swift */; }; 039F58862C7A810100C61658 /* ExpandedPostView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58852C7A810100C61658 /* ExpandedPostView+Logic.swift */; }; 039F58882C7B531800C61658 /* SquircleLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58872C7B531800C61658 /* SquircleLabelStyle.swift */; }; 039F588A2C7B54FE00C61658 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58892C7B54FE00C61658 /* GeneralSettingsView.swift */; }; 039F588C2C7B574E00C61658 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F588B2C7B574E00C61658 /* AdvancedSettingsView.swift */; }; 039F588F2C7B599800C61658 /* ThemeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F588E2C7B599800C61658 /* ThemeLabel.swift */; }; 039F58912C7B5C7A00C61658 /* ContentView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58902C7B5C7A00C61658 /* ContentView+Logic.swift */; }; 039F58932C7B616600C61658 /* CommentSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58922C7B616600C61658 /* CommentSettingsView.swift */; }; 039F58952C7B618F00C61658 /* InboxSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58942C7B618F00C61658 /* InboxSettingsView.swift */; }; 039F58972C7B68F100C61658 /* AboutMlemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58962C7B68F100C61658 /* AboutMlemView.swift */; }; 039F58992C7B697D00C61658 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039F58982C7B697D00C61658 /* Bundle+Extensions.swift */; }; 03A630ED2D497005009A47A6 /* ExternalLinkSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A630EC2D497005009A47A6 /* ExternalLinkSettingsView.swift */; }; 03A630EF2D497143009A47A6 /* TappableLinksSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A630EE2D497143009A47A6 /* TappableLinksSettingsView.swift */; }; 03A630F12D497674009A47A6 /* ShieldsBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A630F02D497674009A47A6 /* ShieldsBadgeView.swift */; }; 03A630F42D4976EB009A47A6 /* ShieldsBadgeView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A630F32D4976EB009A47A6 /* ShieldsBadgeView+Logic.swift */; }; 03A6315E2D4D1A1B009A47A6 /* DefaultFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A6315D2D4D1A1B009A47A6 /* DefaultFeedSettingsView.swift */; }; 03A631602D4D1CBB009A47A6 /* HapticSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A6315F2D4D1CBB009A47A6 /* HapticSettingsView.swift */; }; 03A6316D2D4E3D24009A47A6 /* ModeratorActionSeparationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A6316C2D4E3D24009A47A6 /* ModeratorActionSeparationSettingsView.swift */; }; 03A631CC2D4FD18C009A47A6 /* Post+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A631CA2D4FCEF7009A47A6 /* Post+Mock.swift */; }; 03A814292ED1BCA90023E9E8 /* ModlogView+Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A814282ED1BCA90023E9E8 /* ModlogView+Filters.swift */; }; 03A818AD2EDCDBA20023E9E8 /* FederationMode+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A818AC2EDCDBA20023E9E8 /* FederationMode+Extensions.swift */; }; 03A82FA12C0D1E8500D01A5C /* ApiClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A82FA02C0D1E8500D01A5C /* ApiClient+Extensions.swift */; }; 03A82FA32C0D1F2400D01A5C /* View+ExternalApiWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A82FA22C0D1F2400D01A5C /* View+ExternalApiWarning.swift */; }; 03A9FD162D7CEC09007A734D /* Theming in Frameworks */ = {isa = PBXBuildFile; productRef = 03A9FD152D7CEC09007A734D /* Theming */; }; 03A9FD182D7CFC20007A734D /* Palette+Oled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A9FD172D7CFC20007A734D /* Palette+Oled.swift */; }; 03A9FD1A2D7CFC69007A734D /* Palette+Monochrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A9FD192D7CFC69007A734D /* Palette+Monochrome.swift */; }; 03A9FD1C2D7CFD5C007A734D /* Palette+Solarized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A9FD1B2D7CFD5C007A734D /* Palette+Solarized.swift */; }; 03A9FD1E2D7CFF0D007A734D /* Palette+Dracula.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A9FD1D2D7CFF0D007A734D /* Palette+Dracula.swift */; }; 03A9FD202D7D0072007A734D /* PaletteOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A9FD1F2D7D0072007A734D /* PaletteOption.swift */; }; 03AB484F2CBAE33500567FF9 /* MarkdownWithLinkList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB484E2CBAE33500567FF9 /* MarkdownWithLinkList.swift */; }; 03AB48522CBC042E00567FF9 /* AccountContentSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB48512CBC042E00567FF9 /* AccountContentSettingsView.swift */; }; 03AB48552CBC0B8000567FF9 /* AccountAdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB48542CBC0B8000567FF9 /* AccountAdvancedSettingsView.swift */; }; 03AB48572CBC0DFC00567FF9 /* AccountSignInSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB48562CBC0DFC00567FF9 /* AccountSignInSettingsView.swift */; }; 03AB48592CBC14CE00567FF9 /* AccountEmailSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB48582CBC14CE00567FF9 /* AccountEmailSettingsView.swift */; }; 03AB906F2D418C200054D9E1 /* CommentJumpButtonSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AB906E2D418C200054D9E1 /* CommentJumpButtonSettingsView.swift */; }; 03ABE5B62DB79A0E00374AFF /* DateComponents+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ABE5B52DB79A0E00374AFF /* DateComponents+Extensions.swift */; }; 03ABE5E42DB8E57000374AFF /* AccountAgeVisibilitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ABE5E32DB8E57000374AFF /* AccountAgeVisibilitySettingsView.swift */; }; 03ACE71A2DFD57CC00FD66C0 /* SiteSoftware+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACE7192DFD57CC00FD66C0 /* SiteSoftware+Extensions.swift */; }; 03AD09E82CF88007001EF9F7 /* MoreRepliesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AD09E72CF88007001EF9F7 /* MoreRepliesButton.swift */; }; 03AD0A822CFDBFA0001EF9F7 /* AccountLocalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AD0A812CFDBFA0001EF9F7 /* AccountLocalSettingsView.swift */; }; 03AD0A842CFDC557001EF9F7 /* AccountNicknameFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AD0A832CFDC557001EF9F7 /* AccountNicknameFieldView.swift */; }; 03AF91DD2C1B23E500E56644 /* ImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AF91DC2C1B23E500E56644 /* ImageViewer.swift */; }; 03AF91E12C1B25DE00E56644 /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AF91E02C1B25DE00E56644 /* UIDevice+Extensions.swift */; }; 03AF91E32C1C616F00E56644 /* InteractionBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AF91E22C1C616F00E56644 /* InteractionBarView.swift */; }; 03AF91E52C1C61FA00E56644 /* PostBarConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AF91E42C1C61FA00E56644 /* PostBarConfiguration.swift */; }; 03AF91EA2C1CE96600E56644 /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AF91E92C1CE96600E56644 /* Counter.swift */; }; 03AFD0DF2C3B2E000054B8AD /* PersonListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFD0DE2C3B2E000054B8AD /* PersonListRow.swift */; }; 03AFD0E12C3B30390054B8AD /* InstanceListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFD0E02C3B30390054B8AD /* InstanceListRow.swift */; }; 03AFD0E32C3C0C540054B8AD /* InstanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFD0E22C3C0C540054B8AD /* InstanceView.swift */; }; 03B045F62E26D64900540EFB /* SiteSoftwareType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B045F52E26D64900540EFB /* SiteSoftwareType+Extensions.swift */; }; 03B04FC02C5FC32300824128 /* SimpleAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B04FBF2C5FC32300824128 /* SimpleAvatarView.swift */; }; 03B0EB6F2C87827A00F79FDF /* ExpandedPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B0EB6E2C87827A00F79FDF /* ExpandedPostView.swift */; }; 03B25B2F2CC43F8600EB6DF5 /* InstanceSafetyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B2E2CC43F8600EB6DF5 /* InstanceSafetyView.swift */; }; 03B25B312CC4403500EB6DF5 /* Fediseer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B302CC4403500EB6DF5 /* Fediseer.swift */; }; 03B25B332CC440A600EB6DF5 /* FediseerOpinionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B322CC440A600EB6DF5 /* FediseerOpinionView.swift */; }; 03B25B352CC4446400EB6DF5 /* FediseerOpinionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B342CC4446400EB6DF5 /* FediseerOpinionListView.swift */; }; 03B25B372CC4478600EB6DF5 /* FediseerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B362CC4478600EB6DF5 /* FediseerInfoView.swift */; }; 03B25B3B2CC44FFF00EB6DF5 /* UploadConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B25B3A2CC44FFF00EB6DF5 /* UploadConfirmationView.swift */; }; 03B431B42C4481C3001A1EB5 /* MarkdownTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431B32C4481C3001A1EB5 /* MarkdownTextEditor.swift */; }; 03B431B62C454D49001A1EB5 /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431B52C454D49001A1EB5 /* UIImage+Extensions.swift */; }; 03B431BC2C455838001A1EB5 /* LargePostBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431BB2C455838001A1EB5 /* LargePostBodyView.swift */; }; 03B431C02C45ABFB001A1EB5 /* UITextView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431BF2C45ABFB001A1EB5 /* UITextView+Extensions.swift */; }; 03B431C22C45BA00001A1EB5 /* MarkdownEditorToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431C12C45BA00001A1EB5 /* MarkdownEditorToolbarView.swift */; }; 03B431C42C45BA45001A1EB5 /* AccountPickerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431C32C45BA45001A1EB5 /* AccountPickerMenu.swift */; }; 03B62B772CE295530077E9C8 /* RulesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B62B762CE295530077E9C8 /* RulesListView.swift */; }; 03B62B792CE2A2C00077E9C8 /* RulesPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B62B782CE2A2C00077E9C8 /* RulesPickerView.swift */; }; 03B62C402CE811CB0077E9C8 /* PersonBanEditorView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B62C3F2CE811CB0077E9C8 /* PersonBanEditorView+Logic.swift */; }; 03B72B672C2888EE0023A6C4 /* View+ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B72B662C2888EE0023A6C4 /* View+ContextMenu.swift */; }; 03B72B6B2C28A0190023A6C4 /* SubscriptionListSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B72B6A2C28A0190023A6C4 /* SubscriptionListSettingsView.swift */; }; 03B7F3352EEEC70F00B00F6A /* NoteEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7F3342EEEC70F00B00F6A /* NoteEditorView.swift */; }; 03BF11C32D3D135D00CC1F66 /* SearchView+CreatorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BF11C22D3D135D00CC1F66 /* SearchView+CreatorPicker.swift */; }; 03BF11C52D3D3AFB00CC1F66 /* PostReadIndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BF11C42D3D3AFB00CC1F66 /* PostReadIndicatorSettingsView.swift */; }; 03BF11C72D3D634A00CC1F66 /* AccessibilitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BF11C62D3D634A00CC1F66 /* AccessibilitySettingsView.swift */; }; 03BF11CA2D4027E900CC1F66 /* DevicePickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BF11C92D4027E900CC1F66 /* DevicePickerItem.swift */; }; 03BF11CC2D404F2C00CC1F66 /* CommentMaximumDepthSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BF11CB2D404F2C00CC1F66 /* CommentMaximumDepthSettingsView.swift */; }; 03C93CF02BEFFB1A00327BFE /* LoginCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C93CEF2BEFFB1A00327BFE /* LoginCredentialsView.swift */; }; 03CBD18D2C6120F600E870BC /* PersonFlair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CBD18C2C6120F600E870BC /* PersonFlair.swift */; }; 03CCDAA02BF2795300C0C851 /* LoginPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CCDA9F2BF2795300C0C851 /* LoginPage.swift */; }; 03CCDAA42BF2852E00C0C851 /* LoginTotpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CCDAA32BF2852E00C0C851 /* LoginTotpView.swift */; }; 03D001DD2EC53A97001BF97D /* NavigationPage+PresentationDetents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D001DC2EC53A97001BF97D /* NavigationPage+PresentationDetents.swift */; }; 03D006322ECCBA95001BF97D /* QuickSwipeAction+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D006312ECCBA95001BF97D /* QuickSwipeAction+Actions.swift */; }; 03D0273C2CD3BA5100984519 /* PersonContent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D0273B2CD3BA5100984519 /* PersonContent+Extensions.swift */; }; 03D283FA2D256E1E00A6659B /* VisitHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D283F92D256E1E00A6659B /* VisitHistory.swift */; }; 03D283FC2D25A3F700A6659B /* VisitHistory+CodedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D283FB2D25A3F700A6659B /* VisitHistory+CodedData.swift */; }; 03D283FE2D25EEC500A6659B /* SearchView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D283FD2D25EEC500A6659B /* SearchView+Views.swift */; }; 03D284002D26F09500A6659B /* Instance+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D283FF2D26F09500A6659B /* Instance+Extensions.swift */; }; 03D284022D29E03C00A6659B /* FeedFilterButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D284012D29E03C00A6659B /* FeedFilterButtonStyle.swift */; }; 03D284062D2AEE3A00A6659B /* TabBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D284052D2AEE3A00A6659B /* TabBarSettingsView.swift */; }; 03D2A6372C00F92400ED4FF2 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A6362C00F92400ED4FF2 /* Session.swift */; }; 03D2A6392C00FAE000ED4FF2 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A6382C00FAE000ED4FF2 /* UserAccount.swift */; }; 03D2A63B2C010B7500ED4FF2 /* GuestAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A63A2C010B7500ED4FF2 /* GuestAccount.swift */; }; 03D2A63D2C010CD400ED4FF2 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A63C2C010CD400ED4FF2 /* UserSession.swift */; }; 03D2A63F2C010DBF00ED4FF2 /* GuestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A63E2C010DBF00ED4FF2 /* GuestSession.swift */; }; 03D2A6422C011F4A00ED4FF2 /* AccountListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D2A6412C011F4A00ED4FF2 /* AccountListRowBody.swift */; }; 03D3A1D42BB88EF1009DE55E /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D3A1D32BB88EF1009DE55E /* Action.swift */; }; 03D3A1E52BB8B7A3009DE55E /* ActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D3A1E42BB8B7A3009DE55E /* ActionType.swift */; }; 03D3A1EF2BB9CA1D009DE55E /* MenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D3A1EE2BB9CA1D009DE55E /* MenuButton.swift */; }; 03D3A1F12BB9D48E009DE55E /* BasicAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D3A1F02BB9D48E009DE55E /* BasicAction.swift */; }; 03D3A1F32BB9D49B009DE55E /* ActionGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D3A1F22BB9D49B009DE55E /* ActionGroup.swift */; }; 03D65D702F4B046F0041ADAF /* ContextMenuSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D65D6F2F4B046F0041ADAF /* ContextMenuSettingsView.swift */; }; 03D662A52F5377630041ADAF /* ActionSeedSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D662A42F5377630041ADAF /* ActionSeedSections.swift */; }; 03D8BF432DA55B6900506687 /* Icons in Frameworks */ = {isa = PBXBuildFile; productRef = 03D8BF422DA55B6900506687 /* Icons */; }; 03DA4FB72CF115FB001C3C77 /* CommentEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B431B12C44409D001A1EB5 /* CommentEditorView.swift */; }; 03DAEA772C64074E0064DE64 /* SubscriptionListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DAEA762C64074E0064DE64 /* SubscriptionListItemView.swift */; }; 03DD69422D4FDE8900F8950D /* Person+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DD69412D4FDE8900F8950D /* Person+Mock.swift */; }; 03DD69442D4FEA0000F8950D /* PreviewModifier+SampleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DD69432D4FEA0000F8950D /* PreviewModifier+SampleEnvironment.swift */; }; 03E0EF432CA73D7A002CB66C /* PostStubResolutionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0EF422CA73D7A002CB66C /* PostStubResolutionPage.swift */; }; 03E0EF452CA74036002CB66C /* CommentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0EF442CA74036002CB66C /* CommentPage.swift */; }; 03E46ACB2D1216CE002589DB /* PostViewLinkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E46ACA2D1216CE002589DB /* PostViewLinkType.swift */; }; 03E46ACD2D121C19002589DB /* HeadlinePostBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E46ACC2D121C19002589DB /* HeadlinePostBodyView.swift */; }; 03E46AD22D130681002589DB /* VotesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E46AD12D130681002589DB /* VotesListView.swift */; }; 03E46AD42D130728002589DB /* ScoringOperation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E46AD32D130728002589DB /* ScoringOperation+Extensions.swift */; }; 03E614E52C0BCCAA00F692A4 /* PostSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC7A2BFADA8B00DBC0EF /* PostSize.swift */; }; 03E614E72C0BCDC200F692A4 /* FullyQualifiedLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E614E62C0BCDC200F692A4 /* FullyQualifiedLinkView.swift */; }; 03EC83252E916C51004698BB /* View+SafeAreaBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC83242E916C51004698BB /* View+SafeAreaBar.swift */; }; 03EC83EE2E958A44004698BB /* OpenGraph in Frameworks */ = {isa = PBXBuildFile; productRef = 03EC83ED2E958A44004698BB /* OpenGraph */; }; 03EC83F02E9590D3004698BB /* LinkHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC83EF2E9590D3004698BB /* LinkHostView.swift */; }; 03EC84422E959AA5004698BB /* PostEditorWebsitePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC84412E959AA5004698BB /* PostEditorWebsitePreviewView.swift */; }; 03EC85EC2E9D8F37004698BB /* Actions in Frameworks */ = {isa = PBXBuildFile; productRef = 03EC85EB2E9D8F37004698BB /* Actions */; }; 03EC86462E9E9D4D004698BB /* PopupAnchorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC86452E9E9D4C004698BB /* PopupAnchorModel.swift */; }; 03ECD7192C81195000D48BF6 /* PostEditorView+LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ECD7182C81195000D48BF6 /* PostEditorView+LinkView.swift */; }; 03ECD71B2C811D6700D48BF6 /* PostEditorView+ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ECD71A2C811D6700D48BF6 /* PostEditorView+ImageView.swift */; }; 03ECD71F2C864DB700D48BF6 /* ImageUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ECD71E2C864DB700D48BF6 /* ImageUploadManager.swift */; }; 03ECD7212C8654BA00D48BF6 /* PostEditorView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ECD7202C8654BA00D48BF6 /* PostEditorView+Toolbar.swift */; }; 03F6BD942D500DED006A425E /* PersonMockType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BD932D500DED006A425E /* PersonMockType.swift */; }; 03F6BD982D500E2A006A425E /* PersonMockType+Realistic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BD972D500E2A006A425E /* PersonMockType+Realistic.swift */; }; 03F6BD9A2D501041006A425E /* SeededRandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BD992D501041006A425E /* SeededRandomNumberGenerator.swift */; }; 03F6BD9C2D501478006A425E /* ActorIdentifier+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BD9B2D501478006A425E /* ActorIdentifier+Mock.swift */; }; 03F6BDAD2D516615006A425E /* Community+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BDAC2D516615006A425E /* Community+Mock.swift */; }; 03F6BDAF2D516636006A425E /* CommunityMockType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BDAE2D516636006A425E /* CommunityMockType.swift */; }; 03F6BDB12D52AA00006A425E /* CommunityMockType+Realistic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BDB02D52AA00006A425E /* CommunityMockType+Realistic.swift */; }; 03F6BDBC2D52B7FE006A425E /* PostMockType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BDBB2D52B7FE006A425E /* PostMockType.swift */; }; 03F6BDF82D555F6E006A425E /* ModMailInteractionBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6BDF72D555F6E006A425E /* ModMailInteractionBarSettingsView.swift */; }; 03F967272CE218110081C9A3 /* PersonBanEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F967262CE218110081C9A3 /* PersonBanEditorView.swift */; }; 03F9672B2CE221220081C9A3 /* Label+Profile1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F9672A2CE221220081C9A3 /* Label+Profile1.swift */; }; 03FA318C2C6FECAE00D47FA3 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 03FA318B2C6FECAE00D47FA3 /* Flow */; }; 03FA318F2C6FEF2000D47FA3 /* InteractionBarActionLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FA318E2C6FEF2000D47FA3 /* InteractionBarActionLabelView.swift */; }; 03FD6CB02C9B719100500FD6 /* View+PopupAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FD6CAF2C9B719100500FD6 /* View+PopupAnchor.swift */; }; 03FE14042BF93FDD00A8377F /* ErrorDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FE14032BF93FDD00A8377F /* ErrorDetails.swift */; }; 03FE14082BF94FFB00A8377F /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FE14072BF94FFB00A8377F /* ErrorView.swift */; }; 03FE140C2BF953B000A8377F /* HandleError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FE140B2BF953B000A8377F /* HandleError.swift */; }; 50C99B562A61D792005D57DD /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 50C99B552A61D792005D57DD /* Dependencies */; }; 6332FDBD27EFAF7C0009A98A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 6332FDBC27EFAF7B0009A98A /* Settings.bundle */; }; 636250DC2A18111400FC59B4 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 636250DB2A18111400FC59B4 /* KeychainAccess */; }; 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6363D5C427EE196700E34822 /* MlemApp.swift */; }; 6363D5C727EE196700E34822 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6363D5C627EE196700E34822 /* ContentView.swift */; }; 6363D5C927EE196A00E34822 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6363D5C827EE196A00E34822 /* Assets.xcassets */; }; 6DE1183C2A4A217400810C7E /* Profile View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE1183B2A4A217400810C7E /* Profile View.swift */; }; 814CEF632F44577A0090F812 /* HiddenReadBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 814CEF622F44577A0090F812 /* HiddenReadBannerView.swift */; }; 81A179BC2DDE591700B17017 /* LongPressActionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A179BB2DDE591700B17017 /* LongPressActionSettingsView.swift */; }; 81A179BF2DDE5BF300B17017 /* TabBarLongPressAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A179BE2DDE5BF300B17017 /* TabBarLongPressAction.swift */; }; 81C4B4332F493C5E001406A1 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 81C4B4322F493C5E001406A1 /* InfoPlist.xcstrings */; }; 81DE61C52F48AF44006E4C36 /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81DE61C42F48AF44006E4C36 /* UniformTypeIdentifiers.framework */; }; 81DE61D02F48AF44006E4C36 /* OpenInMlem.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 81DE61C32F48AF44006E4C36 /* OpenInMlem.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 81DE61DB2F48AF4C006E4C36 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = 81DE61D62F48AF4C006E4C36 /* Action.js */; }; 81DE61DD2F48AF4C006E4C36 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 81DE61D92F48AF4C006E4C36 /* Media.xcassets */; }; 81DE61DE2F48AF4C006E4C36 /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81DE61D72F48AF4C006E4C36 /* ActionRequestHandler.swift */; }; AD1B0D372A5F7A260006F554 /* Licenses.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B0D362A5F7A260006F554 /* Licenses.swift */; }; B104A6D82A59BF3C00B3E725 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6D72A59BF3C00B3E725 /* Nuke */; }; B104A6DA2A59BF3C00B3E725 /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6D92A59BF3C00B3E725 /* NukeExtensions */; }; B104A6DC2A59BF3C00B3E725 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6DB2A59BF3C00B3E725 /* NukeUI */; }; B104A6DE2A59BF3C00B3E725 /* NukeVideo in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6DD2A59BF3C00B3E725 /* NukeVideo */; }; B1B78D642A51D53900F72485 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B78D632A51D53900F72485 /* AppDelegate.swift */; }; CD03742A2D1DBFD2001E85FA /* ReadCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0374292D1DBFCF001E85FA /* ReadCheck.swift */; }; CD03B5BE2F3BA16400AEF786 /* Blockable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD03B5BD2F3BA16100AEF786 /* Blockable+Extensions.swift */; }; CD0C5C102E99629A0074D5A4 /* ExportablePostEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0C5C0F2E9962970074D5A4 /* ExportablePostEditorView.swift */; }; CD0E06F72C0E739F00445849 /* PostType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0E06F62C0E739F00445849 /* PostType+Extensions.swift */; }; CD0F280A2C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0F28092C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift */; }; CD10FA772C7A8622008985AD /* ImageSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD10FA762C7A8622008985AD /* ImageSaver.swift */; }; CD13CC592C583C7A001AF428 /* WebsitePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD13CC582C583C7A001AF428 /* WebsitePreviewView.swift */; }; CD13CC5B2C588B34001AF428 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD13CC5A2C588B34001AF428 /* WebView.swift */; }; CD13CC652C5D2B9D001AF428 /* CircleCroppedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD13CC642C5D2B9D001AF428 /* CircleCroppedImageView.swift */; }; CD1446212A5B328E00610EF1 /* Privacy Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446202A5B328E00610EF1 /* Privacy Policy.swift */; }; CD1446252A5B357900610EF1 /* Document.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446242A5B357900610EF1 /* Document.swift */; }; CD1446272A5B36DA00610EF1 /* EULA.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446262A5B36DA00610EF1 /* EULA.swift */; }; CD1B2E212C7F84160075C7EA /* View+MarkReadOnScroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1B2E202C7F84160075C7EA /* View+MarkReadOnScroll.swift */; }; CD1C64152D3428710006B3C1 /* CommunityView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1C64142D34286E0006B3C1 /* CommunityView+Logic.swift */; }; CD1D31832C56D742001B434B /* View+WidthReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1D31822C56D742001B434B /* View+WidthReader.swift */; }; CD1DF6382D38357500F7851E /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1DF6372D38357100F7851E /* MediaView.swift */; }; CD1DF63E2D387E8500F7851E /* MediaView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1DF63D2D387E8300F7851E /* MediaView+Views.swift */; }; CD24CAFC2D5568FE0032B5E8 /* DiscussionLanguageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD24CAFB2D5568F90032B5E8 /* DiscussionLanguageSettingsView.swift */; }; CD27839A2D9B366000DD4C69 /* ZoomableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2783992D9B365D00DD4C69 /* ZoomableImageView.swift */; }; CD2C86542D5556C00034CD8A /* MlemError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2C86532D5556BE0034CD8A /* MlemError.swift */; }; CD3153072C38421B00BC5FBE /* View+LoadFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3153062C38421B00BC5FBE /* View+LoadFeed.swift */; }; CD332D792CA7175500A53988 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD332D782CA7175200A53988 /* PlayButton.swift */; }; CD332D7E2CA7486000A53988 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD332D7D2CA7485D00A53988 /* String+Extensions.swift */; }; CD33CA522D3C18BF00106C8C /* ImageViewer+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */; }; CD3485BB2D501470006748B8 /* ZoomSliderLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3485BA2D501463006748B8 /* ZoomSliderLocation.swift */; }; CD3485BD2D501573006748B8 /* ZoomSliderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3485BC2D50156E006748B8 /* ZoomSliderSettingsView.swift */; }; CD3FC6802D4A75090088E63B /* CounterApperance+StaticValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FC67F2D4A75050088E63B /* CounterApperance+StaticValues.swift */; }; CD4368C12AE23FD400BD8BD1 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = CD4368C02AE23FD400BD8BD1 /* Semaphore */; }; CD43E8B32BF2C24E007C3D71 /* ContentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */; }; CD44C92C2E5CC8B900F24AC8 /* ConditionalLabelStyleViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD44C92B2E5CC8B600F24AC8 /* ConditionalLabelStyleViewModifier.swift */; }; CD45CB0D2D1880E8008BC729 /* FiltersSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */; }; CD4B66DA2DCE809D00D28EB4 /* InstanceUptimeView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4B66D92DCE809A00D28EB4 /* InstanceUptimeView+Views.swift */; }; CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */; }; CD4D583F2B86855F00B82964 /* MlemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D583E2B86855F00B82964 /* MlemTests.swift */; }; CD4D58412B86858100B82964 /* MlemUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58402B86858100B82964 /* MlemUITests.swift */; }; CD4D58A52B86BD1B00B82964 /* PersistenceRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58A42B86BD1B00B82964 /* PersistenceRepository.swift */; }; CD4D58AA2B86BE5900B82964 /* AccountListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58A92B86BE5900B82964 /* AccountListView.swift */; }; CD4D58AD2B86BE7100B82964 /* QuickSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58AC2B86BE7100B82964 /* QuickSwitcherView.swift */; }; CD4D58B32B86BFD400B82964 /* AccountsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58B22B86BFD400B82964 /* AccountsTracker.swift */; }; CD4D58B52B86BFFB00B82964 /* PersistenceRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58B42B86BFFA00B82964 /* PersistenceRepository+Dependency.swift */; }; CD4D58B92B86D9F800B82964 /* AccountListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58B82B86D9F800B82964 /* AccountListRow.swift */; }; CD4D58BB2B86DA7D00B82964 /* AccountListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58BA2B86DA7D00B82964 /* AccountListView+Logic.swift */; }; CD4D58C82B86DCED00B82964 /* AvatarType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58C72B86DCED00B82964 /* AvatarType.swift */; }; CD4D58CF2B86DDEC00B82964 /* AccountSortMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58CE2B86DDEC00B82964 /* AccountSortMode.swift */; }; CD4D58EB2B86E63300B82964 /* AssociatedColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58EA2B86E63300B82964 /* AssociatedColor.swift */; }; CD4D58F82B87B0D100B82964 /* InternetSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D58F72B87B0D100B82964 /* InternetSpeed.swift */; }; CD4D59142B87B36B00B82964 /* InternetConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D59132B87B36B00B82964 /* InternetConnectionManager.swift */; }; CD4D59162B87B38C00B82964 /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D59152B87B38C00B82964 /* UIApplication+Extensions.swift */; }; CD4D59182B87B3B000B82964 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4D59172B87B3B000B82964 /* UIViewController+Extensions.swift */; }; CD4E386D2C836F8C009B24F2 /* UIUserInterfaceStyle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4E386C2C836F8C009B24F2 /* UIUserInterfaceStyle+Extensions.swift */; }; CD4ED8472BF110FA00EFA0A2 /* TabReselectTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */; }; CD4ED84A2BF1113800EFA0A2 /* View+TabReselectConsumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4ED8492BF1113800EFA0A2 /* View+TabReselectConsumer.swift */; }; CD5581DE2C7B8B820043FAC3 /* ImageFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5581DD2C7B8B820043FAC3 /* ImageFunctions.swift */; }; CD57AFA52D0377EB00AB3956 /* AnimationControlLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */; }; CD5C197D2D97096F0089614C /* ZoomCurves.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5C197C2D97096D0089614C /* ZoomCurves.swift */; }; CD5C197F2D970E5F0089614C /* MomentumStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5C197E2D970E570089614C /* MomentumStatus.swift */; }; CD5CAA0C2D41AE57008E20F2 /* EmbeddingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5CAA0B2D41AE52008E20F2 /* EmbeddingSettingsView.swift */; }; CD5D8E2B2D6D5AB100AF5CE4 /* CGSize+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5D8E2A2D6D5AAE00AF5CE4 /* CGSize+Extensions.swift */; }; CD635E1B2C94DACD00864F75 /* BypassProxyWarningSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD635E1A2C94DACD00864F75 /* BypassProxyWarningSheet.swift */; }; CD6436502D483C96002668FB /* InteractionBarEditorView+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD64364F2D483C8F002668FB /* InteractionBarEditorView+Views.swift */; }; CD6DC02A2D86513D00693B16 /* AnimatedAvatarBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6DC0292D86513800693B16 /* AnimatedAvatarBehavior.swift */; }; CD6DC02C2D86540A00693B16 /* AnimatedAvatarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6DC02B2D86540500693B16 /* AnimatedAvatarSettingsView.swift */; }; CD737FBA2F771BF600E46411 /* InstanceSummarySoftware+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD737FB92F771BF100E46411 /* InstanceSummarySoftware+Extensions.swift */; }; CD756A962ED765EC0031D7D1 /* ExportableCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD756A952ED765E90031D7D1 /* ExportableCommentView.swift */; }; CD756A982ED7669C0031D7D1 /* ExportableCommentEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD756A972ED766930031D7D1 /* ExportableCommentEditorView.swift */; }; CD77437F2C1BA5CE0085BB43 /* MultiplatformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD77437E2C1BA5CE0085BB43 /* MultiplatformView.swift */; }; CD7743892C20EDEE0085BB43 /* VotesModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7743882C20EDEE0085BB43 /* VotesModel+Extensions.swift */; }; CD7882A72BFFD1A3002E1A30 /* ThumbnailLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7882A62BFFD1A3002E1A30 /* ThumbnailLocation.swift */; }; CD7882A92BFFDFC7002E1A30 /* PostTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7882A82BFFDFC7002E1A30 /* PostTag.swift */; }; CD7882AB2C013005002E1A30 /* EllipsisMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7882AA2C013005002E1A30 /* EllipsisMenu.swift */; }; CD79281F2C73B52A00FA712D /* EndOfFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD79281E2C73B52A00FA712D /* EndOfFeedView.swift */; }; CD7928232C73CBA400FA712D /* TileScoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7928222C73CBA400FA712D /* TileScoreView.swift */; }; CD7928262C73E73400FA712D /* PersonView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7928252C73E73400FA712D /* PersonView+Logic.swift */; }; CD7AC2CC2D7FDFFB00A671B7 /* MediaView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7AC2CB2D7FDFF700A671B7 /* MediaView+Logic.swift */; }; CD7BF9322D18F4ED0020F2C5 /* FiltersTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7BF9312D18F4EB0020F2C5 /* FiltersTracker.swift */; }; CD7C4E572F3B92BA00ADCBDD /* View+ReloadOnAccountSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7C4E562F3B92A600ADCBDD /* View+ReloadOnAccountSwitch.swift */; }; CD7DB9712C49C17200DCC542 /* PersonContentGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9702C49C17200DCC542 /* PersonContentGridView.swift */; }; CD7DB9732C4AEDDE00DCC542 /* TileCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9722C4AEDDE00DCC542 /* TileCommentView.swift */; }; CD7DB9762C4D6C0A00DCC542 /* FeedCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9752C4D6C0A00DCC542 /* FeedCommentView.swift */; }; CD869FCC2C15F8AC00FC8B5B /* BubblePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD869FCB2C15F8AC00FC8B5B /* BubblePickerView.swift */; }; CD869FCE2C15F90C00FC8B5B /* ChildSizeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD869FCD2C15F90C00FC8B5B /* ChildSizeReader.swift */; }; CD87BEC32D5132BE0099F190 /* FilterViolationWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD87BEC22D5132BB0099F190 /* FilterViolationWarning.swift */; }; CD8DB93A2D22004000EB0C7B /* Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8DB9392D22003D00EB0C7B /* Animations.swift */; }; CD93420B2DCD069800945333 /* InstanceUptimeView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD93420A2DCD069800945333 /* InstanceUptimeView+Logic.swift */; }; CD950E0F2F0ED6F7002A0595 /* FeedPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD950E042F0ED6F7002A0595 /* FeedPostView.swift */; }; CD9857A42C5E7F9D0084C71F /* NsfwOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9857A32C5E7F9D0084C71F /* NsfwOverlayView.swift */; }; CD9BD5812D8F8F7D0006AB7F /* ZoomRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9BD5802D8F8F750006AB7F /* ZoomRecognizer.swift */; }; CD9CFA2A2C2E1E8400739BBC /* FeedHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9CFA292C2E1E8400739BBC /* FeedHeaderView.swift */; }; CD9CFA2C2C2E1EF300739BBC /* FeedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9CFA2B2C2E1EF300739BBC /* FeedIconView.swift */; }; CD9CFA302C2E22F600739BBC /* FeedDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9CFA2F2C2E22F600739BBC /* FeedDescription.swift */; }; CD9CFA372C306DAB00739BBC /* PostGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9CFA362C306DAB00739BBC /* PostGridView.swift */; }; CD9D243D2CC1DF59006E5F3F /* AccountType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9D243C2CC1DF55006E5F3F /* AccountType.swift */; }; CDA1D2A52ED8C46B0077A9EA /* ExportableViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA1D2A42ED8C4670077A9EA /* ExportableViewComponents.swift */; }; CDA67A9F2D9B32A100E5D17B /* CGPoint+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA67A9E2D9B329D00E5D17B /* CGPoint+Extensions.swift */; }; CDA683F82C77E577000C4486 /* NsfwBlurBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA683F72C77E577000C4486 /* NsfwBlurBehavior.swift */; }; CDA711FB2DB5CAC3008BC3ED /* Media in Frameworks */ = {isa = PBXBuildFile; productRef = CDA711FA2DB5CAC3008BC3ED /* Media */; }; CDAA02DB2C810DB200D75633 /* Calendar+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDAA02DA2C810DB200D75633 /* Calendar+Extensions.swift */; }; CDAA02E12C817AAB00D75633 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDAA02E02C817AAB00D75633 /* Color+Extensions.swift */; }; CDAA02E32C821C9100D75633 /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDAA02E22C821C9100D75633 /* Divider.swift */; }; CDADDB272F49210A00A4214A /* CommunityStubResolutionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDADDB262F49210A00A4214A /* CommunityStubResolutionPage.swift */; }; CDB2EC7D2BFADAB300DBC0EF /* CompactPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC7C2BFADAB300DBC0EF /* CompactPostView.swift */; }; CDB2EC7F2BFADACC00DBC0EF /* HeadlinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC7E2BFADACC00DBC0EF /* HeadlinePostView.swift */; }; CDB2EC812BFADADF00DBC0EF /* LargePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC802BFADADF00DBC0EF /* LargePostView.swift */; }; CDB2EC862BFADD7C00DBC0EF /* FullyQualifiedLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC852BFADD7C00DBC0EF /* FullyQualifiedLabelView.swift */; }; CDB2EC882BFAE14800DBC0EF /* FullyQualifiedNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC872BFAE14800DBC0EF /* FullyQualifiedNameView.swift */; }; CDB2EC8A2BFAEFDF00DBC0EF /* ThumbnailImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB2EC892BFAEFDF00DBC0EF /* ThumbnailImageView.swift */; }; CDB3DDFD2DA485D200F407AB /* SettingsValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3DDFC2DA485D000F407AB /* SettingsValues.swift */; }; CDB41E8A2C83C24400BD2DE9 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB41E892C83C24400BD2DE9 /* Section.swift */; }; CDB738292CB8A6A5005B11BB /* View+PaletteBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB738282CB8A69D005B11BB /* View+PaletteBorder.swift */; }; CDB95FCD2EBD3230008669D9 /* SmallOverlayButtonLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB95FCC2EBD3212008669D9 /* SmallOverlayButtonLabel.swift */; }; CDBE78C22F38DD8D008B254C /* PersonStubResolutionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBE78C12F38DD89008B254C /* PersonStubResolutionPage.swift */; }; CDBEC30F2E84C9F800F30B00 /* ExportablePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBEC30E2E84C9F300F30B00 /* ExportablePostView.swift */; }; CDBFCB652C03920C008CD468 /* PostLinkHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBFCB642C03920C008CD468 /* PostLinkHostView.swift */; }; CDBFCB6A2C04EFFE008CD468 /* PostSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBFCB692C04EFFE008CD468 /* PostSettingsView.swift */; }; CDBFCB6C2C054AA7008CD468 /* TilePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBFCB6B2C054AA7008CD468 /* TilePostView.swift */; }; CDC44A362D1CBC280030F01C /* ReadPostIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC44A352D1CBC200030F01C /* ReadPostIndicator.swift */; }; CDCA44B42C176A4700C092B3 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCA44B32C176A4700C092B3 /* Array+Extensions.swift */; }; CDCC1BA72D99BDB7006579DF /* GestureRecognizers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC1BA62D99BDB0006579DF /* GestureRecognizers.swift */; }; CDCC1BA92D99BEC1006579DF /* ZoomRecognizerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC1BA82D99BEA8006579DF /* ZoomRecognizerCoordinator.swift */; }; CDCC7FED2D9AEDC800CE18DA /* BridgeDragValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC7FEC2D9AEDBE00CE18DA /* BridgeDragValue.swift */; }; CDCC7FEF2D9B1E7100CE18DA /* CachedComputation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC7FEE2D9B1E6E00CE18DA /* CachedComputation.swift */; }; CDCC7FF12D9B27D800CE18DA /* ZoomRecognizerCoordinator+GestureRecognition.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC7FF02D9B27CE00CE18DA /* ZoomRecognizerCoordinator+GestureRecognition.swift */; }; CDCC7FF32D9B283A00CE18DA /* ZoomRecognizerCoordinator+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCC7FF22D9B283400CE18DA /* ZoomRecognizerCoordinator+Logic.swift */; }; CDCF5A582F1426A5006748E8 /* CommentStubResolutionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCF5A572F1426A0006748E8 /* CommentStubResolutionPage.swift */; }; CDD4A09C2C8A122F0001AD1A /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD4A09B2C8A122F0001AD1A /* Settings.swift */; }; CDD4A09E2C8B69FC0001AD1A /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD4A09D2C8B69FC0001AD1A /* Button.swift */; }; CDD4A0A02C8B985D0001AD1A /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD4A09F2C8B985D0001AD1A /* ImportExportSettingsView.swift */; }; CDD8B94C2C8234BC00510EBB /* Form.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD8B94B2C8234BC00510EBB /* Form.swift */; }; CDD8E30D2EEA07F100FC4C8D /* ExportableCommentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD8E30C2EEA07EE00FC4C8D /* ExportableCommentLoader.swift */; }; CDD99C3C2C73F3FF0010367F /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD99C3B2C73F3FF0010367F /* DeleteAccountView.swift */; }; CDD99C3E2C73F4380010367F /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD99C3D2C73F4380010367F /* WarningView.swift */; }; CDDA49A82F7044D2004A5AFF /* InstanceStubResolutionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA49A72F7044CD004A5AFF /* InstanceStubResolutionPage.swift */; }; CDE1F18F2C63D75A008AF042 /* LegacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F18E2C63D75A008AF042 /* LegacySettings.swift */; }; CDE1F1942C63DF44008AF042 /* PlatformConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F1932C63DF44008AF042 /* PlatformConstants.swift */; }; CDE1F1962C63DF89008AF042 /* PhoneConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F1952C63DF89008AF042 /* PhoneConstants.swift */; }; CDE1F1982C63DFC9008AF042 /* PadConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F1972C63DFC9008AF042 /* PadConstants.swift */; }; CDE1F19C2C63E2EB008AF042 /* SettingPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F19B2C63E2EB008AF042 /* SettingPropertyWrapper.swift */; }; CDE1F19E2C63E306008AF042 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1F19D2C63E306008AF042 /* Constants.swift */; }; CDE4AC472CA372B600981010 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = CDE4AC462CA372B600981010 /* SDWebImageWebPCoder */; }; CDE88C352E68938D00183AE5 /* View+AccountSwitcherGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE88C342E68938800183AE5 /* View+AccountSwitcherGesture.swift */; }; CDEE15522D22190600EB9D7B /* ErrorsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEE15512D22190000EB9D7B /* ErrorsTracker.swift */; }; CDEE15542D22364B00EB9D7B /* ErrorLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEE15532D22364500EB9D7B /* ErrorLogView.swift */; }; CDF60A012E998BB5005FA3F1 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF60A002E998BB2005FA3F1 /* Data+Extensions.swift */; }; CDF8C1912D5D502400295CBA /* InteractionBarWidgetPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8C1902D5D501E00295CBA /* InteractionBarWidgetPickerView.swift */; }; CDF9EF332AB2845C003F885B /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9EF322AB2845C003F885B /* Icons.swift */; }; CDFB8C692C7796020070845F /* View+DynamicBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDFB8C682C7796020070845F /* View+DynamicBlur.swift */; }; CDFF9A332D88C3C1009E02E2 /* CGFloat+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDFF9A322D88C3BE009E02E2 /* CGFloat+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 6363D5D727EE196A00E34822 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6363D5B927EE196700E34822 /* Project object */; proxyType = 1; remoteGlobalIDString = 6363D5C027EE196700E34822; remoteInfo = Mlem; }; 6363D5E127EE196A00E34822 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6363D5B927EE196700E34822 /* Project object */; proxyType = 1; remoteGlobalIDString = 6363D5C027EE196700E34822; remoteInfo = Mlem; }; 81DE61CE2F48AF44006E4C36 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6363D5B927EE196700E34822 /* Project object */; proxyType = 1; remoteGlobalIDString = 81DE61C22F48AF44006E4C36; remoteInfo = OpenInMlem; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 81DE61D12F48AF44006E4C36 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( 81DE61D02F48AF44006E4C36 /* OpenInMlem.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 0300309C2C4163C9009A65FF /* CommentTreeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentTreeTracker.swift; sourceTree = ""; }; 030030A02C416B0B009A65FF /* RefreshPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshPopupView.swift; sourceTree = ""; }; 030050D22D109B7E002B1E99 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; 030050D42D10AE30002B1E99 /* Report+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Report+Extensions.swift"; sourceTree = ""; }; 030056A32D7DB05A00EB0BA3 /* SharingLinksSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingLinksSettingsView.swift; sourceTree = ""; }; 030056A52D7DBD4F00EB0BA3 /* Sharable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sharable+Extensions.swift"; sourceTree = ""; }; 030056BA2D7E137800EB0BA3 /* ShareInstancePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInstancePickerView.swift; sourceTree = ""; }; 0302A87F2F9BC379000A127A /* SearchHomeCategoryLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHomeCategoryLabelStyle.swift; sourceTree = ""; }; 03036C732C71408700C6DA1D /* CounterAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterAppearance.swift; sourceTree = ""; }; 03036C822C727D0500C6DA1D /* InteractionBarEditorView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InteractionBarEditorView+Logic.swift"; sourceTree = ""; }; 03049A192C6502F300FF6889 /* FormSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormSection.swift; sourceTree = ""; }; 03049A1B2C65039400FF6889 /* ActiveUserCountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveUserCountView.swift; sourceTree = ""; }; 03049A1D2C6508F400FF6889 /* RegistrationMode+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RegistrationMode+Extensions.swift"; sourceTree = ""; }; 03049A1F2C650A8100FF6889 /* FormReadout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormReadout.swift; sourceTree = ""; }; 03049A212C650B2C00FF6889 /* CommunityDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityDetailsView.swift; sourceTree = ""; }; 0305EBA92D32B3B80066E5AD /* ModlogView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModlogView+Logic.swift"; sourceTree = ""; }; 0305EBAB2D32C9300066E5AD /* ModlogEntryType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModlogEntryType+Extensions.swift"; sourceTree = ""; }; 0305EBB12D35C1B70066E5AD /* RegistrationApplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationApplicationView.swift; sourceTree = ""; }; 030778EB2C52ED350018E61C /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 030BCB1A2C3EA5FD0037680F /* InstanceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceDetailsView.swift; sourceTree = ""; }; 030E95E62C80A20A0045BC2C /* View+NavigationTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NavigationTransition.swift"; sourceTree = ""; }; 030EE3032D651A4100D58C2C /* View+Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Refreshable.swift"; sourceTree = ""; }; 030FF67A2BC8521600F6BFAC /* CustomTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabView.swift; sourceTree = ""; }; 030FF67C2BC8524500F6BFAC /* CustomTabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabItem.swift; sourceTree = ""; }; 030FF67E2BC8544600F6BFAC /* CustomTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabBarController.swift; sourceTree = ""; }; 030FF6802BC859FD00F6BFAC /* CustomTabViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabViewHostingController.swift; sourceTree = ""; }; 0311ADB62E4DF49800EC3120 /* SearchHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHomeView.swift; sourceTree = ""; }; 0311ADB82E4E0E0800EC3120 /* VisitAgainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitAgainView.swift; sourceTree = ""; }; 0311ADBC2E4F668900EC3120 /* TopCommunitiesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopCommunitiesListView.swift; sourceTree = ""; }; 0311ADBE2E4F68D000EC3120 /* TopPeopleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopPeopleListView.swift; sourceTree = ""; }; 0311ADC02E4F693B00EC3120 /* TopInstancesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopInstancesListView.swift; sourceTree = ""; }; 03134A4F2BEAD245002662CC /* NavigationLink+NavigationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationLink+NavigationPage.swift"; sourceTree = ""; }; 03134A512BEAD69F002662CC /* SettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; 03134A572BEC1C46002662CC /* AccountListSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListSettingsView.swift; sourceTree = ""; }; 03134A592BEC2253002662CC /* AvatarStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackView.swift; sourceTree = ""; }; 0315B1BD2C74C3D6006D4F82 /* CommentEditorView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentEditorView+Logic.swift"; sourceTree = ""; }; 0315B1C02C74C71A006D4F82 /* CommentEditorView+Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentEditorView+Context.swift"; sourceTree = ""; }; 0315B1C52C754802006D4F82 /* PostEditorView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorView+Logic.swift"; sourceTree = ""; }; 0316CD632C382A6A009EA8EA /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; 0318BA9E2D72405F006CA71F /* PostSortType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostSortType+Extensions.swift"; sourceTree = ""; }; 031CA0D82E4FBFD800CF0C0F /* MarkAllAsReadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAllAsReadButton.swift; sourceTree = ""; }; 031CA4AD2E58A84E00CF0C0F /* View+WithSheetSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+WithSheetSearch.swift"; sourceTree = ""; }; 031CA5742E5900C800CF0C0F /* SwipeConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwipeConfiguration+Extensions.swift"; sourceTree = ""; }; 031CA5B42E590B0D00CF0C0F /* QuickSwipeAction+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QuickSwipeAction+Extensions.swift"; sourceTree = ""; }; 031CA5B62E599F7E00CF0C0F /* View+QuickSwipes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+QuickSwipes.swift"; sourceTree = ""; }; 031DBA672F9A65DC00B4BAE4 /* BackendClient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackendClient+Extensions.swift"; sourceTree = ""; }; 031DBA762F9A93AD00B4BAE4 /* EventRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRowView.swift; sourceTree = ""; }; 031DBA782F9A95B200B4BAE4 /* SearchHomeLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHomeLabelStyle.swift; sourceTree = ""; }; 031DBA7A2F9A993000B4BAE4 /* SearchHomeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHomeListView.swift; sourceTree = ""; }; 031E2D502BEF961D0003BC45 /* SubscriptionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListView.swift; sourceTree = ""; }; 031E2D5A2BEFC9460003BC45 /* ThemeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingsView.swift; sourceTree = ""; }; 031E2D5C2BEFCC630003BC45 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 031EC52F2E5F77D7003408B7 /* FeedContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedContext.swift; sourceTree = ""; }; 0320B64E2C8A638A00D38548 /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; 0320B6532C8B65EB00D38548 /* Captcha+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Captcha+Extensions.swift"; sourceTree = ""; }; 0320B6572C8BB3C400D38548 /* SignUpView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SignUpView+Views.swift"; sourceTree = ""; }; 0320B6592C8CBB0D00D38548 /* SignUpView+EmailConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SignUpView+EmailConfirmationView.swift"; sourceTree = ""; }; 0320B65B2C8CFBDC00D38548 /* ImageUploadHistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadHistoryManager.swift; sourceTree = ""; }; 0320B6602C8DFCF100D38548 /* SearchView+FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+FiltersView.swift"; sourceTree = ""; }; 0320B6622C8F8D5A00D38548 /* InstanceSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSort.swift; sourceTree = ""; }; 0320B6642C91DBD500D38548 /* NavigationPage+View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NavigationPage+View.swift"; sourceTree = ""; }; 0320B6662C93504600D38548 /* SignUpView+Logic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SignUpView+Logic.swift"; sourceTree = ""; }; 0320B6682C93506300D38548 /* SearchView+Logic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SearchView+Logic.swift"; sourceTree = ""; }; 0324FA762C1F0AE100F6247D /* Readout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Readout.swift; sourceTree = ""; }; 0324FA7A2C1F2CD200F6247D /* InfoStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoStackView.swift; sourceTree = ""; }; 0325B9392D3A9E8100E28B97 /* InboxBadgeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxBadgeSettingsView.swift; sourceTree = ""; }; 0325B93B2D3AA62500E28B97 /* InboxItemType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxItemType+Extensions.swift"; sourceTree = ""; }; 0325B93D2D3AAE9E00E28B97 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 03267D812BED489C009D6268 /* AvatarBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarBannerView.swift; sourceTree = ""; }; 03267D832BED49CE009D6268 /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = ""; }; 032A22002EB2A4D100E8B346 /* PersonContentFeedLoader+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonContentFeedLoader+Extensions.swift"; sourceTree = ""; }; 032C32032C3439C600595286 /* ActorIdentifiable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ActorIdentifiable+Extensions.swift"; sourceTree = ""; }; 032C32072C34469900595286 /* SelectableContentProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelectableContentProviding+Extensions.swift"; sourceTree = ""; }; 032C32092C34495D00595286 /* SelectTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTextView.swift; sourceTree = ""; }; 032C32152C36F65500595286 /* ReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyView.swift; sourceTree = ""; }; 032C32172C36F70300595286 /* ReplyBarConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyBarConfiguration.swift; sourceTree = ""; }; 0331715D2CCD6D95002DA370 /* ContentPurgeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPurgeEditorView.swift; sourceTree = ""; }; 033171772CCE89E3002DA370 /* PurgableProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurgableProviding+Extensions.swift"; sourceTree = ""; }; 0335AE102D8991330094FFD9 /* View+HiddenNavigationTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+HiddenNavigationTitle.swift"; sourceTree = ""; }; 033819272D4424D9000AFC55 /* SafetyWarningsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyWarningsSettingsView.swift; sourceTree = ""; }; 033EF40F2CB9AEF7004D8A3F /* ExpandedPostView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExpandedPostView+Views.swift"; sourceTree = ""; }; 033F84482D18D1F400D87A9E /* MessageFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageFeedView.swift; sourceTree = ""; }; 033F844C2D18D90900D87A9E /* MessageBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubbleView.swift; sourceTree = ""; }; 033F84502D196AFD00D87A9E /* MessageFeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageFeedView+Logic.swift"; sourceTree = ""; }; 033F84652D1C780900D87A9E /* ModlogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogButtonView.swift; sourceTree = ""; }; 033F84702D1C784600D87A9E /* ModlogEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogEntryView.swift; sourceTree = ""; }; 033F84712D1C784600D87A9E /* ModlogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogView.swift; sourceTree = ""; }; 033F84772D1D68D200D87A9E /* ModlogEntryContent+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModlogEntryContent+Extensions.swift"; sourceTree = ""; }; 033F84AC2C298466002E3EDF /* SectionIndexTitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionIndexTitles.swift; sourceTree = ""; }; 033F84B02C29907F002E3EDF /* FeedbackType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackType.swift; sourceTree = ""; }; 033F84BA2C2ACB96002E3EDF /* CommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentView.swift; sourceTree = ""; }; 033F84BC2C2ACC5F002E3EDF /* CommentBarConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBarConfiguration.swift; sourceTree = ""; }; 033F84C02C2AD072002E3EDF /* CommentTreeNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentTreeNode.swift; sourceTree = ""; }; 033F84C22C2B12AA002E3EDF /* InstanceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSummary.swift; sourceTree = ""; }; 033F84C72C2B193D002E3EDF /* MlemStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemStats.swift; sourceTree = ""; }; 033F84CB2C2B1E46002E3EDF /* HandleThreadiverseLinksModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleThreadiverseLinksModifier.swift; sourceTree = ""; }; 033FCAEB2C57DCCD007B7CD1 /* ListingType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListingType+Extensions.swift"; sourceTree = ""; }; 033FCAED2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaptchaDifficulty+Extensions.swift"; sourceTree = ""; }; 033FCAF32C59843E007B7CD1 /* CommunityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityView.swift; sourceTree = ""; }; 033FCB222C5E3933007B7CD1 /* AlternateIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternateIcon.swift; sourceTree = ""; }; 033FCB232C5E3933007B7CD1 /* AlternateIconCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternateIconCell.swift; sourceTree = ""; }; 033FCB242C5E3933007B7CD1 /* AlternateIconLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternateIconLabel.swift; sourceTree = ""; }; 033FCB252C5E3933007B7CD1 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; 033FCB3D2C5E7FA9007B7CD1 /* View+OutdatedFeedPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+OutdatedFeedPopup.swift"; sourceTree = ""; }; 034065C22D83742900637308 /* View+NavigtionStackPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NavigtionStackPreview.swift"; sourceTree = ""; }; 034147FC2D8F5844005503AF /* ExpandedPostHistoryTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedPostHistoryTracker.swift; sourceTree = ""; }; 0343C0472D3AD6DB001CF709 /* Set+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Extensions.swift"; sourceTree = ""; }; 034690922D0F4D720073E664 /* RemovableProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemovableProviding+Extensions.swift"; sourceTree = ""; }; 034690982D105DFD0073E664 /* InboxView+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxView+Types.swift"; sourceTree = ""; }; 0348F98C2DDBB526006639CD /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 034A82022EBA688F00E5F904 /* LinkEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkEditorView.swift; sourceTree = ""; }; 034A85702EC0A1FA00E5F904 /* InstanceCommunityListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceCommunityListView.swift; sourceTree = ""; }; 034B947E2C091EDD00039AF4 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; 034B94802C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityOrPersonStub+Extensions.swift"; sourceTree = ""; }; 034B94822C09340A00039AF4 /* MarkdownConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkdownConfiguration+Extensions.swift"; sourceTree = ""; }; 034B94882C09360A00039AF4 /* Int+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Extensions.swift"; sourceTree = ""; }; 034B948D2C0937BA00039AF4 /* FancyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyScrollView.swift; sourceTree = ""; }; 034CC02F2D22C5BE00C557D3 /* WarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningOverlayView.swift; sourceTree = ""; }; 03500C212BF5594100CAA076 /* ToastType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastType.swift; sourceTree = ""; }; 03500C232BF55D0E00CAA076 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 03500C262BF69D1D00CAA076 /* ToastModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModel.swift; sourceTree = ""; }; 03500C2A2BF7F1B100CAA076 /* ToastOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastOverlayView.swift; sourceTree = ""; }; 03500C2C2BF7FC2500CAA076 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; 03531EEB2C2D81DC004A3464 /* LinkSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSettingsView.swift; sourceTree = ""; }; 03531EED2C2D9298004A3464 /* SearchSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSheetView.swift; sourceTree = ""; }; 03531EF02C2DA298004A3464 /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 03531EF42C2DA610004A3464 /* NavigationSearchType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSearchType.swift; sourceTree = ""; }; 035394852C9CDC1100795AA5 /* SubscriptionListNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListNavigationButton.swift; sourceTree = ""; }; 0353948A2CA076D000795AA5 /* InboxView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxView+Views.swift"; sourceTree = ""; }; 0353948C2CA080EB00795AA5 /* FeedWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWelcomeView.swift; sourceTree = ""; }; 0353948E2CA088E600795AA5 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; 035394922CA1AE2C00795AA5 /* UptimeData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UptimeData.swift; sourceTree = ""; }; 035394942CA1AE6300795AA5 /* InstanceUptimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceUptimeView.swift; sourceTree = ""; }; 035394982CA1B20B00795AA5 /* InstanceView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceView+Logic.swift"; sourceTree = ""; }; 0353949B2CA4B3E800795AA5 /* CrossPostListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossPostListView.swift; sourceTree = ""; }; 0355F9452C150B2300605248 /* ExternalApiInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalApiInfoView.swift; sourceTree = ""; }; 035BE0862BDD8DA000F77D73 /* NavigationRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootView.swift; sourceTree = ""; }; 035BE0882BDD901B00F77D73 /* NavigationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationPage.swift; sourceTree = ""; }; 035BE08A2BDD903100F77D73 /* NavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModel.swift; sourceTree = ""; }; 035BE08C2BDE88EC00F77D73 /* NavigationLayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLayerView.swift; sourceTree = ""; }; 035BE08E2BDE911900F77D73 /* NavigationLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLayer.swift; sourceTree = ""; }; 035BE0902BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NavigationSheetModifiers.swift"; sourceTree = ""; }; 035DF9102EB2AA160021DE8C /* PersonContentGridView+FeedLoaderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonContentGridView+FeedLoaderType.swift"; sourceTree = ""; }; 035DF9122EB2AF8E0021DE8C /* GetContentFilter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GetContentFilter+Extensions.swift"; sourceTree = ""; }; 035DFA222EB3F7240021DE8C /* CommunityAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityAboutView.swift; sourceTree = ""; }; 035DFA242EB3FB550021DE8C /* CommunityDescriptionEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityDescriptionEditorView.swift; sourceTree = ""; }; 035EDEEC2C2DE94B00F51144 /* _assignIfNotEqual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _assignIfNotEqual.swift; sourceTree = ""; }; 035EDEED2C2DE94B00F51144 /* DefaultTextInputType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextInputType.swift; sourceTree = ""; }; 035EDEEE2C2DE94B00F51144 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 035EDEEF2C2DE94B00F51144 /* SearchBar+NavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchBar+NavigationView.swift"; sourceTree = ""; }; 035EDEF02C2DE94B00F51144 /* SearchBarExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarExtensions.swift; sourceTree = ""; }; 035EDEFA2C2DF98700F51144 /* CommunityListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRowBody.swift; sourceTree = ""; }; 035EDF002C2ECFE000F51144 /* Searchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Searchable.swift; sourceTree = ""; }; 035EDF022C2ED0DE00F51144 /* PersonListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonListRowBody.swift; sourceTree = ""; }; 03600D922D4531DF00C704CB /* PrivacyBypassImageProxySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBypassImageProxySettingsView.swift; sourceTree = ""; }; 0368F3422D72796B007DEB70 /* LanguagePickerSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePickerSheetView.swift; sourceTree = ""; }; 0368F34C2D733215007DEB70 /* SortTimeRange+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SortTimeRange+Extensions.swift"; sourceTree = ""; }; 0368F34E2D734066007DEB70 /* SearchSortType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchSortType+Extensions.swift"; sourceTree = ""; }; 0368F3682D7349D8007DEB70 /* LanguageListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageListRowBody.swift; sourceTree = ""; }; 0369B3522BFA514B001EFEDF /* ToastLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastLocation.swift; sourceTree = ""; }; 0369B3552BFA6824001EFEDF /* InboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxView.swift; sourceTree = ""; }; 0369B35A2BFB86E3001EFEDF /* Account.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; 036A84542D98253400E95D50 /* UpdateBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateBannerView.swift; sourceTree = ""; }; 036A84D72D99531400E95D50 /* View+ConditionalNavigationTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalNavigationTitle.swift"; sourceTree = ""; }; 036CC3AE2B8145C30098B6A1 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 036ED6782D0AF3740018E5EA /* PinnedSortTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedSortTracker.swift; sourceTree = ""; }; 036ED67A2D0B004D0018E5EA /* AdvancedSortView+SortButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdvancedSortView+SortButton.swift"; sourceTree = ""; }; 036ED67C2D0B006C0018E5EA /* TopSortPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSortPicker.swift; sourceTree = ""; }; 036ED67E2D0B9A520018E5EA /* CommunitySearchSortPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunitySearchSortPicker.swift; sourceTree = ""; }; 036ED6822D0C483B0018E5EA /* ProfileProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileProviding+Extensions.swift"; sourceTree = ""; }; 036FFA2C2D45110C00998D8A /* ChangePasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordView.swift; sourceTree = ""; }; 036FFA2E2D45197300998D8A /* PrivacySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsView.swift; sourceTree = ""; }; 0370299C2D6B70F400B749DF /* MockApiClient+Realistic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockApiClient+Realistic.swift"; sourceTree = ""; }; 0370299E2D6B743B00B749DF /* PostMockType+Realistic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostMockType+Realistic.swift"; sourceTree = ""; }; 037029A02D6B9A3900B749DF /* View+TabBarPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TabBarPreview.swift"; sourceTree = ""; }; 037029A22D6B9B8400B749DF /* ContentView+Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Tab.swift"; sourceTree = ""; }; 0372EBCD2D36FBCF00257095 /* RegistrationApplication+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RegistrationApplication+Extensions.swift"; sourceTree = ""; }; 0372EC1F2D370F0200257095 /* RegistrationApplicationDenialEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationApplicationDenialEditorView.swift; sourceTree = ""; }; 037331A32C9CB12D00C826E1 /* EnvironmentValues+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+Extensions.swift"; sourceTree = ""; }; 037352322F27A83900341673 /* PostPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostPollView.swift; sourceTree = ""; }; 0377BD742DE219A400E38593 /* OnboardingUsernameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingUsernameView.swift; sourceTree = ""; }; 0377BD782DE22D4E00E38593 /* UsernameValidity+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UsernameValidity+Extensions.swift"; sourceTree = ""; }; 0377BE062DE645E100E38593 /* OnboardingRecommendInstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRecommendInstanceView.swift; sourceTree = ""; }; 0377BE3A2DE8E70D00E38593 /* HapticLevel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HapticLevel+Extensions.swift"; sourceTree = ""; }; 0377BE9A2DEA328900E38593 /* OnboardingEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingEmailView.swift; sourceTree = ""; }; 0377BE9E2DEA361600E38593 /* OnboardingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingModel.swift; sourceTree = ""; }; 037DE0742CE023E3007F7B92 /* BlockListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListView.swift; sourceTree = ""; }; 037DE0792CE108D9007F7B92 /* FooterLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FooterLinkView.swift; sourceTree = ""; }; 037F77EC2D3B064B00D4E180 /* SettingsDeviceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDeviceView.swift; sourceTree = ""; }; 037F783E2D3BD05500D4E180 /* PostSettingsView+PostSizePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostSettingsView+PostSizePicker.swift"; sourceTree = ""; }; 037F78402D3C00E600D4E180 /* SettingsInteractionBarSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInteractionBarSummaryView.swift; sourceTree = ""; }; 037F78422D3C129B00D4E180 /* PostThumbnailSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostThumbnailSettingsView.swift; sourceTree = ""; }; 037F78442D3C25AB00D4E180 /* PostSubscriptionIndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSubscriptionIndicatorSettingsView.swift; sourceTree = ""; }; 037FC06F2E4A6B16009E3E63 /* InstanceView+About.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceView+About.swift"; sourceTree = ""; }; 038028D22CAB3D2D0091A8A2 /* ShareActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivity.swift; sourceTree = ""; }; 038028D42CAB479D0091A8A2 /* PostEditorView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorView+Views.swift"; sourceTree = ""; }; 038028D72CACAB960091A8A2 /* ModeratorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorSettingsView.swift; sourceTree = ""; }; 038028D92CACACD30091A8A2 /* PostEllipsisMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEllipsisMenus.swift; sourceTree = ""; }; 038028F52CB096960091A8A2 /* SearchView+FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+FilterModels.swift"; sourceTree = ""; }; 038028F72CB097A10091A8A2 /* SearchView+InstancePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+InstancePicker.swift"; sourceTree = ""; }; 038028F92CB097CB0091A8A2 /* SearchView+LocationPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+LocationPicker.swift"; sourceTree = ""; }; 038028FC2CB72A2A0091A8A2 /* ContentRemovalEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentRemovalEditorView.swift; sourceTree = ""; }; 038028FE2CB72AC90091A8A2 /* ReasonShortcutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReasonShortcutView.swift; sourceTree = ""; }; 0380965E2C10AA80003ED1D8 /* AppState+Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Transition.swift"; sourceTree = ""; }; 038096602C10AAD8003ED1D8 /* TransitionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionView.swift; sourceTree = ""; }; 038188982D43E0F30073E88D /* SafetySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetySettingsView.swift; sourceTree = ""; }; 0381889A2D4412A40073E88D /* SafetyBlurNsfwSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyBlurNsfwSettingsView.swift; sourceTree = ""; }; 0381F7132F670F95008A7731 /* SwipeActionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionConfiguration.swift; sourceTree = ""; }; 0381F7152F671427008A7731 /* PostBarConfiguration+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostBarConfiguration+Types.swift"; sourceTree = ""; }; 0381F7232F672258008A7731 /* CommentBarConfiguration+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentBarConfiguration+Types.swift"; sourceTree = ""; }; 0381F7272F6724D3008A7731 /* ReplyBarConfiguration+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReplyBarConfiguration+Types.swift"; sourceTree = ""; }; 0382A7EF2C09F0F800C79DDA /* PersonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonView.swift; sourceTree = ""; }; 0382A7F12C0A758E00C79DDA /* ProfileDateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDateView.swift; sourceTree = ""; }; 0382A7F32C0A76A900C79DDA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; 0389DDC22C38907C0005B808 /* Message1Providing+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message1Providing+Extensions.swift"; sourceTree = ""; }; 0389DDC42C38917A0005B808 /* InboxItemProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxItemProviding+Extensions.swift"; sourceTree = ""; }; 0389DDC62C389F840005B808 /* UnreadCount+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnreadCount+Extensions.swift"; sourceTree = ""; }; 0389DDC82C39658E0005B808 /* Binding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Extensions.swift"; sourceTree = ""; }; 0389DDCE2C39CB0E0005B808 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 0389DDD02C39E1030005B808 /* InstanceListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceListRowBody.swift; sourceTree = ""; }; 0389DDD22C39E4D40005B808 /* PasteLinkButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteLinkButtonView.swift; sourceTree = ""; }; 0389DDD42C39F1290005B808 /* CommunityListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRow.swift; sourceTree = ""; }; 0389DDDA2C3AB6340005B808 /* ActionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBuilder.swift; sourceTree = ""; }; 038C85682D861A2100543F70 /* Comment+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comment+Mock.swift"; sourceTree = ""; }; 038C85D72D87696F00543F70 /* FeedToolbarOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedToolbarOptions.swift; sourceTree = ""; }; 038C85E52D88337100543F70 /* CommentMockType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentMockType.swift; sourceTree = ""; }; 038C86562D888EC100543F70 /* CommentSortType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentSortType+Extensions.swift"; sourceTree = ""; }; 038E1ABB2F58B9EF00D30F01 /* CommunityActionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityActionConfiguration.swift; sourceTree = ""; }; 038E1ABF2F58C76A00D30F01 /* SwipeActionEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionEditorView.swift; sourceTree = ""; }; 038E1AC92F59C18100D30F01 /* CommunitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunitySettingsView.swift; sourceTree = ""; }; 038E5C122F6D617C00C54DEB /* ImageViewerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerSettingsView.swift; sourceTree = ""; }; 038E5E882F6D694500C54DEB /* ImageViewerShowControlsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerShowControlsSettingsView.swift; sourceTree = ""; }; 038E5E8A2F6D6C3800C54DEB /* ImageViewerDismissSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerDismissSettingsView.swift; sourceTree = ""; }; 038E62DF2F6F0FC600C54DEB /* ContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuConfiguration.swift; sourceTree = ""; }; 0391E0F82CFF17AE0040CCA8 /* ProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSettingsView.swift; sourceTree = ""; }; 0391E0FA2D0066240040CCA8 /* ImageUploadMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadMenu.swift; sourceTree = ""; }; 0391E0FD2D05B2DF0040CCA8 /* AdvancedSortView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSortView.swift; sourceTree = ""; }; 0395BCF72D9C57DE00865B33 /* View+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Background.swift"; sourceTree = ""; }; 0397D45F2C66113F002C6CDC /* CommentBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBodyView.swift; sourceTree = ""; }; 0397D4632C676CA8002C6CDC /* FeedSortPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSortPicker.swift; sourceTree = ""; }; 0397D46B2C67E583002C6CDC /* SortingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortingSettingsView.swift; sourceTree = ""; }; 0397D4712C68078F002C6CDC /* PrefetchingConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrefetchingConfiguration+Extensions.swift"; sourceTree = ""; }; 0397D4792C693444002C6CDC /* ReportEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportEditorView.swift; sourceTree = ""; }; 0397D47F2C693A88002C6CDC /* [BlockNode]+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "[BlockNode]+Extensions.swift"; sourceTree = ""; }; 0397D4852C6A24D2002C6CDC /* ReportableProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportableProviding+Extensions.swift"; sourceTree = ""; }; 0397D48B2C6BE9A2002C6CDC /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; 0397D48F2C6CE871002C6CDC /* PostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorView.swift; sourceTree = ""; }; 0397D4922C6CE87E002C6CDC /* PostEditorTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorTargetView.swift; sourceTree = ""; }; 0397D4992C6EA6EE002C6CDC /* InteractionBarEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionBarEditorView.swift; sourceTree = ""; }; 0397D49B2C6EA73C002C6CDC /* InteractionBarConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionBarConfiguration.swift; sourceTree = ""; }; 0397D4A12C6EB035002C6CDC /* ActionAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionAppearance.swift; sourceTree = ""; }; 0397D4A32C6FBC04002C6CDC /* ActionAppearance+StaticValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ActionAppearance+StaticValues.swift"; sourceTree = ""; }; 039D75632C4EEE69004F24C2 /* DeletableProviding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeletableProviding+Extensions.swift"; sourceTree = ""; }; 039EFEC22BEEBEE0003AC372 /* LoginInstancePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginInstancePickerView.swift; sourceTree = ""; }; 039F58802C7A7E5900C61658 /* JumpButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpButtonView.swift; sourceTree = ""; }; 039F58832C7A7F2C00C61658 /* CommentJumpButtonLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentJumpButtonLocation.swift; sourceTree = ""; }; 039F58852C7A810100C61658 /* ExpandedPostView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExpandedPostView+Logic.swift"; sourceTree = ""; }; 039F58872C7B531800C61658 /* SquircleLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquircleLabelStyle.swift; sourceTree = ""; }; 039F58892C7B54FE00C61658 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; 039F588B2C7B574E00C61658 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; 039F588E2C7B599800C61658 /* ThemeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeLabel.swift; sourceTree = ""; }; 039F58902C7B5C7A00C61658 /* ContentView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Logic.swift"; sourceTree = ""; }; 039F58922C7B616600C61658 /* CommentSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentSettingsView.swift; sourceTree = ""; }; 039F58942C7B618F00C61658 /* InboxSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxSettingsView.swift; sourceTree = ""; }; 039F58962C7B68F100C61658 /* AboutMlemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutMlemView.swift; sourceTree = ""; }; 039F58982C7B697D00C61658 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; 03A630EC2D497005009A47A6 /* ExternalLinkSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLinkSettingsView.swift; sourceTree = ""; }; 03A630EE2D497143009A47A6 /* TappableLinksSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableLinksSettingsView.swift; sourceTree = ""; }; 03A630F02D497674009A47A6 /* ShieldsBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldsBadgeView.swift; sourceTree = ""; }; 03A630F32D4976EB009A47A6 /* ShieldsBadgeView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShieldsBadgeView+Logic.swift"; sourceTree = ""; }; 03A6315C2D4D15F1009A47A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 03A6315D2D4D1A1B009A47A6 /* DefaultFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultFeedSettingsView.swift; sourceTree = ""; }; 03A6315F2D4D1CBB009A47A6 /* HapticSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticSettingsView.swift; sourceTree = ""; }; 03A6316C2D4E3D24009A47A6 /* ModeratorActionSeparationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorActionSeparationSettingsView.swift; sourceTree = ""; }; 03A631CA2D4FCEF7009A47A6 /* Post+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Mock.swift"; sourceTree = ""; }; 03A814282ED1BCA90023E9E8 /* ModlogView+Filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModlogView+Filters.swift"; sourceTree = ""; }; 03A818AC2EDCDBA20023E9E8 /* FederationMode+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FederationMode+Extensions.swift"; sourceTree = ""; }; 03A82FA02C0D1E8500D01A5C /* ApiClient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApiClient+Extensions.swift"; sourceTree = ""; }; 03A82FA22C0D1F2400D01A5C /* View+ExternalApiWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ExternalApiWarning.swift"; sourceTree = ""; }; 03A9FD172D7CFC20007A734D /* Palette+Oled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Palette+Oled.swift"; sourceTree = ""; }; 03A9FD192D7CFC69007A734D /* Palette+Monochrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Palette+Monochrome.swift"; sourceTree = ""; }; 03A9FD1B2D7CFD5C007A734D /* Palette+Solarized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Palette+Solarized.swift"; sourceTree = ""; }; 03A9FD1D2D7CFF0D007A734D /* Palette+Dracula.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Palette+Dracula.swift"; sourceTree = ""; }; 03A9FD1F2D7D0072007A734D /* PaletteOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteOption.swift; sourceTree = ""; }; 03AB484E2CBAE33500567FF9 /* MarkdownWithLinkList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownWithLinkList.swift; sourceTree = ""; }; 03AB48512CBC042E00567FF9 /* AccountContentSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentSettingsView.swift; sourceTree = ""; }; 03AB48542CBC0B8000567FF9 /* AccountAdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAdvancedSettingsView.swift; sourceTree = ""; }; 03AB48562CBC0DFC00567FF9 /* AccountSignInSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInSettingsView.swift; sourceTree = ""; }; 03AB48582CBC14CE00567FF9 /* AccountEmailSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountEmailSettingsView.swift; sourceTree = ""; }; 03AB906E2D418C200054D9E1 /* CommentJumpButtonSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentJumpButtonSettingsView.swift; sourceTree = ""; }; 03ABE5B52DB79A0E00374AFF /* DateComponents+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateComponents+Extensions.swift"; sourceTree = ""; }; 03ABE5E32DB8E57000374AFF /* AccountAgeVisibilitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAgeVisibilitySettingsView.swift; sourceTree = ""; }; 03ACE7192DFD57CC00FD66C0 /* SiteSoftware+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteSoftware+Extensions.swift"; sourceTree = ""; }; 03AD09E72CF88007001EF9F7 /* MoreRepliesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreRepliesButton.swift; sourceTree = ""; }; 03AD0A812CFDBFA0001EF9F7 /* AccountLocalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLocalSettingsView.swift; sourceTree = ""; }; 03AD0A832CFDC557001EF9F7 /* AccountNicknameFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountNicknameFieldView.swift; sourceTree = ""; }; 03AF91DC2C1B23E500E56644 /* ImageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; 03AF91E02C1B25DE00E56644 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; 03AF91E22C1C616F00E56644 /* InteractionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionBarView.swift; sourceTree = ""; }; 03AF91E42C1C61FA00E56644 /* PostBarConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBarConfiguration.swift; sourceTree = ""; }; 03AF91E92C1CE96600E56644 /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; 03AFD0DE2C3B2E000054B8AD /* PersonListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonListRow.swift; sourceTree = ""; }; 03AFD0E02C3B30390054B8AD /* InstanceListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceListRow.swift; sourceTree = ""; }; 03AFD0E22C3C0C540054B8AD /* InstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceView.swift; sourceTree = ""; }; 03B045F52E26D64900540EFB /* SiteSoftwareType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteSoftwareType+Extensions.swift"; sourceTree = ""; }; 03B04FBF2C5FC32300824128 /* SimpleAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleAvatarView.swift; sourceTree = ""; }; 03B0EB6E2C87827A00F79FDF /* ExpandedPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedPostView.swift; sourceTree = ""; }; 03B25B2E2CC43F8600EB6DF5 /* InstanceSafetyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSafetyView.swift; sourceTree = ""; }; 03B25B302CC4403500EB6DF5 /* Fediseer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fediseer.swift; sourceTree = ""; }; 03B25B322CC440A600EB6DF5 /* FediseerOpinionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerOpinionView.swift; sourceTree = ""; }; 03B25B342CC4446400EB6DF5 /* FediseerOpinionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerOpinionListView.swift; sourceTree = ""; }; 03B25B362CC4478600EB6DF5 /* FediseerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerInfoView.swift; sourceTree = ""; }; 03B25B3A2CC44FFF00EB6DF5 /* UploadConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadConfirmationView.swift; sourceTree = ""; }; 03B431B12C44409D001A1EB5 /* CommentEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorView.swift; sourceTree = ""; }; 03B431B32C4481C3001A1EB5 /* MarkdownTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTextEditor.swift; sourceTree = ""; }; 03B431B52C454D49001A1EB5 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; 03B431BB2C455838001A1EB5 /* LargePostBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePostBodyView.swift; sourceTree = ""; }; 03B431BF2C45ABFB001A1EB5 /* UITextView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+Extensions.swift"; sourceTree = ""; }; 03B431C12C45BA00001A1EB5 /* MarkdownEditorToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorToolbarView.swift; sourceTree = ""; }; 03B431C32C45BA45001A1EB5 /* AccountPickerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerMenu.swift; sourceTree = ""; }; 03B62B762CE295530077E9C8 /* RulesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesListView.swift; sourceTree = ""; }; 03B62B782CE2A2C00077E9C8 /* RulesPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesPickerView.swift; sourceTree = ""; }; 03B62C3F2CE811CB0077E9C8 /* PersonBanEditorView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonBanEditorView+Logic.swift"; sourceTree = ""; }; 03B72B662C2888EE0023A6C4 /* View+ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ContextMenu.swift"; sourceTree = ""; }; 03B72B6A2C28A0190023A6C4 /* SubscriptionListSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListSettingsView.swift; sourceTree = ""; }; 03B7F3342EEEC70F00B00F6A /* NoteEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEditorView.swift; sourceTree = ""; }; 03BF11C22D3D135D00CC1F66 /* SearchView+CreatorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+CreatorPicker.swift"; sourceTree = ""; }; 03BF11C42D3D3AFB00CC1F66 /* PostReadIndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostReadIndicatorSettingsView.swift; sourceTree = ""; }; 03BF11C62D3D634A00CC1F66 /* AccessibilitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilitySettingsView.swift; sourceTree = ""; }; 03BF11C92D4027E900CC1F66 /* DevicePickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePickerItem.swift; sourceTree = ""; }; 03BF11CB2D404F2C00CC1F66 /* CommentMaximumDepthSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentMaximumDepthSettingsView.swift; sourceTree = ""; }; 03C93CEF2BEFFB1A00327BFE /* LoginCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCredentialsView.swift; sourceTree = ""; }; 03CBD18C2C6120F600E870BC /* PersonFlair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonFlair.swift; sourceTree = ""; }; 03CCDA9F2BF2795300C0C851 /* LoginPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginPage.swift; sourceTree = ""; }; 03CCDAA32BF2852E00C0C851 /* LoginTotpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTotpView.swift; sourceTree = ""; }; 03D001DC2EC53A97001BF97D /* NavigationPage+PresentationDetents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationPage+PresentationDetents.swift"; sourceTree = ""; }; 03D006312ECCBA95001BF97D /* QuickSwipeAction+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QuickSwipeAction+Actions.swift"; sourceTree = ""; }; 03D0273B2CD3BA5100984519 /* PersonContent+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonContent+Extensions.swift"; sourceTree = ""; }; 03D283F92D256E1E00A6659B /* VisitHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistory.swift; sourceTree = ""; }; 03D283FB2D25A3F700A6659B /* VisitHistory+CodedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisitHistory+CodedData.swift"; sourceTree = ""; }; 03D283FD2D25EEC500A6659B /* SearchView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchView+Views.swift"; sourceTree = ""; }; 03D283FF2D26F09500A6659B /* Instance+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Extensions.swift"; sourceTree = ""; }; 03D284012D29E03C00A6659B /* FeedFilterButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFilterButtonStyle.swift; sourceTree = ""; }; 03D284052D2AEE3A00A6659B /* TabBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarSettingsView.swift; sourceTree = ""; }; 03D2A6362C00F92400ED4FF2 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; 03D2A6382C00FAE000ED4FF2 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = ""; }; 03D2A63A2C010B7500ED4FF2 /* GuestAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestAccount.swift; sourceTree = ""; }; 03D2A63C2C010CD400ED4FF2 /* UserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; 03D2A63E2C010DBF00ED4FF2 /* GuestSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSession.swift; sourceTree = ""; }; 03D2A6412C011F4A00ED4FF2 /* AccountListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListRowBody.swift; sourceTree = ""; }; 03D3A1D32BB88EF1009DE55E /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; 03D3A1E42BB8B7A3009DE55E /* ActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionType.swift; sourceTree = ""; }; 03D3A1EE2BB9CA1D009DE55E /* MenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuButton.swift; sourceTree = ""; }; 03D3A1F02BB9D48E009DE55E /* BasicAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAction.swift; sourceTree = ""; }; 03D3A1F22BB9D49B009DE55E /* ActionGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionGroup.swift; sourceTree = ""; }; 03D65D6F2F4B046F0041ADAF /* ContextMenuSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuSettingsView.swift; sourceTree = ""; }; 03D662A42F5377630041ADAF /* ActionSeedSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSeedSections.swift; sourceTree = ""; }; 03DAEA762C64074E0064DE64 /* SubscriptionListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListItemView.swift; sourceTree = ""; }; 03DD69412D4FDE8900F8950D /* Person+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+Mock.swift"; sourceTree = ""; }; 03DD69432D4FEA0000F8950D /* PreviewModifier+SampleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreviewModifier+SampleEnvironment.swift"; sourceTree = ""; }; 03E0EF422CA73D7A002CB66C /* PostStubResolutionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostStubResolutionPage.swift; sourceTree = ""; }; 03E0EF442CA74036002CB66C /* CommentPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentPage.swift; sourceTree = ""; }; 03E46ACA2D1216CE002589DB /* PostViewLinkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewLinkType.swift; sourceTree = ""; }; 03E46ACC2D121C19002589DB /* HeadlinePostBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinePostBodyView.swift; sourceTree = ""; }; 03E46AD12D130681002589DB /* VotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotesListView.swift; sourceTree = ""; }; 03E46AD32D130728002589DB /* ScoringOperation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScoringOperation+Extensions.swift"; sourceTree = ""; }; 03E614E62C0BCDC200F692A4 /* FullyQualifiedLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullyQualifiedLinkView.swift; sourceTree = ""; }; 03EC83242E916C51004698BB /* View+SafeAreaBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBar.swift"; sourceTree = ""; }; 03EC83EF2E9590D3004698BB /* LinkHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkHostView.swift; sourceTree = ""; }; 03EC84412E959AA5004698BB /* PostEditorWebsitePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorWebsitePreviewView.swift; sourceTree = ""; }; 03EC86452E9E9D4C004698BB /* PopupAnchorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupAnchorModel.swift; sourceTree = ""; }; 03ECD7182C81195000D48BF6 /* PostEditorView+LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorView+LinkView.swift"; sourceTree = ""; }; 03ECD71A2C811D6700D48BF6 /* PostEditorView+ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorView+ImageView.swift"; sourceTree = ""; }; 03ECD71E2C864DB700D48BF6 /* ImageUploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadManager.swift; sourceTree = ""; }; 03ECD7202C8654BA00D48BF6 /* PostEditorView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditorView+Toolbar.swift"; sourceTree = ""; }; 03F6BD932D500DED006A425E /* PersonMockType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonMockType.swift; sourceTree = ""; }; 03F6BD972D500E2A006A425E /* PersonMockType+Realistic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonMockType+Realistic.swift"; sourceTree = ""; }; 03F6BD992D501041006A425E /* SeededRandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeededRandomNumberGenerator.swift; sourceTree = ""; }; 03F6BD9B2D501478006A425E /* ActorIdentifier+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ActorIdentifier+Mock.swift"; sourceTree = ""; }; 03F6BDAC2D516615006A425E /* Community+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Community+Mock.swift"; sourceTree = ""; }; 03F6BDAE2D516636006A425E /* CommunityMockType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityMockType.swift; sourceTree = ""; }; 03F6BDB02D52AA00006A425E /* CommunityMockType+Realistic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityMockType+Realistic.swift"; sourceTree = ""; }; 03F6BDBB2D52B7FE006A425E /* PostMockType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMockType.swift; sourceTree = ""; }; 03F6BDF72D555F6E006A425E /* ModMailInteractionBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModMailInteractionBarSettingsView.swift; sourceTree = ""; }; 03F967262CE218110081C9A3 /* PersonBanEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonBanEditorView.swift; sourceTree = ""; }; 03F9672A2CE221220081C9A3 /* Label+Profile1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Label+Profile1.swift"; sourceTree = ""; }; 03FA318E2C6FEF2000D47FA3 /* InteractionBarActionLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionBarActionLabelView.swift; sourceTree = ""; }; 03FD6CAF2C9B719100500FD6 /* View+PopupAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PopupAnchor.swift"; sourceTree = ""; }; 03FE14032BF93FDD00A8377F /* ErrorDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetails.swift; sourceTree = ""; }; 03FE14072BF94FFB00A8377F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 03FE140B2BF953B000A8377F /* HandleError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleError.swift; sourceTree = ""; }; 630D753C27F65E44006E60C9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 6332FDBC27EFAF7B0009A98A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 6363D5C127EE196700E34822 /* Mlem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mlem.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6363D5C427EE196700E34822 /* MlemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemApp.swift; sourceTree = ""; }; 6363D5C627EE196700E34822 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 6363D5C827EE196A00E34822 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6363D5D627EE196A00E34822 /* MlemTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MlemTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6363D5E027EE196A00E34822 /* MlemUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MlemUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6DE1183B2A4A217400810C7E /* Profile View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile View.swift"; sourceTree = ""; }; 814CEF622F44577A0090F812 /* HiddenReadBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenReadBannerView.swift; sourceTree = ""; }; 81A179BB2DDE591700B17017 /* LongPressActionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPressActionSettingsView.swift; sourceTree = ""; }; 81A179BE2DDE5BF300B17017 /* TabBarLongPressAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarLongPressAction.swift; sourceTree = ""; }; 81C4B4322F493C5E001406A1 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 81DE61C32F48AF44006E4C36 /* OpenInMlem.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInMlem.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 81DE61C42F48AF44006E4C36 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 81DE61D62F48AF4C006E4C36 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; }; 81DE61D72F48AF4C006E4C36 /* ActionRequestHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = ""; }; 81DE61D82F48AF4C006E4C36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 81DE61D92F48AF4C006E4C36 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; AD1B0D362A5F7A260006F554 /* Licenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Licenses.swift; sourceTree = ""; }; B104A6E12A5AFC9F00B3E725 /* Mlem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mlem.entitlements; sourceTree = ""; }; B1B78D632A51D53900F72485 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; CD0374292D1DBFCF001E85FA /* ReadCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadCheck.swift; sourceTree = ""; }; CD03B5BD2F3BA16100AEF786 /* Blockable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blockable+Extensions.swift"; sourceTree = ""; }; CD0C5C0F2E9962970074D5A4 /* ExportablePostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportablePostEditorView.swift; sourceTree = ""; }; CD0E06F62C0E739F00445849 /* PostType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostType+Extensions.swift"; sourceTree = ""; }; CD0E07002C12707700445849 /* ToolbarEllipsisMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarEllipsisMenu.swift; sourceTree = ""; }; CD0F28092C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsAtTopSubscriber.swift"; sourceTree = ""; }; CD10FA762C7A8622008985AD /* ImageSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaver.swift; sourceTree = ""; }; CD13CC582C583C7A001AF428 /* WebsitePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewView.swift; sourceTree = ""; }; CD13CC5A2C588B34001AF428 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; CD13CC642C5D2B9D001AF428 /* CircleCroppedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleCroppedImageView.swift; sourceTree = ""; }; CD1446202A5B328E00610EF1 /* Privacy Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Privacy Policy.swift"; sourceTree = ""; }; CD1446242A5B357900610EF1 /* Document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Document.swift; sourceTree = ""; }; CD1446262A5B36DA00610EF1 /* EULA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EULA.swift; sourceTree = ""; }; CD1B2E202C7F84160075C7EA /* View+MarkReadOnScroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+MarkReadOnScroll.swift"; sourceTree = ""; }; CD1C64142D34286E0006B3C1 /* CommunityView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityView+Logic.swift"; sourceTree = ""; }; CD1D31822C56D742001B434B /* View+WidthReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+WidthReader.swift"; sourceTree = ""; }; CD1DF6372D38357100F7851E /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; CD1DF63D2D387E8300F7851E /* MediaView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Views.swift"; sourceTree = ""; }; CD24CAFB2D5568F90032B5E8 /* DiscussionLanguageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionLanguageSettingsView.swift; sourceTree = ""; }; CD2783992D9B365D00DD4C69 /* ZoomableImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableImageView.swift; sourceTree = ""; }; CD2C86532D5556BE0034CD8A /* MlemError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemError.swift; sourceTree = ""; }; CD3153062C38421B00BC5FBE /* View+LoadFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+LoadFeed.swift"; sourceTree = ""; }; CD332D782CA7175200A53988 /* PlayButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; CD332D7D2CA7485D00A53988 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageViewer+Views.swift"; sourceTree = ""; }; CD3485BA2D501463006748B8 /* ZoomSliderLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomSliderLocation.swift; sourceTree = ""; }; CD3485BC2D50156E006748B8 /* ZoomSliderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomSliderSettingsView.swift; sourceTree = ""; }; CD3FC67F2D4A75050088E63B /* CounterApperance+StaticValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CounterApperance+StaticValues.swift"; sourceTree = ""; }; CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLoader.swift; sourceTree = ""; }; CD44C92B2E5CC8B600F24AC8 /* ConditionalLabelStyleViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalLabelStyleViewModifier.swift; sourceTree = ""; }; CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersSettingsView.swift; sourceTree = ""; }; CD4B66D92DCE809A00D28EB4 /* InstanceUptimeView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceUptimeView+Views.swift"; sourceTree = ""; }; CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; CD4D583E2B86855F00B82964 /* MlemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemTests.swift; sourceTree = ""; }; CD4D58402B86858100B82964 /* MlemUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemUITests.swift; sourceTree = ""; }; CD4D58A42B86BD1B00B82964 /* PersistenceRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersistenceRepository.swift; sourceTree = ""; }; CD4D58A92B86BE5900B82964 /* AccountListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListView.swift; sourceTree = ""; }; CD4D58AC2B86BE7100B82964 /* QuickSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherView.swift; sourceTree = ""; }; CD4D58B22B86BFD400B82964 /* AccountsTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsTracker.swift; sourceTree = ""; }; CD4D58B42B86BFFA00B82964 /* PersistenceRepository+Dependency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PersistenceRepository+Dependency.swift"; sourceTree = ""; }; CD4D58B82B86D9F800B82964 /* AccountListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListRow.swift; sourceTree = ""; }; CD4D58BA2B86DA7D00B82964 /* AccountListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountListView+Logic.swift"; sourceTree = ""; }; CD4D58C72B86DCED00B82964 /* AvatarType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarType.swift; sourceTree = ""; }; CD4D58CE2B86DDEC00B82964 /* AccountSortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountSortMode.swift; path = Mlem/App/Protocols/AccountSortMode.swift; sourceTree = SOURCE_ROOT; }; CD4D58EA2B86E63300B82964 /* AssociatedColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedColor.swift; sourceTree = ""; }; CD4D58F72B87B0D100B82964 /* InternetSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetSpeed.swift; sourceTree = ""; }; CD4D59132B87B36B00B82964 /* InternetConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetConnectionManager.swift; sourceTree = ""; }; CD4D59152B87B38C00B82964 /* UIApplication+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extensions.swift"; sourceTree = ""; }; CD4D59172B87B3B000B82964 /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = ""; }; CD4E386C2C836F8C009B24F2 /* UIUserInterfaceStyle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIUserInterfaceStyle+Extensions.swift"; sourceTree = ""; }; CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabReselectTracker.swift; sourceTree = ""; }; CD4ED8492BF1113800EFA0A2 /* View+TabReselectConsumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TabReselectConsumer.swift"; sourceTree = ""; }; CD5581DD2C7B8B820043FAC3 /* ImageFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFunctions.swift; sourceTree = ""; }; CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationControlLayer.swift; sourceTree = ""; }; CD5C197C2D97096D0089614C /* ZoomCurves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomCurves.swift; sourceTree = ""; }; CD5C197E2D970E570089614C /* MomentumStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MomentumStatus.swift; sourceTree = ""; }; CD5CAA0B2D41AE52008E20F2 /* EmbeddingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddingSettingsView.swift; sourceTree = ""; }; CD5D8E2A2D6D5AAE00AF5CE4 /* CGSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Extensions.swift"; sourceTree = ""; }; CD635E1A2C94DACD00864F75 /* BypassProxyWarningSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BypassProxyWarningSheet.swift; sourceTree = ""; }; CD64364F2D483C8F002668FB /* InteractionBarEditorView+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InteractionBarEditorView+Views.swift"; sourceTree = ""; }; CD6DC0292D86513800693B16 /* AnimatedAvatarBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedAvatarBehavior.swift; sourceTree = ""; }; CD6DC02B2D86540500693B16 /* AnimatedAvatarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedAvatarSettingsView.swift; sourceTree = ""; }; CD737FB92F771BF100E46411 /* InstanceSummarySoftware+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceSummarySoftware+Extensions.swift"; sourceTree = ""; }; CD756A952ED765E90031D7D1 /* ExportableCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableCommentView.swift; sourceTree = ""; }; CD756A972ED766930031D7D1 /* ExportableCommentEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableCommentEditorView.swift; sourceTree = ""; }; CD77437E2C1BA5CE0085BB43 /* MultiplatformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiplatformView.swift; sourceTree = ""; }; CD7743882C20EDEE0085BB43 /* VotesModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VotesModel+Extensions.swift"; sourceTree = ""; }; CD7882A62BFFD1A3002E1A30 /* ThumbnailLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailLocation.swift; sourceTree = ""; }; CD7882A82BFFDFC7002E1A30 /* PostTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTag.swift; sourceTree = ""; }; CD7882AA2C013005002E1A30 /* EllipsisMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EllipsisMenu.swift; sourceTree = ""; }; CD79281E2C73B52A00FA712D /* EndOfFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndOfFeedView.swift; sourceTree = ""; }; CD7928222C73CBA400FA712D /* TileScoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileScoreView.swift; sourceTree = ""; }; CD7928252C73E73400FA712D /* PersonView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonView+Logic.swift"; sourceTree = ""; }; CD7AC2CB2D7FDFF700A671B7 /* MediaView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaView+Logic.swift"; sourceTree = ""; }; CD7BF9312D18F4EB0020F2C5 /* FiltersTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersTracker.swift; sourceTree = ""; }; CD7C4E562F3B92A600ADCBDD /* View+ReloadOnAccountSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ReloadOnAccountSwitch.swift"; sourceTree = ""; }; CD7DB9702C49C17200DCC542 /* PersonContentGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonContentGridView.swift; sourceTree = ""; }; CD7DB9722C4AEDDE00DCC542 /* TileCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileCommentView.swift; sourceTree = ""; }; CD7DB9752C4D6C0A00DCC542 /* FeedCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCommentView.swift; sourceTree = ""; }; CD869FCB2C15F8AC00FC8B5B /* BubblePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubblePickerView.swift; sourceTree = ""; }; CD869FCD2C15F90C00FC8B5B /* ChildSizeReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildSizeReader.swift; sourceTree = ""; }; CD869FCF2C15F92E00FC8B5B /* Int+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Extensions.swift"; sourceTree = ""; }; CD87BEC22D5132BB0099F190 /* FilterViolationWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViolationWarning.swift; sourceTree = ""; }; CD8DB9392D22003D00EB0C7B /* Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animations.swift; sourceTree = ""; }; CD93420A2DCD069800945333 /* InstanceUptimeView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceUptimeView+Logic.swift"; sourceTree = ""; }; CD950E042F0ED6F7002A0595 /* FeedPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPostView.swift; sourceTree = ""; }; CD9857A32C5E7F9D0084C71F /* NsfwOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NsfwOverlayView.swift; sourceTree = ""; }; CD9BD5802D8F8F750006AB7F /* ZoomRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomRecognizer.swift; sourceTree = ""; }; CD9CFA292C2E1E8400739BBC /* FeedHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedHeaderView.swift; sourceTree = ""; }; CD9CFA2B2C2E1EF300739BBC /* FeedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIconView.swift; sourceTree = ""; }; CD9CFA2F2C2E22F600739BBC /* FeedDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedDescription.swift; sourceTree = ""; }; CD9CFA362C306DAB00739BBC /* PostGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostGridView.swift; sourceTree = ""; }; CD9D243C2CC1DF55006E5F3F /* AccountType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountType.swift; sourceTree = ""; }; CDA1D2A42ED8C4670077A9EA /* ExportableViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableViewComponents.swift; sourceTree = ""; }; CDA67A9E2D9B329D00E5D17B /* CGPoint+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGPoint+Extensions.swift"; sourceTree = ""; }; CDA683F72C77E577000C4486 /* NsfwBlurBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NsfwBlurBehavior.swift; sourceTree = ""; }; CDAA02DA2C810DB200D75633 /* Calendar+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Extensions.swift"; sourceTree = ""; }; CDAA02E02C817AAB00D75633 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; CDAA02E22C821C9100D75633 /* Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Divider.swift; sourceTree = ""; }; CDADDB262F49210A00A4214A /* CommunityStubResolutionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityStubResolutionPage.swift; sourceTree = ""; }; CDB2EC7A2BFADA8B00DBC0EF /* PostSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSize.swift; sourceTree = ""; }; CDB2EC7C2BFADAB300DBC0EF /* CompactPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPostView.swift; sourceTree = ""; }; CDB2EC7E2BFADACC00DBC0EF /* HeadlinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinePostView.swift; sourceTree = ""; }; CDB2EC802BFADADF00DBC0EF /* LargePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePostView.swift; sourceTree = ""; }; CDB2EC852BFADD7C00DBC0EF /* FullyQualifiedLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullyQualifiedLabelView.swift; sourceTree = ""; }; CDB2EC872BFAE14800DBC0EF /* FullyQualifiedNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullyQualifiedNameView.swift; sourceTree = ""; }; CDB2EC892BFAEFDF00DBC0EF /* ThumbnailImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailImageView.swift; sourceTree = ""; }; CDB3DDFC2DA485D000F407AB /* SettingsValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsValues.swift; sourceTree = ""; }; CDB41E892C83C24400BD2DE9 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; CDB738282CB8A69D005B11BB /* View+PaletteBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PaletteBorder.swift"; sourceTree = ""; }; CDB95FCC2EBD3212008669D9 /* SmallOverlayButtonLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallOverlayButtonLabel.swift; sourceTree = ""; }; CDBE78C12F38DD89008B254C /* PersonStubResolutionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonStubResolutionPage.swift; sourceTree = ""; }; CDBEC30E2E84C9F300F30B00 /* ExportablePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportablePostView.swift; sourceTree = ""; }; CDBFCB642C03920C008CD468 /* PostLinkHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostLinkHostView.swift; sourceTree = ""; }; CDBFCB692C04EFFE008CD468 /* PostSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettingsView.swift; sourceTree = ""; }; CDBFCB6B2C054AA7008CD468 /* TilePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilePostView.swift; sourceTree = ""; }; CDC44A352D1CBC200030F01C /* ReadPostIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadPostIndicator.swift; sourceTree = ""; }; CDCA44B32C176A4700C092B3 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; CDCC1BA62D99BDB0006579DF /* GestureRecognizers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureRecognizers.swift; sourceTree = ""; }; CDCC1BA82D99BEA8006579DF /* ZoomRecognizerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomRecognizerCoordinator.swift; sourceTree = ""; }; CDCC7FEC2D9AEDBE00CE18DA /* BridgeDragValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeDragValue.swift; sourceTree = ""; }; CDCC7FEE2D9B1E6E00CE18DA /* CachedComputation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedComputation.swift; sourceTree = ""; }; CDCC7FF02D9B27CE00CE18DA /* ZoomRecognizerCoordinator+GestureRecognition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ZoomRecognizerCoordinator+GestureRecognition.swift"; sourceTree = ""; }; CDCC7FF22D9B283400CE18DA /* ZoomRecognizerCoordinator+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ZoomRecognizerCoordinator+Logic.swift"; sourceTree = ""; }; CDCF5A572F1426A0006748E8 /* CommentStubResolutionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentStubResolutionPage.swift; sourceTree = ""; }; CDD4A09B2C8A122F0001AD1A /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; CDD4A09D2C8B69FC0001AD1A /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; CDD4A09F2C8B985D0001AD1A /* ImportExportSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExportSettingsView.swift; sourceTree = ""; }; CDD8B94B2C8234BC00510EBB /* Form.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Form.swift; sourceTree = ""; }; CDD8E30C2EEA07EE00FC4C8D /* ExportableCommentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableCommentLoader.swift; sourceTree = ""; }; CDD99C3B2C73F3FF0010367F /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; CDD99C3D2C73F4380010367F /* WarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningView.swift; sourceTree = ""; }; CDDA49A72F7044CD004A5AFF /* InstanceStubResolutionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStubResolutionPage.swift; sourceTree = ""; }; CDE1F18E2C63D75A008AF042 /* LegacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacySettings.swift; sourceTree = ""; }; CDE1F1932C63DF44008AF042 /* PlatformConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformConstants.swift; sourceTree = ""; }; CDE1F1952C63DF89008AF042 /* PhoneConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneConstants.swift; sourceTree = ""; }; CDE1F1972C63DFC9008AF042 /* PadConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadConstants.swift; sourceTree = ""; }; CDE1F19B2C63E2EB008AF042 /* SettingPropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingPropertyWrapper.swift; sourceTree = ""; }; CDE1F19D2C63E306008AF042 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; CDE88C342E68938800183AE5 /* View+AccountSwitcherGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AccountSwitcherGesture.swift"; sourceTree = ""; }; CDEE15512D22190000EB9D7B /* ErrorsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorsTracker.swift; sourceTree = ""; }; CDEE15532D22364500EB9D7B /* ErrorLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogView.swift; sourceTree = ""; }; CDF60A002E998BB2005FA3F1 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; CDF8C1902D5D501E00295CBA /* InteractionBarWidgetPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionBarWidgetPickerView.swift; sourceTree = ""; }; CDF9EF322AB2845C003F885B /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; CDFB8C682C7796020070845F /* View+DynamicBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DynamicBlur.swift"; sourceTree = ""; }; CDFF9A322D88C3BE009E02E2 /* CGFloat+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGFloat+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 031DBA6A2F9A8D6500B4BAE4 /* Events */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Events; sourceTree = ""; }; 03DA26AC2D79F29700E66267 /* Packages */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Packages; sourceTree = ""; }; 03EC85ED2E9D925B004698BB /* Actions */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Actions; sourceTree = ""; }; 03F6BDA12D502382006A425E /* Preview Content */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Preview Content"; sourceTree = ""; }; CD2276D42F369C5F0024AEB1 /* Person */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Person; sourceTree = ""; }; CD6CD11A2F25BF1300566122 /* Interactable */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Interactable; sourceTree = ""; }; CD6CD1212F25C36800566122 /* Post */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Post; sourceTree = ""; }; CD6CD1222F25EC6A00566122 /* Comment */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Comment; sourceTree = ""; }; CDADDB222F491D2F00A4214A /* Community */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Community; sourceTree = ""; }; CDC71F242F15A6B900D314B1 /* ExpectedViews */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ExpectedViews; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 6363D5BE27EE196700E34822 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 0377BF9B2DF0E0E000E38593 /* Rest in Frameworks */, 03A9FD162D7CEC09007A734D /* Theming in Frameworks */, 038100512F6AE867008A7731 /* MlemBackend in Frameworks */, 03FA318C2C6FECAE00D47FA3 /* Flow in Frameworks */, 030FF6792BC84F7E00F6BFAC /* SwiftUIIntrospect in Frameworks */, 0341480D2D8F63A6005503AF /* MlemMiddleware in Frameworks */, B104A6DA2A59BF3C00B3E725 /* NukeExtensions in Frameworks */, 03EC85EC2E9D8F37004698BB /* Actions in Frameworks */, 037386472BDAFE81007492B5 /* LemmyMarkdownUI in Frameworks */, CDA711FB2DB5CAC3008BC3ED /* Media in Frameworks */, B104A6D82A59BF3C00B3E725 /* Nuke in Frameworks */, 0347A6FB2F97F4CF00EFD670 /* FediverseEvents in Frameworks */, 03EC83EE2E958A44004698BB /* OpenGraph in Frameworks */, 50C99B562A61D792005D57DD /* Dependencies in Frameworks */, 636250DC2A18111400FC59B4 /* KeychainAccess in Frameworks */, CD4368C12AE23FD400BD8BD1 /* Semaphore in Frameworks */, B104A6DE2A59BF3C00B3E725 /* NukeVideo in Frameworks */, 0368799A2DA1320000E796EF /* ComponentViews in Frameworks */, 031CA5772E5900E900CF0C0F /* QuickSwipes in Frameworks */, 0377BE292DE7A2DE00E38593 /* Haptics in Frameworks */, 03D8BF432DA55B6900506687 /* Icons in Frameworks */, CDE4AC472CA372B600981010 /* SDWebImageWebPCoder in Frameworks */, B104A6DC2A59BF3C00B3E725 /* NukeUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5D327EE196A00E34822 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5DD27EE196A00E34822 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 81DE61C02F48AF44006E4C36 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 81DE61C52F48AF44006E4C36 /* UniformTypeIdentifiers.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 03049A182C6502DB00FF6889 /* Form */ = { isa = PBXGroup; children = ( 0397D48B2C6BE9A2002C6CDC /* CollapsibleSection.swift */, 03049A192C6502F300FF6889 /* FormSection.swift */, 03049A1F2C650A8100FF6889 /* FormReadout.swift */, 03049A1B2C65039400FF6889 /* ActiveUserCountView.swift */, ); path = Form; sourceTree = ""; }; 030BCB192C3EA5E20037680F /* Instance */ = { isa = PBXGroup; children = ( CDDA49A72F7044CD004A5AFF /* InstanceStubResolutionPage.swift */, CD4B66D92DCE809A00D28EB4 /* InstanceUptimeView+Views.swift */, CD93420A2DCD069800945333 /* InstanceUptimeView+Logic.swift */, 03AFD0E22C3C0C540054B8AD /* InstanceView.swift */, 034A85702EC0A1FA00E5F904 /* InstanceCommunityListView.swift */, 037FC06F2E4A6B16009E3E63 /* InstanceView+About.swift */, 035394982CA1B20B00795AA5 /* InstanceView+Logic.swift */, 030BCB1A2C3EA5FD0037680F /* InstanceDetailsView.swift */, 035394922CA1AE2C00795AA5 /* UptimeData.swift */, 035394942CA1AE6300795AA5 /* InstanceUptimeView.swift */, 03B25B2E2CC43F8600EB6DF5 /* InstanceSafetyView.swift */, 03B25B342CC4446400EB6DF5 /* FediseerOpinionListView.swift */, 03B25B362CC4478600EB6DF5 /* FediseerInfoView.swift */, 03B25B322CC440A600EB6DF5 /* FediseerOpinionView.swift */, 03B25B302CC4403500EB6DF5 /* Fediseer.swift */, ); path = Instance; sourceTree = ""; }; 0311ADB52E4DF49100EC3120 /* Home */ = { isa = PBXGroup; children = ( 031DBA7A2F9A993000B4BAE4 /* SearchHomeListView.swift */, 031DBA782F9A95B200B4BAE4 /* SearchHomeLabelStyle.swift */, 031DBA762F9A93AD00B4BAE4 /* EventRowView.swift */, 0302A87F2F9BC379000A127A /* SearchHomeCategoryLabelStyle.swift */, 0311ADB62E4DF49800EC3120 /* SearchHomeView.swift */, 0311ADBC2E4F668900EC3120 /* TopCommunitiesListView.swift */, 0311ADBE2E4F68D000EC3120 /* TopPeopleListView.swift */, 0311ADC02E4F693B00EC3120 /* TopInstancesListView.swift */, ); path = Home; sourceTree = ""; }; 03134A492BEACF46002662CC /* Settings */ = { isa = PBXGroup; children = ( 81A179BB2DDE591700B17017 /* LongPressActionSettingsView.swift */, CD6DC02B2D86540500693B16 /* AnimatedAvatarSettingsView.swift */, 0368F3672D7349C2007DEB70 /* Discussion Languages */, CD3485BC2D50156E006748B8 /* ZoomSliderSettingsView.swift */, 039F58962C7B68F100C61658 /* AboutMlemView.swift */, 03BF11C62D3D634A00CC1F66 /* AccessibilitySettingsView.swift */, 03AB48542CBC0B8000567FF9 /* AccountAdvancedSettingsView.swift */, 03AB48582CBC14CE00567FF9 /* AccountEmailSettingsView.swift */, 03AB48512CBC042E00567FF9 /* AccountContentSettingsView.swift */, 03D65D6F2F4B046F0041ADAF /* ContextMenuSettingsView.swift */, 03134A572BEC1C46002662CC /* AccountListSettingsView.swift */, 03AD0A812CFDBFA0001EF9F7 /* AccountLocalSettingsView.swift */, 03AD0A832CFDC557001EF9F7 /* AccountNicknameFieldView.swift */, 03267D832BED49CE009D6268 /* AccountSettingsView.swift */, 03AB48562CBC0DFC00567FF9 /* AccountSignInSettingsView.swift */, 039F588B2C7B574E00C61658 /* AdvancedSettingsView.swift */, 037DE0742CE023E3007F7B92 /* BlockListView.swift */, 039F58922C7B616600C61658 /* CommentSettingsView.swift */, 03AB906E2D418C200054D9E1 /* CommentJumpButtonSettingsView.swift */, 03BF11CB2D404F2C00CC1F66 /* CommentMaximumDepthSettingsView.swift */, 0353948E2CA088E600795AA5 /* DeveloperSettingsView.swift */, CD5CAA0B2D41AE52008E20F2 /* EmbeddingSettingsView.swift */, CDEE15532D22364500EB9D7B /* ErrorLogView.swift */, CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */, 039F58892C7B54FE00C61658 /* GeneralSettingsView.swift */, 03ABE5E32DB8E57000374AFF /* AccountAgeVisibilitySettingsView.swift */, 03A6315F2D4D1CBB009A47A6 /* HapticSettingsView.swift */, 03A6315D2D4D1A1B009A47A6 /* DefaultFeedSettingsView.swift */, 036FFA2E2D45197300998D8A /* PrivacySettingsView.swift */, 038188982D43E0F30073E88D /* SafetySettingsView.swift */, 03600D922D4531DF00C704CB /* PrivacyBypassImageProxySettingsView.swift */, 0381889A2D4412A40073E88D /* SafetyBlurNsfwSettingsView.swift */, 033819272D4424D9000AFC55 /* SafetyWarningsSettingsView.swift */, CDD4A09F2C8B985D0001AD1A /* ImportExportSettingsView.swift */, 0325B9392D3A9E8100E28B97 /* InboxBadgeSettingsView.swift */, 039F58942C7B618F00C61658 /* InboxSettingsView.swift */, 03531EEB2C2D81DC004A3464 /* LinkSettingsView.swift */, 038E5C122F6D617C00C54DEB /* ImageViewerSettingsView.swift */, 038E5E882F6D694500C54DEB /* ImageViewerShowControlsSettingsView.swift */, 038E5E8A2F6D6C3800C54DEB /* ImageViewerDismissSettingsView.swift */, 03A630EE2D497143009A47A6 /* TappableLinksSettingsView.swift */, 03A630EC2D497005009A47A6 /* ExternalLinkSettingsView.swift */, 030056A32D7DB05A00EB0BA3 /* SharingLinksSettingsView.swift */, 038028D72CACAB960091A8A2 /* ModeratorSettingsView.swift */, 03F6BDF72D555F6E006A425E /* ModMailInteractionBarSettingsView.swift */, 03A6316C2D4E3D24009A47A6 /* ModeratorActionSeparationSettingsView.swift */, 03BF11C42D3D3AFB00CC1F66 /* PostReadIndicatorSettingsView.swift */, CDBFCB692C04EFFE008CD468 /* PostSettingsView.swift */, 038E1AC92F59C18100D30F01 /* CommunitySettingsView.swift */, 037F783E2D3BD05500D4E180 /* PostSettingsView+PostSizePicker.swift */, 037F78442D3C25AB00D4E180 /* PostSubscriptionIndicatorSettingsView.swift */, 037F78422D3C129B00D4E180 /* PostThumbnailSettingsView.swift */, 0391E0F82CFF17AE0040CCA8 /* ProfileSettingsView.swift */, 031E2D5C2BEFCC630003BC45 /* SettingsView.swift */, 036FFA2C2D45110C00998D8A /* ChangePasswordView.swift */, 0397D46B2C67E583002C6CDC /* SortingSettingsView.swift */, 03B72B6A2C28A0190023A6C4 /* SubscriptionListSettingsView.swift */, 03D284052D2AEE3A00A6659B /* TabBarSettingsView.swift */, 031E2D5A2BEFC9460003BC45 /* ThemeSettingsView.swift */, 038E1ABF2F58C76A00D30F01 /* SwipeActionEditorView.swift */, 039F588D2C7B598000C61658 /* Components */, 033FCB262C5E3933007B7CD1 /* Icon */, 0397D4982C6EA68A002C6CDC /* InteractionBarEditor */, ); path = Settings; sourceTree = ""; }; 03134A4E2BEAD23A002662CC /* Views */ = { isa = PBXGroup; children = ( CD4ED8482BF1112A00EFA0A2 /* View Modifiers */, 03134A4F2BEAD245002662CC /* NavigationLink+NavigationPage.swift */, 03F9672A2CE221220081C9A3 /* Label+Profile1.swift */, 03DD69432D4FEA0000F8950D /* PreviewModifier+SampleEnvironment.swift */, ); path = Views; sourceTree = ""; }; 0315B1BC2C74C3CD006D4F82 /* CommentEditor */ = { isa = PBXGroup; children = ( 03B431B12C44409D001A1EB5 /* CommentEditorView.swift */, 0315B1BD2C74C3D6006D4F82 /* CommentEditorView+Logic.swift */, 0315B1C02C74C71A006D4F82 /* CommentEditorView+Context.swift */, 0397D4902C6CE871002C6CDC /* PostEditor */, ); path = CommentEditor; sourceTree = ""; }; 03267D802BED4714009D6268 /* Avatar */ = { isa = PBXGroup; children = ( 034B947E2C091EDD00039AF4 /* ProfileHeaderView.swift */, 03267D812BED489C009D6268 /* AvatarBannerView.swift */, 03134A592BEC2253002662CC /* AvatarStackView.swift */, ); path = Avatar; sourceTree = ""; }; 033F844B2D18D8F400D87A9E /* MessageFeedView */ = { isa = PBXGroup; children = ( 033F84482D18D1F400D87A9E /* MessageFeedView.swift */, 033F84502D196AFD00D87A9E /* MessageFeedView+Logic.swift */, 033F844C2D18D90900D87A9E /* MessageBubbleView.swift */, ); path = MessageFeedView; sourceTree = ""; }; 033F84722D1C784600D87A9E /* Modlog */ = { isa = PBXGroup; children = ( 033F84702D1C784600D87A9E /* ModlogEntryView.swift */, 033F84712D1C784600D87A9E /* ModlogView.swift */, 03A814282ED1BCA90023E9E8 /* ModlogView+Filters.swift */, 0305EBA92D32B3B80066E5AD /* ModlogView+Logic.swift */, ); path = Modlog; sourceTree = ""; }; 033F84C62C2B192F002E3EDF /* MlemStats */ = { isa = PBXGroup; children = ( 033F84C22C2B12AA002E3EDF /* InstanceSummary.swift */, 033F84C72C2B193D002E3EDF /* MlemStats.swift */, ); path = MlemStats; sourceTree = ""; }; 033FCAF12C598406007B7CD1 /* Person */ = { isa = PBXGroup; children = ( CDBE78C12F38DD89008B254C /* PersonStubResolutionPage.swift */, 0382A7EF2C09F0F800C79DDA /* PersonView.swift */, CD7928252C73E73400FA712D /* PersonView+Logic.swift */, ); path = Person; sourceTree = ""; }; 033FCAF22C598435007B7CD1 /* Community */ = { isa = PBXGroup; children = ( CDADDB262F49210A00A4214A /* CommunityStubResolutionPage.swift */, CD1C64142D34286E0006B3C1 /* CommunityView+Logic.swift */, 035DFA222EB3F7240021DE8C /* CommunityAboutView.swift */, 033FCAF32C59843E007B7CD1 /* CommunityView.swift */, 03049A212C650B2C00FF6889 /* CommunityDetailsView.swift */, 0397D4632C676CA8002C6CDC /* FeedSortPicker.swift */, 036ED67C2D0B006C0018E5EA /* TopSortPicker.swift */, 036ED67E2D0B9A520018E5EA /* CommunitySearchSortPicker.swift */, 0391E0FD2D05B2DF0040CCA8 /* AdvancedSortView.swift */, 036ED67A2D0B004D0018E5EA /* AdvancedSortView+SortButton.swift */, ); path = Community; sourceTree = ""; }; 033FCB262C5E3933007B7CD1 /* Icon */ = { isa = PBXGroup; children = ( 033FCB222C5E3933007B7CD1 /* AlternateIcon.swift */, 033FCB232C5E3933007B7CD1 /* AlternateIconCell.swift */, 033FCB242C5E3933007B7CD1 /* AlternateIconLabel.swift */, 033FCB252C5E3933007B7CD1 /* IconSettingsView.swift */, ); path = Icon; sourceTree = ""; }; 0348F98B2DDBB505006639CD /* Onboarding */ = { isa = PBXGroup; children = ( 0348F98C2DDBB526006639CD /* OnboardingView.swift */, 0377BE062DE645E100E38593 /* OnboardingRecommendInstanceView.swift */, 0377BD742DE219A400E38593 /* OnboardingUsernameView.swift */, 0377BE9A2DEA328900E38593 /* OnboardingEmailView.swift */, 0377BE9E2DEA361600E38593 /* OnboardingModel.swift */, ); path = Onboarding; sourceTree = ""; }; 03500C252BF694A800CAA076 /* Toast */ = { isa = PBXGroup; children = ( 03500C262BF69D1D00CAA076 /* ToastModel.swift */, 03500C2A2BF7F1B100CAA076 /* ToastOverlayView.swift */, 03500C2C2BF7FC2500CAA076 /* ToastView.swift */, 03500C212BF5594100CAA076 /* ToastType.swift */, 0369B3522BFA514B001EFEDF /* ToastLocation.swift */, 03500C232BF55D0E00CAA076 /* Toast.swift */, ); path = Toast; sourceTree = ""; }; 03531EEF2C2DA291004A3464 /* Search */ = { isa = PBXGroup; children = ( 0311ADB52E4DF49100EC3120 /* Home */, 035EDEF72C2DF93C00F51144 /* Results */, 035EDEEB2C2DE93E00F51144 /* SearchBar */, 0389DDCE2C39CB0E0005B808 /* SearchView.swift */, 03D283FD2D25EEC500A6659B /* SearchView+Views.swift */, 0320B6602C8DFCF100D38548 /* SearchView+FiltersView.swift */, 03BF11C22D3D135D00CC1F66 /* SearchView+CreatorPicker.swift */, 038028F72CB097A10091A8A2 /* SearchView+InstancePicker.swift */, 038028F92CB097CB0091A8A2 /* SearchView+LocationPicker.swift */, 038028F52CB096960091A8A2 /* SearchView+FilterModels.swift */, 0320B6682C93506300D38548 /* SearchView+Logic.swift */, 0389DDD22C39E4D40005B808 /* PasteLinkButtonView.swift */, 03531EED2C2D9298004A3464 /* SearchSheetView.swift */, 03531EF02C2DA298004A3464 /* SearchResultsView.swift */, 035EDF002C2ECFE000F51144 /* Searchable.swift */, 03D283F92D256E1E00A6659B /* VisitHistory.swift */, 03D283FB2D25A3F700A6659B /* VisitHistory+CodedData.swift */, ); path = Search; sourceTree = ""; }; 035BE0852BDD8D9100F77D73 /* Navigation */ = { isa = PBXGroup; children = ( 035BE0862BDD8DA000F77D73 /* NavigationRootView.swift */, 035BE08C2BDE88EC00F77D73 /* NavigationLayerView.swift */, 035BE08A2BDD903100F77D73 /* NavigationModel.swift */, 035BE08E2BDE911900F77D73 /* NavigationLayer.swift */, 035BE0882BDD901B00F77D73 /* NavigationPage.swift */, 03D001DC2EC53A97001BF97D /* NavigationPage+PresentationDetents.swift */, 0320B6642C91DBD500D38548 /* NavigationPage+View.swift */, 03531EF42C2DA610004A3464 /* NavigationSearchType.swift */, 035BE0902BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift */, 03134A512BEAD69F002662CC /* SettingsPage.swift */, 03CCDA9F2BF2795300C0C851 /* LoginPage.swift */, ); path = Navigation; sourceTree = ""; }; 035EDEEB2C2DE93E00F51144 /* SearchBar */ = { isa = PBXGroup; children = ( 035EDEEC2C2DE94B00F51144 /* _assignIfNotEqual.swift */, 035EDEED2C2DE94B00F51144 /* DefaultTextInputType.swift */, 035EDEEE2C2DE94B00F51144 /* SearchBar.swift */, 031CA4AD2E58A84E00CF0C0F /* View+WithSheetSearch.swift */, 035EDEEF2C2DE94B00F51144 /* SearchBar+NavigationView.swift */, 035EDEF02C2DE94B00F51144 /* SearchBarExtensions.swift */, ); path = SearchBar; sourceTree = ""; }; 035EDEF72C2DF93C00F51144 /* Results */ = { isa = PBXGroup; children = ( 035EDEFA2C2DF98700F51144 /* CommunityListRowBody.swift */, 035EDF022C2ED0DE00F51144 /* PersonListRowBody.swift */, 0389DDD02C39E1030005B808 /* InstanceListRowBody.swift */, 0389DDD42C39F1290005B808 /* CommunityListRow.swift */, 03AFD0DE2C3B2E000054B8AD /* PersonListRow.swift */, 03AFD0E02C3B30390054B8AD /* InstanceListRow.swift */, ); path = Results; sourceTree = ""; }; 0368F3672D7349C2007DEB70 /* Discussion Languages */ = { isa = PBXGroup; children = ( CD24CAFB2D5568F90032B5E8 /* DiscussionLanguageSettingsView.swift */, 0368F3422D72796B007DEB70 /* LanguagePickerSheetView.swift */, 0368F3682D7349D8007DEB70 /* LanguageListRowBody.swift */, ); path = "Discussion Languages"; sourceTree = ""; }; 0369B3542BFA681B001EFEDF /* Inbox */ = { isa = PBXGroup; children = ( 0369B3552BFA6824001EFEDF /* InboxView.swift */, 034690982D105DFD0073E664 /* InboxView+Types.swift */, 0353948A2CA076D000795AA5 /* InboxView+Views.swift */, 031CA0D82E4FBFD800CF0C0F /* MarkAllAsReadButton.swift */, ); path = Inbox; sourceTree = ""; }; 0369B3592BFB86C6001EFEDF /* Account */ = { isa = PBXGroup; children = ( CD9D243C2CC1DF55006E5F3F /* AccountType.swift */, 0369B35A2BFB86E3001EFEDF /* Account.swift */, 03D2A6382C00FAE000ED4FF2 /* UserAccount.swift */, 03D2A63A2C010B7500ED4FF2 /* GuestAccount.swift */, ); path = Account; sourceTree = ""; }; 037386422BDAF574007492B5 /* Frameworks */ = { isa = PBXGroup; children = ( 81DE61C42F48AF44006E4C36 /* UniformTypeIdentifiers.framework */, ); name = Frameworks; sourceTree = ""; }; 0382A7EE2C09F0F800C79DDA /* Pages */ = { isa = PBXGroup; children = ( CD87BEBF2D51328B0099F190 /* Editors */, CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */, 033FCAF22C598435007B7CD1 /* Community */, 033FCAF12C598406007B7CD1 /* Person */, 030BCB192C3EA5E20037680F /* Instance */, 0355F9452C150B2300605248 /* ExternalApiInfoView.swift */, 03AF91DC2C1B23E500E56644 /* ImageViewer.swift */, CDD99C3B2C73F3FF0010367F /* DeleteAccountView.swift */, 03B25B3A2CC44FFF00EB6DF5 /* UploadConfirmationView.swift */, 03E46AD12D130681002589DB /* VotesListView.swift */, 033F84722D1C784600D87A9E /* Modlog */, 033F844B2D18D8F400D87A9E /* MessageFeedView */, ); name = Pages; path = Mlem/App/Views/Pages; sourceTree = SOURCE_ROOT; }; 0397D4902C6CE871002C6CDC /* PostEditor */ = { isa = PBXGroup; children = ( 0397D48F2C6CE871002C6CDC /* PostEditorView.swift */, 038028D42CAB479D0091A8A2 /* PostEditorView+Views.swift */, 03ECD7202C8654BA00D48BF6 /* PostEditorView+Toolbar.swift */, 0315B1C52C754802006D4F82 /* PostEditorView+Logic.swift */, 03ECD7182C81195000D48BF6 /* PostEditorView+LinkView.swift */, 03ECD71A2C811D6700D48BF6 /* PostEditorView+ImageView.swift */, 0397D4922C6CE87E002C6CDC /* PostEditorTargetView.swift */, 03EC84412E959AA5004698BB /* PostEditorWebsitePreviewView.swift */, 034A82022EBA688F00E5F904 /* LinkEditorView.swift */, ); path = PostEditor; sourceTree = ""; }; 0397D4982C6EA68A002C6CDC /* InteractionBarEditor */ = { isa = PBXGroup; children = ( CDF8C1902D5D501E00295CBA /* InteractionBarWidgetPickerView.swift */, CD64364F2D483C8F002668FB /* InteractionBarEditorView+Views.swift */, 0397D4992C6EA6EE002C6CDC /* InteractionBarEditorView.swift */, 03036C822C727D0500C6DA1D /* InteractionBarEditorView+Logic.swift */, ); path = InteractionBarEditor; sourceTree = ""; }; 039D75652C4FC56A004F24C2 /* Images */ = { isa = PBXGroup; children = ( CD13CC682C5D3CCA001AF428 /* Wrappers */, CD13CC672C5D3CBE001AF428 /* Core */, CD13CC662C5D3CA7001AF428 /* Helpers */, ); path = Images; sourceTree = ""; }; 039F588D2C7B598000C61658 /* Components */ = { isa = PBXGroup; children = ( CD44C92B2E5CC8B600F24AC8 /* ConditionalLabelStyleViewModifier.swift */, 039F58872C7B531800C61658 /* SquircleLabelStyle.swift */, 039F588E2C7B599800C61658 /* ThemeLabel.swift */, 0325B93D2D3AAE9E00E28B97 /* SettingsHeaderView.swift */, 037F77EC2D3B064B00D4E180 /* SettingsDeviceView.swift */, 037F78402D3C00E600D4E180 /* SettingsInteractionBarSummaryView.swift */, 03BF11C92D4027E900CC1F66 /* DevicePickerItem.swift */, ); path = Components; sourceTree = ""; }; 03A630F22D4976DE009A47A6 /* ShieldsBadgeView */ = { isa = PBXGroup; children = ( 03A630F02D497674009A47A6 /* ShieldsBadgeView.swift */, 03A630F32D4976EB009A47A6 /* ShieldsBadgeView+Logic.swift */, ); path = ShieldsBadgeView; sourceTree = ""; }; 03A631C92D4FCEE3009A47A6 /* MlemMiddleware Mock */ = { isa = PBXGroup; children = ( 03A631CA2D4FCEF7009A47A6 /* Post+Mock.swift */, 038C85682D861A2100543F70 /* Comment+Mock.swift */, 03DD69412D4FDE8900F8950D /* Person+Mock.swift */, 03F6BDAC2D516615006A425E /* Community+Mock.swift */, 03F6BD932D500DED006A425E /* PersonMockType.swift */, 03F6BDAE2D516636006A425E /* CommunityMockType.swift */, 03F6BDB02D52AA00006A425E /* CommunityMockType+Realistic.swift */, 0370299C2D6B70F400B749DF /* MockApiClient+Realistic.swift */, 03F6BD972D500E2A006A425E /* PersonMockType+Realistic.swift */, 03F6BD9B2D501478006A425E /* ActorIdentifier+Mock.swift */, 03F6BDBB2D52B7FE006A425E /* PostMockType.swift */, 0370299E2D6B743B00B749DF /* PostMockType+Realistic.swift */, 038C85E52D88337100543F70 /* CommentMockType.swift */, ); path = "MlemMiddleware Mock"; sourceTree = ""; }; 03AF91E62C1C65AE00E56644 /* Interaction */ = { isa = PBXGroup; children = ( 0381F7132F670F95008A7731 /* SwipeActionConfiguration.swift */, 038E62DF2F6F0FC600C54DEB /* ContextMenuConfiguration.swift */, 038E1ABB2F58B9EF00D30F01 /* CommunityActionConfiguration.swift */, 0397D49B2C6EA73C002C6CDC /* InteractionBarConfiguration.swift */, 03AF91E42C1C61FA00E56644 /* PostBarConfiguration.swift */, 0381F7152F671427008A7731 /* PostBarConfiguration+Types.swift */, 033F84BC2C2ACC5F002E3EDF /* CommentBarConfiguration.swift */, 0381F7232F672258008A7731 /* CommentBarConfiguration+Types.swift */, 032C32172C36F70300595286 /* ReplyBarConfiguration.swift */, 0381F7272F6724D3008A7731 /* ReplyBarConfiguration+Types.swift */, 03D662A42F5377630041ADAF /* ActionSeedSections.swift */, ); path = Interaction; sourceTree = ""; }; 03B0EB6D2C87673D00F79FDF /* ExpandedPost */ = { isa = PBXGroup; children = ( CDCF5A572F1426A0006748E8 /* CommentStubResolutionPage.swift */, 0300309C2C4163C9009A65FF /* CommentTreeTracker.swift */, 03E0EF442CA74036002CB66C /* CommentPage.swift */, 03B0EB6E2C87827A00F79FDF /* ExpandedPostView.swift */, 033EF40F2CB9AEF7004D8A3F /* ExpandedPostView+Views.swift */, 03AD09E72CF88007001EF9F7 /* MoreRepliesButton.swift */, 039F58852C7A810100C61658 /* ExpandedPostView+Logic.swift */, 03E0EF422CA73D7A002CB66C /* PostStubResolutionPage.swift */, 034147FC2D8F5844005503AF /* ExpandedPostHistoryTracker.swift */, ); path = ExpandedPost; sourceTree = ""; }; 03D2A6352C00F91A00ED4FF2 /* Session */ = { isa = PBXGroup; children = ( 03D2A6362C00F92400ED4FF2 /* Session.swift */, 03D2A63C2C010CD400ED4FF2 /* UserSession.swift */, 03D2A63E2C010DBF00ED4FF2 /* GuestSession.swift */, ); path = Session; sourceTree = ""; }; 03D2A6402C011F3E00ED4FF2 /* ListRow */ = { isa = PBXGroup; children = ( CD4D58B82B86D9F800B82964 /* AccountListRow.swift */, 03D2A6412C011F4A00ED4FF2 /* AccountListRowBody.swift */, ); path = ListRow; sourceTree = ""; }; 03D3A1EB2BB8CDFB009DE55E /* Action */ = { isa = PBXGroup; children = ( CD3FC67F2D4A75050088E63B /* CounterApperance+StaticValues.swift */, 03AF91E92C1CE96600E56644 /* Counter.swift */, 0324FA762C1F0AE100F6247D /* Readout.swift */, 03D3A1D32BB88EF1009DE55E /* Action.swift */, 0397D4A12C6EB035002C6CDC /* ActionAppearance.swift */, 0397D4A32C6FBC04002C6CDC /* ActionAppearance+StaticValues.swift */, 03036C732C71408700C6DA1D /* CounterAppearance.swift */, 03D3A1F02BB9D48E009DE55E /* BasicAction.swift */, 03D3A1F22BB9D49B009DE55E /* ActionGroup.swift */, 0389DDDA2C3AB6340005B808 /* ActionBuilder.swift */, 03D3A1E42BB8B7A3009DE55E /* ActionType.swift */, 038028D22CAB3D2D0091A8A2 /* ShareActivity.swift */, ); path = Action; sourceTree = ""; }; 03FA318D2C6FEF0E00D47FA3 /* InteractionBar */ = { isa = PBXGroup; children = ( 03AF91E22C1C616F00E56644 /* InteractionBarView.swift */, 03FA318E2C6FEF2000D47FA3 /* InteractionBarActionLabelView.swift */, ); path = InteractionBar; sourceTree = ""; }; 6318EDC427EE4E0500BFCAE8 /* Models */ = { isa = PBXGroup; children = ( 03D2A6352C00F91A00ED4FF2 /* Session */, 03D3A1EB2BB8CDFB009DE55E /* Action */, 0369B3592BFB86C6001EFEDF /* Account */, CD4D58CC2B86DDC300B82964 /* Settings */, 03FE14032BF93FDD00A8377F /* ErrorDetails.swift */, 033F84C62C2B192F002E3EDF /* MlemStats */, 033F84B02C29907F002E3EDF /* FeedbackType.swift */, 031EC52F2E5F77D7003408B7 /* FeedContext.swift */, 033F84C02C2AD072002E3EDF /* CommentTreeNode.swift */, 03ECD71E2C864DB700D48BF6 /* ImageUploadManager.swift */, 031DBA6A2F9A8D6500B4BAE4 /* Events */, 0320B65B2C8CFBDC00D38548 /* ImageUploadHistoryManager.swift */, 03F6BD992D501041006A425E /* SeededRandomNumberGenerator.swift */, ); path = Models; sourceTree = ""; }; 6363D5B827EE196700E34822 = { isa = PBXGroup; children = ( 03A6315C2D4D15F1009A47A6 /* PrivacyInfo.xcprivacy */, 6363D5C327EE196700E34822 /* Mlem */, 6363D5D927EE196A00E34822 /* MlemTests */, 6363D5E327EE196A00E34822 /* MlemUITests */, 81DE61DA2F48AF4C006E4C36 /* OpenInMlem */, 6363D5C227EE196700E34822 /* Products */, 037386422BDAF574007492B5 /* Frameworks */, ); sourceTree = ""; }; 6363D5C227EE196700E34822 /* Products */ = { isa = PBXGroup; children = ( 6363D5C127EE196700E34822 /* Mlem.app */, 6363D5D627EE196A00E34822 /* MlemTests.xctest */, 6363D5E027EE196A00E34822 /* MlemUITests.xctest */, 81DE61C32F48AF44006E4C36 /* OpenInMlem.appex */, ); name = Products; sourceTree = ""; }; 6363D5C327EE196700E34822 /* Mlem */ = { isa = PBXGroup; children = ( 03DA26AC2D79F29700E66267 /* Packages */, CD4D583B2B867BB300B82964 /* App */, 6363D5C827EE196A00E34822 /* Assets.xcassets */, 030778EB2C52ED350018E61C /* Localizable.xcstrings */, 630D753C27F65E44006E60C9 /* Info.plist */, B104A6E12A5AFC9F00B3E725 /* Mlem.entitlements */, 03F6BDA12D502382006A425E /* Preview Content */, 6332FDBC27EFAF7B0009A98A /* Settings.bundle */, ); path = Mlem; sourceTree = ""; }; 6363D5D927EE196A00E34822 /* MlemTests */ = { isa = PBXGroup; children = ( CD4D583E2B86855F00B82964 /* MlemTests.swift */, ); path = MlemTests; sourceTree = ""; }; 6363D5E327EE196A00E34822 /* MlemUITests */ = { isa = PBXGroup; children = ( CD4D58402B86858100B82964 /* MlemUITests.swift */, ); path = MlemUITests; sourceTree = ""; }; 6363D5F327EE1BA900E34822 /* Views */ = { isa = PBXGroup; children = ( CD4D58A82B86BE4C00B82964 /* Shared */, CD4D583C2B867C2900B82964 /* Root */, ); path = Views; sourceTree = ""; }; 6363D5F427EE1BAE00E34822 /* Tabs */ = { isa = PBXGroup; children = ( 0369B3542BFA681B001EFEDF /* Inbox */, 03134A492BEACF46002662CC /* Settings */, CD4BAD382B4C6C1B00A1E726 /* Feeds */, 6DE1183A2A4A215F00810C7E /* Profile */, ); path = Tabs; sourceTree = ""; }; 6DE1183A2A4A215F00810C7E /* Profile */ = { isa = PBXGroup; children = ( 6DE1183B2A4A217400810C7E /* Profile View.swift */, ); path = Profile; sourceTree = ""; }; 81DE61DA2F48AF4C006E4C36 /* OpenInMlem */ = { isa = PBXGroup; children = ( 81DE61D62F48AF4C006E4C36 /* Action.js */, 81DE61D72F48AF4C006E4C36 /* ActionRequestHandler.swift */, 81DE61D82F48AF4C006E4C36 /* Info.plist */, 81DE61D92F48AF4C006E4C36 /* Media.xcassets */, 81C4B4322F493C5E001406A1 /* InfoPlist.xcstrings */, ); path = OpenInMlem; sourceTree = ""; }; CD13CC662C5D3CA7001AF428 /* Helpers */ = { isa = PBXGroup; children = ( CDB95FCC2EBD3212008669D9 /* SmallOverlayButtonLabel.swift */, CD5C197B2D9709660089614C /* ZoomRecognizer */, CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */, CD332D782CA7175200A53988 /* PlayButton.swift */, CD9857A32C5E7F9D0084C71F /* NsfwOverlayView.swift */, ); path = Helpers; sourceTree = ""; }; CD13CC672C5D3CBE001AF428 /* Core */ = { isa = PBXGroup; children = ( CD1DF6372D38357100F7851E /* MediaView.swift */, CD1DF63D2D387E8300F7851E /* MediaView+Views.swift */, CD7AC2CB2D7FDFF700A671B7 /* MediaView+Logic.swift */, ); path = Core; sourceTree = ""; }; CD13CC682C5D3CCA001AF428 /* Wrappers */ = { isa = PBXGroup; children = ( CD2783992D9B365D00DD4C69 /* ZoomableImageView.swift */, 03B04FBF2C5FC32300824128 /* SimpleAvatarView.swift */, CDB2EC892BFAEFDF00DBC0EF /* ThumbnailImageView.swift */, CD13CC642C5D2B9D001AF428 /* CircleCroppedImageView.swift */, ); path = Wrappers; sourceTree = ""; }; CD14461F2A5B328600610EF1 /* Data */ = { isa = PBXGroup; children = ( CD1446202A5B328E00610EF1 /* Privacy Policy.swift */, CD1446242A5B357900610EF1 /* Document.swift */, CD1446262A5B36DA00610EF1 /* EULA.swift */, AD1B0D362A5F7A260006F554 /* Licenses.swift */, ); path = Data; sourceTree = ""; }; CD1464522D63FE6F00202619 /* Legacy */ = { isa = PBXGroup; children = ( CDE1F18E2C63D75A008AF042 /* LegacySettings.swift */, ); path = Legacy; sourceTree = ""; }; CD317D4D2BE97FFB008F63E2 /* Colors */ = { isa = PBXGroup; children = ( 03A9FD172D7CFC20007A734D /* Palette+Oled.swift */, 03A9FD192D7CFC69007A734D /* Palette+Monochrome.swift */, 03A9FD1D2D7CFF0D007A734D /* Palette+Dracula.swift */, 03A9FD1B2D7CFD5C007A734D /* Palette+Solarized.swift */, ); path = Colors; sourceTree = ""; }; CD4BAD382B4C6C1B00A1E726 /* Feeds */ = { isa = PBXGroup; children = ( CD7928212C73CB9B00FA712D /* Components */, CD7DB9742C4D6BEF00DCC542 /* Feed Comments */, CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */, 031E2D502BEF961D0003BC45 /* SubscriptionListView.swift */, 035394852C9CDC1100795AA5 /* SubscriptionListNavigationButton.swift */, 03DAEA762C64074E0064DE64 /* SubscriptionListItemView.swift */, CD9CFA2D2C2E22E200739BBC /* Feed Header */, CDE021AB2BFA43220052FD61 /* Feed Posts */, 033F84AC2C298466002E3EDF /* SectionIndexTitles.swift */, 0311ADB82E4E0E0800EC3120 /* VisitAgainView.swift */, ); path = Feeds; sourceTree = ""; }; CD4D583B2B867BB300B82964 /* App */ = { isa = PBXGroup; children = ( 03EC85ED2E9D925B004698BB /* Actions */, CD1464522D63FE6F00202619 /* Legacy */, CDE1F18D2C63D646008AF042 /* Configuration */, CD14461F2A5B328600610EF1 /* Data */, CD4D58C62B86DCE500B82964 /* Enums */, CD4D58C22B86DC5800B82964 /* Extensions */, CD4D58942B86BA9E00B82964 /* Globals */, CD4D59112B87B35D00B82964 /* Logic */, 6318EDC427EE4E0500BFCAE8 /* Models */, CD4D58C92B86DD3200B82964 /* Protocols */, 6363D5F327EE1BA900E34822 /* Views */, ); path = App; sourceTree = ""; }; CD4D583C2B867C2900B82964 /* Root */ = { isa = PBXGroup; children = ( B1B78D632A51D53900F72485 /* AppDelegate.swift */, 6363D5C627EE196700E34822 /* ContentView.swift */, 037029A22D6B9B8400B749DF /* ContentView+Tab.swift */, 039F58902C7B5C7A00C61658 /* ContentView+Logic.swift */, 6363D5C427EE196700E34822 /* MlemApp.swift */, 038096602C10AAD8003ED1D8 /* TransitionView.swift */, CDA1E81A2B8FC39A007953EF /* Login */, 6363D5F427EE1BAE00E34822 /* Tabs */, ); path = Root; sourceTree = ""; }; CD4D58672B86B38600B82964 /* Dependencies */ = { isa = PBXGroup; children = ( CD4D58B42B86BFFA00B82964 /* PersistenceRepository+Dependency.swift */, ); path = Dependencies; sourceTree = ""; }; CD4D58942B86BA9E00B82964 /* Globals */ = { isa = PBXGroup; children = ( CD4D59212B87BD0800B82964 /* Definitions */, CD4D58672B86B38600B82964 /* Dependencies */, ); path = Globals; sourceTree = ""; }; CD4D58A82B86BE4C00B82964 /* Shared */ = { isa = PBXGroup; children = ( 03B431C32C45BA45001A1EB5 /* AccountPickerMenu.swift */, CD635E1A2C94DACD00864F75 /* BypassProxyWarningSheet.swift */, 0397D45F2C66113F002C6CDC /* CommentBodyView.swift */, 033F84BA2C2ACB96002E3EDF /* CommentView.swift */, CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */, 030FF67E2BC8544600F6BFAC /* CustomTabBarController.swift */, 030FF67C2BC8524500F6BFAC /* CustomTabItem.swift */, 030FF67A2BC8521600F6BFAC /* CustomTabView.swift */, 030FF6802BC859FD00F6BFAC /* CustomTabViewHostingController.swift */, CD7882AA2C013005002E1A30 /* EllipsisMenu.swift */, CD79281E2C73B52A00FA712D /* EndOfFeedView.swift */, 03FE14072BF94FFB00A8377F /* ErrorView.swift */, 034B948D2C0937BA00039AF4 /* FancyScrollView.swift */, 03D284012D29E03C00A6659B /* FeedFilterButtonStyle.swift */, 038C85D72D87696F00543F70 /* FeedToolbarOptions.swift */, 037DE0792CE108D9007F7B92 /* FooterLinkView.swift */, 033F84CB2C2B1E46002E3EDF /* HandleThreadiverseLinksModifier.swift */, 0391E0FA2D0066240040CCA8 /* ImageUploadMenu.swift */, 0324FA7A2C1F2CD200F6247D /* InfoStackView.swift */, 039F58802C7A7E5900C61658 /* JumpButtonView.swift */, 03EC83EF2E9590D3004698BB /* LinkHostView.swift */, 03B431C12C45BA00001A1EB5 /* MarkdownEditorToolbarView.swift */, 03B431B32C4481C3001A1EB5 /* MarkdownTextEditor.swift */, 03AB484E2CBAE33500567FF9 /* MarkdownWithLinkList.swift */, 03D3A1EE2BB9CA1D009DE55E /* MenuButton.swift */, 0316CD632C382A6A009EA8EA /* MessageView.swift */, 033F84652D1C780900D87A9E /* ModlogButtonView.swift */, CD77437E2C1BA5CE0085BB43 /* MultiplatformView.swift */, CD7DB9702C49C17200DCC542 /* PersonContentGridView.swift */, 035DF9102EB2AA160021DE8C /* PersonContentGridView+FeedLoaderType.swift */, 038028D92CACACD30091A8A2 /* PostEllipsisMenus.swift */, CD9CFA362C306DAB00739BBC /* PostGridView.swift */, 0382A7F12C0A758E00C79DDA /* ProfileDateView.swift */, CD0374292D1DBFCF001E85FA /* ReadCheck.swift */, 038028FE2CB72AC90091A8A2 /* ReasonShortcutView.swift */, 030030A02C416B0B009A65FF /* RefreshPopupView.swift */, 0305EBB12D35C1B70066E5AD /* RegistrationApplicationView.swift */, 032C32152C36F65500595286 /* ReplyView.swift */, 030050D22D109B7E002B1E99 /* ReportView.swift */, 03B62B762CE295530077E9C8 /* RulesListView.swift */, 03B62B782CE2A2C00077E9C8 /* RulesPickerView.swift */, 032C32092C34495D00595286 /* SelectTextView.swift */, 030056BA2D7E137800EB0BA3 /* ShareInstancePickerView.swift */, CD0E07002C12707700445849 /* ToolbarEllipsisMenu.swift */, 034CC02F2D22C5BE00C557D3 /* WarningOverlayView.swift */, CDD99C3D2C73F4380010367F /* WarningView.swift */, CD13CC582C583C7A001AF428 /* WebsitePreviewView.swift */, CD13CC5A2C588B34001AF428 /* WebView.swift */, CD4D58AB2B86BE6100B82964 /* Accounts */, 03267D802BED4714009D6268 /* Avatar */, CD869FCA2C15F8A100FC8B5B /* Bubble Picker */, 03B0EB6D2C87673D00F79FDF /* ExpandedPost */, CDBEC30D2E84C9DB00F30B00 /* ExportableViews */, 03049A182C6502DB00FF6889 /* Form */, 039D75652C4FC56A004F24C2 /* Images */, 03FA318D2C6FEF0E00D47FA3 /* InteractionBar */, CDB2EC842BFADD5E00DBC0EF /* Labels */, 03D2A6402C011F3E00ED4FF2 /* ListRow */, 035BE0852BDD8D9100F77D73 /* Navigation */, 0382A7EE2C09F0F800C79DDA /* Pages */, CDAA02E42C82236200D75633 /* Palette Components */, 03531EEF2C2DA291004A3464 /* Search */, 03A630F22D4976DE009A47A6 /* ShieldsBadgeView */, 03500C252BF694A800CAA076 /* Toast */, CDC71F242F15A6B900D314B1 /* ExpectedViews */, ); path = Shared; sourceTree = ""; }; CD4D58AB2B86BE6100B82964 /* Accounts */ = { isa = PBXGroup; children = ( CD4D58A92B86BE5900B82964 /* AccountListView.swift */, CD4D58AC2B86BE7100B82964 /* QuickSwitcherView.swift */, CD4D58BA2B86DA7D00B82964 /* AccountListView+Logic.swift */, ); path = Accounts; sourceTree = ""; }; CD4D58C22B86DC5800B82964 /* Extensions */ = { isa = PBXGroup; children = ( 031DBA672F9A65DC00B4BAE4 /* BackendClient+Extensions.swift */, CD737FB92F771BF100E46411 /* InstanceSummarySoftware+Extensions.swift */, CD03B5BD2F3BA16100AEF786 /* Blockable+Extensions.swift */, CDF60A002E998BB2005FA3F1 /* Data+Extensions.swift */, CDA67A9E2D9B329D00E5D17B /* CGPoint+Extensions.swift */, CDFF9A322D88C3BE009E02E2 /* CGFloat+Extensions.swift */, CD5D8E2A2D6D5AAE00AF5CE4 /* CGSize+Extensions.swift */, 03A631C92D4FCEE3009A47A6 /* MlemMiddleware Mock */, CDAA02E02C817AAB00D75633 /* Color+Extensions.swift */, CD332D7D2CA7485D00A53988 /* String+Extensions.swift */, 0397D47F2C693A88002C6CDC /* [BlockNode]+Extensions.swift */, 03A82FA02C0D1E8500D01A5C /* ApiClient+Extensions.swift */, 033FCAEB2C57DCCD007B7CD1 /* ListingType+Extensions.swift */, 03049A1D2C6508F400FF6889 /* RegistrationMode+Extensions.swift */, 0318BA9E2D72405F006CA71F /* PostSortType+Extensions.swift */, 038C86562D888EC100543F70 /* CommentSortType+Extensions.swift */, 0368F34E2D734066007DEB70 /* SearchSortType+Extensions.swift */, 03A818AC2EDCDBA20023E9E8 /* FederationMode+Extensions.swift */, 0368F34C2D733215007DEB70 /* SortTimeRange+Extensions.swift */, CDCA44B32C176A4700C092B3 /* Array+Extensions.swift */, 0389DDC82C39658E0005B808 /* Binding+Extensions.swift */, 039F58982C7B697D00C61658 /* Bundle+Extensions.swift */, CDAA02DA2C810DB200D75633 /* Calendar+Extensions.swift */, 033FCAED2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift */, 0382A7F32C0A76A900C79DDA /* Date+Extensions.swift */, 034B94882C09360A00039AF4 /* Int+Extensions.swift */, CD869FCF2C15F92E00FC8B5B /* Int+Extensions.swift */, 034B94822C09340A00039AF4 /* MarkdownConfiguration+Extensions.swift */, CD0E06F62C0E739F00445849 /* PostType+Extensions.swift */, 0397D4712C68078F002C6CDC /* PrefetchingConfiguration+Extensions.swift */, CD4D59152B87B38C00B82964 /* UIApplication+Extensions.swift */, 0343C0472D3AD6DB001CF709 /* Set+Extensions.swift */, 03AF91E02C1B25DE00E56644 /* UIDevice+Extensions.swift */, 03B431B52C454D49001A1EB5 /* UIImage+Extensions.swift */, 03B431BF2C45ABFB001A1EB5 /* UITextView+Extensions.swift */, CD4D59172B87B3B000B82964 /* UIViewController+Extensions.swift */, CDA8A0052BE43B350022F7ED /* Content Models */, 03134A4E2BEAD23A002662CC /* Views */, 037331A32C9CB12D00C826E1 /* EnvironmentValues+Extensions.swift */, CD4E386C2C836F8C009B24F2 /* UIUserInterfaceStyle+Extensions.swift */, 03ABE5B52DB79A0E00374AFF /* DateComponents+Extensions.swift */, 0377BE3A2DE8E70D00E38593 /* HapticLevel+Extensions.swift */, 0377BD782DE22D4E00E38593 /* UsernameValidity+Extensions.swift */, 03D006312ECCBA95001BF97D /* QuickSwipeAction+Actions.swift */, 031CA5742E5900C800CF0C0F /* SwipeConfiguration+Extensions.swift */, 031CA5B42E590B0D00CF0C0F /* QuickSwipeAction+Extensions.swift */, 032A22002EB2A4D100E8B346 /* PersonContentFeedLoader+Extensions.swift */, 035DF9122EB2AF8E0021DE8C /* GetContentFilter+Extensions.swift */, ); name = Extensions; path = Utility/Extensions; sourceTree = ""; }; CD4D58C62B86DCE500B82964 /* Enums */ = { isa = PBXGroup; children = ( 81A179BE2DDE5BF300B17017 /* TabBarLongPressAction.swift */, CD6DC0292D86513800693B16 /* AnimatedAvatarBehavior.swift */, CD2C86532D5556BE0034CD8A /* MlemError.swift */, CD3485BA2D501463006748B8 /* ZoomSliderLocation.swift */, CDC44A352D1CBC200030F01C /* ReadPostIndicator.swift */, CD4D58C72B86DCED00B82964 /* AvatarType.swift */, 03CBD18C2C6120F600E870BC /* PersonFlair.swift */, 03AF91E62C1C65AE00E56644 /* Interaction */, CDA683F72C77E577000C4486 /* NsfwBlurBehavior.swift */, 039F58832C7A7F2C00C61658 /* CommentJumpButtonLocation.swift */, 0320B6622C8F8D5A00D38548 /* InstanceSort.swift */, 03E46ACA2D1216CE002589DB /* PostViewLinkType.swift */, ); path = Enums; sourceTree = ""; }; CD4D58C92B86DD3200B82964 /* Protocols */ = { isa = PBXGroup; children = ( CD4D58EA2B86E63300B82964 /* AssociatedColor.swift */, ); path = Protocols; sourceTree = ""; }; CD4D58CC2B86DDC300B82964 /* Settings */ = { isa = PBXGroup; children = ( CD4D58CD2B86DDC800B82964 /* Options */, ); path = Settings; sourceTree = ""; }; CD4D58CD2B86DDC800B82964 /* Options */ = { isa = PBXGroup; children = ( CD4D58CE2B86DDEC00B82964 /* AccountSortMode.swift */, CD4D58F72B87B0D100B82964 /* InternetSpeed.swift */, CDB2EC7A2BFADA8B00DBC0EF /* PostSize.swift */, CD7882A62BFFD1A3002E1A30 /* ThumbnailLocation.swift */, ); path = Options; sourceTree = ""; }; CD4D59112B87B35D00B82964 /* Logic */ = { isa = PBXGroup; children = ( CD8DB9392D22003D00EB0C7B /* Animations.swift */, CD4D59122B87B36300B82964 /* Networking */, 03FE140B2BF953B000A8377F /* HandleError.swift */, CD10FA762C7A8622008985AD /* ImageSaver.swift */, CD5581DD2C7B8B820043FAC3 /* ImageFunctions.swift */, ); path = Logic; sourceTree = ""; }; CD4D59122B87B36300B82964 /* Networking */ = { isa = PBXGroup; children = ( CD4D59132B87B36B00B82964 /* InternetConnectionManager.swift */, ); path = Networking; sourceTree = ""; }; CD4D59212B87BD0800B82964 /* Definitions */ = { isa = PBXGroup; children = ( CDEE15512D22190000EB9D7B /* ErrorsTracker.swift */, CD7BF9312D18F4EB0020F2C5 /* FiltersTracker.swift */, CD4D58B22B86BFD400B82964 /* AccountsTracker.swift */, 036CC3AE2B8145C30098B6A1 /* AppState.swift */, 0380965E2C10AA80003ED1D8 /* AppState+Transition.swift */, CD4D58A42B86BD1B00B82964 /* PersistenceRepository.swift */, CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */, 03A9FD1F2D7D0072007A734D /* PaletteOption.swift */, ); path = Definitions; sourceTree = ""; }; CD4ED8482BF1112A00EFA0A2 /* View Modifiers */ = { isa = PBXGroup; children = ( CD7C4E562F3B92A600ADCBDD /* View+ReloadOnAccountSwitch.swift */, CDE88C342E68938800183AE5 /* View+AccountSwitcherGesture.swift */, CDB738282CB8A69D005B11BB /* View+PaletteBorder.swift */, 030EE3032D651A4100D58C2C /* View+Refreshable.swift */, CD4ED8492BF1113800EFA0A2 /* View+TabReselectConsumer.swift */, 03A82FA22C0D1F2400D01A5C /* View+ExternalApiWarning.swift */, 030E95E62C80A20A0045BC2C /* View+NavigationTransition.swift */, 03EC83242E916C51004698BB /* View+SafeAreaBar.swift */, 0335AE102D8991330094FFD9 /* View+HiddenNavigationTitle.swift */, 036A84D72D99531400E95D50 /* View+ConditionalNavigationTitle.swift */, CD3153062C38421B00BC5FBE /* View+LoadFeed.swift */, 03B72B662C2888EE0023A6C4 /* View+ContextMenu.swift */, CD1D31822C56D742001B434B /* View+WidthReader.swift */, 033FCB3D2C5E7FA9007B7CD1 /* View+OutdatedFeedPopup.swift */, CD0F28092C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift */, 0395BCF72D9C57DE00865B33 /* View+Background.swift */, CDFB8C682C7796020070845F /* View+DynamicBlur.swift */, CD1B2E202C7F84160075C7EA /* View+MarkReadOnScroll.swift */, 03FD6CAF2C9B719100500FD6 /* View+PopupAnchor.swift */, 03EC86452E9E9D4C004698BB /* PopupAnchorModel.swift */, 037029A02D6B9A3900B749DF /* View+TabBarPreview.swift */, 034065C22D83742900637308 /* View+NavigtionStackPreview.swift */, 031CA5B62E599F7E00CF0C0F /* View+QuickSwipes.swift */, ); path = "View Modifiers"; sourceTree = ""; }; CD5C197B2D9709660089614C /* ZoomRecognizer */ = { isa = PBXGroup; children = ( CDCC7FF22D9B283400CE18DA /* ZoomRecognizerCoordinator+Logic.swift */, CDCC7FF02D9B27CE00CE18DA /* ZoomRecognizerCoordinator+GestureRecognition.swift */, CDCC7FEE2D9B1E6E00CE18DA /* CachedComputation.swift */, CDCC7FEC2D9AEDBE00CE18DA /* BridgeDragValue.swift */, CDCC1BA82D99BEA8006579DF /* ZoomRecognizerCoordinator.swift */, CDCC1BA62D99BDB0006579DF /* GestureRecognizers.swift */, CD5C197E2D970E570089614C /* MomentumStatus.swift */, CD5C197C2D97096D0089614C /* ZoomCurves.swift */, CD9BD5802D8F8F750006AB7F /* ZoomRecognizer.swift */, ); path = ZoomRecognizer; sourceTree = ""; }; CD7928212C73CB9B00FA712D /* Components */ = { isa = PBXGroup; children = ( 814CEF622F44577A0090F812 /* HiddenReadBannerView.swift */, CD7928222C73CBA400FA712D /* TileScoreView.swift */, 0353948C2CA080EB00795AA5 /* FeedWelcomeView.swift */, 036A84542D98253400E95D50 /* UpdateBannerView.swift */, ); path = Components; sourceTree = ""; }; CD7DB9742C4D6BEF00DCC542 /* Feed Comments */ = { isa = PBXGroup; children = ( CD7DB9722C4AEDDE00DCC542 /* TileCommentView.swift */, CD7DB9752C4D6C0A00DCC542 /* FeedCommentView.swift */, ); path = "Feed Comments"; sourceTree = ""; }; CD869FCA2C15F8A100FC8B5B /* Bubble Picker */ = { isa = PBXGroup; children = ( CD869FCB2C15F8AC00FC8B5B /* BubblePickerView.swift */, CD869FCD2C15F90C00FC8B5B /* ChildSizeReader.swift */, ); path = "Bubble Picker"; sourceTree = ""; }; CD87BEBF2D51328B0099F190 /* Editors */ = { isa = PBXGroup; children = ( 03B7F3342EEEC70F00B00F6A /* NoteEditorView.swift */, CD87BEC22D5132BB0099F190 /* FilterViolationWarning.swift */, 0315B1BC2C74C3CD006D4F82 /* CommentEditor */, 0397D4792C693444002C6CDC /* ReportEditorView.swift */, 038028FC2CB72A2A0091A8A2 /* ContentRemovalEditorView.swift */, 0372EC1F2D370F0200257095 /* RegistrationApplicationDenialEditorView.swift */, 0331715D2CCD6D95002DA370 /* ContentPurgeEditorView.swift */, 03F967262CE218110081C9A3 /* PersonBanEditorView.swift */, 03B62C3F2CE811CB0077E9C8 /* PersonBanEditorView+Logic.swift */, 035DFA242EB3FB550021DE8C /* CommunityDescriptionEditorView.swift */, ); path = Editors; sourceTree = ""; }; CD9CFA2D2C2E22E200739BBC /* Feed Header */ = { isa = PBXGroup; children = ( CD9CFA2F2C2E22F600739BBC /* FeedDescription.swift */, CD9CFA2B2C2E1EF300739BBC /* FeedIconView.swift */, CD9CFA292C2E1E8400739BBC /* FeedHeaderView.swift */, ); path = "Feed Header"; sourceTree = ""; }; CDA1E81A2B8FC39A007953EF /* Login */ = { isa = PBXGroup; children = ( 0348F98B2DDBB505006639CD /* Onboarding */, 039EFEC22BEEBEE0003AC372 /* LoginInstancePickerView.swift */, 03C93CEF2BEFFB1A00327BFE /* LoginCredentialsView.swift */, 03CCDAA32BF2852E00C0C851 /* LoginTotpView.swift */, 0320B64E2C8A638A00D38548 /* SignUpView.swift */, 0320B6572C8BB3C400D38548 /* SignUpView+Views.swift */, 0320B6662C93504600D38548 /* SignUpView+Logic.swift */, 0320B6592C8CBB0D00D38548 /* SignUpView+EmailConfirmationView.swift */, ); path = Login; sourceTree = ""; }; CDA8A0052BE43B350022F7ED /* Content Models */ = { isa = PBXGroup; children = ( CDADDB222F491D2F00A4214A /* Community */, CD2276D42F369C5F0024AEB1 /* Person */, CD6CD1222F25EC6A00566122 /* Comment */, CD6CD1212F25C36800566122 /* Post */, CD6CD11A2F25BF1300566122 /* Interactable */, 032C32032C3439C600595286 /* ActorIdentifiable+Extensions.swift */, 030056A52D7DBD4F00EB0BA3 /* Sharable+Extensions.swift */, 0305EBAB2D32C9300066E5AD /* ModlogEntryType+Extensions.swift */, 0320B6532C8B65EB00D38548 /* Captcha+Extensions.swift */, 034B94802C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift */, 039D75632C4EEE69004F24C2 /* DeletableProviding+Extensions.swift */, 0389DDC42C38917A0005B808 /* InboxItemProviding+Extensions.swift */, 0325B93B2D3AA62500E28B97 /* InboxItemType+Extensions.swift */, 03D283FF2D26F09500A6659B /* Instance+Extensions.swift */, 0389DDC22C38907C0005B808 /* Message1Providing+Extensions.swift */, 033F84772D1D68D200D87A9E /* ModlogEntryContent+Extensions.swift */, 03D0273B2CD3BA5100984519 /* PersonContent+Extensions.swift */, 036ED6822D0C483B0018E5EA /* ProfileProviding+Extensions.swift */, 033171772CCE89E3002DA370 /* PurgableProviding+Extensions.swift */, 0372EBCD2D36FBCF00257095 /* RegistrationApplication+Extensions.swift */, 034690922D0F4D720073E664 /* RemovableProviding+Extensions.swift */, 030050D42D10AE30002B1E99 /* Report+Extensions.swift */, 0397D4852C6A24D2002C6CDC /* ReportableProviding+Extensions.swift */, 03E46AD32D130728002589DB /* ScoringOperation+Extensions.swift */, 032C32072C34469900595286 /* SelectableContentProviding+Extensions.swift */, 0389DDC62C389F840005B808 /* UnreadCount+Extensions.swift */, CD7743882C20EDEE0085BB43 /* VotesModel+Extensions.swift */, 03ACE7192DFD57CC00FD66C0 /* SiteSoftware+Extensions.swift */, 03B045F52E26D64900540EFB /* SiteSoftwareType+Extensions.swift */, ); path = "Content Models"; sourceTree = ""; }; CDAA02E42C82236200D75633 /* Palette Components */ = { isa = PBXGroup; children = ( CDAA02E22C821C9100D75633 /* Divider.swift */, CDD8B94B2C8234BC00510EBB /* Form.swift */, CDB41E892C83C24400BD2DE9 /* Section.swift */, CDD4A09D2C8B69FC0001AD1A /* Button.swift */, ); path = "Palette Components"; sourceTree = ""; }; CDB2EC842BFADD5E00DBC0EF /* Labels */ = { isa = PBXGroup; children = ( CDB2EC852BFADD7C00DBC0EF /* FullyQualifiedLabelView.swift */, 03E614E62C0BCDC200F692A4 /* FullyQualifiedLinkView.swift */, CDB2EC872BFAE14800DBC0EF /* FullyQualifiedNameView.swift */, ); path = Labels; sourceTree = ""; }; CDBEC30D2E84C9DB00F30B00 /* ExportableViews */ = { isa = PBXGroup; children = ( CDD8E30C2EEA07EE00FC4C8D /* ExportableCommentLoader.swift */, CDA1D2A42ED8C4670077A9EA /* ExportableViewComponents.swift */, CD756A972ED766930031D7D1 /* ExportableCommentEditorView.swift */, CD756A952ED765E90031D7D1 /* ExportableCommentView.swift */, CD0C5C0F2E9962970074D5A4 /* ExportablePostEditorView.swift */, CDBEC30E2E84C9F300F30B00 /* ExportablePostView.swift */, ); path = ExportableViews; sourceTree = ""; }; CDBFCB662C04EA59008CD468 /* Feed Post Components */ = { isa = PBXGroup; children = ( CD7882A82BFFDFC7002E1A30 /* PostTag.swift */, CDBFCB642C03920C008CD468 /* PostLinkHostView.swift */, 0353949B2CA4B3E800795AA5 /* CrossPostListView.swift */, ); path = "Feed Post Components"; sourceTree = ""; }; CDE021AB2BFA43220052FD61 /* Feed Posts */ = { isa = PBXGroup; children = ( CDB2EC7C2BFADAB300DBC0EF /* CompactPostView.swift */, CD950E042F0ED6F7002A0595 /* FeedPostView.swift */, 03E46ACC2D121C19002589DB /* HeadlinePostBodyView.swift */, CDB2EC7E2BFADACC00DBC0EF /* HeadlinePostView.swift */, 03B431BB2C455838001A1EB5 /* LargePostBodyView.swift */, CDB2EC802BFADADF00DBC0EF /* LargePostView.swift */, CDBFCB6B2C054AA7008CD468 /* TilePostView.swift */, CDBFCB662C04EA59008CD468 /* Feed Post Components */, 037352322F27A83900341673 /* PostPollView.swift */, ); path = "Feed Posts"; sourceTree = ""; }; CDE1F18D2C63D646008AF042 /* Configuration */ = { isa = PBXGroup; children = ( CDF9EF322AB2845C003F885B /* Icons.swift */, CD317D4D2BE97FFB008F63E2 /* Colors */, CDE1F19A2C63E293008AF042 /* Constants */, CDE1F1992C63E277008AF042 /* User Settings */, ); path = Configuration; sourceTree = ""; }; CDE1F1992C63E277008AF042 /* User Settings */ = { isa = PBXGroup; children = ( CDD4A09B2C8A122F0001AD1A /* Settings.swift */, CDB3DDFC2DA485D000F407AB /* SettingsValues.swift */, CDE1F19B2C63E2EB008AF042 /* SettingPropertyWrapper.swift */, 036ED6782D0AF3740018E5EA /* PinnedSortTracker.swift */, ); path = "User Settings"; sourceTree = ""; }; CDE1F19A2C63E293008AF042 /* Constants */ = { isa = PBXGroup; children = ( CDE1F19F2C63E388008AF042 /* Platform Constants */, CDE1F19D2C63E306008AF042 /* Constants.swift */, ); path = Constants; sourceTree = ""; }; CDE1F19F2C63E388008AF042 /* Platform Constants */ = { isa = PBXGroup; children = ( CDE1F1932C63DF44008AF042 /* PlatformConstants.swift */, CDE1F1952C63DF89008AF042 /* PhoneConstants.swift */, CDE1F1972C63DFC9008AF042 /* PadConstants.swift */, ); path = "Platform Constants"; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ 03E96D082BD6ED7E00B7A98F /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ 6363D5C027EE196700E34822 /* Mlem */ = { isa = PBXNativeTarget; buildConfigurationList = 6363D5EA27EE196A00E34822 /* Build configuration list for PBXNativeTarget "Mlem" */; buildPhases = ( 03E96D082BD6ED7E00B7A98F /* Headers */, 6363D5BD27EE196700E34822 /* Sources */, 50F830EB2A47CC4F00D67099 /* Swiftlint */, 6363D5BE27EE196700E34822 /* Frameworks */, 6363D5BF27EE196700E34822 /* Resources */, 81DE61D12F48AF44006E4C36 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( 81DE61CF2F48AF44006E4C36 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 031DBA6A2F9A8D6500B4BAE4 /* Events */, 03DA26AC2D79F29700E66267 /* Packages */, 03EC85ED2E9D925B004698BB /* Actions */, 03F6BDA12D502382006A425E /* Preview Content */, CD2276D42F369C5F0024AEB1 /* Person */, CD6CD11A2F25BF1300566122 /* Interactable */, CD6CD1212F25C36800566122 /* Post */, CD6CD1222F25EC6A00566122 /* Comment */, CDADDB222F491D2F00A4214A /* Community */, CDC71F242F15A6B900D314B1 /* ExpectedViews */, ); name = Mlem; packageProductDependencies = ( 636250DB2A18111400FC59B4 /* KeychainAccess */, B104A6D72A59BF3C00B3E725 /* Nuke */, B104A6D92A59BF3C00B3E725 /* NukeExtensions */, B104A6DB2A59BF3C00B3E725 /* NukeUI */, B104A6DD2A59BF3C00B3E725 /* NukeVideo */, 50C99B552A61D792005D57DD /* Dependencies */, CD4368C02AE23FD400BD8BD1 /* Semaphore */, 030FF6782BC84F7E00F6BFAC /* SwiftUIIntrospect */, 037386462BDAFE81007492B5 /* LemmyMarkdownUI */, 03FA318B2C6FECAE00D47FA3 /* Flow */, CDE4AC462CA372B600981010 /* SDWebImageWebPCoder */, 03A9FD152D7CEC09007A734D /* Theming */, 0341480C2D8F63A6005503AF /* MlemMiddleware */, 036879992DA1320000E796EF /* ComponentViews */, CDA711FA2DB5CAC3008BC3ED /* Media */, 03D8BF422DA55B6900506687 /* Icons */, 0377BF9A2DF0E0E000E38593 /* Rest */, 0377BE282DE7A2DE00E38593 /* Haptics */, 031CA5762E5900E900CF0C0F /* QuickSwipes */, 03EC83ED2E958A44004698BB /* OpenGraph */, 03EC85EB2E9D8F37004698BB /* Actions */, 038100502F6AE867008A7731 /* MlemBackend */, 0347A6FA2F97F4CF00EFD670 /* FediverseEvents */, ); productName = Mlem; productReference = 6363D5C127EE196700E34822 /* Mlem.app */; productType = "com.apple.product-type.application"; }; 6363D5D527EE196A00E34822 /* MlemTests */ = { isa = PBXNativeTarget; buildConfigurationList = 6363D5ED27EE196A00E34822 /* Build configuration list for PBXNativeTarget "MlemTests" */; buildPhases = ( 6363D5D227EE196A00E34822 /* Sources */, 6363D5D327EE196A00E34822 /* Frameworks */, 6363D5D427EE196A00E34822 /* Resources */, ); buildRules = ( ); dependencies = ( 6363D5D827EE196A00E34822 /* PBXTargetDependency */, ); name = MlemTests; productName = MlemTests; productReference = 6363D5D627EE196A00E34822 /* MlemTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 6363D5DF27EE196A00E34822 /* MlemUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 6363D5F027EE196A00E34822 /* Build configuration list for PBXNativeTarget "MlemUITests" */; buildPhases = ( 6363D5DC27EE196A00E34822 /* Sources */, 6363D5DD27EE196A00E34822 /* Frameworks */, 6363D5DE27EE196A00E34822 /* Resources */, ); buildRules = ( ); dependencies = ( 6363D5E227EE196A00E34822 /* PBXTargetDependency */, ); name = MlemUITests; productName = MlemUITests; productReference = 6363D5E027EE196A00E34822 /* MlemUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; 81DE61C22F48AF44006E4C36 /* OpenInMlem */ = { isa = PBXNativeTarget; buildConfigurationList = 81DE61D52F48AF44006E4C36 /* Build configuration list for PBXNativeTarget "OpenInMlem" */; buildPhases = ( 81DE61BF2F48AF44006E4C36 /* Sources */, 81DE61C02F48AF44006E4C36 /* Frameworks */, 81DE61C12F48AF44006E4C36 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = OpenInMlem; packageProductDependencies = ( ); productName = OpenInMlem; productReference = 81DE61C32F48AF44006E4C36 /* OpenInMlem.appex */; productType = "com.apple.product-type.app-extension"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 6363D5B927EE196700E34822 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 1610; TargetAttributes = { 6363D5C027EE196700E34822 = { CreatedOnToolsVersion = 13.3; }; 6363D5D527EE196A00E34822 = { CreatedOnToolsVersion = 13.3; LastSwiftMigration = 1500; TestTargetID = 6363D5C027EE196700E34822; }; 6363D5DF27EE196A00E34822 = { CreatedOnToolsVersion = 13.3; LastSwiftMigration = 1500; TestTargetID = 6363D5C027EE196700E34822; }; 81DE61C22F48AF44006E4C36 = { CreatedOnToolsVersion = 26.2; }; }; }; buildConfigurationList = 6363D5BC27EE196700E34822 /* Build configuration list for PBXProject "Mlem" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, "en-GB", fr, ); mainGroup = 6363D5B827EE196700E34822; packageReferences = ( 636250DA2A18111400FC59B4 /* XCRemoteSwiftPackageReference "KeychainAccess" */, B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */, 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */, CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */, 0392826E2BC84E480097F91A /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 037386452BDAFE81007492B5 /* XCRemoteSwiftPackageReference "LemmyMarkdownUI" */, 03FA318A2C6FEC5400D47FA3 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, CDE4AC412CA3706F00981010 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, 03EC83EC2E958A44004698BB /* XCRemoteSwiftPackageReference "OpenGraph" */, ); productRefGroup = 6363D5C227EE196700E34822 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 6363D5C027EE196700E34822 /* Mlem */, 6363D5D527EE196A00E34822 /* MlemTests */, 6363D5DF27EE196A00E34822 /* MlemUITests */, 81DE61C22F48AF44006E4C36 /* OpenInMlem */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 6363D5BF27EE196700E34822 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6332FDBD27EFAF7C0009A98A /* Settings.bundle in Resources */, 030778EC2C52ED350018E61C /* Localizable.xcstrings in Resources */, 6363D5C927EE196A00E34822 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5D427EE196A00E34822 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5DE27EE196A00E34822 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 81DE61C12F48AF44006E4C36 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 81C4B4332F493C5E001406A1 /* InfoPlist.xcstrings in Resources */, 81DE61DB2F48AF4C006E4C36 /* Action.js in Resources */, 81DE61DD2F48AF4C006E4C36 /* Media.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 50F830EB2A47CC4F00D67099 /* Swiftlint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = Swiftlint; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint lint --strict\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 6363D5BD27EE196700E34822 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 03DA4FB72CF115FB001C3C77 /* CommentEditorView.swift in Sources */, 039F58822C7A7EF300C61658 /* ToolbarEllipsisMenu.swift in Sources */, CD332D792CA7175500A53988 /* PlayButton.swift in Sources */, 03A9FD1E2D7CFF0D007A734D /* Palette+Dracula.swift in Sources */, 03531EEC2C2D81DC004A3464 /* LinkSettingsView.swift in Sources */, 038E5C132F6D617C00C54DEB /* ImageViewerSettingsView.swift in Sources */, 03AB48552CBC0B8000567FF9 /* AccountAdvancedSettingsView.swift in Sources */, 039F588A2C7B54FE00C61658 /* GeneralSettingsView.swift in Sources */, 03F967272CE218110081C9A3 /* PersonBanEditorView.swift in Sources */, 03AD0A822CFDBFA0001EF9F7 /* AccountLocalSettingsView.swift in Sources */, 034A82032EBA688F00E5F904 /* LinkEditorView.swift in Sources */, 039F58992C7B697D00C61658 /* Bundle+Extensions.swift in Sources */, 035DFA252EB3FB550021DE8C /* CommunityDescriptionEditorView.swift in Sources */, 033F84D92C2B61FB002E3EDF /* ToastType.swift in Sources */, 03600D932D4531DF00C704CB /* PrivacyBypassImageProxySettingsView.swift in Sources */, 03A82FA12C0D1E8500D01A5C /* ApiClient+Extensions.swift in Sources */, 0397D47A2C693444002C6CDC /* ReportEditorView.swift in Sources */, CDA683F82C77E577000C4486 /* NsfwBlurBehavior.swift in Sources */, 03B25B2F2CC43F8600EB6DF5 /* InstanceSafetyView.swift in Sources */, CDADDB272F49210A00A4214A /* CommunityStubResolutionPage.swift in Sources */, 035DF9112EB2AA160021DE8C /* PersonContentGridView+FeedLoaderType.swift in Sources */, 03E0EF452CA74036002CB66C /* CommentPage.swift in Sources */, 030BCB1B2C3EA5FD0037680F /* InstanceDetailsView.swift in Sources */, 03134A582BEC1C46002662CC /* AccountListSettingsView.swift in Sources */, CDCC1BA72D99BDB7006579DF /* GestureRecognizers.swift in Sources */, 03E614E52C0BCCAA00F692A4 /* PostSize.swift in Sources */, 03B72B672C2888EE0023A6C4 /* View+ContextMenu.swift in Sources */, 81A179BF2DDE5BF300B17017 /* TabBarLongPressAction.swift in Sources */, CDD8E30D2EEA07F100FC4C8D /* ExportableCommentLoader.swift in Sources */, 03B25B352CC4446400EB6DF5 /* FediseerOpinionListView.swift in Sources */, CDB2EC7D2BFADAB300DBC0EF /* CompactPostView.swift in Sources */, CD33CA522D3C18BF00106C8C /* ImageViewer+Views.swift in Sources */, 031CA5B52E590B0D00CF0C0F /* QuickSwipeAction+Extensions.swift in Sources */, 030056A42D7DB05A00EB0BA3 /* SharingLinksSettingsView.swift in Sources */, 0315B1C12C74C71A006D4F82 /* CommentEditorView+Context.swift in Sources */, 03D283FE2D25EEC500A6659B /* SearchView+Views.swift in Sources */, 03AFD0DF2C3B2E000054B8AD /* PersonListRow.swift in Sources */, 03A6315E2D4D1A1B009A47A6 /* DefaultFeedSettingsView.swift in Sources */, 03DAEA772C64074E0064DE64 /* SubscriptionListItemView.swift in Sources */, 0324FA772C1F0AE100F6247D /* Readout.swift in Sources */, 037F783F2D3BD05500D4E180 /* PostSettingsView+PostSizePicker.swift in Sources */, 0389DDCF2C39CB0E0005B808 /* SearchView.swift in Sources */, 03A630F42D4976EB009A47A6 /* ShieldsBadgeView+Logic.swift in Sources */, CD1DF63E2D387E8500F7851E /* MediaView+Views.swift in Sources */, 03FE14042BF93FDD00A8377F /* ErrorDetails.swift in Sources */, 0353948B2CA076D000795AA5 /* InboxView+Views.swift in Sources */, 03D662A52F5377630041ADAF /* ActionSeedSections.swift in Sources */, 03BF11CC2D404F2C00CC1F66 /* CommentMaximumDepthSettingsView.swift in Sources */, 0389DDD12C39E1030005B808 /* InstanceListRowBody.swift in Sources */, 0305EBAA2D32B3B80066E5AD /* ModlogView+Logic.swift in Sources */, 033FCAF42C59843E007B7CD1 /* CommunityView.swift in Sources */, CD7928262C73E73400FA712D /* PersonView+Logic.swift in Sources */, 0382A7F22C0A758E00C79DDA /* ProfileDateView.swift in Sources */, 031E2D5B2BEFC9460003BC45 /* ThemeSettingsView.swift in Sources */, CDCC7FED2D9AEDC800CE18DA /* BridgeDragValue.swift in Sources */, 03D3A1F12BB9D48E009DE55E /* BasicAction.swift in Sources */, 033F84662D1C780900D87A9E /* ModlogButtonView.swift in Sources */, 03D2A6392C00FAE000ED4FF2 /* UserAccount.swift in Sources */, CDB738292CB8A6A5005B11BB /* View+PaletteBorder.swift in Sources */, CD1DF6382D38357500F7851E /* MediaView.swift in Sources */, CD756A962ED765EC0031D7D1 /* ExportableCommentView.swift in Sources */, 03C93CF02BEFFB1A00327BFE /* LoginCredentialsView.swift in Sources */, CD13CC652C5D2B9D001AF428 /* CircleCroppedImageView.swift in Sources */, 0325B93E2D3AAE9E00E28B97 /* SettingsHeaderView.swift in Sources */, 0305EBAC2D32C9300066E5AD /* ModlogEntryType+Extensions.swift in Sources */, 03A9FD1C2D7CFD5C007A734D /* Palette+Solarized.swift in Sources */, 03D284022D29E03C00A6659B /* FeedFilterButtonStyle.swift in Sources */, 03AD0A842CFDC557001EF9F7 /* AccountNicknameFieldView.swift in Sources */, 035EDF032C2ED0DE00F51144 /* PersonListRowBody.swift in Sources */, 03FD6CB02C9B719100500FD6 /* View+PopupAnchor.swift in Sources */, 0331715E2CCD6D95002DA370 /* ContentPurgeEditorView.swift in Sources */, CDBFCB652C03920C008CD468 /* PostLinkHostView.swift in Sources */, 03B04FC02C5FC32300824128 /* SimpleAvatarView.swift in Sources */, 031CA5B72E599F7E00CF0C0F /* View+QuickSwipes.swift in Sources */, 035BE08D2BDE88EC00F77D73 /* NavigationLayerView.swift in Sources */, 035BE0872BDD8DA000F77D73 /* NavigationRootView.swift in Sources */, 03A9FD182D7CFC20007A734D /* Palette+Oled.swift in Sources */, 03A630EF2D497143009A47A6 /* TappableLinksSettingsView.swift in Sources */, 038E62E02F6F0FC600C54DEB /* ContextMenuConfiguration.swift in Sources */, CDCC7FF12D9B27D800CE18DA /* ZoomRecognizerCoordinator+GestureRecognition.swift in Sources */, 0315B1BE2C74C3D6006D4F82 /* CommentEditorView+Logic.swift in Sources */, CDB2EC7F2BFADACC00DBC0EF /* HeadlinePostView.swift in Sources */, CD87BEC32D5132BE0099F190 /* FilterViolationWarning.swift in Sources */, 037352332F27A83900341673 /* PostPollView.swift in Sources */, 0318BA9F2D72405F006CA71F /* PostSortType+Extensions.swift in Sources */, 032C32162C36F65500595286 /* ReplyView.swift in Sources */, CD1D31832C56D742001B434B /* View+WidthReader.swift in Sources */, 0325B93A2D3A9E8100E28B97 /* InboxBadgeSettingsView.swift in Sources */, CD7928232C73CBA400FA712D /* TileScoreView.swift in Sources */, 03AF91E12C1B25DE00E56644 /* UIDevice+Extensions.swift in Sources */, 0389DDC52C38917A0005B808 /* InboxItemProviding+Extensions.swift in Sources */, 036ED67B2D0B004D0018E5EA /* AdvancedSortView+SortButton.swift in Sources */, CD13CC5B2C588B34001AF428 /* WebView.swift in Sources */, 0372EC202D370F0200257095 /* RegistrationApplicationDenialEditorView.swift in Sources */, 039EFEC32BEEBEE0003AC372 /* LoginInstancePickerView.swift in Sources */, 03AB484F2CBAE33500567FF9 /* MarkdownWithLinkList.swift in Sources */, 03531EF12C2DA298004A3464 /* SearchResultsView.swift in Sources */, 033F84512D196AFD00D87A9E /* MessageFeedView+Logic.swift in Sources */, CD9CFA2A2C2E1E8400739BBC /* FeedHeaderView.swift in Sources */, 03BF11CA2D4027E900CC1F66 /* DevicePickerItem.swift in Sources */, 037029A12D6B9A3900B749DF /* View+TabBarPreview.swift in Sources */, 033F84C12C2AD072002E3EDF /* CommentTreeNode.swift in Sources */, 038E1ACA2F59C18100D30F01 /* CommunitySettingsView.swift in Sources */, 034B94832C09340A00039AF4 /* MarkdownConfiguration+Extensions.swift in Sources */, 034CC0302D22C5BE00C557D3 /* WarningOverlayView.swift in Sources */, CDB3DDFD2DA485D200F407AB /* SettingsValues.swift in Sources */, 034690932D0F4D720073E664 /* RemovableProviding+Extensions.swift in Sources */, 037F78432D3C129B00D4E180 /* PostThumbnailSettingsView.swift in Sources */, 039F58952C7B618F00C61658 /* InboxSettingsView.swift in Sources */, CD4D58CF2B86DDEC00B82964 /* AccountSortMode.swift in Sources */, CDE1F18F2C63D75A008AF042 /* LegacySettings.swift in Sources */, 03D2A6372C00F92400ED4FF2 /* Session.swift in Sources */, 03AF91DD2C1B23E500E56644 /* ImageViewer.swift in Sources */, CD13CC592C583C7A001AF428 /* WebsitePreviewView.swift in Sources */, CDA67A9F2D9B32A100E5D17B /* CGPoint+Extensions.swift in Sources */, 03BF11C52D3D3AFB00CC1F66 /* PostReadIndicatorSettingsView.swift in Sources */, CD869FCE2C15F90C00FC8B5B /* ChildSizeReader.swift in Sources */, 035EDEF12C2DE94B00F51144 /* DefaultTextInputType.swift in Sources */, 039F588C2C7B574E00C61658 /* AdvancedSettingsView.swift in Sources */, CDDA49A82F7044D2004A5AFF /* InstanceStubResolutionPage.swift in Sources */, 0320B6672C93504600D38548 /* SignUpView+Logic.swift in Sources */, 03E46ACD2D121C19002589DB /* HeadlinePostBodyView.swift in Sources */, 814CEF632F44577A0090F812 /* HiddenReadBannerView.swift in Sources */, 039F58882C7B531800C61658 /* SquircleLabelStyle.swift in Sources */, 035EDEF22C2DE94B00F51144 /* _assignIfNotEqual.swift in Sources */, 035EDEF32C2DE94B00F51144 /* SearchBar.swift in Sources */, 03049A1A2C6502F300FF6889 /* FormSection.swift in Sources */, 035EDEF42C2DE94B00F51144 /* SearchBar+NavigationView.swift in Sources */, 0381F7282F6724D3008A7731 /* ReplyBarConfiguration+Types.swift in Sources */, CD5581DE2C7B8B820043FAC3 /* ImageFunctions.swift in Sources */, 034065C32D83742900637308 /* View+NavigtionStackPreview.swift in Sources */, 035EDEF52C2DE94B00F51144 /* SearchBarExtensions.swift in Sources */, 03134A522BEAD69F002662CC /* SettingsPage.swift in Sources */, 03049A1C2C65039400FF6889 /* ActiveUserCountView.swift in Sources */, 03A9FD202D7D0072007A734D /* PaletteOption.swift in Sources */, 0389DDDB2C3AB6340005B808 /* ActionBuilder.swift in Sources */, 036ED67F2D0B9A520018E5EA /* CommunitySearchSortPicker.swift in Sources */, 038028FA2CB097CB0091A8A2 /* SearchView+LocationPicker.swift in Sources */, CDD8B94C2C8234BC00510EBB /* Form.swift in Sources */, 036FFA2F2D45197300998D8A /* PrivacySettingsView.swift in Sources */, 0320B6652C91DBD500D38548 /* NavigationPage+View.swift in Sources */, 03B25B332CC440A600EB6DF5 /* FediseerOpinionView.swift in Sources */, 038C85D82D87696F00543F70 /* FeedToolbarOptions.swift in Sources */, 03F6BD942D500DED006A425E /* PersonMockType.swift in Sources */, 03E46AD42D130728002589DB /* ScoringOperation+Extensions.swift in Sources */, 03CCDAA02BF2795300C0C851 /* LoginPage.swift in Sources */, 032C32082C34469900595286 /* SelectableContentProviding+Extensions.swift in Sources */, 03E46ACB2D1216CE002589DB /* PostViewLinkType.swift in Sources */, 0305EBB22D35C1B70066E5AD /* RegistrationApplicationView.swift in Sources */, 032C32182C36F70300595286 /* ReplyBarConfiguration.swift in Sources */, CDD4A0A02C8B985D0001AD1A /* ImportExportSettingsView.swift in Sources */, 03B431B62C454D49001A1EB5 /* UIImage+Extensions.swift in Sources */, 03EC86462E9E9D4D004698BB /* PopupAnchorModel.swift in Sources */, CD93420B2DCD069800945333 /* InstanceUptimeView+Logic.swift in Sources */, CD1446212A5B328E00610EF1 /* Privacy Policy.swift in Sources */, 03AFD0E32C3C0C540054B8AD /* InstanceView.swift in Sources */, CDE88C352E68938D00183AE5 /* View+AccountSwitcherGesture.swift in Sources */, CDD4A09C2C8A122F0001AD1A /* Settings.swift in Sources */, 03AB906F2D418C200054D9E1 /* CommentJumpButtonSettingsView.swift in Sources */, 0381F7162F671427008A7731 /* PostBarConfiguration+Types.swift in Sources */, 033819282D4424D9000AFC55 /* SafetyWarningsSettingsView.swift in Sources */, 030EE3042D651A4100D58C2C /* View+Refreshable.swift in Sources */, 03ECD7212C8654BA00D48BF6 /* PostEditorView+Toolbar.swift in Sources */, CD3485BD2D501573006748B8 /* ZoomSliderSettingsView.swift in Sources */, 033EF4102CB9AEF7004D8A3F /* ExpandedPostView+Views.swift in Sources */, CDF60A012E998BB5005FA3F1 /* Data+Extensions.swift in Sources */, 03FE140C2BF953B000A8377F /* HandleError.swift in Sources */, 0320B65A2C8CBB0D00D38548 /* SignUpView+EmailConfirmationView.swift in Sources */, 03ECD71F2C864DB700D48BF6 /* ImageUploadManager.swift in Sources */, CD1446272A5B36DA00610EF1 /* EULA.swift in Sources */, 036A84D82D99531400E95D50 /* View+ConditionalNavigationTitle.swift in Sources */, CD79281F2C73B52A00FA712D /* EndOfFeedView.swift in Sources */, CDE1F19C2C63E2EB008AF042 /* SettingPropertyWrapper.swift in Sources */, 0368F3692D7349D8007DEB70 /* LanguageListRowBody.swift in Sources */, 033F84732D1C784600D87A9E /* ModlogEntryView.swift in Sources */, 033F84742D1C784600D87A9E /* ModlogView.swift in Sources */, 038096612C10AAD8003ED1D8 /* TransitionView.swift in Sources */, 0382A7F42C0A76A900C79DDA /* Date+Extensions.swift in Sources */, CDBE78C22F38DD8D008B254C /* PersonStubResolutionPage.swift in Sources */, CD9D243D2CC1DF59006E5F3F /* AccountType.swift in Sources */, 0320B6632C8F8D5A00D38548 /* InstanceSort.swift in Sources */, 0395BCF82D9C57DE00865B33 /* View+Background.swift in Sources */, 0397D4912C6CE871002C6CDC /* PostEditorView.swift in Sources */, 033171782CCE89E3002DA370 /* PurgableProviding+Extensions.swift in Sources */, 03D2A63B2C010B7500ED4FF2 /* GuestAccount.swift in Sources */, 03A818AD2EDCDBA20023E9E8 /* FederationMode+Extensions.swift in Sources */, CDE1F1962C63DF89008AF042 /* PhoneConstants.swift in Sources */, 031EC5302E5F77D7003408B7 /* FeedContext.swift in Sources */, 0397D4862C6A24D2002C6CDC /* ReportableProviding+Extensions.swift in Sources */, CD4ED84A2BF1113800EFA0A2 /* View+TabReselectConsumer.swift in Sources */, CD6DC02C2D86540A00693B16 /* AnimatedAvatarSettingsView.swift in Sources */, CD4E386D2C836F8C009B24F2 /* UIUserInterfaceStyle+Extensions.swift in Sources */, 03F6BDF82D555F6E006A425E /* ModMailInteractionBarSettingsView.swift in Sources */, CD4D58A52B86BD1B00B82964 /* PersistenceRepository.swift in Sources */, 037F78412D3C00E600D4E180 /* SettingsInteractionBarSummaryView.swift in Sources */, 035394862C9CDC1100795AA5 /* SubscriptionListNavigationButton.swift in Sources */, 039F58842C7A7F2C00C61658 /* CommentJumpButtonLocation.swift in Sources */, CDA1D2A52ED8C46B0077A9EA /* ExportableViewComponents.swift in Sources */, CD1B2E212C7F84160075C7EA /* View+MarkReadOnScroll.swift in Sources */, 033F84AD2C298466002E3EDF /* SectionIndexTitles.swift in Sources */, CDEE15542D22364B00EB9D7B /* ErrorLogView.swift in Sources */, 03FA318F2C6FEF2000D47FA3 /* InteractionBarActionLabelView.swift in Sources */, CDE1F1982C63DFC9008AF042 /* PadConstants.swift in Sources */, 03B25B312CC4403500EB6DF5 /* Fediseer.swift in Sources */, 038028F82CB097A10091A8A2 /* SearchView+InstancePicker.swift in Sources */, CD5C197D2D97096F0089614C /* ZoomCurves.swift in Sources */, 034147FD2D8F5844005503AF /* ExpandedPostHistoryTracker.swift in Sources */, 03EC84422E959AA5004698BB /* PostEditorWebsitePreviewView.swift in Sources */, CD9CFA2C2C2E1EF300739BBC /* FeedIconView.swift in Sources */, 038188992D43E0F30073E88D /* SafetySettingsView.swift in Sources */, CD1446252A5B357900610EF1 /* Document.swift in Sources */, 033F84B12C29907F002E3EDF /* FeedbackType.swift in Sources */, 0377BD752DE219A400E38593 /* OnboardingUsernameView.swift in Sources */, CDB2EC812BFADADF00DBC0EF /* LargePostView.swift in Sources */, CD03B5BE2F3BA16400AEF786 /* Blockable+Extensions.swift in Sources */, 0389DDC92C39658E0005B808 /* Binding+Extensions.swift in Sources */, 03A9FD1A2D7CFC69007A734D /* Palette+Monochrome.swift in Sources */, 0320B65C2C8CFBDC00D38548 /* ImageUploadHistoryManager.swift in Sources */, CD7882A72BFFD1A3002E1A30 /* ThumbnailLocation.swift in Sources */, 03036C742C71408700C6DA1D /* CounterAppearance.swift in Sources */, 0397D4722C68078F002C6CDC /* PrefetchingConfiguration+Extensions.swift in Sources */, 0389DDC32C38907C0005B808 /* Message1Providing+Extensions.swift in Sources */, 038E5E892F6D694500C54DEB /* ImageViewerShowControlsSettingsView.swift in Sources */, 03B431B42C4481C3001A1EB5 /* MarkdownTextEditor.swift in Sources */, 0372EBCE2D36FBCF00257095 /* RegistrationApplication+Extensions.swift in Sources */, 038028FD2CB72A2A0091A8A2 /* ContentRemovalEditorView.swift in Sources */, 03B431C42C45BA45001A1EB5 /* AccountPickerMenu.swift in Sources */, 039F58812C7A7E5900C61658 /* JumpButtonView.swift in Sources */, 033F84C82C2B193D002E3EDF /* MlemStats.swift in Sources */, CD7DB9732C4AEDDE00DCC542 /* TileCommentView.swift in Sources */, CD4D58AA2B86BE5900B82964 /* AccountListView.swift in Sources */, CD4D58B92B86D9F800B82964 /* AccountListRow.swift in Sources */, 03B72B6B2C28A0190023A6C4 /* SubscriptionListSettingsView.swift in Sources */, 03F6BD982D500E2A006A425E /* PersonMockType+Realistic.swift in Sources */, CD27839A2D9B366000DD4C69 /* ZoomableImageView.swift in Sources */, CD4D58B32B86BFD400B82964 /* AccountsTracker.swift in Sources */, 0381889B2D4412A40073E88D /* SafetyBlurNsfwSettingsView.swift in Sources */, 03ECD71B2C811D6700D48BF6 /* PostEditorView+ImageView.swift in Sources */, 038C85692D861A2100543F70 /* Comment+Mock.swift in Sources */, 0397D48C2C6BE9A2002C6CDC /* CollapsibleSection.swift in Sources */, CD737FBA2F771BF600E46411 /* InstanceSummarySoftware+Extensions.swift in Sources */, 035DFA232EB3F7240021DE8C /* CommunityAboutView.swift in Sources */, 0311ADB72E4DF49800EC3120 /* SearchHomeView.swift in Sources */, 0377BE9B2DEA328900E38593 /* OnboardingEmailView.swift in Sources */, 038028D82CACAB960091A8A2 /* ModeratorSettingsView.swift in Sources */, 033F84CC2C2B1E46002E3EDF /* HandleThreadiverseLinksModifier.swift in Sources */, 0389DDD32C39E4D40005B808 /* PasteLinkButtonView.swift in Sources */, 03AF91EA2C1CE96600E56644 /* Counter.swift in Sources */, 03D283FC2D25A3F700A6659B /* VisitHistory+CodedData.swift in Sources */, 03B431C22C45BA00001A1EB5 /* MarkdownEditorToolbarView.swift in Sources */, 03AD09E82CF88007001EF9F7 /* MoreRepliesButton.swift in Sources */, 034B948E2C0937BA00039AF4 /* FancyScrollView.swift in Sources */, CD332D7E2CA7486000A53988 /* String+Extensions.swift in Sources */, 037331A42C9CB12D00C826E1 /* EnvironmentValues+Extensions.swift in Sources */, CD4D58B52B86BFFB00B82964 /* PersistenceRepository+Dependency.swift in Sources */, 03A630F12D497674009A47A6 /* ShieldsBadgeView.swift in Sources */, CD4D58EB2B86E63300B82964 /* AssociatedColor.swift in Sources */, CD4B66DA2DCE809D00D28EB4 /* InstanceUptimeView+Views.swift in Sources */, 0311ADBF2E4F68D000EC3120 /* TopPeopleListView.swift in Sources */, CD3153072C38421B00BC5FBE /* View+LoadFeed.swift in Sources */, 0302A8802F9BC379000A127A /* SearchHomeCategoryLabelStyle.swift in Sources */, 039F588F2C7B599800C61658 /* ThemeLabel.swift in Sources */, 037029A32D6B9B8400B749DF /* ContentView+Tab.swift in Sources */, CD2C86542D5556C00034CD8A /* MlemError.swift in Sources */, CD9857A42C5E7F9D0084C71F /* NsfwOverlayView.swift in Sources */, 030E95E72C80A20A0045BC2C /* View+NavigationTransition.swift in Sources */, CD45CB0D2D1880E8008BC729 /* FiltersSettingsView.swift in Sources */, 038028FF2CB72AC90091A8A2 /* ReasonShortcutView.swift in Sources */, 03A6316D2D4E3D24009A47A6 /* ModeratorActionSeparationSettingsView.swift in Sources */, 03D2A6422C011F4A00ED4FF2 /* AccountListRowBody.swift in Sources */, 03134A5A2BEC2253002662CC /* AvatarStackView.swift in Sources */, 03AB48572CBC0DFC00567FF9 /* AccountSignInSettingsView.swift in Sources */, 037F78452D3C25AB00D4E180 /* PostSubscriptionIndicatorSettingsView.swift in Sources */, 0397D49C2C6EA73C002C6CDC /* InteractionBarConfiguration.swift in Sources */, 03D3A1F32BB9D49B009DE55E /* ActionGroup.swift in Sources */, 03B7F3352EEEC70F00B00F6A /* NoteEditorView.swift in Sources */, 03049A222C650B2C00FF6889 /* CommunityDetailsView.swift in Sources */, 03F6BD9C2D501478006A425E /* ActorIdentifier+Mock.swift in Sources */, 0381F7242F672258008A7731 /* CommentBarConfiguration+Types.swift in Sources */, CD7882A92BFFDFC7002E1A30 /* PostTag.swift in Sources */, 0353948F2CA088E600795AA5 /* DeveloperSettingsView.swift in Sources */, CD7882AB2C013005002E1A30 /* EllipsisMenu.swift in Sources */, CDBFCB6A2C04EFFE008CD468 /* PostSettingsView.swift in Sources */, 0368F34D2D733215007DEB70 /* SortTimeRange+Extensions.swift in Sources */, 03AB48592CBC14CE00567FF9 /* AccountEmailSettingsView.swift in Sources */, CD4D58C82B86DCED00B82964 /* AvatarType.swift in Sources */, 03531EEE2C2D9298004A3464 /* SearchSheetView.swift in Sources */, CDE1F19E2C63E306008AF042 /* Constants.swift in Sources */, 031DBA772F9A93AD00B4BAE4 /* EventRowView.swift in Sources */, CD4D58BB2B86DA7D00B82964 /* AccountListView+Logic.swift in Sources */, CD43E8B32BF2C24E007C3D71 /* ContentLoader.swift in Sources */, 031E2D5D2BEFCC630003BC45 /* SettingsView.swift in Sources */, CDCA44B42C176A4700C092B3 /* Array+Extensions.swift in Sources */, 03CCDAA42BF2852E00C0C851 /* LoginTotpView.swift in Sources */, 033F84782D1D68D200D87A9E /* ModlogEntryContent+Extensions.swift in Sources */, CD4ED8472BF110FA00EFA0A2 /* TabReselectTracker.swift in Sources */, CDCC7FF32D9B283A00CE18DA /* ZoomRecognizerCoordinator+Logic.swift in Sources */, 033FCAEE2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift in Sources */, 035BE0912BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift in Sources */, CDAA02DB2C810DB200D75633 /* Calendar+Extensions.swift in Sources */, CD5C197F2D970E5F0089614C /* MomentumStatus.swift in Sources */, CD4D59142B87B36B00B82964 /* InternetConnectionManager.swift in Sources */, 0324FA7B2C1F2CD200F6247D /* InfoStackView.swift in Sources */, CD7DB9762C4D6C0A00DCC542 /* FeedCommentView.swift in Sources */, CDCC1BA92D99BEC1006579DF /* ZoomRecognizerCoordinator.swift in Sources */, 03ABE5E42DB8E57000374AFF /* AccountAgeVisibilitySettingsView.swift in Sources */, 03D283FA2D256E1E00A6659B /* VisitHistory.swift in Sources */, 03D3A1EF2BB9CA1D009DE55E /* MenuButton.swift in Sources */, 039F58862C7A810100C61658 /* ExpandedPostView+Logic.swift in Sources */, CD1C64152D3428710006B3C1 /* CommunityView+Logic.swift in Sources */, 031E2D512BEF961D0003BC45 /* SubscriptionListView.swift in Sources */, CDD4A09E2C8B69FC0001AD1A /* Button.swift in Sources */, 038028DA2CACACD30091A8A2 /* PostEllipsisMenus.swift in Sources */, 030FF67B2BC8521600F6BFAC /* CustomTabView.swift in Sources */, 03531EF52C2DA610004A3464 /* NavigationSearchType.swift in Sources */, CDB2EC8A2BFAEFDF00DBC0EF /* ThumbnailImageView.swift in Sources */, 0397D4932C6CE87E002C6CDC /* PostEditorTargetView.swift in Sources */, 034690992D105DFD0073E664 /* InboxView+Types.swift in Sources */, 031DBA792F9A95B200B4BAE4 /* SearchHomeLabelStyle.swift in Sources */, 03F6BDB12D52AA00006A425E /* CommunityMockType+Realistic.swift in Sources */, 0311ADC12E4F693B00EC3120 /* TopInstancesListView.swift in Sources */, 033F84492D18D1F400D87A9E /* MessageFeedView.swift in Sources */, 030050D52D10AE30002B1E99 /* Report+Extensions.swift in Sources */, 0369B3562BFA6824001EFEDF /* InboxView.swift in Sources */, CD4D59162B87B38C00B82964 /* UIApplication+Extensions.swift in Sources */, 038E5E8B2F6D6C3800C54DEB /* ImageViewerDismissSettingsView.swift in Sources */, CDB41E8A2C83C24400BD2DE9 /* Section.swift in Sources */, CD7BF9322D18F4ED0020F2C5 /* FiltersTracker.swift in Sources */, 81A179BC2DDE591700B17017 /* LongPressActionSettingsView.swift in Sources */, CD44C92C2E5CC8B900F24AC8 /* ConditionalLabelStyleViewModifier.swift in Sources */, 0381F7142F670F95008A7731 /* SwipeActionConfiguration.swift in Sources */, 03B045F62E26D64900540EFB /* SiteSoftwareType+Extensions.swift in Sources */, 0391E0FB2D0066240040CCA8 /* ImageUploadMenu.swift in Sources */, 035BE08B2BDD903100F77D73 /* NavigationModel.swift in Sources */, 033F84BB2C2ACB96002E3EDF /* CommentView.swift in Sources */, CDEE15522D22190600EB9D7B /* ErrorsTracker.swift in Sources */, 03B62B792CE2A2C00077E9C8 /* RulesPickerView.swift in Sources */, 0389DDD52C39F1290005B808 /* CommunityListRow.swift in Sources */, 038C86572D888EC100543F70 /* CommentSortType+Extensions.swift in Sources */, CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */, 03A630ED2D497005009A47A6 /* ExternalLinkSettingsView.swift in Sources */, 0377BE9F2DEA361600E38593 /* OnboardingModel.swift in Sources */, 0311ADBD2E4F668900EC3120 /* TopCommunitiesListView.swift in Sources */, 03049A1E2C6508F400FF6889 /* RegistrationMode+Extensions.swift in Sources */, 037DE0752CE023E3007F7B92 /* BlockListView.swift in Sources */, CD3FC6802D4A75090088E63B /* CounterApperance+StaticValues.swift in Sources */, 038028D52CAB479D0091A8A2 /* PostEditorView+Views.swift in Sources */, 0320B64F2C8A638A00D38548 /* SignUpView.swift in Sources */, 036FFA2D2D45110C00998D8A /* ChangePasswordView.swift in Sources */, 03ECD7192C81195000D48BF6 /* PostEditorView+LinkView.swift in Sources */, 030056BB2D7E137800EB0BA3 /* ShareInstancePickerView.swift in Sources */, 0320B6582C8BB3C400D38548 /* SignUpView+Views.swift in Sources */, 03036C832C727D0500C6DA1D /* InteractionBarEditorView+Logic.swift in Sources */, 6DE1183C2A4A217400810C7E /* Profile View.swift in Sources */, 03CBD18D2C6120F600E870BC /* PersonFlair.swift in Sources */, 03EC83252E916C51004698BB /* View+SafeAreaBar.swift in Sources */, 033FCB3E2C5E7FA9007B7CD1 /* View+OutdatedFeedPopup.swift in Sources */, CD10FA772C7A8622008985AD /* ImageSaver.swift in Sources */, 03267D822BED489C009D6268 /* AvatarBannerView.swift in Sources */, 031CA5752E5900C800CF0C0F /* SwipeConfiguration+Extensions.swift in Sources */, 03D65D702F4B046F0041ADAF /* ContextMenuSettingsView.swift in Sources */, 03500C2D2BF7FC2500CAA076 /* ToastView.swift in Sources */, 0311ADB92E4E0E0800EC3120 /* VisitAgainView.swift in Sources */, CDB95FCD2EBD3230008669D9 /* SmallOverlayButtonLabel.swift in Sources */, 0316CD642C382A6A009EA8EA /* MessageView.swift in Sources */, CDF9EF332AB2845C003F885B /* Icons.swift in Sources */, 03AF91E32C1C616F00E56644 /* InteractionBarView.swift in Sources */, 0397D4802C693A88002C6CDC /* [BlockNode]+Extensions.swift in Sources */, 035DF9132EB2AF8E0021DE8C /* GetContentFilter+Extensions.swift in Sources */, 030056A62D7DBD4F00EB0BA3 /* Sharable+Extensions.swift in Sources */, 03DD69422D4FDE8900F8950D /* Person+Mock.swift in Sources */, 03D284062D2AEE3A00A6659B /* TabBarSettingsView.swift in Sources */, 03D006322ECCBA95001BF97D /* QuickSwipeAction+Actions.swift in Sources */, 0320B6612C8DFCF100D38548 /* SearchView+FiltersView.swift in Sources */, 034B94892C09360A00039AF4 /* Int+Extensions.swift in Sources */, 036A84552D98253400E95D50 /* UpdateBannerView.swift in Sources */, 039D75642C4EEE69004F24C2 /* DeletableProviding+Extensions.swift in Sources */, 0391E0FE2D05B2DF0040CCA8 /* AdvancedSortView.swift in Sources */, 031CA0D92E4FBFD800CF0C0F /* MarkAllAsReadButton.swift in Sources */, CDB2EC882BFAE14800DBC0EF /* FullyQualifiedNameView.swift in Sources */, 031DBA7B2F9A993000B4BAE4 /* SearchHomeListView.swift in Sources */, 03500C272BF69D1D00CAA076 /* ToastModel.swift in Sources */, CD24CAFC2D5568FE0032B5E8 /* DiscussionLanguageSettingsView.swift in Sources */, 0369B35D2BFB86E3001EFEDF /* Account.swift in Sources */, CD0C5C102E99629A0074D5A4 /* ExportablePostEditorView.swift in Sources */, 03A814292ED1BCA90023E9E8 /* ModlogView+Filters.swift in Sources */, 036CC3AF2B8145C30098B6A1 /* AppState.swift in Sources */, 033F844D2D18D90900D87A9E /* MessageBubbleView.swift in Sources */, 03134A502BEAD245002662CC /* NavigationLink+NavigationPage.swift in Sources */, 03B431BC2C455838001A1EB5 /* LargePostBodyView.swift in Sources */, CDFB8C692C7796020070845F /* View+DynamicBlur.swift in Sources */, CD5CAA0C2D41AE57008E20F2 /* EmbeddingSettingsView.swift in Sources */, 03B25B372CC4478600EB6DF5 /* FediseerInfoView.swift in Sources */, 032A22012EB2A4D100E8B346 /* PersonContentFeedLoader+Extensions.swift in Sources */, 03B62B772CE295530077E9C8 /* RulesListView.swift in Sources */, 03500C2B2BF7F1B100CAA076 /* ToastOverlayView.swift in Sources */, 032C32042C3439C600595286 /* ActorIdentifiable+Extensions.swift in Sources */, CD57AFA52D0377EB00AB3956 /* AnimationControlLayer.swift in Sources */, CDFF9A332D88C3C1009E02E2 /* CGFloat+Extensions.swift in Sources */, CD5D8E2B2D6D5AB100AF5CE4 /* CGSize+Extensions.swift in Sources */, CD3485BB2D501470006748B8 /* ZoomSliderLocation.swift in Sources */, CD4D59182B87B3B000B82964 /* UIViewController+Extensions.swift in Sources */, 034B94812C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift in Sources */, 0320B6692C93506300D38548 /* SearchView+Logic.swift in Sources */, 039F58932C7B616600C61658 /* CommentSettingsView.swift in Sources */, 037DE07A2CE108D9007F7B92 /* FooterLinkView.swift in Sources */, 03D3A1E52BB8B7A3009DE55E /* ActionType.swift in Sources */, 0348F98D2DDBB526006639CD /* OnboardingView.swift in Sources */, 0320B6542C8B65EB00D38548 /* Captcha+Extensions.swift in Sources */, 0397D4A42C6FBC04002C6CDC /* ActionAppearance+StaticValues.swift in Sources */, 03B431C02C45ABFB001A1EB5 /* UITextView+Extensions.swift in Sources */, CDF8C1912D5D502400295CBA /* InteractionBarWidgetPickerView.swift in Sources */, 034B947F2C091EDD00039AF4 /* ProfileHeaderView.swift in Sources */, 03E0EF432CA73D7A002CB66C /* PostStubResolutionPage.swift in Sources */, CD9BD5812D8F8F7D0006AB7F /* ZoomRecognizer.swift in Sources */, 030030A12C416B0B009A65FF /* RefreshPopupView.swift in Sources */, 037FC0702E4A6B16009E3E63 /* InstanceView+About.swift in Sources */, CDBEC30F2E84C9F800F30B00 /* ExportablePostView.swift in Sources */, 0368F3432D72796B007DEB70 /* LanguagePickerSheetView.swift in Sources */, 0397D4A22C6EB035002C6CDC /* ActionAppearance.swift in Sources */, 035394952CA1AE6300795AA5 /* InstanceUptimeView.swift in Sources */, CDB2EC862BFADD7C00DBC0EF /* FullyQualifiedLabelView.swift in Sources */, CD7DB9712C49C17200DCC542 /* PersonContentGridView.swift in Sources */, 0368F34F2D734066007DEB70 /* SearchSortType+Extensions.swift in Sources */, 03D2A63D2C010CD400ED4FF2 /* UserSession.swift in Sources */, 03AFD0E12C3B30390054B8AD /* InstanceListRow.swift in Sources */, CD0E06F72C0E739F00445849 /* PostType+Extensions.swift in Sources */, CD635E1B2C94DACD00864F75 /* BypassProxyWarningSheet.swift in Sources */, 033F84BD2C2ACC5F002E3EDF /* CommentBarConfiguration.swift in Sources */, 03AF91E52C1C61FA00E56644 /* PostBarConfiguration.swift in Sources */, 03D3A1D42BB88EF1009DE55E /* Action.swift in Sources */, 6363D5C727EE196700E34822 /* ContentView.swift in Sources */, CD0F280A2C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift in Sources */, 035BE0892BDD901B00F77D73 /* NavigationPage.swift in Sources */, 035BE08F2BDE911900F77D73 /* NavigationLayer.swift in Sources */, 035394992CA1B20B00795AA5 /* InstanceView+Logic.swift in Sources */, CD8DB93A2D22004000EB0C7B /* Animations.swift in Sources */, 03DD69442D4FEA0000F8950D /* PreviewModifier+SampleEnvironment.swift in Sources */, 03ACE71A2DFD57CC00FD66C0 /* SiteSoftware+Extensions.swift in Sources */, CD869FCC2C15F8AC00FC8B5B /* BubblePickerView.swift in Sources */, 03267D842BED49CE009D6268 /* AccountSettingsView.swift in Sources */, CDAA02E12C817AAB00D75633 /* Color+Extensions.swift in Sources */, 03B62C402CE811CB0077E9C8 /* PersonBanEditorView+Logic.swift in Sources */, CD6DC02A2D86513D00693B16 /* AnimatedAvatarBehavior.swift in Sources */, 03A82FA32C0D1F2400D01A5C /* View+ExternalApiWarning.swift in Sources */, 033F84C32C2B12AA002E3EDF /* InstanceSummary.swift in Sources */, CD6436502D483C96002668FB /* InteractionBarEditorView+Views.swift in Sources */, 0369B3532BFA514B001EFEDF /* ToastLocation.swift in Sources */, 03F6BD9A2D501041006A425E /* SeededRandomNumberGenerator.swift in Sources */, 036ED6792D0AF3740018E5EA /* PinnedSortTracker.swift in Sources */, CDC44A362D1CBC280030F01C /* ReadPostIndicator.swift in Sources */, 038E1ABC2F58B9EF00D30F01 /* CommunityActionConfiguration.swift in Sources */, 0353948D2CA080EB00795AA5 /* FeedWelcomeView.swift in Sources */, 038E1AC02F58C76A00D30F01 /* SwipeActionEditorView.swift in Sources */, CD950E0F2F0ED6F7002A0595 /* FeedPostView.swift in Sources */, 0343C0482D3AD6DB001CF709 /* Set+Extensions.swift in Sources */, 031DBA682F9A65DC00B4BAE4 /* BackendClient+Extensions.swift in Sources */, 034A85712EC0A1FA00E5F904 /* InstanceCommunityListView.swift in Sources */, CDCC7FEF2D9B1E7100CE18DA /* CachedComputation.swift in Sources */, 03049A202C650A8100FF6889 /* FormReadout.swift in Sources */, 030050D32D109B7E002B1E99 /* ReportView.swift in Sources */, 0397D49A2C6EA6EE002C6CDC /* InteractionBarEditorView.swift in Sources */, 03F6BDAF2D516636006A425E /* CommunityMockType.swift in Sources */, 0300309D2C4163C9009A65FF /* CommentTreeTracker.swift in Sources */, 03A631CC2D4FD18C009A47A6 /* Post+Mock.swift in Sources */, 03FE14082BF94FFB00A8377F /* ErrorView.swift in Sources */, 0325B93C2D3AA62500E28B97 /* InboxItemType+Extensions.swift in Sources */, 030FF67D2BC8524500F6BFAC /* CustomTabItem.swift in Sources */, CDAA02E32C821C9100D75633 /* Divider.swift in Sources */, 03D284002D26F09500A6659B /* Instance+Extensions.swift in Sources */, 03BF11C72D3D634A00CC1F66 /* AccessibilitySettingsView.swift in Sources */, 033FCAEC2C57DCCD007B7CD1 /* ListingType+Extensions.swift in Sources */, 033FCB272C5E3933007B7CD1 /* AlternateIconLabel.swift in Sources */, 033FCB282C5E3933007B7CD1 /* AlternateIcon.swift in Sources */, 038028F62CB096960091A8A2 /* SearchView+FilterModels.swift in Sources */, 033FCB292C5E3933007B7CD1 /* IconSettingsView.swift in Sources */, CD03742A2D1DBFD2001E85FA /* ReadCheck.swift in Sources */, 035394932CA1AE2C00795AA5 /* UptimeData.swift in Sources */, 036ED6832D0C483B0018E5EA /* ProfileProviding+Extensions.swift in Sources */, 03EC83F02E9590D3004698BB /* LinkHostView.swift in Sources */, 033FCB2A2C5E3933007B7CD1 /* AlternateIconCell.swift in Sources */, 0389DDC72C389F840005B808 /* UnreadCount+Extensions.swift in Sources */, 03500C242BF55D0E00CAA076 /* Toast.swift in Sources */, 0377BD792DE22D4E00E38593 /* UsernameValidity+Extensions.swift in Sources */, 03F6BDBC2D52B7FE006A425E /* PostMockType.swift in Sources */, 038028D32CAB3D2D0091A8A2 /* ShareActivity.swift in Sources */, CDCF5A582F1426A5006748E8 /* CommentStubResolutionPage.swift in Sources */, 036ED67D2D0B006C0018E5EA /* TopSortPicker.swift in Sources */, 030FF67F2BC8544700F6BFAC /* CustomTabBarController.swift in Sources */, CD4D58F82B87B0D100B82964 /* InternetSpeed.swift in Sources */, 0353949C2CA4B3E800795AA5 /* CrossPostListView.swift in Sources */, 0382A7F02C09F0F800C79DDA /* PersonView.swift in Sources */, CDBFCB6C2C054AA7008CD468 /* TilePostView.swift in Sources */, 0380965F2C10AA80003ED1D8 /* AppState+Transition.swift in Sources */, 03BF11C32D3D135D00CC1F66 /* SearchView+CreatorPicker.swift in Sources */, 031CA4AE2E58A84E00CF0C0F /* View+WithSheetSearch.swift in Sources */, 0315B1C62C754802006D4F82 /* PostEditorView+Logic.swift in Sources */, 0370299F2D6B743B00B749DF /* PostMockType+Realistic.swift in Sources */, CDE1F1942C63DF44008AF042 /* PlatformConstants.swift in Sources */, 0335AE112D8991330094FFD9 /* View+HiddenNavigationTitle.swift in Sources */, 0397D4602C66113F002C6CDC /* CommentBodyView.swift in Sources */, CD9CFA372C306DAB00739BBC /* PostGridView.swift in Sources */, 0397D46C2C67E583002C6CDC /* SortingSettingsView.swift in Sources */, 0370299D2D6B70F400B749DF /* MockApiClient+Realistic.swift in Sources */, 03AB48522CBC042E00567FF9 /* AccountContentSettingsView.swift in Sources */, 0377BE072DE645E100E38593 /* OnboardingRecommendInstanceView.swift in Sources */, 03D2A63F2C010DBF00ED4FF2 /* GuestSession.swift in Sources */, CDD99C3E2C73F4380010367F /* WarningView.swift in Sources */, 030FF6812BC859FD00F6BFAC /* CustomTabViewHostingController.swift in Sources */, 035EDF012C2ECFE000F51144 /* Searchable.swift in Sources */, CD756A982ED7669C0031D7D1 /* ExportableCommentEditorView.swift in Sources */, CD7743892C20EDEE0085BB43 /* VotesModel+Extensions.swift in Sources */, CD4D58AD2B86BE7100B82964 /* QuickSwitcherView.swift in Sources */, 0397D4642C676CA8002C6CDC /* FeedSortPicker.swift in Sources */, 0355F9462C150B2300605248 /* ExternalApiInfoView.swift in Sources */, 03D0273C2CD3BA5100984519 /* PersonContent+Extensions.swift in Sources */, 03A631602D4D1CBB009A47A6 /* HapticSettingsView.swift in Sources */, CD7C4E572F3B92BA00ADCBDD /* View+ReloadOnAccountSwitch.swift in Sources */, CD7AC2CC2D7FDFFB00A671B7 /* MediaView+Logic.swift in Sources */, 03F9672B2CE221220081C9A3 /* Label+Profile1.swift in Sources */, 03B25B3B2CC44FFF00EB6DF5 /* UploadConfirmationView.swift in Sources */, CDD99C3C2C73F3FF0010367F /* DeleteAccountView.swift in Sources */, CD9CFA302C2E22F600739BBC /* FeedDescription.swift in Sources */, 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */, 03B0EB6F2C87827A00F79FDF /* ExpandedPostView.swift in Sources */, 03D001DD2EC53A97001BF97D /* NavigationPage+PresentationDetents.swift in Sources */, 039F58912C7B5C7A00C61658 /* ContentView+Logic.swift in Sources */, 032C320A2C34495D00595286 /* SelectTextView.swift in Sources */, 0377BE3B2DE8E70D00E38593 /* HapticLevel+Extensions.swift in Sources */, 03E46AD22D130681002589DB /* VotesListView.swift in Sources */, AD1B0D372A5F7A260006F554 /* Licenses.swift in Sources */, 03F6BDAD2D516615006A425E /* Community+Mock.swift in Sources */, 039F58972C7B68F100C61658 /* AboutMlemView.swift in Sources */, 035EDEFB2C2DF98700F51144 /* CommunityListRowBody.swift in Sources */, 038C85E62D88337100543F70 /* CommentMockType.swift in Sources */, CD77437F2C1BA5CE0085BB43 /* MultiplatformView.swift in Sources */, 0391E0F92CFF17AE0040CCA8 /* ProfileSettingsView.swift in Sources */, 03E614E72C0BCDC200F692A4 /* FullyQualifiedLinkView.swift in Sources */, B1B78D642A51D53900F72485 /* AppDelegate.swift in Sources */, 03ABE5B62DB79A0E00374AFF /* DateComponents+Extensions.swift in Sources */, 037F77ED2D3B064B00D4E180 /* SettingsDeviceView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5D227EE196A00E34822 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( CD4D583F2B86855F00B82964 /* MlemTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6363D5DC27EE196A00E34822 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( CD4D58412B86858100B82964 /* MlemUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 81DE61BF2F48AF44006E4C36 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 81DE61DE2F48AF4C006E4C36 /* ActionRequestHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 6363D5D827EE196A00E34822 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6363D5C027EE196700E34822 /* Mlem */; targetProxy = 6363D5D727EE196A00E34822 /* PBXContainerItemProxy */; }; 6363D5E227EE196A00E34822 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6363D5C027EE196700E34822 /* Mlem */; targetProxy = 6363D5E127EE196A00E34822 /* PBXContainerItemProxy */; }; 81DE61CF2F48AF44006E4C36 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 81DE61C22F48AF44006E4C36 /* OpenInMlem */; targetProxy = 81DE61CE2F48AF44006E4C36 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 6363D5E827EE196A00E34822 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_CODE_COVERAGE = NO; 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; 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.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 6363D5E927EE196A00E34822 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_CODE_COVERAGE = NO; 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.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; "SWIFT_OBJC_BRIDGING_HEADER[arch=*]" = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 6363D5EB27EE196A00E34822 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_CODE_COVERAGE = NO; CODE_SIGN_ENTITLEMENTS = Mlem/Mlem.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "local build"; DEVELOPMENT_ASSET_PATHS = "\"Mlem/Preview Content\""; DEVELOPMENT_TEAM = 8B9GNJW88W; ENABLE_PREVIEWS = YES; EXCLUDED_SOURCE_FILE_NAMES = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 6363D5EC27EE196A00E34822 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_CODE_COVERAGE = NO; CODE_SIGN_ENTITLEMENTS = Mlem/Mlem.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "local build"; DEVELOPMENT_ASSET_PATHS = "\"Mlem/Preview Content\""; DEVELOPMENT_TEAM = 8B9GNJW88W; ENABLE_PREVIEWS = YES; EXCLUDED_SOURCE_FILE_NAMES = ( "\"$(PROJECT_DIR)/Mlem/App/Utility/Extensions/MlemMiddleware Mock/*\"", "\"$(PROJECT_DIR)/Mlem/Preview Content/PreviewLocalizable.xcstrings\"", ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; 6363D5EE27EE196A00E34822 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 76ULHQGAPN; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.MlemTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mlem.app/Mlem"; }; name = Debug; }; 6363D5EF27EE196A00E34822 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 76ULHQGAPN; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.MlemTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mlem.app/Mlem"; }; name = Release; }; 6363D5F127EE196A00E34822 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 76ULHQGAPN; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.MlemUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Mlem; }; name = Debug; }; 6363D5F227EE196A00E34822 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 76ULHQGAPN; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.MlemUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Mlem; }; name = Release; }; 81DE61D22F48AF44006E4C36 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = ActionIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "local build"; DEVELOPMENT_TEAM = 8B9GNJW88W; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenInMlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Open in Mlem"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem.OpenUrlInMlem; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 81DE61D32F48AF44006E4C36 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = ActionIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "local build"; DEVELOPMENT_TEAM = 8B9GNJW88W; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenInMlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Open in Mlem"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem.OpenUrlInMlem; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 6363D5BC27EE196700E34822 /* Build configuration list for PBXProject "Mlem" */ = { isa = XCConfigurationList; buildConfigurations = ( 6363D5E827EE196A00E34822 /* Debug */, 6363D5E927EE196A00E34822 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6363D5EA27EE196A00E34822 /* Build configuration list for PBXNativeTarget "Mlem" */ = { isa = XCConfigurationList; buildConfigurations = ( 6363D5EB27EE196A00E34822 /* Debug */, 6363D5EC27EE196A00E34822 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6363D5ED27EE196A00E34822 /* Build configuration list for PBXNativeTarget "MlemTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 6363D5EE27EE196A00E34822 /* Debug */, 6363D5EF27EE196A00E34822 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6363D5F027EE196A00E34822 /* Build configuration list for PBXNativeTarget "MlemUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( 6363D5F127EE196A00E34822 /* Debug */, 6363D5F227EE196A00E34822 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 81DE61D52F48AF44006E4C36 /* Build configuration list for PBXNativeTarget "OpenInMlem" */ = { isa = XCConfigurationList; buildConfigurations = ( 81DE61D22F48AF44006E4C36 /* Debug */, 81DE61D32F48AF44006E4C36 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ 037386452BDAFE81007492B5 /* XCRemoteSwiftPackageReference "LemmyMarkdownUI" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mlemgroup/LemmyMarkdownUI"; requirement = { kind = upToNextMinorVersion; minimumVersion = 0.13.0; }; }; 0392826E2BC84E480097F91A /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; requirement = { kind = upToNextMajorVersion; minimumVersion = "1.4.0-beta.4"; }; }; 03EC83EC2E958A44004698BB /* XCRemoteSwiftPackageReference "OpenGraph" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/satoshi-takano/OpenGraph"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.6.0; }; }; 03FA318A2C6FEC5400D47FA3 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tevelee/SwiftUI-Flow"; requirement = { kind = upToNextMajorVersion; minimumVersion = 3.1.1; }; }; 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.5.1; }; }; 636250DA2A18111400FC59B4 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; requirement = { branch = master; kind = branch; }; }; B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke"; requirement = { kind = upToNextMajorVersion; minimumVersion = 12.1.2; }; }; CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/groue/Semaphore"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.0.8; }; }; CDE4AC412CA3706F00981010 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.14.6; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 030FF6782BC84F7E00F6BFAC /* SwiftUIIntrospect */ = { isa = XCSwiftPackageProductDependency; package = 0392826E2BC84E480097F91A /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; 031CA5762E5900E900CF0C0F /* QuickSwipes */ = { isa = XCSwiftPackageProductDependency; productName = QuickSwipes; }; 0341480C2D8F63A6005503AF /* MlemMiddleware */ = { isa = XCSwiftPackageProductDependency; productName = MlemMiddleware; }; 0347A6FA2F97F4CF00EFD670 /* FediverseEvents */ = { isa = XCSwiftPackageProductDependency; productName = FediverseEvents; }; 036879992DA1320000E796EF /* ComponentViews */ = { isa = XCSwiftPackageProductDependency; productName = ComponentViews; }; 037386462BDAFE81007492B5 /* LemmyMarkdownUI */ = { isa = XCSwiftPackageProductDependency; package = 037386452BDAFE81007492B5 /* XCRemoteSwiftPackageReference "LemmyMarkdownUI" */; productName = LemmyMarkdownUI; }; 0377BE282DE7A2DE00E38593 /* Haptics */ = { isa = XCSwiftPackageProductDependency; productName = Haptics; }; 0377BF9A2DF0E0E000E38593 /* Rest */ = { isa = XCSwiftPackageProductDependency; productName = Rest; }; 038100502F6AE867008A7731 /* MlemBackend */ = { isa = XCSwiftPackageProductDependency; productName = MlemBackend; }; 03A9FD152D7CEC09007A734D /* Theming */ = { isa = XCSwiftPackageProductDependency; productName = Theming; }; 03D8BF422DA55B6900506687 /* Icons */ = { isa = XCSwiftPackageProductDependency; productName = Icons; }; 03EC83ED2E958A44004698BB /* OpenGraph */ = { isa = XCSwiftPackageProductDependency; package = 03EC83EC2E958A44004698BB /* XCRemoteSwiftPackageReference "OpenGraph" */; productName = OpenGraph; }; 03EC85EB2E9D8F37004698BB /* Actions */ = { isa = XCSwiftPackageProductDependency; productName = Actions; }; 03FA318B2C6FECAE00D47FA3 /* Flow */ = { isa = XCSwiftPackageProductDependency; package = 03FA318A2C6FEC5400D47FA3 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; productName = Flow; }; 50C99B552A61D792005D57DD /* Dependencies */ = { isa = XCSwiftPackageProductDependency; package = 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = Dependencies; }; 636250DB2A18111400FC59B4 /* KeychainAccess */ = { isa = XCSwiftPackageProductDependency; package = 636250DA2A18111400FC59B4 /* XCRemoteSwiftPackageReference "KeychainAccess" */; productName = KeychainAccess; }; B104A6D72A59BF3C00B3E725 /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */; productName = Nuke; }; B104A6D92A59BF3C00B3E725 /* NukeExtensions */ = { isa = XCSwiftPackageProductDependency; package = B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */; productName = NukeExtensions; }; B104A6DB2A59BF3C00B3E725 /* NukeUI */ = { isa = XCSwiftPackageProductDependency; package = B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */; productName = NukeUI; }; B104A6DD2A59BF3C00B3E725 /* NukeVideo */ = { isa = XCSwiftPackageProductDependency; package = B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */; productName = NukeVideo; }; CD4368C02AE23FD400BD8BD1 /* Semaphore */ = { isa = XCSwiftPackageProductDependency; package = CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */; productName = Semaphore; }; CDA711FA2DB5CAC3008BC3ED /* Media */ = { isa = XCSwiftPackageProductDependency; productName = Media; }; CDE4AC462CA372B600981010 /* SDWebImageWebPCoder */ = { isa = XCSwiftPackageProductDependency; package = CDE4AC412CA3706F00981010 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; productName = SDWebImageWebPCoder; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6363D5B927EE196700E34822 /* Project object */; } ================================================ FILE: MlemTests/DateTests.swift ================================================ // // Software Name: Mlem // SPDX-FileCopyrightText: Copyright (c) Mlem Group // SPDX-License-Identifier: GPL-3.0 // // This software is distributed under the GNU General Public License v3.0 license, // the text of which is available at https://www.gnu.org/licenses/gpl-3.0-standalone.html // or see the "LICENSE" file for more details. // import SwiftUI import Testing /// Contains tests cases to check some `Date` extensions utils. /// NOTE: Supposed the language of the device / simulator under tests is english struct DateTests { @Test( "Get relative time must return the localized expected elapsed time for not cake day and more than one year", .bug("https://github.com/mlemgroup/mlem/issues/2032") ) func get_relative_time_returns_localized_string_for_more_than_one_year() { let dateFormatter = DateFormatter() var profileCreationDate: Date, someDate: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2023-11-14 12:05:00 +0000")! dateFormatter.dateFormat = "dd/MM/yyyy" someDate = dateFormatter.date(from: "15/05/2025")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: someDate, unitsStyle: .full) // Then #expect(relativeTimeString == "1 year ago") } @Test("Get relative time must return the elapsed days for accounts of several days old") func get_relative_time_must_return_elapsed_days_for_accounts_of_several_days_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-16 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-05-20 15:30:22 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "4 days ago") } @Test("Get relative time must return the elapsed weeks for accounts of several weeks old") func get_relative_time_must_return_elapsed_days_for_accounts_of_several_weeks_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-01 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-05-15 15:30:22 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "2 weeks ago") } @Test("Get relative time must return the elapsed months for accounts of several months old") func get_relative_time_must_return_elapsed_months_for_accounts_of_several_months_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-16 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-12-16 15:30:22 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "7 months ago") } @Test("Get relative time must return the elapsed hours for accounts younger than one day but older than one hour") func get_relative_time_must_return_elapsed_hours_for_accounts_of_several_hours_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-16 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-05-16 15:30:22 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "3 hours ago") } @Test("Get relative time must return the elapsed minutes for accounts younger than one hour") func get_relative_time_must_return_elapsed_hours_for_accounts_of_less_one_hour_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-16 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-05-16 12:30:22 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "25 minutes ago") } @Test("Get relative time must return the elapsed seconds for accounts of some seconds old") func get_relative_time_must_return_elapsed_seconds_for_accounts_of_some_seconds_old() { let dateFormatter = DateFormatter() var profileCreationDate: Date, profileCreationDateABitLater: Date // Given dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" profileCreationDate = dateFormatter.date(from: "2025-05-16 12:05:00 +0000")! profileCreationDateABitLater = dateFormatter.date(from: "2025-05-16 12:05:42 +0000")! // When let relativeTimeString = profileCreationDate.getRelativeTime(date: profileCreationDateABitLater, unitsStyle: .full) // Then #expect(relativeTimeString == "42 seconds ago") } } ================================================ FILE: MlemTests/MlemTests.swift ================================================ // // MlemTests.swift // MlemTests // // Created by David Bureš on 25.03.2022. // @testable import Mlem import XCTest class MlemTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. // Any test you write for XCTest can be annotated as throws and async. // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. } } ================================================ FILE: MlemUITests/MlemUITests.swift ================================================ // // MlemUITests.swift // MlemUITests // // Created by David Bureš on 25.03.2022. // import XCTest class MlemUITests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() // Use XCTAssert and related functions to verify your tests produce the correct results. } func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } } ================================================ FILE: OpenInMlem/Action.js ================================================ // // Action.js // OpenInMlem // // Created by Bedir Ekim on 2026-02-20. // var Action = function() {}; Action.prototype = { run: function(arguments) { arguments.completionFunction({ "url" : document.URL }) }, finalize: function(arguments) { var deeplink = arguments["deeplink"] if (deeplink) { document.location.href = deeplink } } }; var ExtensionPreprocessingJS = new Action ================================================ FILE: OpenInMlem/ActionRequestHandler.swift ================================================ // // ActionRequestHandler.swift // OpenInMlem // // Created by Bedir Ekim on 2026-02-20. // import UIKit import MobileCoreServices import UniformTypeIdentifiers class ActionRequestHandler: NSObject, NSExtensionRequestHandling { var extensionContext: NSExtensionContext? func beginRequest(with context: NSExtensionContext) { extensionContext = context guard let inputItems = context.inputItems as? [NSExtensionItem] else { done(nil) return } for item in inputItems { for provider in item.attachments ?? [] where provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) { provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { item, _ in guard let dictionary = item as? [String: Any], let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any], let urlString = results["url"] as? String, var components = URLComponents(string: urlString) else { self.done(nil) return } components.scheme = "mlem" guard let deeplink = components.url?.absoluteString else { self.done(nil) return } OperationQueue.main.addOperation { self.done(["deeplink": deeplink]) } } return } } done(nil) } private func done(_ resultsForJS: [String: Any]?) { if let resultsForJS { let dictionary = [NSExtensionJavaScriptFinalizeArgumentKey: resultsForJS] let provider = NSItemProvider(item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier) let item = NSExtensionItem() item.attachments = [provider] extensionContext?.completeRequest(returningItems: [item]) } else { extensionContext?.completeRequest(returningItems: []) } extensionContext = nil } } ================================================ FILE: OpenInMlem/Info.plist ================================================ NSExtension NSExtensionAttributes NSExtensionActivationRule NSExtensionActivationSupportsFileWithMaxCount 0 NSExtensionActivationSupportsImageWithMaxCount 0 NSExtensionActivationSupportsMovieWithMaxCount 0 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount 1 NSExtensionJavaScriptPreprocessingFile Action NSExtensionServiceAllowsFinderPreviewItem NSExtensionServiceAllowsTouchBarItem NSExtensionServiceFinderPreviewIconName NSActionTemplate NSExtensionServiceTouchBarBezelColorName TouchBarBezel NSExtensionServiceTouchBarIconName NSActionTemplate NSExtensionPointIdentifier com.apple.services NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ActionRequestHandler ================================================ FILE: OpenInMlem/InfoPlist.xcstrings ================================================ { "sourceLanguage" : "en", "strings" : { "CFBundleDisplayName" : { "comment" : "Bundle display name", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Open in Mlem" } }, "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir dans Mlem" } } } }, "CFBundleName" : { "comment" : "Bundle name", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "OpenInMlem" } } }, "shouldTranslate" : false }, "NSHumanReadableCopyright" : { "comment" : "Copyright (human-readable)", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "" } } }, "shouldTranslate" : false } }, "version" : "1.0" } ================================================ FILE: OpenInMlem/Media.xcassets/ActionIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "ActionIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OpenInMlem/Media.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: PrivacyInfo.xcprivacy ================================================ NSPrivacyAccessedAPITypes NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons CA92.1 ================================================ FILE: README.md ================================================ # [Mlem](https://mlem.group) - A Beautiful iOS Client for Lemmy Mlem is a beautiful, intuitive, open source iOS client for [Lemmy](https://join-lemmy.org) that lets you effortlessly participate in conversations across all Lemmy servers. Download on the App Store > [!NOTE] > Mlem requires iOS 18.0 or later. If you'd like to participate in the beta version of Mlem, you can [join our Testflight program](https://testflight.apple.com/join/W6ajfKQt). ## ✨ Why Use Mlem Mlem is built from the ground up to be intuitive and efficient. Its sleek interface lets you fully engage with your favorite communities free of clutter or distraction. Engineered for long-term performance, Mlem won't drain your battery or slow down your device, so you can scroll comfortably all day and night. ## 🚀 Features | Feeds | Search | |:--------:|:--------:| | | | | Threads | Customize | |:--------:|:--------:| | | | | Themes | Icons | |:--------:|:--------:| | | | ## 💬 Want to chat about Mlem? You're welcome to join our [community on lemmy.ml](https://lemmy.ml/c/mlemapp) or [Matrix room](https://matrix.to/#/#mlemappspace:matrix.org)! ## 🤝 Contributing We welcome contributions from the community! Whether you're interested in fixing bugs or adding new features, your contributions are always welcome and appreciated. Check out our [contribution guide](./CONTRIBUTING.md) to get started! ## 📄 License Mlem is fully open source, licensed under GPL 3.0 with an addendum for compliance with the Apple App Store. See [LICENSE](./LICENSE) for details. ### App Icons Beehaw Community Icon by Aaron Schneider is included under [CC-BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). ================================================ FILE: brewfile ================================================ brew "swiftlint" brew "swiftformat"