From 03f2ca0c8ab7d6e6558195918e294cacc2b277bc Mon Sep 17 00:00:00 2001 From: Stephen Mulyadi Date: Tue, 13 Aug 2024 15:41:17 +0700 Subject: [PATCH] [Frontend][Version][Update] lexicon version v2.2.0 (#72) * lexicon version 2.2.0 * disable detox test --- .github/ISSUE_TEMPLATE/bug_report.md | 33 +- .github/ISSUE_TEMPLATE/new_feature.md | 15 + .github/workflows/delete-build-jet-cache.yml | 18 + .github/workflows/e2e-test-android.yml | 111 ++ .github/workflows/main.yml | 46 +- .github/workflows/path-filters.yml | 54 + .github/workflows/test.yml | 34 + api/deploy/Dockerfile | 2 +- api/package.json | 1 + api/src/__tests__/cookiesStringify.ts | 12 - api/src/client.ts | 23 +- api/src/constants/server.ts | 2 +- .../__tests__/cookiesStringify.test.ts | 60 + .../__tests__/decodeEncodeToken.test.ts} | 2 +- .../helpers/__tests__/errorHandler.test.ts | 220 ++ .../__tests__/getModifiedUserAgent.test.ts | 13 + .../__tests__/getPosterTypeDetails.test.ts} | 2 +- .../__tests__/getTopicAuthor.test.ts} | 7 +- .../__tests__/getTopicPostPath.test.ts} | 2 +- .../__tests__/getTopicTimings.test.ts} | 2 +- .../__tests__/getUpdatedLikedTopic.test.ts} | 2 +- .../__tests__/likeErrorHandler.test.ts | 97 + .../__tests__/parseTopicUrl.test.ts} | 2 +- api/src/helpers/__tests__/poll.test.ts | 182 ++ .../__tests__/privateMessagesMerger.test.ts} | 7 +- .../__tests__/processRawContent.test.ts} | 50 +- api/src/helpers/__tests__/topicDetail.test.ts | 52 + api/src/helpers/auth.ts | 178 +- api/src/helpers/cookiesStringify.ts | 73 +- api/src/helpers/getTopicAuthor.ts | 16 +- api/src/helpers/index.ts | 1 + ...likeErroHandler.ts => likeErrorHandler.ts} | 0 api/src/helpers/processRawContent.ts | 43 + api/src/helpers/siteSettings.ts | 24 + .../resolvers/auth/activateAccountMutation.ts | 37 + .../resolvers/auth/authenticateLoginLink.ts | 32 + .../resolvers/auth/loginWithAppleMutation.ts | 24 + .../auth/requestLoginLinkMutation.ts | 46 + api/src/resolvers/index.ts | 5 + api/src/resolvers/site/aboutQuery.ts | 17 +- api/src/resolvers/site/pluginStatusQuery.ts | 37 + api/src/resolvers/site/siteQuery.ts | 3 + .../topics/likeTopicOrPostMutation.ts | 5 +- api/src/resolvers/upload/uploadMutation.ts | 12 +- api/src/scalars/PosterOutputUnion.ts | 4 +- api/src/typeSchemas/LoginOutput.ts | 1 + api/src/typeSchemas/PluginStatus.ts | 9 + api/src/typeSchemas/SiteSetting.ts | 6 + api/src/typeSchemas/Topic.ts | 27 +- api/src/typeSchemas/TopicPoster.ts | 15 + api/src/typeSchemas/UserActions.ts | 6 + api/src/typeSchemas/index.ts | 1 + api/src/types/dataTypes.ts | 19 +- api/yarn.lock | 274 ++- .../docs/activation-with-link/intro.md | 9 + .../setup/enable-activate-with-link.md | 25 + .../setup/verify-activate-with-link.md | 55 + documentation/docs/app-store.md | 10 +- documentation/docs/concepts.md | 7 +- documentation/docs/discourse-features.md | 69 +- documentation/docs/discourse-plugin-enable.md | 30 +- documentation/docs/discourse-plugin.md | 18 +- .../setup/enable-email-deep-linking.md | 6 +- .../setup/verify-email-deep-linking.md | 4 +- documentation/docs/intro.md | 6 +- documentation/docs/login-with-apple/intro.md | 9 + .../setup/enable-login-with-apple.md | 29 + .../setup/verify-login-with-apple.md | 30 + documentation/docs/login-with-link/intro.md | 9 + .../setup/enable-login-with-link.md | 25 + .../setup/verify-login-with-link.md | 39 + documentation/docs/optimal.md | 11 +- documentation/docs/play-store.md | 10 +- .../setup/enable-push-notifications.md | 10 +- .../setup/verify-push-notifications.md | 4 +- documentation/docs/quick-start.md | 5 +- documentation/docs/setup.md | 14 + documentation/docs/supported-devices.md | 12 +- documentation/docs/technologies.md | 6 +- documentation/docusaurus.config.js | 17 +- documentation/sidebars.js | 54 +- documentation/src/css/image.css | 9 + documentation/src/pages/index.js | 2 +- .../img/screenshot/Mobile-LoginWithApple.png | Bin 0 -> 65594 bytes .../static/img/screenshot/Website_SignUp.png | Bin 0 -> 283595 bytes .../Discourse-Plugin-Email-notification.png | Bin ...ourse-Plugin-EmailDeepLinking-Settings.png | Bin .../{ => plugins}/Discourse-Plugin-Enable.png | Bin .../Discourse-Plugin-PushNotif-Settings.png | Bin .../Discourse-Plugin-Settings.png | Bin .../{ => plugins}/Mobile-PushNotification.png | Bin ...course-Plugin-ActivationWithLink-Email.png | Bin 0 -> 102272 bytes ...ourse-Plugin-EmailDeepLinking-Settings.png | Bin 0 -> 195466 bytes ...ourse-Plugin-Enable-ActivationWithLink.png | Bin 0 -> 173715 bytes .../version-2.2.0/Discourse-Plugin-Enable.png | Bin 0 -> 258620 bytes ...scourse-Plugin-Login-With-Apple-App-ID.png | Bin 0 -> 23124 bytes .../Discourse-Plugin-Login-With-Apple.png | Bin 0 -> 28031 bytes .../Discourse-Plugin-Login-With-Link.png | Bin 0 -> 54826 bytes .../Discourse-Plugin-LoginWithLink-Email.png | Bin 0 -> 126559 bytes .../Discourse-Plugin-PushNotif-Settings.png | Bin 0 -> 130172 bytes .../Discourse-Plugin-Settings.png | Bin 0 -> 51147 bytes .../Mobile-ActivationWithLink-Redirect.png | Bin 0 -> 62628 bytes .../Mobile-LoginWithLink-Redirect.png | Bin 0 -> 62628 bytes .../version-2.2.0/Mobile-LoginWithLink.png | Bin 0 -> 62423 bytes .../version-1.0.0/discourse-features.md | 2 +- .../version-2.0.0/discourse-features.md | 2 +- .../version-2.0.0/discourse-plugin-enable.md | 4 +- .../setup/enable-email-deep-linking.md | 4 +- .../setup/verify-email-deep-linking.md | 2 +- .../setup/enable-push-notifications.md | 8 +- .../setup/verify-push-notifications.md | 4 +- .../versioned_docs/version-2.1.0/app-store.md | 282 +++ .../versioned_docs/version-2.1.0/assets.md | 47 + .../version-2.1.0/commercial-support.md | 9 + .../versioned_docs/version-2.1.0/concepts.md | 68 + .../version-2.1.0/contributing.md | 131 ++ .../versioned_docs/version-2.1.0/customize.md | 28 + .../versioned_docs/version-2.1.0/dedicated.md | 300 +++ .../version-2.1.0/deployment.md | 86 + .../version-2.1.0/discourse-features.md | 53 + .../version-2.1.0/discourse-plugin-enable.md | 34 + .../discourse-plugin-installation.md | 82 + .../version-2.1.0/discourse-plugin.md | 15 + .../version-2.1.0/email-deep-linking/intro.md | 9 + .../setup/enable-email-deep-linking.md | 25 + .../setup/verify-email-deep-linking.md | 49 + .../version-2.1.0/env-mobile.md | 119 ++ .../versioned_docs/version-2.1.0/env-prose.md | 15 + .../versioned_docs/version-2.1.0/intro.md | 142 ++ .../version-2.1.0/lexicon-updates.md | 17 + .../versioned_docs/version-2.1.0/optimal.md | 98 + .../version-2.1.0/play-store.md | 168 ++ .../version-2.1.0/publish-app.md | 11 + .../push-notifications/introduction.md | 8 + .../push-notifications/plugin-interaction.md | 31 + .../setup/enable-push-notifications.md | 32 + .../setup/verify-push-notifications.md | 33 + .../version-2.1.0/quick-start.md | 65 + .../versioned_docs/version-2.1.0/rationale.md | 73 + .../versioned_docs/version-2.1.0/setup.md | 376 ++++ .../version-2.1.0/supported-devices.md | 33 + .../version-2.1.0/technologies.md | 21 + .../versioned_docs/version-2.1.0/theming.md | 247 +++ .../version-2.1.0/troubleshooting-build.md | 128 ++ .../version-2.1.0/tutorial/building.md | 152 ++ .../version-2.1.0/tutorial/install-prose.md | 360 ++++ .../version-2.1.0/tutorial/intro.md | 47 + .../version-2.1.0/tutorial/publishing.md | 97 + .../tutorial/setup-cloud-server.md | 27 + .../version-2.1.0/tutorial/setup-discourse.md | 306 +++ .../version-2.1.0/tutorial/setup-mobile.md | 120 ++ .../version-2.1.0/tutorial/setup.md | 98 + .../version-2.1.0/tutorial/updating.md | 68 + .../version-2.1.0/tutorial/white-label.md | 82 + .../version-2.1.0/white-labeling.md | 13 + .../activation-with-link/intro.md | 9 + .../setup/enable-activate-with-link.md | 25 + .../setup/verify-activate-with-link.md | 55 + .../versioned_docs/version-2.2.0/app-store.md | 282 +++ .../versioned_docs/version-2.2.0/assets.md | 47 + .../version-2.2.0/commercial-support.md | 9 + .../versioned_docs/version-2.2.0/concepts.md | 68 + .../version-2.2.0/contributing.md | 131 ++ .../versioned_docs/version-2.2.0/customize.md | 28 + .../versioned_docs/version-2.2.0/dedicated.md | 300 +++ .../version-2.2.0/deployment.md | 86 + .../version-2.2.0/discourse-features.md | 57 + .../version-2.2.0/discourse-plugin-enable.md | 54 + .../discourse-plugin-installation.md | 82 + .../version-2.2.0/discourse-plugin.md | 23 + .../version-2.2.0/email-deep-linking/intro.md | 9 + .../setup/enable-email-deep-linking.md | 25 + .../setup/verify-email-deep-linking.md | 49 + .../version-2.2.0/env-mobile.md | 119 ++ .../versioned_docs/version-2.2.0/env-prose.md | 15 + .../versioned_docs/version-2.2.0/intro.md | 142 ++ .../version-2.2.0/lexicon-updates.md | 17 + .../version-2.2.0/login-with-apple/intro.md | 9 + .../setup/enable-login-with-apple.md | 29 + .../setup/verify-login-with-apple.md | 30 + .../version-2.2.0/login-with-link/intro.md | 9 + .../setup/enable-login-with-link.md | 25 + .../setup/verify-login-with-link.md | 39 + .../versioned_docs/version-2.2.0/optimal.md | 98 + .../version-2.2.0/play-store.md | 168 ++ .../version-2.2.0/publish-app.md | 11 + .../push-notifications/introduction.md | 8 + .../push-notifications/plugin-interaction.md | 31 + .../setup/enable-push-notifications.md | 32 + .../setup/verify-push-notifications.md | 33 + .../version-2.2.0/quick-start.md | 65 + .../versioned_docs/version-2.2.0/rationale.md | 73 + .../versioned_docs/version-2.2.0/setup.md | 376 ++++ .../version-2.2.0/supported-devices.md | 33 + .../version-2.2.0/technologies.md | 21 + .../versioned_docs/version-2.2.0/theming.md | 247 +++ .../version-2.2.0/troubleshooting-build.md | 128 ++ .../version-2.2.0/tutorial/building.md | 152 ++ .../version-2.2.0/tutorial/install-prose.md | 360 ++++ .../version-2.2.0/tutorial/intro.md | 47 + .../version-2.2.0/tutorial/publishing.md | 97 + .../tutorial/setup-cloud-server.md | 27 + .../version-2.2.0/tutorial/setup-discourse.md | 306 +++ .../version-2.2.0/tutorial/setup-mobile.md | 120 ++ .../version-2.2.0/tutorial/setup.md | 98 + .../version-2.2.0/tutorial/updating.md | 68 + .../version-2.2.0/tutorial/white-label.md | 82 + .../version-2.2.0/white-labeling.md | 13 + .../version-2.1.0-sidebars.json | 57 + .../version-2.2.0-sidebars.json | 88 + documentation/versions.json | 7 +- documentation/yarn.lock | 105 +- frontend/.detoxrc.js | 55 + frontend/.gitignore | 5 + frontend/Config.mock.ts | 28 + frontend/Config.ts | 7 +- frontend/app.json | 25 +- frontend/assets/iconNotifications.png | Bin 0 -> 696 bytes frontend/assets/icons/BoldText.svg | 4 + frontend/assets/icons/BulletList.svg | 4 + frontend/assets/icons/ItalicText.svg | 4 + frontend/assets/icons/NumberList.svg | 4 + frontend/assets/icons/QuoteText.svg | 4 + frontend/codegen.ts | 10 + frontend/docs/testing/detox.md | 197 ++ frontend/docs/testing/example-Ios-CI.md | 90 + frontend/e2e/apollo-mock/assets/grinning.png | Bin 0 -> 804 bytes .../e2e/apollo-mock/assets/heart_eyes.png | Bin 0 -> 974 bytes frontend/e2e/apollo-mock/assets/smile.png | Bin 0 -> 809 bytes frontend/e2e/apollo-mock/data/categories.ts | 16 + frontend/e2e/apollo-mock/data/index.ts | 9 + .../e2e/apollo-mock/data/messageDetails.ts | 24 + frontend/e2e/apollo-mock/data/messages.ts | 40 + frontend/e2e/apollo-mock/data/posts.ts | 299 +++ frontend/e2e/apollo-mock/data/site.ts | 34 + frontend/e2e/apollo-mock/data/token.ts | 2 + frontend/e2e/apollo-mock/data/topicDetails.ts | 69 + frontend/e2e/apollo-mock/data/topics.ts | 270 +++ frontend/e2e/apollo-mock/data/users.ts | 176 ++ .../resolvers/categoriesResolver.ts | 11 + frontend/e2e/apollo-mock/resolvers/index.ts | 12 + .../apollo-mock/resolvers/loginResolvers.ts | 63 + .../apollo-mock/resolvers/messageResolver.ts | 73 + .../resolvers/mutationsResolver.ts | 10 + .../apollo-mock/resolvers/pollResolvers.ts | 77 + .../apollo-mock/resolvers/profileResolver.ts | 71 + .../apollo-mock/resolvers/queriesResolvers.ts | 28 + .../e2e/apollo-mock/resolvers/siteResolver.ts | 19 + .../apollo-mock/resolvers/topicsResolvers.ts | 217 ++ .../resolvers/userActivityResolver.ts | 37 + .../apollo-mock/resolvers/userResolvers.ts | 11 + .../resolvers/userStatusResolver.ts | 30 + frontend/e2e/apollo-mock/server.ts | 91 + frontend/e2e/global/constant.ts | 1 + frontend/e2e/global/index.ts | 1 + frontend/e2e/helpers/deepLink.ts | 39 + frontend/e2e/helpers/index.ts | 6 + frontend/e2e/helpers/link.ts | 16 + frontend/e2e/helpers/login.ts | 26 + frontend/e2e/helpers/logout.ts | 12 + frontend/e2e/helpers/post.ts | 25 + frontend/e2e/helpers/tab.ts | 7 + frontend/e2e/init.ts | 23 + frontend/e2e/jest.config.js | 13 + frontend/e2e/tests/activity.e2e.ts | 17 + frontend/e2e/tests/deepLink.e2e.ts | 59 + frontend/e2e/tests/login.e2e.ts | 45 + frontend/e2e/tests/messages.e2e.ts | 133 ++ frontend/e2e/tests/polls.e2e.ts | 110 + frontend/e2e/tests/profile.e2e.ts | 24 + frontend/e2e/tests/topics.e2e.ts | 193 ++ frontend/e2e/tests/userStatus.e2e.ts | 123 ++ frontend/eas.json | 30 +- frontend/metro.config.js | 12 +- frontend/package.json | 43 +- .../react-native-reanimated+3.3.0.patch | 13 + frontend/scripts/android-E2E.sh | 14 + frontend/src/App.tsx | 15 - frontend/src/__mocks__/mockData.ts | 18 +- frontend/src/components/Author.tsx | 9 +- frontend/src/components/BottomMenu.tsx | 73 +- .../CustomFlatList/CustomFlatList.tsx | 3 +- .../src/components/Header/CustomHeader.tsx | 1 + frontend/src/components/Header/HeaderItem.tsx | 3 + frontend/src/components/MentionList.tsx | 1 + frontend/src/components/Metrics/Metrics.tsx | 4 +- frontend/src/components/NestedComment.tsx | 25 +- .../src/components/Poll/PollChoiceCard.tsx | 4 +- frontend/src/components/Poll/PollPreview.tsx | 2 +- .../PostItem/PostDetailHeaderItem.tsx | 3 +- frontend/src/components/PostItem/PostItem.tsx | 3 + .../components/PostItem/SearchPostItem.tsx | 1 + .../PostItem/UserInformationPostItem.tsx | 5 +- frontend/src/components/RepliedPost.tsx | 8 +- frontend/src/components/RequestError.tsx | 4 +- frontend/src/components/StackedAvatars.tsx | 6 +- frontend/src/components/TextArea.tsx | 19 +- frontend/src/components/UserStatus.tsx | 12 +- frontend/src/constants/alert.ts | 5 + frontend/src/constants/defaultValues.ts | 2 +- frontend/src/constants/errorTypes.ts | 28 +- frontend/src/constants/index.ts | 1 + frontend/src/constants/links.ts | 48 +- frontend/src/constants/route.ts | 47 +- frontend/src/constants/theme/fonts.ts | 10 +- frontend/src/core-ui/AppleSignInButton.tsx | 35 + frontend/src/core-ui/Emoji.tsx | 8 +- frontend/src/core-ui/FloatingButton.tsx | 1 + frontend/src/core-ui/Icon.tsx | 5 +- frontend/src/core-ui/index.ts | 1 + frontend/src/graphql/client.ts | 9 +- frontend/src/graphql/server/auth.ts | 65 +- frontend/src/graphql/server/getTopicDetail.ts | 4 +- frontend/src/graphql/server/profile.ts | 4 +- frontend/src/graphql/server/site.ts | 10 + frontend/src/graphql/server/topics.ts | 4 +- frontend/src/graphql/server/userActivity.ts | 1 + .../helpers/__tests__/experienceId.test.ts | 2 +- .../helpers/__tests__/fontFormatting.test.ts | 138 ++ .../__tests__/getDistanceToNow.test.ts | 5 + frontend/src/helpers/bottomMenu.ts | 268 ++- .../src/helpers/createCachedStorage.mock.tsx | 121 ++ frontend/src/helpers/createCachedStorage.tsx | 26 +- frontend/src/helpers/errorHandler.ts | 3 +- frontend/src/helpers/errorMessage.ts | 1 + frontend/src/helpers/experienceId.ts | 2 +- frontend/src/helpers/fontFormatting.ts | 130 ++ frontend/src/helpers/getDistanceToNow.ts | 6 +- .../helpers/getExpoPushTokenHandler.mock.ts | 19 + frontend/src/helpers/getTextInputRules.ts | 13 + frontend/src/helpers/handleDuplicates.ts | 4 +- frontend/src/helpers/index.ts | 3 +- frontend/src/helpers/isFlatList.ts | 9 - frontend/src/helpers/linking.ts | 8 +- frontend/src/helpers/notificationHandler.ts | 12 +- frontend/src/helpers/paginationHandler.ts | 104 +- frontend/src/helpers/storage.mock.ts | 13 + frontend/src/helpers/textArea.ts | 75 + frontend/src/helpers/transformTopicToPost.ts | 6 +- frontend/src/hooks/auth/useActivateAccount.ts | 22 + .../hooks/auth/useAuthenticateLoginLink.ts | 24 + frontend/src/hooks/auth/useLoginWithApple.ts | 24 + frontend/src/hooks/auth/useLogout.ts | 16 +- .../src/hooks/auth/useRequestLoginLink.ts | 24 + frontend/src/hooks/index.ts | 4 + frontend/src/hooks/post/useLikeTopicOrPost.ts | 5 +- frontend/src/hooks/post/useLoadMorePost.ts | 29 +- frontend/src/hooks/site/usePluginStatus.ts | 31 + frontend/src/hooks/site/useSiteSettings.ts | 2 + frontend/src/hooks/useKASVWorkaround.ts | 6 +- frontend/src/hooks/useLoadFonts.ts | 9 +- frontend/src/hooks/useUpdateApp.ts | 39 + frontend/src/icons.ts | 10 + frontend/src/navigation/AppNavigator.tsx | 61 +- .../src/navigation/RootStackNavigator.tsx | 5 + frontend/src/navigation/TabNavigator.tsx | 6 +- frontend/src/plugins/Notification.js | 37 + .../src/reactiveVars/tokenReactive.mock.tsx | 25 + frontend/src/screens/EditProfile.tsx | 12 +- .../screens/EditUserStatus/EditUserStatus.tsx | 13 +- frontend/src/screens/EmojiPicker.tsx | 2 + frontend/src/screens/Home/Home.tsx | 84 +- .../screens/Home/components/HomeNavBar.tsx | 10 +- frontend/src/screens/Hyperlink.tsx | 2 + frontend/src/screens/Login.tsx | 273 ++- .../screens/MessageDetail/ImagePreview.tsx | 31 +- .../screens/MessageDetail/MessageDetail.tsx | 34 +- .../components/ReplyInputField.tsx | 11 +- .../MessageDetail/components/ToolTip.tsx | 6 +- .../Messages/Components/MessageCard.tsx | 3 + frontend/src/screens/Messages/Messages.tsx | 2 + frontend/src/screens/NewMessage.tsx | 73 +- frontend/src/screens/NewPoll.tsx | 59 +- frontend/src/screens/NewPost.tsx | 84 +- .../screens/Notifications/Notifications.tsx | 19 +- .../components/NotificationItem.tsx | 27 +- .../src/screens/PostDetail/PostDetail.tsx | 3 + .../hooks/useNotificationScroll.tsx | 12 +- frontend/src/screens/PostReply.tsx | 71 +- frontend/src/screens/Profile/Profile.tsx | 14 +- frontend/src/screens/Search.tsx | 5 + .../src/screens/SelectUser/SelectUser.tsx | 2 +- .../SelectUser/components/UserItem.tsx | 1 + frontend/src/screens/UserInformation.tsx | 1 + frontend/src/theme/theme.ts | 49 +- frontend/src/types/DiscourseNotification.ts | 9 + frontend/src/types/ErrorSchema.ts | 24 + frontend/src/types/Navigation.ts | 7 +- frontend/src/types/NumericString.ts | 13 + frontend/src/types/Theme.ts | 12 + frontend/src/types/Types.ts | 7 +- .../src/types/__tests__/NumericString.test.ts | 28 + frontend/src/types/index.ts | 5 + frontend/src/types/pagination.ts | 19 + frontend/src/utils/AuthProvider.tsx | 1 + frontend/src/utils/RedirectProvider.tsx | 20 +- frontend/yarn.lock | 1761 +++++++++++++++-- 397 files changed, 18272 insertions(+), 957 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/new_feature.md create mode 100644 .github/workflows/delete-build-jet-cache.yml create mode 100644 .github/workflows/e2e-test-android.yml create mode 100644 .github/workflows/path-filters.yml create mode 100644 .github/workflows/test.yml delete mode 100644 api/src/__tests__/cookiesStringify.ts create mode 100644 api/src/helpers/__tests__/cookiesStringify.test.ts rename api/src/{__tests__/decodeEncodeToken.ts => helpers/__tests__/decodeEncodeToken.test.ts} (85%) create mode 100644 api/src/helpers/__tests__/errorHandler.test.ts create mode 100644 api/src/helpers/__tests__/getModifiedUserAgent.test.ts rename api/src/{__tests__/getPosterTypeDetails.ts => helpers/__tests__/getPosterTypeDetails.test.ts} (98%) rename api/src/{__tests__/getTopicAuthor.ts => helpers/__tests__/getTopicAuthor.test.ts} (95%) rename api/src/{__tests__/getTopicPostPath.ts => helpers/__tests__/getTopicPostPath.test.ts} (88%) rename api/src/{__tests__/getTopicTimings.ts => helpers/__tests__/getTopicTimings.test.ts} (89%) rename api/src/{__tests__/getUpdatedLikedTopic.ts => helpers/__tests__/getUpdatedLikedTopic.test.ts} (92%) create mode 100644 api/src/helpers/__tests__/likeErrorHandler.test.ts rename api/src/{__tests__/parseTopicUrl.ts => helpers/__tests__/parseTopicUrl.test.ts} (97%) create mode 100644 api/src/helpers/__tests__/poll.test.ts rename api/src/{__tests__/privateMessagesMerger.ts => helpers/__tests__/privateMessagesMerger.test.ts} (93%) rename api/src/{__tests__/processRawContent.ts => helpers/__tests__/processRawContent.test.ts} (83%) create mode 100644 api/src/helpers/__tests__/topicDetail.test.ts rename api/src/helpers/{likeErroHandler.ts => likeErrorHandler.ts} (100%) create mode 100644 api/src/helpers/siteSettings.ts create mode 100644 api/src/resolvers/auth/activateAccountMutation.ts create mode 100644 api/src/resolvers/auth/authenticateLoginLink.ts create mode 100644 api/src/resolvers/auth/loginWithAppleMutation.ts create mode 100644 api/src/resolvers/auth/requestLoginLinkMutation.ts create mode 100644 api/src/resolvers/site/pluginStatusQuery.ts create mode 100644 api/src/typeSchemas/PluginStatus.ts create mode 100644 documentation/docs/activation-with-link/intro.md create mode 100644 documentation/docs/activation-with-link/setup/enable-activate-with-link.md create mode 100644 documentation/docs/activation-with-link/setup/verify-activate-with-link.md create mode 100644 documentation/docs/login-with-apple/intro.md create mode 100644 documentation/docs/login-with-apple/setup/enable-login-with-apple.md create mode 100644 documentation/docs/login-with-apple/setup/verify-login-with-apple.md create mode 100644 documentation/docs/login-with-link/intro.md create mode 100644 documentation/docs/login-with-link/setup/enable-login-with-link.md create mode 100644 documentation/docs/login-with-link/setup/verify-login-with-link.md create mode 100644 documentation/src/css/image.css create mode 100644 documentation/static/img/screenshot/Mobile-LoginWithApple.png create mode 100644 documentation/static/img/screenshot/Website_SignUp.png rename documentation/static/img/screenshot/{ => plugins}/Discourse-Plugin-Email-notification.png (100%) rename documentation/static/img/screenshot/{ => plugins}/Discourse-Plugin-EmailDeepLinking-Settings.png (100%) rename documentation/static/img/screenshot/{ => plugins}/Discourse-Plugin-Enable.png (100%) rename documentation/static/img/screenshot/{ => plugins}/Discourse-Plugin-PushNotif-Settings.png (100%) rename documentation/static/img/screenshot/{ => plugins}/Discourse-Plugin-Settings.png (100%) rename documentation/static/img/screenshot/{ => plugins}/Mobile-PushNotification.png (100%) create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-ActivationWithLink-Email.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-EmailDeepLinking-Settings.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Enable-ActivationWithLink.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Enable.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Login-With-Apple-App-ID.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Login-With-Apple.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Login-With-Link.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-LoginWithLink-Email.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-PushNotif-Settings.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Discourse-Plugin-Settings.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Mobile-ActivationWithLink-Redirect.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Mobile-LoginWithLink-Redirect.png create mode 100644 documentation/static/img/screenshot/plugins/version-2.2.0/Mobile-LoginWithLink.png create mode 100644 documentation/versioned_docs/version-2.1.0/app-store.md create mode 100644 documentation/versioned_docs/version-2.1.0/assets.md create mode 100644 documentation/versioned_docs/version-2.1.0/commercial-support.md create mode 100644 documentation/versioned_docs/version-2.1.0/concepts.md create mode 100644 documentation/versioned_docs/version-2.1.0/contributing.md create mode 100644 documentation/versioned_docs/version-2.1.0/customize.md create mode 100644 documentation/versioned_docs/version-2.1.0/dedicated.md create mode 100644 documentation/versioned_docs/version-2.1.0/deployment.md create mode 100644 documentation/versioned_docs/version-2.1.0/discourse-features.md create mode 100644 documentation/versioned_docs/version-2.1.0/discourse-plugin-enable.md create mode 100644 documentation/versioned_docs/version-2.1.0/discourse-plugin-installation.md create mode 100644 documentation/versioned_docs/version-2.1.0/discourse-plugin.md create mode 100644 documentation/versioned_docs/version-2.1.0/email-deep-linking/intro.md create mode 100644 documentation/versioned_docs/version-2.1.0/email-deep-linking/setup/enable-email-deep-linking.md create mode 100644 documentation/versioned_docs/version-2.1.0/email-deep-linking/setup/verify-email-deep-linking.md create mode 100644 documentation/versioned_docs/version-2.1.0/env-mobile.md create mode 100644 documentation/versioned_docs/version-2.1.0/env-prose.md create mode 100644 documentation/versioned_docs/version-2.1.0/intro.md create mode 100644 documentation/versioned_docs/version-2.1.0/lexicon-updates.md create mode 100644 documentation/versioned_docs/version-2.1.0/optimal.md create mode 100644 documentation/versioned_docs/version-2.1.0/play-store.md create mode 100644 documentation/versioned_docs/version-2.1.0/publish-app.md create mode 100644 documentation/versioned_docs/version-2.1.0/push-notifications/introduction.md create mode 100644 documentation/versioned_docs/version-2.1.0/push-notifications/plugin-interaction.md create mode 100644 documentation/versioned_docs/version-2.1.0/push-notifications/setup/enable-push-notifications.md create mode 100644 documentation/versioned_docs/version-2.1.0/push-notifications/setup/verify-push-notifications.md create mode 100644 documentation/versioned_docs/version-2.1.0/quick-start.md create mode 100644 documentation/versioned_docs/version-2.1.0/rationale.md create mode 100644 documentation/versioned_docs/version-2.1.0/setup.md create mode 100644 documentation/versioned_docs/version-2.1.0/supported-devices.md create mode 100644 documentation/versioned_docs/version-2.1.0/technologies.md create mode 100644 documentation/versioned_docs/version-2.1.0/theming.md create mode 100644 documentation/versioned_docs/version-2.1.0/troubleshooting-build.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/building.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/install-prose.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/intro.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/publishing.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/setup-cloud-server.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/setup-discourse.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/setup-mobile.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/setup.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/updating.md create mode 100644 documentation/versioned_docs/version-2.1.0/tutorial/white-label.md create mode 100644 documentation/versioned_docs/version-2.1.0/white-labeling.md create mode 100644 documentation/versioned_docs/version-2.2.0/activation-with-link/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/activation-with-link/setup/enable-activate-with-link.md create mode 100644 documentation/versioned_docs/version-2.2.0/activation-with-link/setup/verify-activate-with-link.md create mode 100644 documentation/versioned_docs/version-2.2.0/app-store.md create mode 100644 documentation/versioned_docs/version-2.2.0/assets.md create mode 100644 documentation/versioned_docs/version-2.2.0/commercial-support.md create mode 100644 documentation/versioned_docs/version-2.2.0/concepts.md create mode 100644 documentation/versioned_docs/version-2.2.0/contributing.md create mode 100644 documentation/versioned_docs/version-2.2.0/customize.md create mode 100644 documentation/versioned_docs/version-2.2.0/dedicated.md create mode 100644 documentation/versioned_docs/version-2.2.0/deployment.md create mode 100644 documentation/versioned_docs/version-2.2.0/discourse-features.md create mode 100644 documentation/versioned_docs/version-2.2.0/discourse-plugin-enable.md create mode 100644 documentation/versioned_docs/version-2.2.0/discourse-plugin-installation.md create mode 100644 documentation/versioned_docs/version-2.2.0/discourse-plugin.md create mode 100644 documentation/versioned_docs/version-2.2.0/email-deep-linking/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/email-deep-linking/setup/enable-email-deep-linking.md create mode 100644 documentation/versioned_docs/version-2.2.0/email-deep-linking/setup/verify-email-deep-linking.md create mode 100644 documentation/versioned_docs/version-2.2.0/env-mobile.md create mode 100644 documentation/versioned_docs/version-2.2.0/env-prose.md create mode 100644 documentation/versioned_docs/version-2.2.0/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/lexicon-updates.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-apple/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-apple/setup/enable-login-with-apple.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-apple/setup/verify-login-with-apple.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-link/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-link/setup/enable-login-with-link.md create mode 100644 documentation/versioned_docs/version-2.2.0/login-with-link/setup/verify-login-with-link.md create mode 100644 documentation/versioned_docs/version-2.2.0/optimal.md create mode 100644 documentation/versioned_docs/version-2.2.0/play-store.md create mode 100644 documentation/versioned_docs/version-2.2.0/publish-app.md create mode 100644 documentation/versioned_docs/version-2.2.0/push-notifications/introduction.md create mode 100644 documentation/versioned_docs/version-2.2.0/push-notifications/plugin-interaction.md create mode 100644 documentation/versioned_docs/version-2.2.0/push-notifications/setup/enable-push-notifications.md create mode 100644 documentation/versioned_docs/version-2.2.0/push-notifications/setup/verify-push-notifications.md create mode 100644 documentation/versioned_docs/version-2.2.0/quick-start.md create mode 100644 documentation/versioned_docs/version-2.2.0/rationale.md create mode 100644 documentation/versioned_docs/version-2.2.0/setup.md create mode 100644 documentation/versioned_docs/version-2.2.0/supported-devices.md create mode 100644 documentation/versioned_docs/version-2.2.0/technologies.md create mode 100644 documentation/versioned_docs/version-2.2.0/theming.md create mode 100644 documentation/versioned_docs/version-2.2.0/troubleshooting-build.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/building.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/install-prose.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/intro.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/publishing.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/setup-cloud-server.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/setup-discourse.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/setup-mobile.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/setup.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/updating.md create mode 100644 documentation/versioned_docs/version-2.2.0/tutorial/white-label.md create mode 100644 documentation/versioned_docs/version-2.2.0/white-labeling.md create mode 100644 documentation/versioned_sidebars/version-2.1.0-sidebars.json create mode 100644 documentation/versioned_sidebars/version-2.2.0-sidebars.json create mode 100644 frontend/.detoxrc.js create mode 100644 frontend/Config.mock.ts create mode 100644 frontend/assets/iconNotifications.png create mode 100644 frontend/assets/icons/BoldText.svg create mode 100644 frontend/assets/icons/BulletList.svg create mode 100644 frontend/assets/icons/ItalicText.svg create mode 100644 frontend/assets/icons/NumberList.svg create mode 100644 frontend/assets/icons/QuoteText.svg create mode 100644 frontend/docs/testing/detox.md create mode 100644 frontend/docs/testing/example-Ios-CI.md create mode 100644 frontend/e2e/apollo-mock/assets/grinning.png create mode 100644 frontend/e2e/apollo-mock/assets/heart_eyes.png create mode 100644 frontend/e2e/apollo-mock/assets/smile.png create mode 100644 frontend/e2e/apollo-mock/data/categories.ts create mode 100644 frontend/e2e/apollo-mock/data/index.ts create mode 100644 frontend/e2e/apollo-mock/data/messageDetails.ts create mode 100644 frontend/e2e/apollo-mock/data/messages.ts create mode 100644 frontend/e2e/apollo-mock/data/posts.ts create mode 100644 frontend/e2e/apollo-mock/data/site.ts create mode 100644 frontend/e2e/apollo-mock/data/token.ts create mode 100644 frontend/e2e/apollo-mock/data/topicDetails.ts create mode 100644 frontend/e2e/apollo-mock/data/topics.ts create mode 100644 frontend/e2e/apollo-mock/data/users.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/categoriesResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/index.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/loginResolvers.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/messageResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/mutationsResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/pollResolvers.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/profileResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/queriesResolvers.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/siteResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/topicsResolvers.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/userActivityResolver.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/userResolvers.ts create mode 100644 frontend/e2e/apollo-mock/resolvers/userStatusResolver.ts create mode 100644 frontend/e2e/apollo-mock/server.ts create mode 100644 frontend/e2e/global/constant.ts create mode 100644 frontend/e2e/global/index.ts create mode 100644 frontend/e2e/helpers/deepLink.ts create mode 100644 frontend/e2e/helpers/index.ts create mode 100644 frontend/e2e/helpers/link.ts create mode 100644 frontend/e2e/helpers/login.ts create mode 100644 frontend/e2e/helpers/logout.ts create mode 100644 frontend/e2e/helpers/post.ts create mode 100644 frontend/e2e/helpers/tab.ts create mode 100644 frontend/e2e/init.ts create mode 100644 frontend/e2e/jest.config.js create mode 100644 frontend/e2e/tests/activity.e2e.ts create mode 100644 frontend/e2e/tests/deepLink.e2e.ts create mode 100644 frontend/e2e/tests/login.e2e.ts create mode 100644 frontend/e2e/tests/messages.e2e.ts create mode 100644 frontend/e2e/tests/polls.e2e.ts create mode 100644 frontend/e2e/tests/profile.e2e.ts create mode 100644 frontend/e2e/tests/topics.e2e.ts create mode 100644 frontend/e2e/tests/userStatus.e2e.ts create mode 100644 frontend/patches/react-native-reanimated+3.3.0.patch create mode 100644 frontend/scripts/android-E2E.sh create mode 100644 frontend/src/constants/alert.ts create mode 100644 frontend/src/core-ui/AppleSignInButton.tsx create mode 100644 frontend/src/helpers/__tests__/fontFormatting.test.ts create mode 100644 frontend/src/helpers/createCachedStorage.mock.tsx create mode 100644 frontend/src/helpers/fontFormatting.ts create mode 100644 frontend/src/helpers/getExpoPushTokenHandler.mock.ts delete mode 100644 frontend/src/helpers/isFlatList.ts create mode 100644 frontend/src/helpers/storage.mock.ts create mode 100644 frontend/src/helpers/textArea.ts create mode 100644 frontend/src/hooks/auth/useActivateAccount.ts create mode 100644 frontend/src/hooks/auth/useAuthenticateLoginLink.ts create mode 100644 frontend/src/hooks/auth/useLoginWithApple.ts create mode 100644 frontend/src/hooks/auth/useRequestLoginLink.ts create mode 100644 frontend/src/hooks/site/usePluginStatus.ts create mode 100644 frontend/src/hooks/useUpdateApp.ts create mode 100644 frontend/src/plugins/Notification.js create mode 100644 frontend/src/reactiveVars/tokenReactive.mock.tsx create mode 100644 frontend/src/types/DiscourseNotification.ts create mode 100644 frontend/src/types/ErrorSchema.ts create mode 100644 frontend/src/types/NumericString.ts create mode 100644 frontend/src/types/Theme.ts create mode 100644 frontend/src/types/__tests__/NumericString.test.ts create mode 100644 frontend/src/types/pagination.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 62a2ea70..83212e98 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,5 +1,5 @@ --- -name: Bug report +name: Bug report (lexiconhq) about: Create a report to help us improve title: '' labels: '' @@ -17,9 +17,9 @@ assignees: '' --- ### Describe the bug -A clear and concise description of what the bug is. +_A clear and concise description of what the bug is._ -### *REQUIRED:* Include the contents of `api/.env` and `frontend/.env` +### *REQUIRED:* Include the contents of `api/.env` and `frontend/Config.ts` _If you are manually overriding any environment variables when running the package scripts, include those as well._ **api/.env** @@ -33,22 +33,19 @@ _If you are manually overriding any environment variables when running the packa ``` ### _REQUIRED:_ answer the following questions: -- If this is related to your mobile app: - - Are you running it through Expo, or did you build it? - - Is it running on your mobile device or on a simulator? +- _If this is related to your mobile app:_ + - _Are you running it through Expo, or did you build it?_ + - _Is it running on your mobile device or on a simulator?_ ### To Reproduce -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +_Steps to reproduce the behavior_ +***Please use a numbered list** ### Expected behavior -A clear and concise description of what you expected to happen. +_A clear and concise description of what you expected to happen._ ### Screenshots -If applicable, add screenshots to help explain your problem. +_If applicable, add screenshots to help explain your problem._ ### Environments @@ -64,13 +61,13 @@ If applicable, add screenshots to help explain your problem. - Version [e.g. 22] #### GraphQL API (please complete the following information): -Please indicate where the Prose GraphQL API is running. +_Please indicate where the Prose GraphQL API is running._ -Are you only running it locally on your development machine? +- _Are you only running it locally on your development machine?_ -Have you deployed it somewhere? If so, what domain or IP address did you deploy it at? +- _Have you deployed it somewhere? If so, what domain or IP address did you deploy it at?_ -How specifically did you configure the mobile app to connect to the API? +- _How specifically did you configure the mobile app to connect to the API?_ ### Additional context -Add any other context about the problem here. +_Add any other context about the problem here._ diff --git a/.github/ISSUE_TEMPLATE/new_feature.md b/.github/ISSUE_TEMPLATE/new_feature.md new file mode 100644 index 00000000..4e795d2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_feature.md @@ -0,0 +1,15 @@ +--- +name: New Feature +about: A new Lexicon feature +title: '' +labels: '' +assignees: '' + +--- + +## Description + +## Acceptance Criteria +- [ ] + +## Guidance diff --git a/.github/workflows/delete-build-jet-cache.yml b/.github/workflows/delete-build-jet-cache.yml new file mode 100644 index 00000000..ccba2d98 --- /dev/null +++ b/.github/workflows/delete-build-jet-cache.yml @@ -0,0 +1,18 @@ +name: Manually Delete BuildJet Cache + +on: + workflow_dispatch: + inputs: + cache_key: + description: 'BuildJet Cache Key to Delete' + required: true + type: string +jobs: + manually-delete-buildjet-cache: + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v3 + - uses: buildjet/cache-delete@v1 + with: + cache_key: ${{ inputs.cache_key }} diff --git a/.github/workflows/e2e-test-android.yml b/.github/workflows/e2e-test-android.yml new file mode 100644 index 00000000..20052c6e --- /dev/null +++ b/.github/workflows/e2e-test-android.yml @@ -0,0 +1,111 @@ +name: E2E test android + +on: workflow_call + +jobs: + e2e-test-android: + runs-on: ${{matrix.runs-on}} + name: Android Test ${{matrix.runs-on}} + strategy: + fail-fast: false + matrix: + runs-on: [buildjet-4vcpu-ubuntu-2204] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: buildjet/setup-node@v4 + with: + node-version: '20.x' + + - name: Configure JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '11' + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - uses: buildjet/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn + + - name: Generate files + run: yarn generate + + - name: Cache Detox build + id: cache-detox-build + uses: buildjet/cache@v4 + with: + path: frontend/android + key: ${{ runner.os }}-detox-build + + - name: Expo prebuild android and build detox + if: steps.cache-detox-build.outputs.cache-hit != 'true' + run: | + cd frontend + npx expo prebuild --platform android + yarn tests:android:build + + - name: Setup Detox + run: | + cd frontend + npm install -g detox-cli + detox clean-framework-cache && yarn detox build-framework-cache + + - name: Gradle cache + uses: gradle/gradle-build-action@v2 + + - name: AVD cache + uses: buildjet/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-28 + + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + target: google_apis + arch: x86 + profile: pixel_5 + avd-name: Pixel_5_API_28 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run Test + timeout-minutes: 25 + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + target: google_apis + arch: x86 + profile: pixel_5 + avd-name: Pixel_5_API_28 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: bash ${{ github.workspace }}/frontend/scripts/android-E2E.sh + + - name: upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-artifacts-${{matrix.runs-on}} + path: frontend/artifacts/ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 64c8c7c8..5f9969a6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,33 +6,33 @@ on: pull_request: branches: [master] +env: + LIST_REPO_E2E_TEST: ('lexicon','lexicon-kodefox') # list of repo name which want run e2e test + jobs: - build: + # get-env job is run to get env using output because we cannot use env context outside steps if: env.LIST_REPO_E2E_TEST + # https://github.com/actions/runner/issues/2372#issuecomment-1518528105 + + get-env: runs-on: ubuntu-latest + outputs: + LIST_REPO: ${{ env.LIST_REPO_E2E_TEST }} steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - node-version: '16.x' - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + - run: echo "null" - - uses: actions/cache@v3 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + path-filters: + needs: [get-env] + if: contains(needs.get-env.outputs.LIST_REPO,github.event.repository.name) + uses: ./.github/workflows/path-filters.yml - - name: Install dependencies - run: yarn + test: + uses: ./.github/workflows/test.yml - - name: Generate files - run: yarn generate + # Disable Detox tests because they require a connection with a Build Jet account. + # To enable these tests, ensure your repository is connected to Build Jet: https://buildjet.com/for-github-actions - - name: Run tests - run: yarn test + # e2e-test: + # needs: [path-filters, test] + # if: needs.path-filters.outputs.frontendSrc-changes == 'true' && contains(needs.get-env.outputs.LIST_REPO,github.event.repository.name) + # uses: ./.github/workflows/e2e-test-android.yml + # secrets: inherit diff --git a/.github/workflows/path-filters.yml b/.github/workflows/path-filters.yml new file mode 100644 index 00000000..6f868474 --- /dev/null +++ b/.github/workflows/path-filters.yml @@ -0,0 +1,54 @@ +name: Path Filtering + +on: + workflow_call: + outputs: + frontend-changes: + description: 'this is lexicon app changes status' + value: ${{ jobs.path-filters.outputs.frontend-changes }} + frontendSrc-changes: + description: 'this is lexicon app src only status' + value: ${{ jobs.path-filters.outputs.frontendSrc-changes }} + backend-changes: + description: 'this is Backend Prose changes status' + value: ${{ jobs.path-filters.outputs.backend-changes }} + +jobs: + path-filters: + runs-on: ubuntu-latest + outputs: + frontend-changes: ${{ steps.filter-frontend.outputs.isUpdated }} + frontendSrc-changes: ${{ steps.filter-frontendSrc.outputs.isUpdated }} + backend-changes: ${{ steps.filter-backend.outputs.isUpdated }} + + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'api/**' + frontend: + - 'frontend/**' + frontendSrc: + - 'frontend/src/**' + + # run only if 'backend' files were changed + - name: Output filter Backend Prose + id: filter-backend + if: steps.filter.outputs.backend == 'true' + run: echo "isUpdated=true" >> $GITHUB_OUTPUT + + # run only if 'frontend' files were changed + - name: Output filter Lexicon + id: filter-frontend + if: steps.filter.outputs.frontend == 'true' + run: echo "isUpdated=true" >> $GITHUB_OUTPUT + + # run only if 'frontend scene and component' files were changed + - name: Output filter Lexicon src + id: filter-frontendSrc + if: steps.filter.outputs.frontendSrc == 'true' + run: echo "isUpdated=true" >> $GITHUB_OUTPUT diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..bd85975f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: workflow_call + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn + + - name: Generate files + run: yarn generate + + - name: Run tests + run: yarn test diff --git a/api/deploy/Dockerfile b/api/deploy/Dockerfile index beef3819..d004daa1 100644 --- a/api/deploy/Dockerfile +++ b/api/deploy/Dockerfile @@ -1,4 +1,4 @@ -FROM keymetrics/pm2:16-alpine +FROM keymetrics/pm2:18-alpine WORKDIR /app diff --git a/api/package.json b/api/package.json index d51a9ae6..9fddd544 100644 --- a/api/package.json +++ b/api/package.json @@ -29,6 +29,7 @@ "nexus": "^1.4.0-next.11", "querystring": "^0.2.0", "set-cookie-parser": "^2.5.1", + "sharp": "^0.32.6", "snakecase-keys": "^3.2.0", "tough-cookie": "^4.1.3", "winston": "^3.10.0", diff --git a/api/src/__tests__/cookiesStringify.ts b/api/src/__tests__/cookiesStringify.ts deleted file mode 100644 index c08ee22d..00000000 --- a/api/src/__tests__/cookiesStringify.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cookiesStringify } from '../helpers'; - -it('Stringify array of cookies to string', () => { - const inputCoookies = [ - '_t=token; path=/; expires=Sun, 08 Nov 2020 06:16:42 GMT; HttpOnly; SameSite=Lax', - '_forum_session=session; path=/; HttpOnly; SameSite=Lax', - ]; - const expectedOutput = - '_t=token; path=/; expires=Sun, 08 Nov 2020 06:16:42 GMT; HttpOnly; SameSite=Lax;_forum_session=session; path=/; HttpOnly; SameSite=Lax;'; - let stringCookies = cookiesStringify(inputCoookies); - expect(stringCookies).toEqual(expectedOutput); -}); diff --git a/api/src/client.ts b/api/src/client.ts index b9f9e546..0d2d08e8 100644 --- a/api/src/client.ts +++ b/api/src/client.ts @@ -3,14 +3,13 @@ import { ServerResponse } from 'http'; import axios, { AxiosResponse } from 'axios'; import axiosCookieJarSupport from 'axios-cookiejar-support'; import { CookieJar } from 'tough-cookie'; -import setCookie from 'set-cookie-parser'; import { CUSTOM_HEADER_TOKEN, PROSE_DISCOURSE_HOST } from './constants'; import { - cookiesStringify, generateToken, getCsrfSession, getModifiedUserAgent, + mergeCookies, } from './helpers'; export const discourseClient = axios.create({ @@ -62,23 +61,19 @@ export async function getClient(params: GetClientParams) { throw new Error('Not found or private.'); } - let cookies = response.headers['set-cookie']; + let newCookies = response.headers['set-cookie']; + + let resultMerge = mergeCookies({ oldCookies: cookies, newCookies }); /** - * This condition is used to check if there is a valid cookie. - * For the cookie to be refreshed, it must contain an _t cookie and - * it ensures that the cookie format is correct, excluding cookies from the login API, + * This condition checks if there is a valid cookie. + * To refresh the cookie, it must contain an `_t` cookie in the old cookies, indicating that the user is already logged in. + * It also checks for new cookies and ensures the cookie format is correct, excluding cookies from the login API, * which uses the `session.json` endpoint. */ - if ( - cookies && - // eslint-disable-next-line no-underscore-dangle - setCookie.parse(cookies, { map: true })._t && - !response.request.path.includes('session.json') - ) { - let stringCookie = cookiesStringify(cookies); - let token = generateToken(stringCookie); + if (resultMerge && !response.request.path.includes('session.json')) { + let token = generateToken(resultMerge); if (!context.response.headersSent) { context.response.setHeader(CUSTOM_HEADER_TOKEN, token); diff --git a/api/src/constants/server.ts b/api/src/constants/server.ts index e84b3600..ba5be7fc 100644 --- a/api/src/constants/server.ts +++ b/api/src/constants/server.ts @@ -5,7 +5,7 @@ config(); const ACCEPTED_LANGUAGE = 'en-US'; const CONTENT_FORM_URLENCODED = 'application/x-www-form-urlencoded'; const CONTENT_JSON = 'application/json'; -const CUSTOM_HEADER_TOKEN = 'X-Prose-Latest-Token'; +const CUSTOM_HEADER_TOKEN = 'x-prose-latest-token'; export { ACCEPTED_LANGUAGE, diff --git a/api/src/helpers/__tests__/cookiesStringify.test.ts b/api/src/helpers/__tests__/cookiesStringify.test.ts new file mode 100644 index 00000000..6f2444be --- /dev/null +++ b/api/src/helpers/__tests__/cookiesStringify.test.ts @@ -0,0 +1,60 @@ +import { cookiesStringify, mergeCookies } from '..'; + +const inputCookies = [ + '_t=token; path=/; expires=Sun, 08 Nov 2020 06:16:42 GMT; HttpOnly; SameSite=Lax', + '_forum_session=session; path=/; HttpOnly; SameSite=Lax', +]; +describe('cookiesStringify', () => { + it('Stringify array of cookies to string', () => { + const expectedOutput = '_t=token;_forum_session=session;'; + let stringCookies = cookiesStringify(inputCookies); + expect(stringCookies).toEqual(expectedOutput); + }); + it('Stringify string cookie to string', () => { + const expectedOutput = '_t=token;'; + let stringCookies = cookiesStringify(inputCookies[0]); + expect(stringCookies).toEqual(expectedOutput); + }); +}); + +describe('mergeCookies', () => { + it('should return empty string if there are no cookies or _t cookies at old cookies', () => { + const oldCookies = ''; + const oldCookiesWithoutT = + '_forum_session=oldSessionValue;_other_value=randomValue'; + + const result = mergeCookies({ oldCookies, newCookies: inputCookies }); + const result2 = mergeCookies({ + oldCookies: oldCookiesWithoutT, + newCookies: inputCookies, + }); + const result3 = mergeCookies({}); + const result4 = mergeCookies({ oldCookies }); + + const expectedOutput = ''; + + expect(result).toBe(expectedOutput); + expect(result2).toBe(expectedOutput); + expect(result3).toBe(expectedOutput); + expect(result4).toBe(expectedOutput); + }); + it('should replace all value of old cookie', () => { + const oldCookies = '_t=oldTokenValue;_forum_session=oldSessionValue;'; + + let result = mergeCookies({ oldCookies, newCookies: inputCookies }); + const expectedOutput = '_t=token;_forum_session=session;'; + + expect(result).toBe(expectedOutput); + }); + it('should replace old cookie with only provided value', () => { + const oldCookies = '_t=oldTokenValue;_forum_session=oldSessionValue;'; + const newCookies = [ + '_forum_session=newSession; path=/; HttpOnly; SameSite=Lax', + '_other_cookie=otherCookieValue; path=/; HttpOnly;', + ]; + let result = mergeCookies({ oldCookies, newCookies }); + const expectedOutput = + '_t=oldTokenValue;_forum_session=newSession;_other_cookie=otherCookieValue;'; + expect(result).toBe(expectedOutput); + }); +}); diff --git a/api/src/__tests__/decodeEncodeToken.ts b/api/src/helpers/__tests__/decodeEncodeToken.test.ts similarity index 85% rename from api/src/__tests__/decodeEncodeToken.ts rename to api/src/helpers/__tests__/decodeEncodeToken.test.ts index 4a5f758a..000a8bfb 100644 --- a/api/src/__tests__/decodeEncodeToken.ts +++ b/api/src/helpers/__tests__/decodeEncodeToken.test.ts @@ -1,4 +1,4 @@ -import { decodeToken, generateToken } from '../helpers'; +import { decodeToken, generateToken } from '..'; it('The input and output should same', () => { const inputCoookies = diff --git a/api/src/helpers/__tests__/errorHandler.test.ts b/api/src/helpers/__tests__/errorHandler.test.ts new file mode 100644 index 00000000..4e21f9ee --- /dev/null +++ b/api/src/helpers/__tests__/errorHandler.test.ts @@ -0,0 +1,220 @@ +import { + InvalidAccessError, + AuthorizationError, + errorHandler, + SessionExpiredError, +} from '..'; +import { + ChangeUsernameError, + EditPostError, + errorTypes, +} from '../../constants'; + +const baseMockError = { + name: '', + message: '', + isAxiosError: true, + config: {}, + response: { + status: 500, + statusText: '', + headers: {}, + data: {}, + config: { + headers: { + Cookie: '', + }, + }, + }, + toJSON: function () { + throw new Error('custom json function'); + }, +}; + +describe('errorHandler', () => { + it('should throw an error for username already taken', () => { + const mockAxiosErrorUserName = { + ...baseMockError, + name: 'AxiosError', + message: 'Request failed with status code 500', + response: { + ...baseMockError.response, + data: { + errors: ['This username is already taken'], + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorUserName)).toThrowError( + 'This username is already taken', + ); + }); + + it('should throw an error for failed request', () => { + const mockOtherError = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + failed: 'Error message for failed request', + }, + }, + }; + + expect(() => errorHandler(mockOtherError)).toThrowError( + 'Error message for failed request', + ); + }); + + it('should throw an error for edit post error', () => { + const mockAxiosErrorEditPostError = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + errors: [EditPostError], + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorEditPostError)).toThrowError( + `You've passed the time limit to edit this post.`, + ); + }); + + it('should throw an error for change username error', () => { + const mockAxiosErrorChangeUsernameError = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + errors: [ChangeUsernameError], + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorChangeUsernameError)).toThrowError( + 'This username is already taken', + ); + }); + + it('should throw an error invalid access', () => { + const mockAxiosErrorInvalidAccessError = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + errors: [], + error_type: errorTypes.invalidAccess, + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorInvalidAccessError)).toThrowError( + new InvalidAccessError(), + ); + }); + + it('should throw an error unauthenticated access', () => { + const mockAxiosErrorUnauthenticatedAccessError = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + errors: [], + error_type: errorTypes.unauthenticatedAccess, + }, + }, + }; + + expect(() => + errorHandler(mockAxiosErrorUnauthenticatedAccessError), + ).toThrowError(new AuthorizationError()); + }); + + it('should throw an error session expire', () => { + const mockAxiosErrorSessionExpire = { + ...baseMockError, + response: { + ...baseMockError.response, + config: { + headers: { + Cookie: + '_t=jUpkKRhp1mKWuBp0IxBqUT0uYem7mAeruq4iqIWxySvYtQw26czsuhT7YwB7stg4', + }, + }, + data: { + errors: [], + error_type: errorTypes.unauthenticatedAccess, + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorSessionExpire)).toThrowError( + new SessionExpiredError(), + ); + }); + + it('should throw an exceeds file size error', () => { + const mockAxiosErrorFileSize = { + ...baseMockError, + response: { + ...baseMockError.response, + status: 413, + config: { + headers: { + Cookie: + '_t=jUpkKRhp1mKWuBp0IxBqUT0uYem7mAeruq4iqIWxySvYtQw26czsuhT7YwB7stg4', + }, + }, + data: { + error: 'File size exceeds max size', + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorFileSize)).toThrowError( + 'The file size of your image exceeds the maximum allowed file size.', + ); + }); + + it('should throw an forbidden access', () => { + const mockAxiosErrorForbiddenAccess = { + ...baseMockError, + response: { + ...baseMockError.response, + status: 403, + data: 'Forbidden Access Error', + }, + }; + + expect(() => errorHandler(mockAxiosErrorForbiddenAccess)).toThrowError( + 'Forbidden Access Error', + ); + }); + + it('should throw an error for private topic error', () => { + const mockAxiosErrorPrivateTopic = { + ...baseMockError, + response: { + ...baseMockError.response, + data: { + errors: ['The topic cannot be accessed as it is a private topic.'], + }, + }, + }; + + expect(() => errorHandler(mockAxiosErrorPrivateTopic)).toThrowError( + 'The topic cannot be accessed as it is a private topic.', + ); + }); + + it('should throw an error beside axios error', () => { + const mockError: Error = { + name: 'custom error', + message: 'throw custom error', + }; + + expect(() => errorHandler(mockError)).toThrowError('throw custom error'); + }); +}); diff --git a/api/src/helpers/__tests__/getModifiedUserAgent.test.ts b/api/src/helpers/__tests__/getModifiedUserAgent.test.ts new file mode 100644 index 00000000..8c4a8382 --- /dev/null +++ b/api/src/helpers/__tests__/getModifiedUserAgent.test.ts @@ -0,0 +1,13 @@ +import { getModifiedUserAgent } from '..'; + +it('Should check mobile and not mobile agent', () => { + const notMobileAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) AltairGraphQLClient/6.3.1 Chrome/116.0.5845.190 Electron/26.2.2 Safari/537.36'; + let mobileAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + + expect(getModifiedUserAgent(notMobileAgent)).toEqual(`${notMobileAgent} `); + expect(getModifiedUserAgent(mobileAgent)).toEqual( + `${mobileAgent} DiscourseHub`, + ); +}); diff --git a/api/src/__tests__/getPosterTypeDetails.ts b/api/src/helpers/__tests__/getPosterTypeDetails.test.ts similarity index 98% rename from api/src/__tests__/getPosterTypeDetails.ts rename to api/src/helpers/__tests__/getPosterTypeDetails.test.ts index b391bb45..50a12134 100644 --- a/api/src/__tests__/getPosterTypeDetails.ts +++ b/api/src/helpers/__tests__/getPosterTypeDetails.test.ts @@ -1,4 +1,4 @@ -import { getPosterTypeDetails } from '../helpers/getPosterTypeDetails'; +import { getPosterTypeDetails } from '../getPosterTypeDetails'; describe('getPosterTypeDetails', () => { describe('English', () => { diff --git a/api/src/__tests__/getTopicAuthor.ts b/api/src/helpers/__tests__/getTopicAuthor.test.ts similarity index 95% rename from api/src/__tests__/getTopicAuthor.ts rename to api/src/helpers/__tests__/getTopicAuthor.test.ts index 3168d75c..5ebad5b1 100644 --- a/api/src/__tests__/getTopicAuthor.ts +++ b/api/src/helpers/__tests__/getTopicAuthor.test.ts @@ -1,8 +1,5 @@ -import { - getTopicAuthor, - getTopicAuthorUserId, -} from '../helpers/getTopicAuthor'; -import { PosterUnion } from '../types'; +import { getTopicAuthor, getTopicAuthorUserId } from '../getTopicAuthor'; +import { PosterUnion } from '../../types'; function getUserWithId(userId: number, description: string): PosterUnion { return { diff --git a/api/src/__tests__/getTopicPostPath.ts b/api/src/helpers/__tests__/getTopicPostPath.test.ts similarity index 88% rename from api/src/__tests__/getTopicPostPath.ts rename to api/src/helpers/__tests__/getTopicPostPath.test.ts index 214f8f0c..1ab2d527 100644 --- a/api/src/__tests__/getTopicPostPath.ts +++ b/api/src/helpers/__tests__/getTopicPostPath.test.ts @@ -1,4 +1,4 @@ -import { getTopicPostPath } from '../helpers'; +import { getTopicPostPath } from '..'; it('should return posts when the input is array of numbers', () => { expect(getTopicPostPath([2, 3, 4])).toEqual('/posts'); diff --git a/api/src/__tests__/getTopicTimings.ts b/api/src/helpers/__tests__/getTopicTimings.test.ts similarity index 89% rename from api/src/__tests__/getTopicTimings.ts rename to api/src/helpers/__tests__/getTopicTimings.test.ts index 2cb215eb..80f6106d 100644 --- a/api/src/__tests__/getTopicTimings.ts +++ b/api/src/helpers/__tests__/getTopicTimings.test.ts @@ -1,4 +1,4 @@ -import { getTopicTimings } from '../helpers'; +import { getTopicTimings } from '..'; it('Should send back timings input object', () => { const inputPost = [2, 3, 4]; diff --git a/api/src/__tests__/getUpdatedLikedTopic.ts b/api/src/helpers/__tests__/getUpdatedLikedTopic.test.ts similarity index 92% rename from api/src/__tests__/getUpdatedLikedTopic.ts rename to api/src/helpers/__tests__/getUpdatedLikedTopic.test.ts index df01eea1..4728c6d8 100644 --- a/api/src/__tests__/getUpdatedLikedTopic.ts +++ b/api/src/helpers/__tests__/getUpdatedLikedTopic.test.ts @@ -1,4 +1,4 @@ -import { getUpdatedLikedTopic } from '../helpers'; +import { getUpdatedLikedTopic } from '..'; const likeCount = 5; const currentLikedTopicResponse = { diff --git a/api/src/helpers/__tests__/likeErrorHandler.test.ts b/api/src/helpers/__tests__/likeErrorHandler.test.ts new file mode 100644 index 00000000..7fdc72d5 --- /dev/null +++ b/api/src/helpers/__tests__/likeErrorHandler.test.ts @@ -0,0 +1,97 @@ +import { AxiosError } from 'axios'; + +import { LikableEntity, likeErrorHandler } from '../likeErrorHandler'; + +const mockAxiosError: AxiosError = { + name: 'AxiosError', + message: 'Request failed with status code 403', + isAxiosError: true, + config: {}, + response: { + status: 403, + data: {}, + statusText: 'Forbidden', + headers: {}, + config: {}, + }, + toJSON: function () { + throw new Error('custom json function'); + }, +}; + +const actionsSummary = [ + { + id: 1, + hidden: false, + acted: false, + canUndo: false, + canAct: true, + count: 1, + }, + { + id: 2, + hidden: false, + acted: true, + canUndo: false, + canAct: true, + count: 1, + }, + { + id: 2, + hidden: false, + acted: false, + canUndo: false, + canAct: true, + count: 1, + }, +]; +const likableEntityPost: LikableEntity = 'post'; +const like = false; + +describe('likeErrorHandler', () => { + it('should throw error when like action summary is not provided for the post author', () => { + expect(() => + likeErrorHandler(mockAxiosError, { + actionsSummary: [actionsSummary[0]], + likableEntity: likableEntityPost, + like, + }), + ).toThrowError( + `You're not permitted to do like actions to your own ${likableEntityPost}.`, + ); + }); + + it(`should throw error You've liked this post when like again same post`, () => { + expect(() => + likeErrorHandler(mockAxiosError, { + actionsSummary, + likableEntity: likableEntityPost, + like: !like, + }), + ).toThrowError(`You've liked this ${likableEntityPost} before.`); + }); + + it(`should throw error when unlike post which already unlike`, () => { + expect(() => + likeErrorHandler(mockAxiosError, { + actionsSummary: [actionsSummary[2]], + likableEntity: likableEntityPost, + like: like, + }), + ).toThrowError( + `You can't unlike a ${likableEntityPost} you haven't liked before.`, + ); + }); + + it(`should throw error because pass limit unlike`, () => { + expect(() => + likeErrorHandler(mockAxiosError, { + actionsSummary: [actionsSummary[1]], + likableEntity: likableEntityPost, + like: like, + }), + ).toThrowError( + `You've passed the time limit to unlike this ${likableEntityPost}.`, + ); + }); +}); diff --git a/api/src/__tests__/parseTopicUrl.ts b/api/src/helpers/__tests__/parseTopicUrl.test.ts similarity index 97% rename from api/src/__tests__/parseTopicUrl.ts rename to api/src/helpers/__tests__/parseTopicUrl.test.ts index 0f7dbd88..f66fb6d2 100644 --- a/api/src/__tests__/parseTopicUrl.ts +++ b/api/src/helpers/__tests__/parseTopicUrl.test.ts @@ -1,4 +1,4 @@ -import { FilterInput, parseTopicUrl } from '../helpers'; +import { FilterInput, parseTopicUrl } from '..'; it('latest', () => { const filterInput: FilterInput = { diff --git a/api/src/helpers/__tests__/poll.test.ts b/api/src/helpers/__tests__/poll.test.ts new file mode 100644 index 00000000..423852ce --- /dev/null +++ b/api/src/helpers/__tests__/poll.test.ts @@ -0,0 +1,182 @@ +import { Poll, PollsVotes, PreloaderUnion } from '../../types'; +import { formatPolls, formatPollsVotes, formatPreloadedVoters } from '..'; + +const polls: Array = [ + { + name: 'Poll 1', + type: 'regular', + status: 'open', + public: true, + results: 'always', + options: [ + { id: '1', html: 'Option 1', votes: 0 }, + { id: '2', html: 'Option 2', votes: 1 }, + ], + voters: 1, + preloadedVoters: [ + { + id: 1, + username: 'user1', + name: 'User One', + avatarTemplate: 'avatar1', + title: null, + }, + ], + chartType: 'bar', + }, + { + name: 'Poll 2', + type: 'multiple', + status: 'closed', + public: false, + results: 'on_vote', + options: [ + { id: '1', html: 'Option A', votes: 0 }, + { id: '2', html: 'Option B', votes: 1 }, + { id: '3', html: 'Option C', votes: 0 }, + ], + voters: 1, + preloadedVoters: [ + { + id: 2, + username: 'user2', + name: 'User Two', + avatarTemplate: 'avatar2', + title: 'Title', + }, + ], + chartType: 'pie', + }, +]; + +describe('formatPreloadedVoters', () => { + test('should format preloadedVoters data correctly when input is an object', () => { + const preloadedVoters: PreloaderUnion = { + '1': [ + { + id: 1, + username: 'user1', + name: 'User One', + avatarTemplate: 'avatar1', + title: null, + }, + ], + '2': [ + { + id: 2, + username: 'user2', + name: 'User Two', + avatarTemplate: 'avatar2', + title: 'Title', + }, + ], + }; + + const expectedOutput = { + preloadedVoters: [ + { pollOptionId: '1', users: preloadedVoters['1'] }, + { pollOptionId: '2', users: preloadedVoters['2'] }, + ], + }; + + expect(formatPreloadedVoters(preloadedVoters)).toEqual(expectedOutput); + }); + + test('should format preloadedVoters data correctly when input is an array', () => { + const preloadedVoters: PreloaderUnion = [ + { + id: 1, + username: 'user1', + name: 'User One', + avatarTemplate: 'avatar1', + title: null, + }, + { + id: 2, + username: 'user2', + name: 'User Two', + avatarTemplate: 'avatar2', + title: 'Title', + }, + ]; + + const expectedOutput = { + preloadedVoters: [{ pollOptionId: '', users: preloadedVoters }], + }; + + expect(formatPreloadedVoters(preloadedVoters)).toEqual(expectedOutput); + }); +}); + +describe('formatPollsVotes', () => { + it('should return null when input is null and undefined', () => { + expect(formatPollsVotes(null)).toBeNull(); + expect(formatPollsVotes(undefined)).toBeNull(); + }); + + it('should return empty array when input is an empty object', () => { + expect(formatPollsVotes({})).toEqual([]); + }); + + it('should format pollsVotes data correctly', () => { + const pollsVotes = { + poll1: ['option1', 'option2'], + poll2: ['option3', 'option4'], + }; + const expectedFormattedPollsVotes = [ + { pollName: 'poll1', pollOptionIds: ['option1', 'option2'] }, + { pollName: 'poll2', pollOptionIds: ['option3', 'option4'] }, + ]; + expect(formatPollsVotes(pollsVotes)).toEqual(expectedFormattedPollsVotes); + }); +}); + +describe('formatPolls', () => { + test('should return null for formattedPolls and formattedPollsVotes if polls parameter is null or undefined', () => { + expect(formatPolls(null)).toEqual({ + formattedPolls: null, + formattedPollsVotes: null, + }); + expect(formatPolls(undefined)).toEqual({ + formattedPolls: null, + formattedPollsVotes: null, + }); + }); + + test('should format polls data correctly when pollsVotes parameter is null', () => { + const expectedFormattedPolls = polls.map((poll) => ({ + ...poll, + preloadedVoters: [{ pollOptionId: '', users: poll.preloadedVoters }], + })); + const expectedOutput = { + formattedPolls: expectedFormattedPolls, + formattedPollsVotes: null, + }; + + expect(formatPolls(polls)).toEqual(expectedOutput); + }); + + test('should format polls data correctly when pollsVotes parameter is provided', () => { + const pollsVotes: PollsVotes = { + 'Poll 1': ['1', '2'], + 'Poll 2': ['1', '2', '3'], + }; + + const expectedFormattedPolls = polls.map((poll) => ({ + ...poll, + preloadedVoters: [{ pollOptionId: '', users: poll.preloadedVoters }], + })); + const expectedOutput = { + formattedPolls: expectedFormattedPolls, + formattedPollsVotes: [ + { pollName: 'Poll 1', pollOptionIds: ['1', '2'] }, + { + pollName: 'Poll 2', + pollOptionIds: ['1', '2', '3'], + }, + ], + }; + + expect(formatPolls(polls, pollsVotes)).toEqual(expectedOutput); + }); +}); diff --git a/api/src/__tests__/privateMessagesMerger.ts b/api/src/helpers/__tests__/privateMessagesMerger.test.ts similarity index 93% rename from api/src/__tests__/privateMessagesMerger.ts rename to api/src/helpers/__tests__/privateMessagesMerger.test.ts index 3bab620d..0f3d6fc3 100644 --- a/api/src/__tests__/privateMessagesMerger.ts +++ b/api/src/helpers/__tests__/privateMessagesMerger.test.ts @@ -1,7 +1,6 @@ -import { privateMessagesMerger } from '../helpers'; -import { DiscoursePMInput, PMOutput } from '../types'; - -import { createMessage, createUser } from './data'; +import { privateMessagesMerger } from '..'; +import { DiscoursePMInput, PMOutput } from '../../types'; +import { createMessage, createUser } from '../../__tests__/data'; describe('privateMessagesMerger', () => { const topicListDefaults = { diff --git a/api/src/__tests__/processRawContent.ts b/api/src/helpers/__tests__/processRawContent.test.ts similarity index 83% rename from api/src/__tests__/processRawContent.ts rename to api/src/helpers/__tests__/processRawContent.test.ts index beebca59..a96da6cd 100644 --- a/api/src/__tests__/processRawContent.ts +++ b/api/src/helpers/__tests__/processRawContent.test.ts @@ -2,7 +2,8 @@ import { generateMarkdownContent, getCompleteImageVideoUrls, getEmojiImageUrls, -} from '../helpers'; + userActivityMarkdownContent, +} from '..'; describe('getCompleteImageUrls return image urls from html tags', () => { it('should return the last url from srcset in img tag if any', () => { @@ -186,3 +187,50 @@ describe('generate emoji url from image tag', () => { expect(getEmojiImageUrls(content3)).toEqual([]); }); }); + +describe('generate new content for user activity', () => { + it('it should return Content based input', () => { + const content = 'Hello\n who is this'; + const content1 = 'Just want to test\n\n something'; + + expect(userActivityMarkdownContent(content)).toEqual(content); + expect(userActivityMarkdownContent(content1)).toEqual(content1); + }); + it('it should replace content image', () => { + const contentImage = + 'Hello\n [exampleImage1]'; + const contentImageSrc = + 'download'; + + expect(userActivityMarkdownContent(contentImage)).toEqual( + 'Hello\n ![exampleImage1](https://image.jpeg)', + ); + expect(userActivityMarkdownContent(contentImageSrc)).toEqual( + '![undefined](https://wiki.kfox.io/uploads/default/original.jpeg)', + ); + }); + it('it should convert emoji', () => { + const emojiContent = + 'Hello\n :heart:'; + + expect(userActivityMarkdownContent(emojiContent)).toEqual( + 'Hello\n ![emoji-:heart:](https://image/heart.png?v=12)', + ); + }); + it('it should convert mention', () => { + const mentionContent = + 'Is this true? @marcello'; + + expect(userActivityMarkdownContent(mentionContent)).toEqual( + 'Is this true? @marcello', + ); + }); + it('it should convert Link', () => { + const mentionContent = + 'Hello'; + + expect(userActivityMarkdownContent(mentionContent)).toEqual( + '[Hello](https://www.google.com)', + ); + }); +}); diff --git a/api/src/helpers/__tests__/topicDetail.test.ts b/api/src/helpers/__tests__/topicDetail.test.ts new file mode 100644 index 00000000..28ee88c6 --- /dev/null +++ b/api/src/helpers/__tests__/topicDetail.test.ts @@ -0,0 +1,52 @@ +import { getTopicPostPath, validateTopicDetailOptionalArgs } from '..'; + +describe('getTopicPostPath', () => { + it('should return an empty string when input is undefined', () => { + expect(getTopicPostPath()).toBe(''); + }); + + it('should return a path with a single post number', () => { + const postNumber = 123; + expect(getTopicPostPath(postNumber)).toBe('/123'); + }); + + it('should return a path for multiple posts when input is an array', () => { + const postIds = [456, 789]; + expect(getTopicPostPath(postIds)).toBe('/posts'); + }); + + it('should return an empty string when input is undefined', () => { + expect(getTopicPostPath(undefined)).toBe(''); + }); +}); + +describe('validateTopicDetailOptionalArgs', () => { + it('should return an error to only provide post id or number', () => { + const args = { + postIds: [1], + postNumber: 1, + }; + expect(() => validateTopicDetailOptionalArgs(args)).toThrowError( + 'Please provide either only the post IDs or the post number', + ); + }); + + it('should return an error to only provide includeFirstPost', () => { + const args = { + postIds: [1], + includeFirstPost: true, + }; + expect(() => validateTopicDetailOptionalArgs(args)).toThrowError( + 'The first post cannot be included when post IDs are provided', + ); + }); + + it('should not return error', () => { + const args = {}; + const args1 = { postIds: [1, 2] }; + const args2 = { postNumber: 123 }; + expect(() => validateTopicDetailOptionalArgs(args)).not.toThrow(); + expect(() => validateTopicDetailOptionalArgs(args1)).not.toThrow(); + expect(() => validateTopicDetailOptionalArgs(args2)).not.toThrow(); + }); +}); diff --git a/api/src/helpers/auth.ts b/api/src/helpers/auth.ts index a266a5a5..50e9cc35 100644 --- a/api/src/helpers/auth.ts +++ b/api/src/helpers/auth.ts @@ -5,10 +5,15 @@ import camelcaseKey from 'camelcase-keys'; import snakecaseKeys from 'snakecase-keys'; import { discourseClient } from '../client'; -import { CONTENT_FORM_URLENCODED } from '../constants'; +import { + ACCEPTED_LANGUAGE, + CONTENT_FORM_URLENCODED, + CONTENT_JSON, +} from '../constants'; import { cookiesStringify } from './cookiesStringify'; import { SessionExpiredError } from './customErrors'; +import { getSiteDataLexiconPlugin } from './siteSettings'; async function getCsrfSession(cookies?: string) { let { @@ -30,7 +35,23 @@ type Credentials = { secondFactorToken?: string | null; }; type CsrfSession = { csrf: string; initialSessionCookie: string }; +type HpSession = { + initialSessionCookie: string; + passwordConfirmation: string; + challenge: string; +}; type AuthRequest = Credentials & CsrfSession & { client: AxiosInstance }; +type AppleAuthRequest = { identityToken: string } & CsrfSession & { + client: AxiosInstance; + }; +type LoginLinkRequest = { emailToken: string } & CsrfSession & { + client: AxiosInstance; + }; + +type ActivateAccountRequest = { emailToken: string } & CsrfSession & + HpSession & { + client: AxiosInstance; + }; function generateToken(cookies: string) { const buffer = Buffer.from(cookies); @@ -96,9 +117,16 @@ async function authenticate(authRequest: AuthRequest) { } let stringCookie = cookiesStringify(headers['set-cookie']); let token = generateToken(stringCookie); + + let siteData = await getSiteDataLexiconPlugin({ + client, + cookies: headers['set-cookie'], + }); + return { ...camelcaseKey(data, { deep: true }), token, + enableLexiconPushNotifications: siteData.enableLexiconPushNotifications, }; } @@ -119,7 +147,11 @@ async function getHpChallenge(csrfSession: CsrfSession) { await discourseClient.get('/users/hp.json', config); let { errors: oldVersionErrors, error_type: oldVersionErrorType } = oldVersionData; - if (oldVersionErrors && oldVersionErrorType === 'not_found') { + if ( + oldVersionErrors && + (oldVersionErrorType === 'not_found' || + oldVersionErrorType === 'not_logged_in') + ) { let { data: newVersionData, headers: newVersionHeaders } = await discourseClient.get('/session/hp.json', config); data = newVersionData; @@ -170,6 +202,145 @@ async function checkSession(authClient: AxiosInstance) { } } +async function authenticateApple(appleAuthRequest: AppleAuthRequest) { + let { csrf, initialSessionCookie, identityToken, client } = appleAuthRequest; + + let config = { + headers: { + 'Accept-Language': ACCEPTED_LANGUAGE, + 'Content-Type': CONTENT_JSON, + 'x-csrf-token': csrf, + }, + withCredentials: true, + Cookie: initialSessionCookie, + }; + + let { data, headers } = await client.post( + '/lexicon/auth/apple/login.json', + { + id_token: identityToken, + }, + config, + ); + let { error } = data; + if (error) { + throw new Error(error); + } + let stringCookie = cookiesStringify(headers['set-cookie']); + let token = generateToken(stringCookie); + + let siteData = await getSiteDataLexiconPlugin({ + client, + cookies: headers['set-cookie'], + }); + + return { + ...camelcaseKey(data, { deep: true }), + token, + enableLexiconPushNotifications: siteData.enableLexiconPushNotifications, + }; +} + +async function authenticateActivateAccount( + activateAccountRequest: ActivateAccountRequest, +) { + let { csrf, initialSessionCookie, emailToken, client, ...hpValue } = + activateAccountRequest; + + let config = { + headers: { + 'Accept-Language': ACCEPTED_LANGUAGE, + 'Content-Type': CONTENT_FORM_URLENCODED, + 'x-csrf-token': csrf, + }, + withCredentials: true, + Cookie: initialSessionCookie, + }; + + let snakecaseBody = snakecaseKeys({ + token: emailToken, + ...hpValue, + }); + + let { data, headers } = await client.post( + `/lexicon/auth/activate_account.json`, + stringify(snakecaseBody), + config, + ); + let { error } = data; + if (error) { + throw new Error(error); + } + let stringCookie = cookiesStringify(headers['set-cookie']); + let token = generateToken(stringCookie); + + let siteData = await getSiteDataLexiconPlugin({ + client, + cookies: headers['set-cookie'], + }); + + return { + ...camelcaseKey(data, { deep: true }), + token, + enableLexiconPushNotifications: siteData.enableLexiconPushNotifications, + }; +} + +async function authenticateLoginLink(authRequest: LoginLinkRequest) { + let { csrf, initialSessionCookie, emailToken, client } = authRequest; + + let config = { + headers: { + 'x-csrf-token': csrf, + 'Content-Type': CONTENT_FORM_URLENCODED, + }, + withCredentials: true, + Cookie: initialSessionCookie, + }; + let body = stringify({ token: emailToken }); + + let { data, headers } = await client.post( + `/session/email-login/${emailToken}.json`, + body, + config, + ); + let { error, failed } = data; + if (failed) { + return { + ...camelcaseKey(data, { deep: true }), + }; + } + if (error) { + throw new Error(error); + } + let stringCookie = cookiesStringify(headers['set-cookie']); + let token = generateToken(stringCookie); + + let configUser = { + headers: { + 'x-csrf-token': csrf, + }, + withCredentials: true, + Cookie: headers['set-cookie'], + }; + let { data: userData } = await client.get( + `/lexicon/auth/user.json`, + configUser, + ); + + let siteUrl = `/site.json`; + let { + data: { lexicon }, + } = await client.get(siteUrl, configUser); + + return { + ...camelcaseKey(userData, { deep: true }), + token, + enableLexiconPushNotifications: + lexicon?.settings.lexicon_push_notifications_enabled || false, + }; +} + export { getCsrfSession, authenticate, @@ -177,4 +348,7 @@ export { generateToken, getHpChallenge, checkSession, + authenticateApple, + authenticateActivateAccount, + authenticateLoginLink, }; diff --git a/api/src/helpers/cookiesStringify.ts b/api/src/helpers/cookiesStringify.ts index d6d8cc7f..07ea1f16 100644 --- a/api/src/helpers/cookiesStringify.ts +++ b/api/src/helpers/cookiesStringify.ts @@ -1,10 +1,27 @@ import setCookie from 'set-cookie-parser'; -export function cookiesStringify(cookies: Array) { +/** + * This function converts a cookie into the "name=value;" format. + * It is used based on header cookies at the Discourse website, + * where it only shows the cookie's name and value without other data. + * + * @param cookie - The string of the cookie. + * @returns A string representing the cookie in the new format to be used at Lexicon. + */ + +function joinCookieString(cookie: string): string { + let cookies = setCookie.parse(cookie, { decodeValues: false })[0]; + + let newCookie = cookies.name + '=' + cookies.value + ';'; + + return newCookie; +} + +export function cookiesStringify(cookies: Array | string) { let cookieString = ''; if (Array.isArray(cookies)) { for (let cookie of cookies) { - cookieString = cookieString + joinCookieString(cookie) + ';'; + cookieString += joinCookieString(cookie); } } if (typeof cookies === 'string') { @@ -13,6 +30,54 @@ export function cookiesStringify(cookies: Array) { return cookieString; } -function joinCookieString(cookie: string): string { - return setCookie.splitCookiesString(cookie).join(';'); +/** + * This function is used to replace and add new cookies when there are newCookies. + * + * When there is no value of _t in oldCookies or newCookies is undefined, it will return an empty string. + * + * It parses the oldCookies and newCookies, merges the new cookies into the old ones, + * and returns the resulting merged cookie string. + * + * @param {Object} param The parameters object contains: + * - {string|undefined} oldCookies: The old cookies string + * - {Array|undefined} newCookies: list of cookies which can get from response[set-cookies] + * + * @returns {string} The merged cookies string or an empty string if conditions are not met. + */ +export function mergeCookies({ + oldCookies, + newCookies, +}: { + oldCookies?: string; + newCookies?: Array; +}) { + // example old cookies format '_t=value;_forum=session;' + let parseOldCookie = oldCookies + ? setCookie.parse(oldCookies.split(';'), { + map: true, + decodeValues: false, + }) + : {}; + + // eslint-disable-next-line no-underscore-dangle + if (!oldCookies || !parseOldCookie._t || !newCookies) { + return ''; + } + const parsedNewCookies = setCookie.parse(newCookies, { + decodeValues: false, + }); + + // Replaces the old cookie value with the new cookie value if the same cookie name exists. + // Creates a new cookie entry if the new cookie name is not found in the old cookies. + + parsedNewCookies.forEach((newCookie) => { + parseOldCookie[newCookie.name] = { + name: newCookie.name, + value: newCookie.value, + }; + }); + + return Object.entries(parseOldCookie) + .map(([key, { value }]) => `${key}=${value};`) + .join(''); } diff --git a/api/src/helpers/getTopicAuthor.ts b/api/src/helpers/getTopicAuthor.ts index ddaa5298..28c19227 100644 --- a/api/src/helpers/getTopicAuthor.ts +++ b/api/src/helpers/getTopicAuthor.ts @@ -1,10 +1,14 @@ -import { PosterUnion } from '../types'; +import { PosterUnion, TopicPoster } from '../types'; import { getPosterTypeDetails } from './getPosterTypeDetails'; +/** + * Deprecated type TopicPoster which will be remove in version 3 + */ + export function getTopicAuthor( - posters: Readonly>, -): PosterUnion | undefined { + posters: Readonly>, +): PosterUnion | TopicPoster | undefined { return posters.find((poster) => { const { isAuthor } = getPosterTypeDetails(poster.description); return isAuthor; @@ -12,15 +16,15 @@ export function getTopicAuthor( } export function getTopicAuthorUserId( - posters: Readonly>, + posters: Readonly>, ): number | undefined { const author = getTopicAuthor(posters); if (author) { if ('userId' in author) { - return author.userId; + return author.userId || undefined; } else if ('user' in author) { - return author.user.id; + return author.user?.id; } } } diff --git a/api/src/helpers/index.ts b/api/src/helpers/index.ts index 24ef6acb..6576e261 100644 --- a/api/src/helpers/index.ts +++ b/api/src/helpers/index.ts @@ -12,3 +12,4 @@ export * from './privateMessagesMerger'; export * from './processRawContent'; export * from './topicDetail'; export * from './poll'; +export * from './siteSettings'; diff --git a/api/src/helpers/likeErroHandler.ts b/api/src/helpers/likeErrorHandler.ts similarity index 100% rename from api/src/helpers/likeErroHandler.ts rename to api/src/helpers/likeErrorHandler.ts diff --git a/api/src/helpers/processRawContent.ts b/api/src/helpers/processRawContent.ts index 0eff6be5..e012822a 100644 --- a/api/src/helpers/processRawContent.ts +++ b/api/src/helpers/processRawContent.ts @@ -16,6 +16,9 @@ const emojiBBCodeRegex = /(?<=^|\s):\w+:(?:t\d+:)?/g; const emojiImageTagRegex = //g; const emojiTitleRegex = /title="([^"]+)"/g; +const userActivityContentRegex = + /(?:]*src(?:set)?="(.+?)"(?:[^>]*title="([^"]*)")?(?:[^>]*class="([^"]*)")?[^>]*>)|(?:]* href="((https?:)?\/\/[^ ]*\.(?:jpe?g|png|gif|heic|heif|mov|mp4|webm|avi|wmv|flv|webp))"([^>]*?)title="([^"]*)"\s*>(\[.*?\])?<\/a>)|(?:]* class="mention" href="\/u\/([^"]+)">@(.*?)<\/a>)|(?:]* href="([^"]+)"[^>]*>(.*?)<\/a>)/g; + function handleRegexResult( result: RegExpMatchArray, host: string, @@ -183,3 +186,43 @@ export function getMention( return handleRegexResult(result, host, mentionRegex); } } + +export function userActivityMarkdownContent(content: string) { + const markdown = content.replace( + userActivityContentRegex, + ( + _, + imgSrc: string, + imgTitle: string, + imgClass: string, + aHref: string, + _https, + _dataHref, + aTitle: string, + _emptyMention, + _urlName, + nameMention, + linkHref, + linkText, + ) => { + let modifiedImageMarkdown = ``; + + if (imgSrc) { + modifiedImageMarkdown = `![${ + imgClass === 'emoji' || imgClass === 'emoji only-emoji' + ? 'emoji-' + : '' + }${imgTitle}](${imgSrc})`; + } else if (aHref) { + modifiedImageMarkdown = `![${aTitle}](${aHref})`; + } else if (nameMention) { + modifiedImageMarkdown = `@${nameMention}`; + } else if (linkHref && linkText) { + modifiedImageMarkdown = `[${linkText}](${linkHref})`; + } + + return modifiedImageMarkdown; + }, + ); + return markdown; +} diff --git a/api/src/helpers/siteSettings.ts b/api/src/helpers/siteSettings.ts new file mode 100644 index 00000000..4470f561 --- /dev/null +++ b/api/src/helpers/siteSettings.ts @@ -0,0 +1,24 @@ +import { AxiosInstance } from 'axios'; + +type GetSiteDataLexiconParams = { + client: AxiosInstance; + cookies: string; +}; +export async function getSiteDataLexiconPlugin( + params: GetSiteDataLexiconParams, +) { + const { client, cookies } = params; + let config = { + withCredentials: true, + Cookie: cookies, + }; + + let siteUrl = `/site.json`; + + let { data } = await client.get(siteUrl, config); + + return { + enableLexiconPushNotifications: + data?.lexicon?.settings.lexicon_push_notifications_enabled || false, + }; +} diff --git a/api/src/resolvers/auth/activateAccountMutation.ts b/api/src/resolvers/auth/activateAccountMutation.ts new file mode 100644 index 00000000..4563904f --- /dev/null +++ b/api/src/resolvers/auth/activateAccountMutation.ts @@ -0,0 +1,37 @@ +import { FieldResolver, mutationField, stringArg } from 'nexus'; + +import { + authenticateActivateAccount, + getCsrfSession, + getHpChallenge, +} from '../../helpers'; +import { Context } from '../../types'; + +export let activateAccountMutationResolver: FieldResolver< + 'Mutation', + 'activateAccount' +> = async (_, { token }, { client }: Context) => { + try { + let csrfSession = await getCsrfSession(); + let { cookies, ...hpChallenge } = await getHpChallenge(csrfSession); + + return authenticateActivateAccount({ + initialSessionCookie: cookies, + csrf: csrfSession.csrf, + client, + emailToken: token, + ...hpChallenge, + }); + } catch (unknownError) { + const error = unknownError as Error; + throw new Error(`activate-account: ${error.message}`); + } +}; + +export let activateAccountMutation = mutationField('activateAccount', { + type: 'LoginOutput', + args: { + token: stringArg(), + }, + resolve: activateAccountMutationResolver, +}); diff --git a/api/src/resolvers/auth/authenticateLoginLink.ts b/api/src/resolvers/auth/authenticateLoginLink.ts new file mode 100644 index 00000000..715591ec --- /dev/null +++ b/api/src/resolvers/auth/authenticateLoginLink.ts @@ -0,0 +1,32 @@ +import { FieldResolver, mutationField, stringArg } from 'nexus'; + +import { authenticateLoginLink, getCsrfSession } from '../../helpers'; +import { Context } from '../../types'; + +export let authenticateLoginLinkMutationResolver: FieldResolver< + 'Mutation', + 'authenticateLoginLink' +> = async (_, { token }, { client }: Context) => { + try { + let csrfSession = await getCsrfSession(); + return authenticateLoginLink({ + ...csrfSession, + emailToken: token, + client, + }); + } catch (unknownError) { + const error = unknownError as Error; + throw new Error(`LoginError: ${error.message}`); + } +}; + +export let authenticateLoginLinkMutation = mutationField( + 'authenticateLoginLink', + { + type: 'LoginOutput', + args: { + token: stringArg(), + }, + resolve: authenticateLoginLinkMutationResolver, + }, +); diff --git a/api/src/resolvers/auth/loginWithAppleMutation.ts b/api/src/resolvers/auth/loginWithAppleMutation.ts new file mode 100644 index 00000000..500138bf --- /dev/null +++ b/api/src/resolvers/auth/loginWithAppleMutation.ts @@ -0,0 +1,24 @@ +import { FieldResolver, mutationField, stringArg } from 'nexus'; + +import { authenticateApple, getCsrfSession } from '../../helpers'; +import { Context } from '../../types'; + +export let loginWithAppleMutationResolver: FieldResolver< + 'Mutation', + 'loginWithApple' +> = async (_, { identityToken }, { client }: Context) => { + let csrfSession = await getCsrfSession(); + return authenticateApple({ + ...csrfSession, + identityToken, + client, + }); +}; + +export let loginWithAppleMutation = mutationField('loginWithApple', { + type: 'LoginOutput', + args: { + identityToken: stringArg(), + }, + resolve: loginWithAppleMutationResolver, +}); diff --git a/api/src/resolvers/auth/requestLoginLinkMutation.ts b/api/src/resolvers/auth/requestLoginLinkMutation.ts new file mode 100644 index 00000000..e78c5033 --- /dev/null +++ b/api/src/resolvers/auth/requestLoginLinkMutation.ts @@ -0,0 +1,46 @@ +import { stringify } from 'querystring'; + +import { FieldResolver, mutationField, stringArg } from 'nexus'; + +import { errorHandler, getCsrfSession } from '../../helpers'; +import { Context } from '../../types'; +import { ACCEPTED_LANGUAGE, CONTENT_FORM_URLENCODED } from '../../constants'; + +export let requestLoginLinkMutationResolver: FieldResolver< + 'Mutation', + 'requestLoginLink' +> = async (_, { login }, { client }: Context) => { + let { csrf, initialSessionCookie } = await getCsrfSession(); + const config = { + headers: { + 'Accept-Language': ACCEPTED_LANGUAGE, + 'Content-Type': CONTENT_FORM_URLENCODED, + 'x-csrf-token': csrf, + }, + withCredentials: true, + Cookie: initialSessionCookie, + }; + let body = { + login, + }; + + try { + let { data } = await client.post(`/u/email-login`, stringify(body), config); + + if (data.user_found) { + return 'success'; + } else { + throw new Error(`No account matches ${login}`); + } + } catch (e) { + throw errorHandler(e); + } +}; + +export let requestLoginLinkMutation = mutationField('requestLoginLink', { + type: 'String', + args: { + login: stringArg(), + }, + resolve: requestLoginLinkMutationResolver, +}); diff --git a/api/src/resolvers/index.ts b/api/src/resolvers/index.ts index 0334dd99..d9eebbbd 100644 --- a/api/src/resolvers/index.ts +++ b/api/src/resolvers/index.ts @@ -3,6 +3,10 @@ export * from './auth/loginMutation'; export * from './auth/logoutMutation'; export * from './auth/refreshTokenQuery'; export * from './auth/registerMutation'; +export * from './auth/loginWithAppleMutation'; +export * from './auth/activateAccountMutation'; +export * from './auth/requestLoginLinkMutation'; +export * from './auth/authenticateLoginLink'; export * from './email/addEmailMutation'; export * from './email/changeEmailMutation'; @@ -15,6 +19,7 @@ export * from './notifications/pushNotificationMutation'; export * from './site/aboutQuery'; export * from './site/siteQuery'; +export * from './site/pluginStatusQuery'; export * from './topics/bookmarkPostMutation'; export * from './topics/categoryQuery'; diff --git a/api/src/resolvers/site/aboutQuery.ts b/api/src/resolvers/site/aboutQuery.ts index aa76e836..8718e15d 100644 --- a/api/src/resolvers/site/aboutQuery.ts +++ b/api/src/resolvers/site/aboutQuery.ts @@ -11,17 +11,28 @@ let aboutResolver: FieldResolver<'Query', 'about'> = async ( try { let siteUrl = `/about.json`; + /** + * In here when use newest version discourse from 3.2.0.beta4-dev the name of field change into topics_count and posts_count + * + * And for the previous version discourse it use topic_count and post_count + */ + let { data: { about: { - stats: { topic_count: topicCount, post_count: postCount }, + stats: { + topics_count: topicsCount, + topic_count: topicCount, + posts_count: postsCount, + post_count: postCount, + }, }, }, } = await context.client.get(siteUrl); return { - topicCount, - postCount, + topicCount: topicCount || topicsCount, + postCount: postCount || postsCount, }; } catch (error) { throw errorHandler(error); diff --git a/api/src/resolvers/site/pluginStatusQuery.ts b/api/src/resolvers/site/pluginStatusQuery.ts new file mode 100644 index 00000000..d032bdcf --- /dev/null +++ b/api/src/resolvers/site/pluginStatusQuery.ts @@ -0,0 +1,37 @@ +import { FieldResolver, queryField } from 'nexus'; + +import { errorHandler } from '../../helpers'; +import { Context } from '../../types'; +import { ACCEPTED_LANGUAGE, CONTENT_JSON } from '../../constants'; + +let pluginStatusResolver: FieldResolver<'Query', 'pluginStatus'> = async ( + _, + __, + context: Context, +) => { + try { + const config = { + headers: { + 'Accept-Language': ACCEPTED_LANGUAGE, + 'Content-Type': CONTENT_JSON, + }, + }; + let { + data: { apple, loginLink }, + } = await context.client.get(`/lexicon/auth/status.json`, config); + + return { + appleLoginEnabled: apple, + loginLinkEnabled: loginLink, + }; + } catch (error) { + throw errorHandler(error); + } +}; + +let pluginStatusQuery = queryField('pluginStatus', { + type: 'PluginStatus', + resolve: pluginStatusResolver, +}); + +export { pluginStatusQuery }; diff --git a/api/src/resolvers/site/siteQuery.ts b/api/src/resolvers/site/siteQuery.ts index 09875269..cedef727 100644 --- a/api/src/resolvers/site/siteQuery.ts +++ b/api/src/resolvers/site/siteQuery.ts @@ -23,6 +23,7 @@ let siteResolver: FieldResolver<'Query', 'site'> = async ( post_action_types: postActionTypes, uncategorized_category_id: uncategorizedCategoryId = UNCATEGORIZED_CATEGORY_ID, + lexicon, ...siteData }, } = await context.client.get(siteUrl); @@ -90,6 +91,8 @@ let siteResolver: FieldResolver<'Query', 'site'> = async ( discourseBaseUrl: PROSE_DISCOURSE_HOST || '', allowPoll, pollCreateMinimumTrustLevel, + enableLexiconPushNotifications: + lexicon?.settings.lexicon_push_notifications_enabled || false, ...camelcaseKey(siteData, { deep: true }), }; } catch (error) { diff --git a/api/src/resolvers/topics/likeTopicOrPostMutation.ts b/api/src/resolvers/topics/likeTopicOrPostMutation.ts index 08bc32a0..f84fd668 100644 --- a/api/src/resolvers/topics/likeTopicOrPostMutation.ts +++ b/api/src/resolvers/topics/likeTopicOrPostMutation.ts @@ -20,7 +20,10 @@ import { fetchTopicDetail, fetchPost, } from '../../helpers'; -import { LikableEntity, likeErrorHandler } from '../../helpers/likeErroHandler'; +import { + LikableEntity, + likeErrorHandler, +} from '../../helpers/likeErrorHandler'; import { ActionsSummary, Context, LikedTopic } from '../../types'; export let likeTopicOrPostResolver: FieldResolver< diff --git a/api/src/resolvers/upload/uploadMutation.ts b/api/src/resolvers/upload/uploadMutation.ts index 790fed4e..840dad7a 100644 --- a/api/src/resolvers/upload/uploadMutation.ts +++ b/api/src/resolvers/upload/uploadMutation.ts @@ -1,6 +1,7 @@ import camelcaseKeys from 'camelcase-keys'; import FormData from 'form-data'; import { FieldResolver, mutationField, arg, intArg, nullable } from 'nexus'; +import sharp from 'sharp'; import { errorHandler } from '../../helpers'; import { Context } from '../../types'; @@ -17,7 +18,16 @@ export let uploadResolver: FieldResolver<'Mutation', 'upload'> = async ( const fileBuffer = Buffer.from(await file.arrayBuffer()); - form.append('files[]', fileBuffer, file.name); + let resizedImageBuffer = fileBuffer; + + /** + * This condition to optimize file image if more than 1 Mb to use sharp which will resize file size to be optimal + */ + if (file.size > 1000000 && type === 'avatar') { + resizedImageBuffer = await sharp(fileBuffer).toBuffer(); + } + + form.append('files[]', resizedImageBuffer, file.name); form.append('type', type); if (userId) { form.append('user_id', userId); diff --git a/api/src/scalars/PosterOutputUnion.ts b/api/src/scalars/PosterOutputUnion.ts index ef350c01..2b259ce4 100644 --- a/api/src/scalars/PosterOutputUnion.ts +++ b/api/src/scalars/PosterOutputUnion.ts @@ -3,11 +3,11 @@ import { unionType } from 'nexus'; export let PosterOutputUnion = unionType({ name: 'PosterOutputUnion', definition(t) { - t.members('TopicPoster', 'SuggestionTopicPoster'); + t.members('TopicPosterNewUnion', 'SuggestionTopicPoster'); }, resolveType: (item) => { if (item.hasOwnProperty('userId')) { - return 'TopicPoster'; + return 'TopicPosterNewUnion'; } return 'SuggestionTopicPoster'; }, diff --git a/api/src/typeSchemas/LoginOutput.ts b/api/src/typeSchemas/LoginOutput.ts index 7751c4d6..406241b6 100644 --- a/api/src/typeSchemas/LoginOutput.ts +++ b/api/src/typeSchemas/LoginOutput.ts @@ -11,5 +11,6 @@ export let LoginOutput = objectType({ t.field('user', { type: 'UserLite' }); // Auth t.string('token'); + t.boolean('enableLexiconPushNotifications'); }, }); diff --git a/api/src/typeSchemas/PluginStatus.ts b/api/src/typeSchemas/PluginStatus.ts new file mode 100644 index 00000000..2fb4443a --- /dev/null +++ b/api/src/typeSchemas/PluginStatus.ts @@ -0,0 +1,9 @@ +import { objectType } from 'nexus'; + +export let PluginStatus = objectType({ + name: 'PluginStatus', + definition(t) { + t.boolean('appleLoginEnabled'); + t.boolean('loginLinkEnabled'); + }, +}); diff --git a/api/src/typeSchemas/SiteSetting.ts b/api/src/typeSchemas/SiteSetting.ts index 981d84c7..64d61afc 100644 --- a/api/src/typeSchemas/SiteSetting.ts +++ b/api/src/typeSchemas/SiteSetting.ts @@ -42,6 +42,12 @@ export let SiteSetting = objectType({ t.list.field('groups', { type: 'GroupSiteSetting', }); + + /** + * This field for check plugin + */ + + t.boolean('enableLexiconPushNotifications'); }, }); diff --git a/api/src/typeSchemas/Topic.ts b/api/src/typeSchemas/Topic.ts index 3c377dc8..b924be86 100644 --- a/api/src/typeSchemas/Topic.ts +++ b/api/src/typeSchemas/Topic.ts @@ -50,7 +50,32 @@ export let Topic = objectType({ t.nullable.boolean('pinnedGlobally'); t.nullable.boolean('hasSummary'); - t.list.field('posters', { type: 'PosterOutputUnion' }); + /** + * Deprecated posters type which will use postersUnion type for return posters + */ + t.list.field('posters', { type: 'TopicPoster' }); + t.nullable.list.field('postersUnion', { + type: 'PosterOutputUnion', + resolve: ({ posters }) => { + /** + * Which empty data user cannot be happen in here + */ + + let data = posters.map((poster) => { + return { + ...poster, + user: poster.user || { + avatarTemplate: '', + id: 0, + + username: '', + }, + }; + }); + return data; + }, + }); + t.nullable.list.field('participants', { type: 'MessageParticipant', }); diff --git a/api/src/typeSchemas/TopicPoster.ts b/api/src/typeSchemas/TopicPoster.ts index a061081e..04f06b94 100644 --- a/api/src/typeSchemas/TopicPoster.ts +++ b/api/src/typeSchemas/TopicPoster.ts @@ -1,7 +1,22 @@ import { objectType } from 'nexus'; +/** + * Deprecated type TopicPoster + * Which will Union for topic poster + */ + export let TopicPoster = objectType({ name: 'TopicPoster', + definition(t) { + t.nullable.string('extras'); + t.string('description'); + t.nullable.int('userId'); + t.nullable.field('user', { type: 'UserIcon' }); + }, +}); + +export let TopicPosterNewUnion = objectType({ + name: 'TopicPosterNewUnion', definition(t) { t.nullable.string('extras'); t.string('description'); diff --git a/api/src/typeSchemas/UserActions.ts b/api/src/typeSchemas/UserActions.ts index a3aea65c..fde81a05 100644 --- a/api/src/typeSchemas/UserActions.ts +++ b/api/src/typeSchemas/UserActions.ts @@ -1,6 +1,7 @@ import { objectType } from 'nexus'; import { getNormalizedUrlTemplate } from '../resolvers/utils'; +import { userActivityMarkdownContent } from '../helpers'; export let UserActions = objectType({ name: 'UserActions', @@ -37,5 +38,10 @@ export let UserActions = objectType({ t.int('topicId'); t.int('userId'); t.string('username'); + t.nullable.string('markdownContent', { + resolve: ({ excerpt }) => { + return userActivityMarkdownContent(excerpt); + }, + }); }, }); diff --git a/api/src/typeSchemas/index.ts b/api/src/typeSchemas/index.ts index 16b653ca..3d3b433d 100644 --- a/api/src/typeSchemas/index.ts +++ b/api/src/typeSchemas/index.ts @@ -79,3 +79,4 @@ export * from './UserProfileOutput'; export * from './UserTopic'; export * from './HealthCheck'; export * from './UserStatus'; +export * from './PluginStatus'; diff --git a/api/src/types/dataTypes.ts b/api/src/types/dataTypes.ts index 17b179d7..d5f62dbd 100644 --- a/api/src/types/dataTypes.ts +++ b/api/src/types/dataTypes.ts @@ -29,9 +29,18 @@ export const UserIcon = z.object({ export type UserIcon = z.infer; -// TODO: #1174: get to the bottom of why we have both `userId` and -// `user`, and why both can be nullable. Seems we made a mistake somewhere. +/** + * Deprecated TopicPoster type which will use TopicPosterNewUnion type + */ + export const TopicPoster = z.object({ + extras: z.optional(z.nullable(z.string())), + description: z.string(), + userId: z.optional(z.nullable(z.number())), + user: z.optional(z.nullable(UserIcon)), +}); + +export const TopicPosterNewUnion = z.object({ extras: z.optional(z.nullable(z.string())), description: z.string(), userId: z.number(), @@ -44,8 +53,12 @@ export const SuggestionTopicPoster = z.object({ user: UserIcon, }); -export const PosterUnion = z.union([TopicPoster, SuggestionTopicPoster]); +export const PosterUnion = z.union([ + TopicPosterNewUnion, + SuggestionTopicPoster, +]); +export type TopicPoster = z.infer; export type PosterUnion = z.infer; export type Topic = { diff --git a/api/yarn.lock b/api/yarn.lock index 450661eb..feebf6e2 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1336,6 +1336,11 @@ axios@^0.21.2: dependencies: follow-redirects "^1.14.0" +b4a@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9" + integrity sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw== + babel-jest@^26.6.3: version "26.6.3" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz" @@ -1402,6 +1407,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.npmjs.org/base/-/base-0.11.2.tgz" @@ -1420,6 +1430,15 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" @@ -1485,6 +1504,14 @@ buffer-from@1.x, buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + busboy@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz" @@ -1588,6 +1615,11 @@ chokidar@^3.5.1: optionalDependencies: fsevents "~2.3.2" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz" @@ -1659,7 +1691,7 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.6.0: +color-string@^1.6.0, color-string@^1.9.0: version "1.9.1" resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -1675,6 +1707,14 @@ color@^3.1.3: color-convert "^1.9.3" color-string "^1.6.0" +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + colorspace@1.1.x: version "1.1.4" resolved "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz" @@ -1802,9 +1842,21 @@ decimal.js@^10.2.1: integrity sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg== decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz" - integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== + version "0.2.2" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" + integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.4" @@ -1851,6 +1903,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +detect-libc@^2.0.0, detect-libc@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" + integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -1931,7 +1988,7 @@ enabled@2.0.x: resolved "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== -end-of-stream@^1.1.0: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -2321,6 +2378,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz" @@ -2372,6 +2434,11 @@ fast-diff@^1.1.2: resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-fifo@^1.1.0, fast-fifo@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@^3.2.9: version "3.2.11" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz" @@ -2470,9 +2537,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.14.0: - version "1.15.1" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz" - integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== for-in@^1.0.2: version "1.0.2" @@ -2517,6 +2584,11 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" @@ -2608,6 +2680,11 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz" integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" @@ -2784,6 +2861,11 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^4.0.6: version "4.0.6" resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz" @@ -2823,11 +2905,16 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz" @@ -3830,6 +3917,11 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -3842,6 +3934,11 @@ minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.3: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz" @@ -3850,6 +3947,11 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@1.x, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" @@ -3892,6 +3994,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -3910,6 +4017,18 @@ nice-try@^1.0.4: resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-abi@^3.3.0: + version "3.51.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.51.0.tgz#970bf595ef5a26a271307f8a4befa02823d4e87d" + integrity sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA== + dependencies: + semver "^7.3.5" + +node-addon-api@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" + integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== + node-domexception@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" @@ -4236,6 +4355,24 @@ posix-character-classes@^0.1.0: resolved "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz" integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== +prebuild-install@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45" + integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -4335,11 +4472,26 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -4369,7 +4521,7 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" -readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -4518,16 +4670,16 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-buffer@^5.0.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz" @@ -4572,7 +4724,7 @@ saxes@^5.0.1: resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5: +semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.5.4: version "7.5.4" resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -4604,6 +4756,20 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" +sharp@^0.32.6: + version "0.32.6" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.32.6.tgz#6ad30c0b7cd910df65d5f355f774aa4fce45732a" + integrity sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w== + dependencies: + color "^4.2.3" + detect-libc "^2.0.2" + node-addon-api "^6.1.0" + prebuild-install "^7.1.1" + semver "^7.5.4" + simple-get "^4.0.1" + tar-fs "^3.0.4" + tunnel-agent "^0.6.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz" @@ -4647,6 +4813,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0, simple-get@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz" @@ -4813,6 +4993,14 @@ streamsearch@^1.1.0: resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.15.0: + version "2.15.5" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.5.tgz#87bcef4dc7f0b883f9359671203344a4e004c7f1" + integrity sha512-9thPGMkKC2GctCzyCUjME3yR03x2xNo0GPKGkRw2UMYN+gqWa9uqpyNWhmsNCutU5zHmkUum0LsCRQTXUgUCAg== + dependencies: + fast-fifo "^1.1.0" + queue-tick "^1.0.1" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -4896,7 +5084,7 @@ strip-final-newline@^2.0.0: resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== -strip-json-comments@^2.0.0: +strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== @@ -4949,6 +5137,45 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-fs@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf" + integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w== + dependencies: + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^3.1.5" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^3.1.5: + version "3.1.6" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.6.tgz#6520607b55a06f4a2e2e04db360ba7d338cc5bab" + integrity sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz" @@ -5155,6 +5382,13 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -5217,9 +5451,9 @@ unbox-primitive@^1.0.2: which-boxed-primitive "^1.0.2" undici@^5.8.0: - version "5.26.3" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.26.3.tgz#ab3527b3d5bb25b12f898dfd22165d472dd71b79" - integrity sha512-H7n2zmKEWgOllKkIUkLvFmsJQj062lSm3uA4EYApG8gLuiOM0/go9bIoC3HVaSnfg4xunowDE2i9p8drkXuvDw== + version "5.28.4" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" + integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g== dependencies: "@fastify/busboy" "^2.0.0" diff --git a/documentation/docs/activation-with-link/intro.md b/documentation/docs/activation-with-link/intro.md new file mode 100644 index 00000000..94aa9ce0 --- /dev/null +++ b/documentation/docs/activation-with-link/intro.md @@ -0,0 +1,9 @@ +--- +id: intro +title: Introduction +slug: discourse-plugin/activation-with-link +--- + +The Lexicon Discourse plugin provides support for integrating Discourse's email activation with your Lexicon-powered mobile app. Our plugin modifies links in specific Discourse activation emails account so that when a relevant link is tapped and the user has your Lexicon-powered mobile app installed, it will open the app and automatically activate account and log the user in. + +This section of the documentation offers step-by-step instructions to integrate activation with link into your Discourse site so that your users have a more seamless experience with your Lexicon-powered mobile app. diff --git a/documentation/docs/activation-with-link/setup/enable-activate-with-link.md b/documentation/docs/activation-with-link/setup/enable-activate-with-link.md new file mode 100644 index 00000000..6316850b --- /dev/null +++ b/documentation/docs/activation-with-link/setup/enable-activate-with-link.md @@ -0,0 +1,25 @@ +--- +title: Enabling activation account with link +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +This guide will walk you through the necessary steps to activate activation account with link at lexicon app on your Discourse site. + +## Steps + +1. Access your Discourse admin dashboard. + +2. Navigate to the `Plugins` section. + + + +3. Locate the `discourse-lexicon-plugin` and click on the `Settings` button. + +4. Fill in the `lexicon app scheme` setting with your app scheme. The app scheme is required to enable activation with link. + +5. Check the `lexicon activate account link enabled` box in the Lexicon settings section and save your changes. + + + +Once the activation account with link feature is enabled, you will be able to utilize its functionality in your Discourse instance. diff --git a/documentation/docs/activation-with-link/setup/verify-activate-with-link.md b/documentation/docs/activation-with-link/setup/verify-activate-with-link.md new file mode 100644 index 00000000..d8d6c42f --- /dev/null +++ b/documentation/docs/activation-with-link/setup/verify-activate-with-link.md @@ -0,0 +1,55 @@ +--- +title: Verify Activation Account With Link +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Below, we'll walk you through how you can validate the functionality of activation account with a link within your Lexicon-powered mobile app. + +:::note +The steps below assume that **you have already build your Lexicon-powered mobile app with the correct app scheme**. If you are running the app on your machine locally through Expo, these steps will not work. + +In order to test account activation with a link, **you will need to use Lexicon version 2.2.0** for your Lexicon app. This feature only works if the user signs up by themselves, not through an invitation from an admin or moderator. Therefore, it is required to disable the **invite only** setting in the Discourse admin settings. + +::: + +:::info +To be able to test this feature, you need an email account that has not been registered in Discourse. + +You also need to be able to log in as an admin on the Discourse website in case you need to approve new users. This is necessary if you have enabled the `must approve users` setting in the Discourse admin settings. + +::: + +## Steps + +To test activation account with link within your Lexicon-powered mobile app, follow these steps: + +1. Ensure that you have Lexicon-powered mobile app at your device. +2. On your mobile device, open your Lexicon-powered mobile app and sign up using new email or you can sign up from discourse website. + > **Note**: + > + > - Ensure that your email client on your mobile device will receive emails for this account. + > - If you want to sign up using mobile app disable Discourse's setting `login required` + +
+ + +
+ +3. After you finish sign up you will receive email to activate account. +4. Open your email on your phone and check the email sent by your Discourse website. + +
+ +
+ +5. Click the link provided in the email. + +
+ +
+ +6. The link will first open in your mobile web browser. When you click `Open App`, if the Lexicon-powered mobile app is installed and matches the configured app scheme, it should automatically open your app and attempt to log you in. + > **Note:** If your admin settings require user approval, the login will fail, and a popup will appear indicating that a moderator's approval is required. + +And that's it! The Lexicon Discourse plugin will properly log you in with a link through your Discourse site. diff --git a/documentation/docs/app-store.md b/documentation/docs/app-store.md index 941750d5..f91b5fa0 100644 --- a/documentation/docs/app-store.md +++ b/documentation/docs/app-store.md @@ -14,6 +14,7 @@ In this page, we'll cover the process of publishing it on iOS. - An Expo account - XCode is installed on your development machine - EAS CLI 2.6.0 or newer +- The [Lexicon Discourse plugin](./discourse-plugin.md) is already installed on your Discourse instance To get started with TestFlight and publishing your app, you'll need an **Apple Developer account**. @@ -91,9 +92,14 @@ First, you'll need to ensure you've set your app name and slug in `frontend/app. Replace these placeholders with your desired values: +:::info +Note below that `scheme` is included. If you want [email deep linking](./email-deep-linking/intro.md) support in your app, **you must specify a scheme**, and then configure the Lexicon Discourse plugin with the same scheme. +::: + ```json - "name": "", - "slug": "", +"name": "", +"slug": "", +"scheme": "", ``` Next, configure EAS Build by running this command from the `frontend/` directory: diff --git a/documentation/docs/concepts.md b/documentation/docs/concepts.md index f32b0652..44211e43 100644 --- a/documentation/docs/concepts.md +++ b/documentation/docs/concepts.md @@ -44,6 +44,8 @@ Having said that, we chose to build Lexicon with it for two primary reasons. We find that Expo makes us much more effective as developers, and also provides excellent services to facilitate the entire process of building and publishing React Native apps. +In particular, Discourse sites that leverage the [Lexicon Discourse Plugin](./discourse-plugin.md) get the benefit of [push notifications](./push-notifications) through Expo's [push notifications service](https://docs.expo.dev/push-notifications/overview/), which abstracts away Google and Apple's push services into a simple interface. + ## Lexicon Architecture The Lexicon Stack is fairly simple, and only consists of 3 major pieces: @@ -51,13 +53,16 @@ The Lexicon Stack is fairly simple, and only consists of 3 major pieces: - The Lexicon Mobile App - The Prose GraphQL API - A running, accessible Discourse instance +- Optionally, you can install our [Discourse Plugin](./discourse-plugin.md) to enable additional features. Below is a diagram illustrating the typical architecture for a Lexicon-powered mobile app. -IOS Lexicon Login Page +IOS Lexicon Login Page As indicated above, the mobile app makes requests to a deployed Prose GraphQL server. The Prose server has been configured to point at an active Discourse instance of the developer's choice. +If the [Lexicon Discourse Plugin](./discourse-plugin.md) is installed, additional endpoints will be exposed which Prose already knows how to communicate with. + Traffic then flows back from Discourse, through Prose, and returns to the mobile app over a GraphQL interface. diff --git a/documentation/docs/discourse-features.md b/documentation/docs/discourse-features.md index 3183014a..fa303920 100644 --- a/documentation/docs/discourse-features.md +++ b/documentation/docs/discourse-features.md @@ -18,35 +18,40 @@ For this reason, most admin tasks are still best accomplished using the Discours ### Lexicon Mobile App Features -| Feature | Description | Supported | Notes | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- | -| 2FA Login | Allow users with 2FA enabled to be prompted for their 2FA code when logging in | ✅ | Managing 2FA, such as enabling it or disabling it from within the app, is not currently supported | -| Ability to Tag Topics | Create and tag topics to provide relevant metadata for your users | ✅ 🔧 | Configuration required: see [Optimal Experience](optimal#enable-topic-tagging) | -| Topic Previews (Excerpts) | Show an excerpt of the first post in a topic from the Home screen | ✅ 🔧 | Configuration required: see [Optimal Experience](optimal#enable-topic-excerpts) | -| View User Activity | View a user's recent activity—such as topics, posts, and likes—in a single feed from their profile | ✅ | The ability to filter by activity is not currently supported | -| Topic Metrics | Likes, Views, Replies, and Frequent Posters | ✅ | | -| Topic & Post Actions | Ability to like and edit topics and posts | ✅ | | -| View Top & Latest Topics | A Tab View at the top of the main feed provides the ability to switch between Latest and Top activity | ✅ | | -| Search | Search the current Discourse instance for topics and posts based on keywords, categories, and tags | ✅ | | -| Categories | View the category of a topic and filter topics by a given category | ✅ | Categories cannot be created, updated, or deleted | -| Attaching Media to Posts | Users can attach media to a post from the app | ✅ 🔧 | Configuration recommended for supported file extensions-see [Optimal Experience](optimal#configure-upload-extensions) | -| Standard Markdown | Standard Markdown is supported in the editor and rendered correctly in the mobile app | ✅ | Light, incomplete support exists for some of Discourse's custom markup, such as dates | -| Sign Up | Allow users to sign up for an account directly through the mobile app, depending on whether your Discourse instance allows new user registration or not | ✅ | | -| Browsing Public Instances | Allow users to immediately access and browse your Discourse instance from the mobile app if it is not private | ✅ | Users will be prompted to login upon attempting an authenticated action | -| User Profiles | Ability to view users' profiles and edit your own | ✅ | Partial support: displays the user's photo, username, Markdown bio on a single line, and recent activity | -| Post Flagging | Allow users to flag posts for admins to review | ✅ | Admins are not able to review posts in the app, though they will see in-app notifications for flags | -| In-App Notifications | Allow users to see new notifications from the profile screen of the mobile app and mark all notifications as read | ✅ | Some notifications from Discourse are not tappable in the mobile app, such as badge notifications | -| Private messaging | Allow users to start private or group messages with one another | ✅ | | -| Mentions | Allow users to mention a user when creating or editing posts and messages | ✅ | -| Color Scheme | Provides light and dark mode support for users | ✅ | Specify color scheme (light mode, dark mode, or system) from within the app (only local to the user's mobile device) | -| Badges | The ability to see and interact with badges that have been awarded to users on the Discourse instance | ❌ | | -| Post Drafts | Enable users to start composing a draft of a post and return to it later | ❌ | | -| Groups | Enable users to create and participate in private groups of which only group members can view certain topics | ❌ | | -| Admin Features | Discourse admin features generally not available in Lexicon—better suited to a desktop environment | ❌ | Editing posts is supported | -| Post Quotes, Polls, Toggles, and Task Lists | Custom text formatting that enables Discourse-specific features | ❌ | | -| Discourse Emojis | Utilize emojis when creating a topic, making a post, or sending a reply | ❌ | Unicode-based emojis are of course supported | -| Post Bookmarks | Allow users to bookmark certain posts or topics | ❌ | | -| DiscourseConnect (SSO) | Replace Discourse authentication with a Custom Provider | ❌ | | -| Custom Authentication Plugins | Login via OAuth2 or other protocols using custom Discourse Plugins | ❌ | | -| Real-time Chat | Enable users to initiate conversations using the chat feature, either in a channel or through private messaging | ❌ | | -| User Status | Allow other user in community to see user message status | ❌ | | +| Feature | Description | Supported | Notes | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- | +| 2FA Login | Allow users with 2FA enabled to be prompted for their 2FA code when logging in | ✅ | Managing 2FA, such as enabling it or disabling it from within the app, is not currently supported | +| Ability to Tag Topics | Create and tag topics to provide relevant metadata for your users | ✅ 🔧 | Configuration required: see [Optimal Experience](optimal#enable-topic-tagging) | +| Topic Previews (Excerpts) | Show an excerpt of the first post in a topic from the Home screen | ✅ 🔧 | Configuration required: see [Optimal Experience](optimal#enable-topic-excerpts) | +| View User Activity | View a user's recent activity—such as topics, posts, and likes—in a single feed from their profile | ✅ | The ability to filter by activity is not currently supported | +| Topic Metrics | Likes, Views, Replies, and Frequent Posters | ✅ | | +| Topic & Post Actions | Ability to like and edit topics and posts | ✅ | | +| View Top & Latest Topics | A Tab View at the top of the main feed provides the ability to switch between Latest and Top activity | ✅ | | +| Search | Search the current Discourse instance for topics and posts based on keywords, categories, and tags | ✅ | | +| Categories | View the category of a topic and filter topics by a given category | ✅ | Categories cannot be created, updated, or deleted | +| Attaching Media to Posts | Users can attach media to a post from the app | ✅ 🔧 | Configuration recommended for supported file extensions-see [Optimal Experience](optimal#configure-upload-extensions) | +| Standard Markdown | Standard Markdown is supported in the editor and rendered correctly in the mobile app | ✅ | Light, incomplete support exists for some of Discourse's custom markup, such as dates | +| Sign Up | Allow users to sign up for an account directly through the mobile app, depending on whether your Discourse instance allows new user registration or not | ✅ | | +| Browsing Public Instances | Allow users to immediately access and browse your Discourse instance from the mobile app if it is not private | ✅ | Users will be prompted to login upon attempting an authenticated action | +| User Profiles | Ability to view users' profiles and edit your own | ✅ | Partial support: displays the user's photo, username, Markdown bio on a single line, and recent activity | +| Post Flagging | Allow users to flag posts for admins to review | ✅ | Admins are not able to review posts in the app, though they will see in-app notifications for flags | +| Mark Discourse Notifications Read | Allow users to see new notifications from the profile screen of the mobile app and mark all notifications as read | ✅ | Some notifications from Discourse are not tappable in the mobile app, such as badge notifications | +| Private messaging | Allow users to start private or group messages with one another | ✅ | | +| Mentions | Allow users to mention a user when creating or editing posts and messages | ✅ | +| Color Scheme | Provides light and dark mode support for users | ✅ | Specify color scheme (light mode, dark mode, or system) from within the app (only local to the user's mobile device) | +| Discourse Emojis | Utilize emojis when creating a topic, making a post, or sending a reply | ✅ | Discourse BB Code emojis and Unicode-based emojis are fully supported. | +| User Status | Allow users to update their statuses and view the statuses of other users | ✅ | | +| Polls | Allow users to create polls with custom settings in posts and private messages. Enable users to view and vote on the polls. | ✅ | | +| Button Bar for Markup Text | Allows users to automatically create Markdown formatting for posts and messages | ✅ | Supports automatic creation of formatting for bold, italic, quoted text, bullet lists, and numbered lists | +| Sign in With Apple | Allows users to log in using their Apple account | ✅ | Apple email account needs to be registered first on Discourse | +| Login With Link | Allows users to log in using an email login link without inserting a password | ✅ | | +| Activation Account With Link | Enables users to log in to the Lexicon-powered mobile app after activating their account upon signing up. Users receive an activation email from Discourse. | ✅ | | +| Badges | The ability to see and interact with badges that have been awarded to users on the Discourse instance | ❌ | | +| Post Drafts | Enable users to start composing a draft of a post and return to it later | ❌ | | +| Groups | Enable users to create and participate in private groups of which only group members can view certain topics | ❌ | | +| Admin Features | Discourse admin features generally not available in Lexicon—better suited to a desktop environment | ❌ | Editing posts is supported | +| Post Quotes, Toggles, and Task Lists | Custom text formatting that enables Discourse-specific features | ❌ | | +| Post Bookmarks | Allow users to bookmark certain posts or topics | ❌ | | +| DiscourseConnect (SSO) | Replace Discourse authentication with a Custom Provider | ❌ | | +| Custom Authentication Plugins | Login via OAuth2 or other protocols using custom Discourse Plugins | ❌ | | +| Real-time Chat | Enable users to initiate conversations using the chat feature, either in a channel or through private messaging | ❌ | | diff --git a/documentation/docs/discourse-plugin-enable.md b/documentation/docs/discourse-plugin-enable.md index 9c032a6a..4308ebc0 100644 --- a/documentation/docs/discourse-plugin-enable.md +++ b/documentation/docs/discourse-plugin-enable.md @@ -15,20 +15,40 @@ After you have confirmed the plugin has been installed and your Discourse instan You'll notice that the `discourse-lexicon-plugin` is not enabled yet. -Plugin Admin Page +Plugin Admin Page 3. Click on the `Settings` button for the `discourse-lexicon-plugin` entry. 4. Select the feature you want to enable and turn it on. -##### Push Notifications +### Push Notifications -For push notifications, all you need to do is check the box for `lexicon push notifications enabled`. This is covered in [Enable Push Notifications](push-notifications/setup/enable-push-notifications). +For push notifications, all you need to do is check the box for `lexicon push notifications enabled`. This is covered in [Enable Push Notifications](./push-notifications/setup/enable-push-notifications.md). -##### Email Deep Linking +### Email Deep Linking For email deep linking, you need to fill in your app scheme first before enabling it. -Plugin Settings Page +Plugin Settings Page This is covered in detail in [Enable Email Deep Linking](./email-deep-linking/setup/enable-email-deep-linking.md). + +### Login With Link + +For Login with Link, you need to fill in your app scheme first before enabling it and check the box for `Lexicon Login Link Enabled`. + +This is covered in detail in [Enable Login With Link](./login-with-link/setup/enable-login-with-link.md). + +### Activation Account With Link + +For activation account with link, you need to fill in your app scheme first before enabling it. + +Plugin Settings Page + +This is covered in detail in [Enable Activation Account With link](./activation-with-link/setup/enable-activate-with-link.md). + +##### Login With Apple + +For Login with Apple, you need to fill in your app bundle ID first before enabling it and check the box for `Lexicon Apple Login Enabled`. + +This is covered in detail in [Enable Login With Apple](./login-with-apple/setup/enable-login-with-apple.md). diff --git a/documentation/docs/discourse-plugin.md b/documentation/docs/discourse-plugin.md index de85ea89..99e31b90 100644 --- a/documentation/docs/discourse-plugin.md +++ b/documentation/docs/discourse-plugin.md @@ -1,5 +1,5 @@ --- -title: Lexicon Discourse Plugin +title: Introduction slug: discourse-plugin/ --- @@ -7,9 +7,17 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; --- -Discourse lacks native mobile app functionalities such as push notifications and deep linking. +As of Lexicon version 2.0.0, a custom Discourse plugin is available to provide a more seamless mobile integration between Discourse and your Lexicon-powered mobile app. -To address this, Lexicon has developed a custom Discourse plugin that seamlessly integrates push notifications and deep linking capabilities. +The plugin offers two key features for version 2.0.0: -- By leveraging Expo's powerful features, our plugin establishes a secure connection between your Discourse site and Expo's push notification service, delivering real-time updates to users' mobile devices based on your site's activity. -- Additionally, our plugin generates custom deep links in email notifications, allowing users seamlessly launch your Lexicon-powered mobile app directly from their mobile email client. +- **Push notifications**: support for native push notifications on user's mobile devices, according to relevant activity on your Discourse site. Powered by Expo's [push notifications service](https://docs.expo.dev/push-notifications/overview/). +- **Email deep linking**: custom deep links in emails from Discourse, allowing users to seamlessly launch your Lexicon-powered mobile app directly from their mobile email client. + +As of Lexicon version 2.2.0, we have added more features to the Discourse Lexicon plugin: + +The plugin now offers three additional features: + +- **Sign in with Apple**: Support for Lexicon-powered mobile apps to enable login using an Apple account. +- **Login with Link**: Support for Lexicon-powered mobile apps to enable login using a link from an email without needing to input a password in the app. [Login with Link documentation](login-with-link/intro.md) +- **Activation with Link**: Support for activating an account after sign-up for Lexicon-powered mobile apps. diff --git a/documentation/docs/email-deep-linking/setup/enable-email-deep-linking.md b/documentation/docs/email-deep-linking/setup/enable-email-deep-linking.md index 73c7dd3f..5e794ddd 100644 --- a/documentation/docs/email-deep-linking/setup/enable-email-deep-linking.md +++ b/documentation/docs/email-deep-linking/setup/enable-email-deep-linking.md @@ -1,5 +1,5 @@ --- -title: Enabling the Lexicon Discourse plugin +title: Enabling the Email Deep Linking --- import useBaseUrl from '@docusaurus/useBaseUrl'; @@ -12,7 +12,7 @@ This guide will walk you through the necessary steps to activate email deep link 2. Navigate to the `Plugins` section. - + 3. Locate the `discourse-lexicon-plugin` and click on the `Settings` button. @@ -20,6 +20,6 @@ This guide will walk you through the necessary steps to activate email deep link 5. Check the `lexicon email deep linking enabled` box in the Lexicon settings section and save your changes. - + Once the email deep linking feature is enabled, you will be able to utilize its functionality in your Discourse instance. diff --git a/documentation/docs/email-deep-linking/setup/verify-email-deep-linking.md b/documentation/docs/email-deep-linking/setup/verify-email-deep-linking.md index 0fe7642d..0d3187d0 100644 --- a/documentation/docs/email-deep-linking/setup/verify-email-deep-linking.md +++ b/documentation/docs/email-deep-linking/setup/verify-email-deep-linking.md @@ -42,8 +42,8 @@ To test email deep linking within your **published** Lexicon-powered mobile app, 1. Click on the button that says `Visit Message` or `Visit Topic`. The label depends on what activity generated the email (see screenshot below). 1. The link will first open in your mobile web browser. Provided that the Lexicon-powered mobile app is installed and matches the configured app scheme, it should automatically open your app to the relevant topic or message scene. -
- +
+
And that's it! You have successfully completed the steps to enable and test email deep linking in your app. diff --git a/documentation/docs/intro.md b/documentation/docs/intro.md index 901f8e22..f358899d 100644 --- a/documentation/docs/intro.md +++ b/documentation/docs/intro.md @@ -44,9 +44,11 @@ Lexicon is a customizable, pre-built mobile app that provides an elegant mobile ## Features -- Topics, Private Messaging, User Signups, Profile Management, and more. -- Straightforward process to [**customize**](white-labeling) the app for your brand +- Topics, Private Messaging, User Signups, Profile Management, and more - Rapidly build Android and iPhone apps for your existing Discourse site +- [Push Notifications](./push-notifications/introduction.md) direct to your users' mobile devices +- More seamless native Discourse experience [with Email Deep Linking](./email-deep-linking/intro.md) +- Straightforward process to [**customize**](white-labeling) the app for your brand - Backed by a [GraphQL](https://graphql.org/) API - Free and open source! - [Commercial support](commercial-support) available diff --git a/documentation/docs/login-with-apple/intro.md b/documentation/docs/login-with-apple/intro.md new file mode 100644 index 00000000..85e5380a --- /dev/null +++ b/documentation/docs/login-with-apple/intro.md @@ -0,0 +1,9 @@ +--- +id: intro +title: Introduction +slug: discourse-plugin/login-with-apple +--- + +The Lexicon Discourse plugin provides support for integrating Apple's authentication with your Lexicon-powered mobile app. Our plugin enables signing into your Discourse site using Apple authentication. + +This section of the documentation offers step-by-step instructions to integrate this login functionality into your Discourse site, providing your users with a more seamless experience with your Lexicon-powered mobile app. diff --git a/documentation/docs/login-with-apple/setup/enable-login-with-apple.md b/documentation/docs/login-with-apple/setup/enable-login-with-apple.md new file mode 100644 index 00000000..18860cbc --- /dev/null +++ b/documentation/docs/login-with-apple/setup/enable-login-with-apple.md @@ -0,0 +1,29 @@ +--- +title: Enabling login with Apple +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +This guide will walk you through the necessary steps to activate login with Apple at lexicon app on your Discourse site. + +## Steps + +1. Access your Discourse admin dashboard. + +2. Navigate to the `Plugins` section. + + + +3. Locate the `discourse-lexicon-plugin` and click on the `Settings` button. + +4. Fill in the `lexicon apple client id` setting with your app bundle ID. The app bundle ID is required to enable login with Apple. If you haven't register an app bundle ID, you can follow the instructions in this [tutorial](../../app-store#register-a-new-bundle-id) to do so. + +
+ +
+ +5. Check the `lexicon apple login enabled` box in the Lexicon settings section and save your changes. + + + +Once the login with Apple feature is enabled, you will be able to utilize its functionality in your Discourse instance. diff --git a/documentation/docs/login-with-apple/setup/verify-login-with-apple.md b/documentation/docs/login-with-apple/setup/verify-login-with-apple.md new file mode 100644 index 00000000..a3212d72 --- /dev/null +++ b/documentation/docs/login-with-apple/setup/verify-login-with-apple.md @@ -0,0 +1,30 @@ +--- +title: Verify Login With Apple +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Below, we'll walk you through how you can validate the functionality of logging in with Apple within your Lexicon-powered mobile app. + +:::info +In order to be able test logging in with Apple, **you will need to use Lexicon version 2.2.0** for your Lexicon app. +::: + +:::note +Ensure that the Bundle Identifier under iOS section in your `app.json` matches the one in your Discourse's plugin settings. +::: + +## Steps + +To test logging in with Apple within your Lexicon-powered mobile app, follow these steps: + +1. Ensure that you have Lexicon-powered mobile app at your iOS device. +2. On your mobile device, open your Lexicon-powered mobile app. +3. On the login screen, you will see a "Sign in with Apple" button. Click the button and confirm your Apple account. + - **Note**: Ensure that you have a registered account on Discourse using the same email as your Apple account. + +
+ +
+ +And that's it! You will be automatically logged in once your Apple account is confirmed. diff --git a/documentation/docs/login-with-link/intro.md b/documentation/docs/login-with-link/intro.md new file mode 100644 index 00000000..acbd1ec6 --- /dev/null +++ b/documentation/docs/login-with-link/intro.md @@ -0,0 +1,9 @@ +--- +id: intro +title: Introduction +slug: discourse-plugin/login-with-link +--- + +The Lexicon Discourse plugin provides support for integrating Discourse's email login with your Lexicon-powered mobile app. Our plugin modifies links in Discourse login emails so that when a relevant link is tapped, and the user has your Lexicon-powered mobile app installed, it will open the app and automatically log the user in. + +This section of the documentation offers step-by-step instructions to integrate this login functionality into your Discourse site, providing your users with a more seamless experience with your Lexicon-powered mobile app. diff --git a/documentation/docs/login-with-link/setup/enable-login-with-link.md b/documentation/docs/login-with-link/setup/enable-login-with-link.md new file mode 100644 index 00000000..f134ac93 --- /dev/null +++ b/documentation/docs/login-with-link/setup/enable-login-with-link.md @@ -0,0 +1,25 @@ +--- +title: Enabling login with link +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +This guide will walk you through the necessary steps to activate login with link at lexicon app on your Discourse site. + +## Steps + +1. Access your Discourse admin dashboard. + +2. Navigate to the `Plugins` section. + + + +3. Locate the `discourse-lexicon-plugin` and click on the `Settings` button. + +4. Fill in the `lexicon app scheme` setting with your app scheme. The app scheme is required to enable login with linking. + +5. Check the `lexicon login link enabled` box in the Lexicon settings section and save your changes. + + + +Once the login with link feature is enabled, you will be able to utilize its functionality in your Discourse instance. diff --git a/documentation/docs/login-with-link/setup/verify-login-with-link.md b/documentation/docs/login-with-link/setup/verify-login-with-link.md new file mode 100644 index 00000000..50657a0b --- /dev/null +++ b/documentation/docs/login-with-link/setup/verify-login-with-link.md @@ -0,0 +1,39 @@ +--- +title: Verify Login With Link +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +Below, we'll walk you through how you can validate the functionality of login with a link within your Lexicon-powered mobile app. + +:::note +The steps below assume that **you have already build your Lexicon-powered mobile app with the correct app scheme**. If you are running the app on your machine locally through Expo, these steps will not work. +::: + +:::info +In order to be able test login with a link, **you will need to use Lexicon version 2.2.0** for your Lexicon app. +::: + +## Steps + +To test login with a link within your Lexicon-powered mobile app, follow these steps: + +1. Ensure that you have Lexicon-powered mobile app at your device. +2. On your mobile device, open your Lexicon-powered mobile app and log in using one of your accounts. + - **Note**: Ensure that your email client on your mobile device will receive emails for this account. +3. On the login screen, enable `send login link, skip password`. Then, enter your Discourse email account and click the `send link` button. You will receive a popup message to check your email. + +
+ +
+4. Open your email on your phone and check the email sent by your Discourse website. +5. Click the link provided in the email. + +
+ + +
+ +6. The link will first open in your mobile web browser. If the Lexicon-powered mobile app is installed and matches the configured app scheme, it should automatically log you in to your app. + +And that's it! The Lexicon Discourse plugin will properly log you in with a link through your Discourse site. diff --git a/documentation/docs/optimal.md b/documentation/docs/optimal.md index a06199e7..789e1674 100644 --- a/documentation/docs/optimal.md +++ b/documentation/docs/optimal.md @@ -2,7 +2,16 @@ title: 'Optimal Experience' --- -If you're planning to make use of the Lexicon Mobile App, there are a few settings you should tweak on your Discourse instance to provide the best in-app experience to your users. +If you're planning to make use of the Lexicon Mobile App, there are a few adjustments you should make to your Discourse instance to provide the best in-app experience to your users. + +## Install the Lexicon Discourse Plugin + +The Lexicon Discourse plugin enhances the native mobile experience for your users in two key ways: + +- Adds support for push notifications +- Adds support for email deep linking. + +You can read more about the plugin and how to set it up [here](./discourse-plugin.md). ## Enable Topic Excerpts diff --git a/documentation/docs/play-store.md b/documentation/docs/play-store.md index dacdfa8c..f329954e 100644 --- a/documentation/docs/play-store.md +++ b/documentation/docs/play-store.md @@ -13,6 +13,7 @@ If you don't already have a Google Developer account, note that there is a fee t - A [Google Developer Account](https://play.google.com/console/signup) to access the [Google Play Console](https://play.google.com/console) - An Expo account - EAS CLI 2.6.0 or newer +- The [Lexicon Discourse plugin](./discourse-plugin.md) is already installed on your Discourse instance ## Google Play Console @@ -30,9 +31,14 @@ Similar to the approach for [Publishing to the App Store](app-store), if you hav Replace these placeholders with your desired values: +:::info +Note below that `scheme` is included. If you want [email deep linking](./email-deep-linking/intro.md) support in your app, **you must specify a scheme**, and then configure the Lexicon Discourse plugin with the same scheme. +::: + ```json - "name": "", - "slug": "", +"name": "", +"slug": "", +"scheme": "", ``` Then, you need to configure EAS Build by running the following command, or skip to the next [step](play-store#setup-config-values): diff --git a/documentation/docs/push-notifications/setup/enable-push-notifications.md b/documentation/docs/push-notifications/setup/enable-push-notifications.md index e08a9070..7acc5b98 100644 --- a/documentation/docs/push-notifications/setup/enable-push-notifications.md +++ b/documentation/docs/push-notifications/setup/enable-push-notifications.md @@ -1,12 +1,12 @@ --- -title: Enable the Lexicon Discourse plugin +title: Enable Push Notifications --- import useBaseUrl from '@docusaurus/useBaseUrl'; - - + + Below, we'll walk you through the necessary steps to activate push notifications for your Discourse site. @@ -19,13 +19,13 @@ Below, we'll walk you through the necessary steps to activate push notifications 1. Navigate to the Plugins section. - + 4. Click on the `Settings` button for the `discourse-lexicon-plugin` entry. 5. Check the `enable Push Notifications` box in the Lexicon settings section and save your changes. - + Once the push notifications setting is enabled, your users will be able to login through the mobile app and start receiving push notifications. diff --git a/documentation/docs/push-notifications/setup/verify-push-notifications.md b/documentation/docs/push-notifications/setup/verify-push-notifications.md index 26679ffb..7cfe6654 100644 --- a/documentation/docs/push-notifications/setup/verify-push-notifications.md +++ b/documentation/docs/push-notifications/setup/verify-push-notifications.md @@ -5,7 +5,7 @@ title: Verify Push Notifications import useBaseUrl from '@docusaurus/useBaseUrl'; - + Below, we'll walk you through how you can validate the functionality of push notifications within your Lexicon-powered mobile app. @@ -28,6 +28,6 @@ To test push notifications within your Lexicon-powered mobile app, follow these 1. Using a separate account, reply to the post to trigger a notification for the first account. 1. You should receive a push notification on your phone with the reply content from the other account. - + And that's it! The Lexicon Discourse plugin is properly sending push notifications through your Discourse site. diff --git a/documentation/docs/quick-start.md b/documentation/docs/quick-start.md index c72f310c..89f4c8da 100644 --- a/documentation/docs/quick-start.md +++ b/documentation/docs/quick-start.md @@ -6,13 +6,12 @@ title: Quick Start - Node.js 16.14 or newer - The latest version of NPM or Yarn, compatible with Node 16.14 or newer -- Expo CLI 6.0.6 or newer -- EAS CLI 2.6.0 or newer to build and publish the App +- EAS CLI 3.7.2 or newer to build and publish the app - An active Discourse site - If you don’t have one, please follow the instructions in [Development Setup](setup#discourse-host) :::note -Follow the instructions in [Setup Guidance](tutorial/setup) to install the prerequisite depedencies, such as NPM, the Expo CLI, and the EAS CLI. +Follow the instructions in [Setup Guidance](tutorial/setup) to install the prerequisite depedencies, such as NPM and the EAS CLI. ::: ## Installation diff --git a/documentation/docs/setup.md b/documentation/docs/setup.md index 7ef46648..99305a33 100644 --- a/documentation/docs/setup.md +++ b/documentation/docs/setup.md @@ -22,6 +22,16 @@ For detailed instructions on setting up a local development instance of Discours However, if you already have a deployed instance of Discourse, we'd recommend using that instead. +### Install the Lexicon Discourse Plugin + +The Lexicon Discourse Plugin is a Discourse plugin that adds support for [push notifications](./push-notifications/introduction.md) and [email deep linking](./email-deep-linking/intro.md). + +You can install the plugin in your Discourse instance by following the instructions in the [Discourse plugin documentation](./discourse-plugin.md). + +For local development, you're only able to test out push notifications, as email deep linking requires a published app with a [valid app scheme](https://docs.expo.dev/versions/latest/config/app/#scheme). + +If you wish to develop against the plugin itself, you can clone the codebase [here](https://github.com/lexiconhq/discourse-lexicon-plugin.git). + ### Configuration The [Lexicon Stack](concepts#architecture-of-the-lexicon-stack) requires some configuration in order to properly interact with your Discourse server. @@ -125,6 +135,10 @@ In the example above, we have configured the app to point at `https://my-deploye ##### Scenario 2: Run Prose Locally & Access from a Simulator +:::info +If you are running the Prose server locally, you should not expect that the mobile app will continue to function if you turn off your development machine. You must **deploy** the server before attempting to use the mobile app without depending on your development machine. +::: + This approach involves running both the Lexicon Mobile App and the Prose GraphQL API on your development machine. It is accomplished by instructing Expo to launch the Mobile App in the Android or iOS simulator. When developing this way, you can simply set `localDevelopment.proseUrl` to `http://localhost` in `frontend/Config.ts`. And then in `api/.env`, you can set `PROSE_APP_HOSTNAME` to `0.0.0.0`. diff --git a/documentation/docs/supported-devices.md b/documentation/docs/supported-devices.md index 148acbb7..84e6b1bb 100644 --- a/documentation/docs/supported-devices.md +++ b/documentation/docs/supported-devices.md @@ -6,12 +6,16 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; ## iPhone and Android Phones +:::info +Older versions of iOS and Android may work, but are not officially supported. +::: + Once you've published to the App Store and Google Play Store, your published app will work out of the box for your users on both iPhone and Android devices with the following specifications: -| Device | Minimum OS | -| --------------- | ------------------- | -| iPhone | iOS 6 and above | -| Android Devices | Android 5 and above | +| Device | Minimum OS | +| --------------- | -------------------- | +| iPhone | iOS 16 and above | +| Android Devices | Android 13 and above | | Android | iOS | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | diff --git a/documentation/docs/technologies.md b/documentation/docs/technologies.md index 838fcffb..8657288a 100644 --- a/documentation/docs/technologies.md +++ b/documentation/docs/technologies.md @@ -18,8 +18,4 @@ White Label the Lexicon Mobile App to give your users the familiar look and feel Getting started is as easy as spinning up a new server for the Prose GraphQL API, and pointing it at your Discourse instance. No changes are required on your Discourse instance itself. -However, to enable features like [Push Notifications](./push-notifications) and [Email Deep Linking](./email-deep-linking/), you can install our [Discourse Plugin](./discourse-plugin.md). - -However, to provide an [optimal experience](optimal) with features like Tagging and Topic Excerpts, you will need to make some light adjustments. - -This is covered in detail in [Deploying Prose](deployment). +Note: to enable features like [Push Notifications](./push-notifications) and [Email Deep Linking](./email-deep-linking/intro.md), you can install our [Discourse Plugin](./discourse-plugin.md). diff --git a/documentation/docusaurus.config.js b/documentation/docusaurus.config.js index 7a07a24e..ced886ef 100644 --- a/documentation/docusaurus.config.js +++ b/documentation/docusaurus.config.js @@ -64,9 +64,17 @@ module.exports = { editUrl: 'https://github.com/lexiconhq/lexicon/blob/master/documentation/', routeBasePath: '/', - onlyIncludeVersions: ['1.0.0', '2.0.0'], - lastVersion: '2.0.0', + onlyIncludeVersions: ['1.0.0', '2.0.0', '2.1.0', '2.2.0'], + lastVersion: '2.2.0', versions: { + '2.2.0': { + path: 'version-2.2.0', + banner: 'none', + }, + '2.1.0': { + path: 'version-2.1.0', + banner: 'none', + }, '2.0.0': { path: 'version-2.0.0', banner: 'none', @@ -78,7 +86,10 @@ module.exports = { }, }, theme: { - customCss: require.resolve('./src/css/custom.css'), + customCss: [ + require.resolve('./src/css/custom.css'), + require.resolve('./src/css/image.css'), + ], }, }, ], diff --git a/documentation/sidebars.js b/documentation/sidebars.js index e345acf2..7dc4019a 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -15,13 +15,45 @@ module.exports = { 'White Labeling': ['white-labeling', 'assets', 'theming'], 'Deploying Prose': ['deployment', 'env-prose', 'dedicated'], 'Configuring Discourse': ['optimal'], + 'Discourse Plugin': [ + 'discourse-plugin', + 'discourse-plugin-installation', + 'discourse-plugin-enable', + { + 'Push Notifications': [ + 'push-notifications/introduction', + 'push-notifications/plugin-interaction', + 'push-notifications/setup/enable-push-notifications', + 'push-notifications/setup/verify-push-notifications', + ], + 'Email Deep Linking': [ + 'email-deep-linking/intro', + 'email-deep-linking/setup/enable-email-deep-linking', + 'email-deep-linking/setup/verify-email-deep-linking', + ], + 'Login With Link': [ + 'login-with-link/intro', + 'login-with-link/setup/enable-login-with-link', + 'login-with-link/setup/verify-login-with-link', + ], + 'Activation Account With Link': [ + 'activation-with-link/intro', + 'activation-with-link/setup/enable-activate-with-link', + 'activation-with-link/setup/verify-activate-with-link', + ], + 'Login with Apple': [ + 'login-with-apple/intro', + 'login-with-apple/setup/enable-login-with-apple', + 'login-with-apple/setup/verify-login-with-apple', + ], + }, + ], 'Publishing your App': [ 'app-store', 'play-store', 'lexicon-updates', 'troubleshooting-build', ], - Plugin: ['push-notifications/introduction'], }, tutorial: { Tutorial: [ @@ -37,24 +69,4 @@ module.exports = { 'tutorial/updating', ], }, - plugin: [ - { - type: 'doc', - id: 'push-notifications/introduction', // document ID - label: 'Introduction', // sidebar label - }, - { - type: 'doc', - id: 'push-notifications/plugin-interaction', // document ID - label: 'How Push Notifications work with Lexicon', // sidebar label - }, - { - type: 'category', - label: 'Setup', - items: [ - 'push-notifications/setup/enable-push-notifications', - 'push-notifications/setup/verify-push-notifications', - ], - }, - ], }; diff --git a/documentation/src/css/image.css b/documentation/src/css/image.css new file mode 100644 index 00000000..fb04c3c6 --- /dev/null +++ b/documentation/src/css/image.css @@ -0,0 +1,9 @@ +.image-container-center-multiple { + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.image-container-center { + text-align: center; +} diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js index 079a4187..70982829 100644 --- a/documentation/src/pages/index.js +++ b/documentation/src/pages/index.js @@ -2,5 +2,5 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; export default function Home() { - return ; + return ; } diff --git a/documentation/static/img/screenshot/Mobile-LoginWithApple.png b/documentation/static/img/screenshot/Mobile-LoginWithApple.png new file mode 100644 index 0000000000000000000000000000000000000000..c452c711d448229ba9da40a79ef67d5d84ed4d98 GIT binary patch literal 65594 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 U^S$#105_dL3#zYHu441-KbBP?i2wiq literal 0 HcmV?d00001 diff --git a/documentation/static/img/screenshot/Website_SignUp.png b/documentation/static/img/screenshot/Website_SignUp.png new file mode 100644 index 0000000000000000000000000000000000000000..00b270d6ca93f43f9c69dc481203b87363e4cc3d GIT binary patch literal 283595 zcmaI82|SeT_djlHPl<{U6;j!feHoN3CM0DU43R8jDMOgCwn;@WA6J}&hkF*bIx@aa9vxSaXl(_+r>;9Y-LV_vjPtmv^GvIC9 z<9lwTA!+2rdp7+zzNfTTO0GT^8~&*IylvP$^UGP`r@QWR&}d%Z43eg&>8O95%z95O z?x~$(MbH(j(q3QolNNJ*edpz$>7CAhDtYEv;_AyhZkJ0NNpB_7Yrow&Xt`o?N43`M zHGkc=FFzIA=F6atL3e8W`O+`E9s0^P5q*2W(Mo4uxW%z&YmaS@1T!J7{JLBo+O7QZ z#wET_^UmmY->B+`GW4=catqQM@0d80IM00v*sJ%$cH*IuWW~KFac#R+rlp^`o`zR6 z9e8%D%l^1dfbP#L9IcgtNe>mPkJSIt^n$#;Q!RFEHB)TG{)s{o?9B>{W}UudR`Zg(_1hTV6UK%o)ys!pH=UgNcHq0^Frgn zY-!anmc*=TtreNeHwKPcjFmhztTdEA%`JSjPys7k_v(3QbpY$#`&Z(J=fBopW;3Vt zQQE_7*j9e@^)7~!UV&aev?r_jymp|-c#&5kg9BDvZr&;(nm4U=L)YlYu1{a zqxYe?SAJ`(If?!JW_xbBCg@I>{KIKz{)Wf} z^$5ZT5rWKyy;gorw7Q9#YaMA|I&kUI;>5L|s$bi>uVj2aR}`8MDn`>f_u?0sd=|FU zujJBGhCHxAQ$F?c>({T}=1gkt(<~K!ni>2RlY6&Z$Nv(|SY#pdeZT0>iL7*<&<~$t zlJ>mxJ(2Zv@jhKfIm^$7G)#17E^}Vp9coT`doSk2C-I91zU<+PJ1Dp(?Gq|%pZzYm zPh45t;tzSg@=DVSKdk>M`|*H*j+8s?>L;y z47eN;e3O^{$QkB4$8%`TE7t{+YmUF)-R&>V!ymkOmEW1e;~D8pX0Xn=r>SwU52x({ zg49l&V_{VN-W*`FTlScc%8Qo7aabOEf1qR>f6I$;*SrH>euvCy^r~DgvV7sPG#3

*-Og?>bmv5|09zy=$`-$@8AMh>s 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