diff --git a/README.md b/README.md index de95633e2..f7d3bcfe9 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ We would also appreciate sponsoring other contributors to Voyager. If someone he Please check out [CONTRIBUTING.md](./CONTRIBUTING.md) for details on contributing to Voyager. Thank you! πŸ’™ +## πŸ›œ Add a lemmy instance to the curated list + +Voyager curates Lemmy servers for sign up ([see the data](./src/features/auth/login/data/servers.ts)). If you would like to add an instance, please read the [curated servers policy](./src/features/auth/login/data/README.md). + ## πŸ“² PWA Voyager works best added to the homescreen. There are certain features that only work there, like badging and smooth page transitions. diff --git a/android/app/build.gradle b/android/app/build.gradle index 878b84a51..91b2ec385 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "app.vger.voyager" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 205 - versionName "1.34.1" + versionCode 207 + versionName "1.36.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 362602535..5d2317993 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.34.1 + 1.36.0 CFBundleVersion - 205 + 207 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/package.json b/package.json index 5ac8f6a95..4d177962c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "voyager", "description": "A progressive webapp Lemmy client", "private": true, - "version": "1.34.1", + "version": "1.36.0", "type": "module", "packageManager": "pnpm@8.11.0+sha256.5858806c3b292cbec89b5533662168a957358e2bbd86431516d441dc1aface89", "scripts": { @@ -51,7 +51,7 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@github/markdown-toolbar-element": "^2.2.1", - "@ionic/core": "npm:voyager-ionic-core@^7.6.3", + "@ionic/core": "npm:voyager-ionic-core@^7.6.5", "@ionic/react": "7.6.3", "@ionic/react-router": "7.6.3", "@reduxjs/toolkit": "^2.0.1", @@ -123,6 +123,7 @@ "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", + "sass": "^1.69.7", "terser": "^5.26.0", "typescript": "^5.3.3", "ua-parser-js": "^1.0.37", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd998253f..f33e81b6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ devDependencies: version: 5.0.6(@capacitor/core@5.6.0) '@capacitor/assets': specifier: ^3.0.4 - version: 3.0.4(@types/node@20.10.7)(typescript@5.3.3) + version: 3.0.4(@types/node@20.11.5)(typescript@5.3.3) '@capacitor/browser': specifier: ^5.1.0 version: 5.1.0(@capacitor/core@5.6.0) @@ -81,7 +81,7 @@ devDependencies: version: 2.2.1 '@ionic/core': specifier: npm:voyager-ionic-core - version: /voyager-ionic-core@7.6.3 + version: /voyager-ionic-core@7.6.5 '@ionic/react': specifier: 7.6.3 version: 7.6.3(react-dom@18.2.0)(react@18.2.0) @@ -102,7 +102,7 @@ devDependencies: version: 14.5.2(@testing-library/dom@9.3.4) '@trapezedev/configure': specifier: ^7.0.10 - version: 7.0.10(@types/node@20.10.7)(typescript@5.3.3) + version: 7.0.10(@types/node@20.11.5)(typescript@5.3.3) '@types/history': specifier: ^4.7.11 version: 4.7.11 @@ -162,7 +162,7 @@ devDependencies: version: 2.0.6(@capacitor/core@5.6.0) capacitor-set-version: specifier: ^2.2.0 - version: 2.2.0(@types/node@20.10.7)(typescript@5.3.3) + version: 2.2.0(@types/node@20.11.5)(typescript@5.3.3) capacitor-stash-media: specifier: ^1.0.0 version: 1.0.0(@capacitor/core@5.6.0) @@ -295,6 +295,9 @@ devDependencies: remark-stringify: specifier: ^11.0.0 version: 11.0.0 + sass: + specifier: ^1.69.7 + version: 1.69.7 terser: specifier: ^5.26.0 version: 5.26.0 @@ -324,7 +327,7 @@ devDependencies: version: 0.20.2(react-dom@18.2.0)(react@18.2.0) vite: specifier: ^5.0.10 - version: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + version: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) vite-plugin-pwa: specifier: ^0.17.4 version: 0.17.4(vite@5.0.10)(workbox-build@7.0.0)(workbox-window@7.0.0) @@ -333,7 +336,7 @@ devDependencies: version: 4.2.0(rollup@2.79.1)(typescript@5.3.3)(vite@5.0.10) vitest: specifier: ^1.1.1 - version: 1.1.1(@types/node@20.10.7)(jsdom@23.0.1)(terser@5.26.0) + version: 1.1.1(@types/node@20.11.5)(jsdom@23.0.1)(sass@1.69.7)(terser@5.26.0) workbox-window: specifier: ^7.0.0 version: 7.0.0 @@ -485,6 +488,21 @@ packages: - supports-color dev: true + /@babel/helper-define-polyfill-provider@0.5.0(@babel/core@7.23.7): + resolution: {integrity: sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.23.7 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.22.5 + debug: 4.3.4(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-environment-visitor@7.22.20: resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} engines: {node: '>=6.9.0'} @@ -1596,9 +1614,9 @@ packages: '@babel/plugin-transform-unicode-regex': 7.23.3(@babel/core@7.23.7) '@babel/plugin-transform-unicode-sets-regex': 7.23.3(@babel/core@7.23.7) '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.23.7) - babel-plugin-polyfill-corejs2: 0.4.7(@babel/core@7.23.7) + babel-plugin-polyfill-corejs2: 0.4.8(@babel/core@7.23.7) babel-plugin-polyfill-corejs3: 0.8.7(@babel/core@7.23.7) - babel-plugin-polyfill-regenerator: 0.5.4(@babel/core@7.23.7) + babel-plugin-polyfill-regenerator: 0.5.5(@babel/core@7.23.7) core-js-compat: 3.35.0 semver: 6.3.1 transitivePeerDependencies: @@ -1694,7 +1712,7 @@ packages: '@capacitor/core': 5.6.0 dev: true - /@capacitor/assets@3.0.4(@types/node@20.10.7)(typescript@5.3.3): + /@capacitor/assets@3.0.4(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-9t/u2i9vSEWDfarzDJmQEgi07Tozyw2mKZYTkybb2Zkc8ufqR0S6ZzDBmWbsTtOTVwRr0uU9Rx3c8AVbA1xDtA==} engines: {node: '>=10.3.0'} hasBin: true @@ -1702,7 +1720,7 @@ packages: '@capacitor/cli': 5.6.0 '@ionic/utils-array': 2.1.6 '@ionic/utils-fs': 3.1.7 - '@trapezedev/project': 7.0.10(@types/node@20.10.7)(typescript@5.3.3) + '@trapezedev/project': 7.0.10(@types/node@20.11.5)(typescript@5.3.3) commander: 8.3.0 debug: 4.3.4(supports-color@8.1.1) fs-extra: 10.1.0 @@ -2252,7 +2270,7 @@ packages: react: '>=16.8.6' react-dom: '>=16.8.6' dependencies: - '@ionic/core': /voyager-ionic-core@7.6.3 + '@ionic/core': /voyager-ionic-core@7.6.5 ionicons: 7.2.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -2520,7 +2538,7 @@ packages: wrap-ansi: 7.0.0 dev: true - /@oclif/core@2.15.0(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/core@2.15.0(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-fNEMG5DzJHhYmI3MgpByTvltBOMyFcnRIUMxbiz2ai8rhaYgaTHMG3Q38HcosfIvtw9nCjxpcQtC8MN8QtVCcA==} engines: {node: '>=14.0.0'} dependencies: @@ -2547,7 +2565,7 @@ packages: strip-ansi: 6.0.1 supports-color: 8.1.1 supports-hyperlinks: 2.3.0 - ts-node: 10.9.2(@types/node@20.10.7)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.5)(typescript@5.3.3) tslib: 2.6.2 widest-line: 3.1.0 wordwrap: 1.0.0 @@ -2563,11 +2581,11 @@ packages: resolution: {integrity: sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw==} dev: true - /@oclif/plugin-autocomplete@1.4.6(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/plugin-autocomplete@1.4.6(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-dawJk8Eb5dxsHTEttKZIOJkJ9PPKB59hL8BrqdCkr+WB4Xerm3G6rNeGWErOVYcOLe8y+nWAeYUE8OHNPn2E9g==} engines: {node: '>=12.0.0'} dependencies: - '@oclif/core': 2.15.0(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/core': 2.15.0(@types/node@20.11.5)(typescript@5.3.3) chalk: 4.1.2 debug: 4.3.4(supports-color@8.1.1) fs-extra: 9.1.0 @@ -2579,11 +2597,11 @@ packages: - typescript dev: true - /@oclif/plugin-commands@2.2.28(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/plugin-commands@2.2.28(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-w1vQ6WGltMnyjJnnt6Vo/VVtyhz1V0O9McCy0qKIY+os7SunjnUMRNS/y8MZ7b6AjMSdbLGV9/VAYSlWyQg9SQ==} engines: {node: '>=12.0.0'} dependencies: - '@oclif/core': 2.15.0(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/core': 2.15.0(@types/node@20.11.5)(typescript@5.3.3) lodash: 4.17.21 transitivePeerDependencies: - '@swc/core' @@ -2592,11 +2610,11 @@ packages: - typescript dev: true - /@oclif/plugin-help@5.2.20(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/plugin-help@5.2.20(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-u+GXX/KAGL9S10LxAwNUaWdzbEBARJ92ogmM7g3gDVud2HioCmvWQCDohNRVZ9GYV9oKwZ/M8xwd6a1d95rEKQ==} engines: {node: '>=12.0.0'} dependencies: - '@oclif/core': 2.15.0(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/core': 2.15.0(@types/node@20.11.5)(typescript@5.3.3) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -2604,12 +2622,12 @@ packages: - typescript dev: true - /@oclif/plugin-plugins@2.4.7(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/plugin-plugins@2.4.7(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-6fzUDLWrSK7n6+EBrEekEEYrYTCneRoOF9TzojkjuFn1+ailvUlr98G90bblxKOyy8fqMe7QjvqwTgIDQ9ZIzg==} engines: {node: '>=12.0.0'} dependencies: '@oclif/color': 1.0.13 - '@oclif/core': 2.15.0(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/core': 2.15.0(@types/node@20.11.5)(typescript@5.3.3) chalk: 4.1.2 debug: 4.3.4(supports-color@8.1.1) fs-extra: 9.1.0 @@ -2627,11 +2645,11 @@ packages: - typescript dev: true - /@oclif/plugin-version@1.3.10(@types/node@20.10.7)(typescript@5.3.3): + /@oclif/plugin-version@1.3.10(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-TiRZALUcv4hwGTPoTyA3nOWtRew9DT4Ge1FeYx16xnuAsWryvJe3IHXmCm6b1VYhzTJhV2XH5U1DqllrQB2YaA==} engines: {node: '>=14.0.0'} dependencies: - '@oclif/core': 2.15.0(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/core': 2.15.0(@types/node@20.11.5)(typescript@5.3.3) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -3004,6 +3022,12 @@ packages: engines: {node: '>=18'} dev: true + /@stencil/core@4.10.0: + resolution: {integrity: sha512-7lDTPY1IxXN2/C+wQPHt3e/dYgY4YgelA8MxOsU3ZftXtpzWad/QNWhSAtKisJMrSjQh41jMDOgD0yLBwV6E7w==} + engines: {node: '>=16.0.0', npm: '>=7.10.0'} + hasBin: true + dev: true + /@stencil/core@4.9.0: resolution: {integrity: sha512-aWSkhBmk3yPwRAkUwBbzRwmdhb8hKiQ/JMr9m5jthpBZLjtppYbzz6PN2MhSMDfRp6K93eQw5WogSEH4HHuB6w==} engines: {node: '>=16.0.0', npm: '>=7.10.0'} @@ -3207,7 +3231,7 @@ packages: dom-accessibility-api: 0.6.3 lodash: 4.17.21 redent: 3.0.0 - vitest: 1.1.1(@types/node@20.10.7)(jsdom@23.0.1)(terser@5.26.0) + vitest: 1.1.1(@types/node@20.11.5)(jsdom@23.0.1)(sass@1.69.7)(terser@5.26.0) dev: true /@testing-library/react@14.1.2(react-dom@18.2.0)(react@18.2.0): @@ -3237,7 +3261,7 @@ packages: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} dev: true - /@trapezedev/configure@7.0.10(@types/node@20.10.7)(typescript@5.3.3): + /@trapezedev/configure@7.0.10(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-6bhaLpfjSImamthENrtaWntL0MxAFZjKrZOnsQdZ/ae2gVOvhdxTlPGnrIAnVQsRVkEjRB2B8Ih1oHYI/fq9kg==} hasBin: true dependencies: @@ -3246,7 +3270,7 @@ packages: '@ionic/utils-subprocess': 2.1.14 '@ionic/utils-terminal': 2.3.5 '@prettier/plugin-xml': 1.2.0 - '@trapezedev/project': 7.0.10(@types/node@20.10.7)(typescript@5.3.3) + '@trapezedev/project': 7.0.10(@types/node@20.11.5)(typescript@5.3.3) '@types/fs-extra': 9.0.13 '@types/jest': 27.5.2 '@types/lodash': 4.14.202 @@ -3263,7 +3287,7 @@ packages: prompts: 2.4.2 replace: 1.2.2 tmp: 0.2.1 - ts-node: 10.9.2(@types/node@20.10.7)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.5)(typescript@5.3.3) yaml: 1.10.2 yargs: 17.7.2 transitivePeerDependencies: @@ -3279,7 +3303,7 @@ packages: resolution: {integrity: sha512-k822Is3jGroqOTKF0gAFm80LmhFJWBAyZvNtyuXq6uQUzDDe2fj/gHwixP6VFzlpaWKLP7IuR609Xv8gwJCXyg==} dev: true - /@trapezedev/project@7.0.10(@types/node@20.10.7)(typescript@5.3.3): + /@trapezedev/project@7.0.10(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-UjwsStjhHq/+D1bWREmFDoyKql+qFIgJX93zQLg7R6CyWZUdtlGP2hU3l7tsVRtjJBVXpVu5mj8tdwJJoABO3A==} dependencies: '@ionic/utils-fs': 3.1.7 @@ -3304,7 +3328,7 @@ packages: replace: 1.2.2 tempy: 1.0.1 tmp: 0.2.1 - ts-node: 10.9.2(@types/node@20.10.7)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.5)(typescript@5.3.3) xcode: 3.0.1 xml-js: 1.6.11 xpath: 0.0.32 @@ -3486,8 +3510,8 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.10.7: - resolution: {integrity: sha512-fRbIKb8C/Y2lXxB5eVMj4IU7xpdox0Lh8bUPEdtLysaylsml1hOOx1+STloRs/B9nf7C6kPRmmg/V7aQW7usNg==} + /@types/node@20.11.5: + resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} dependencies: undici-types: 5.26.5 dev: true @@ -3550,7 +3574,7 @@ packages: /@types/resolve@1.17.1: resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} dependencies: - '@types/node': 20.10.7 + '@types/node': 20.11.5 dev: true /@types/scheduler@0.16.8: @@ -3766,7 +3790,7 @@ packages: regenerator-runtime: 0.14.1 systemjs: 6.14.3 terser: 5.26.0 - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) transitivePeerDependencies: - supports-color dev: true @@ -3782,7 +3806,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) transitivePeerDependencies: - supports-color dev: true @@ -4180,6 +4204,19 @@ packages: - supports-color dev: true + /babel-plugin-polyfill-corejs2@0.4.8(@babel/core@7.23.7): + resolution: {integrity: sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/compat-data': 7.23.5 + '@babel/core': 7.23.7 + '@babel/helper-define-polyfill-provider': 0.5.0(@babel/core@7.23.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /babel-plugin-polyfill-corejs3@0.8.7(@babel/core@7.23.7): resolution: {integrity: sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==} peerDependencies: @@ -4203,6 +4240,17 @@ packages: - supports-color dev: true + /babel-plugin-polyfill-regenerator@0.5.5(@babel/core@7.23.7): + resolution: {integrity: sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.23.7 + '@babel/helper-define-polyfill-provider': 0.5.0(@babel/core@7.23.7) + transitivePeerDependencies: + - supports-color + dev: true + /bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} dev: true @@ -4484,17 +4532,17 @@ packages: '@capacitor/core': 5.6.0 dev: true - /capacitor-set-version@2.2.0(@types/node@20.10.7)(typescript@5.3.3): + /capacitor-set-version@2.2.0(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-CMWFFA8BxSRQDOCmSjqpZenxdMpRDvLRZLQfpO3958JTjE9+4G9o97Okmqpu2t26KVs8MHoDWjDy5hxD8EigSw==} engines: {node: '>=14.0.0'} hasBin: true dependencies: '@oclif/core': 1.26.2 - '@oclif/plugin-autocomplete': 1.4.6(@types/node@20.10.7)(typescript@5.3.3) - '@oclif/plugin-commands': 2.2.28(@types/node@20.10.7)(typescript@5.3.3) - '@oclif/plugin-help': 5.2.20(@types/node@20.10.7)(typescript@5.3.3) - '@oclif/plugin-plugins': 2.4.7(@types/node@20.10.7)(typescript@5.3.3) - '@oclif/plugin-version': 1.3.10(@types/node@20.10.7)(typescript@5.3.3) + '@oclif/plugin-autocomplete': 1.4.6(@types/node@20.11.5)(typescript@5.3.3) + '@oclif/plugin-commands': 2.2.28(@types/node@20.11.5)(typescript@5.3.3) + '@oclif/plugin-help': 5.2.20(@types/node@20.11.5)(typescript@5.3.3) + '@oclif/plugin-plugins': 2.4.7(@types/node@20.11.5)(typescript@5.3.3) + '@oclif/plugin-version': 1.3.10(@types/node@20.11.5)(typescript@5.3.3) plist: 3.1.0 semver: 7.5.4 tslib: 2.6.2 @@ -7020,6 +7068,10 @@ packages: resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} dev: true + /immutable@4.3.4: + resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} + dev: true + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -7634,7 +7686,7 @@ packages: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.10.7 + '@types/node': 20.11.5 merge-stream: 2.0.0 supports-color: 7.2.0 dev: true @@ -10306,7 +10358,7 @@ packages: jest-worker: 26.6.2 rollup: 2.79.1 serialize-javascript: 4.0.0 - terser: 5.26.0 + terser: 5.27.0 dev: true /rollup@2.79.1: @@ -10393,6 +10445,16 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + /sass@1.69.7: + resolution: {integrity: sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.3.4 + source-map-js: 1.0.2 + dev: true + /sax@1.1.4: resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} dev: true @@ -11080,6 +11142,17 @@ packages: source-map-support: 0.5.21 dev: true + /terser@5.27.0: + resolution: {integrity: sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.5 + acorn: 8.11.3 + commander: 2.20.3 + source-map-support: 0.5.21 + dev: true + /text-extensions@1.9.0: resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} engines: {node: '>=0.10'} @@ -11224,7 +11297,7 @@ packages: typescript: 5.3.3 dev: true - /ts-node@10.9.2(@types/node@20.10.7)(typescript@5.3.3): + /ts-node@10.9.2(@types/node@20.11.5)(typescript@5.3.3): resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true peerDependencies: @@ -11243,7 +11316,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.10.7 + '@types/node': 20.11.5 acorn: 8.11.3 acorn-walk: 8.3.1 arg: 4.1.3 @@ -11734,7 +11807,7 @@ packages: picocolors: 1.0.0 dev: false - /vite-node@1.1.1(@types/node@20.10.7)(terser@5.26.0): + /vite-node@1.1.1(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0): resolution: {integrity: sha512-2bGE5w4jvym5v8llF6Gu1oBrmImoNSs4WmRVcavnG2me6+8UQntTqLiAMFyiAobp+ZXhj5ZFhI7SmLiFr/jrow==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -11743,7 +11816,7 @@ packages: debug: 4.3.4(supports-color@8.1.1) pathe: 1.1.1 picocolors: 1.0.0 - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) transitivePeerDependencies: - '@types/node' - less @@ -11766,7 +11839,7 @@ packages: debug: 4.3.4(supports-color@8.1.1) fast-glob: 3.3.2 pretty-bytes: 6.1.1 - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) workbox-build: 7.0.0 workbox-window: 7.0.0 transitivePeerDependencies: @@ -11781,14 +11854,14 @@ packages: '@rollup/pluginutils': 5.1.0(rollup@2.79.1) '@svgr/core': 8.1.0(typescript@5.3.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) transitivePeerDependencies: - rollup - supports-color - typescript dev: true - /vite@5.0.10(@types/node@20.10.7)(terser@5.26.0): + /vite@5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0): resolution: {integrity: sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -11816,16 +11889,17 @@ packages: terser: optional: true dependencies: - '@types/node': 20.10.7 + '@types/node': 20.11.5 esbuild: 0.19.11 postcss: 8.4.32 rollup: 4.9.2 + sass: 1.69.7 terser: 5.26.0 optionalDependencies: fsevents: 2.3.3 dev: true - /vitest@1.1.1(@types/node@20.10.7)(jsdom@23.0.1)(terser@5.26.0): + /vitest@1.1.1(@types/node@20.11.5)(jsdom@23.0.1)(sass@1.69.7)(terser@5.26.0): resolution: {integrity: sha512-Ry2qs4UOu/KjpXVfOCfQkTnwSXYGrqTbBZxw6reIYEFjSy1QUARRg5pxiI5BEXy+kBVntxUYNMlq4Co+2vD3fQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -11850,7 +11924,7 @@ packages: jsdom: optional: true dependencies: - '@types/node': 20.10.7 + '@types/node': 20.11.5 '@vitest/expect': 1.1.1 '@vitest/runner': 1.1.1 '@vitest/snapshot': 1.1.1 @@ -11870,8 +11944,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.5.1 tinypool: 0.8.1 - vite: 5.0.10(@types/node@20.10.7)(terser@5.26.0) - vite-node: 1.1.1(@types/node@20.10.7)(terser@5.26.0) + vite: 5.0.10(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) + vite-node: 1.1.1(@types/node@20.11.5)(sass@1.69.7)(terser@5.26.0) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -11891,10 +11965,10 @@ packages: '@capacitor/core': 5.6.0 dev: true - /voyager-ionic-core@7.6.3: - resolution: {integrity: sha512-GeOcjOcnOj4rwwXJ23V9u9gM5/hLunFpuOiHf3W0kZkRuZJwBKC8iKrxOkGbcSQPFAg4qPDEWfUt4TJPMQtLGg==} + /voyager-ionic-core@7.6.5: + resolution: {integrity: sha512-vNg3HBsh1ivZaOo3M2fzh2DRyem1CNX/VKkMsKs+I9j0nSnJFYnOT8EYoBFR/A1DJWmiHeYhSGLwFFp/H2Wt2g==} dependencies: - '@stencil/core': 4.9.0 + '@stencil/core': 4.10.0 ionicons: 7.2.2 tslib: 2.6.2 dev: true diff --git a/src/TabbedRoutes.tsx b/src/TabbedRoutes.tsx index 70ff56338..42a9315ce 100644 --- a/src/TabbedRoutes.tsx +++ b/src/TabbedRoutes.tsx @@ -15,7 +15,6 @@ import SearchPage from "./pages/search/SearchPage"; import SearchPostsResultsPage from "./pages/search/results/SearchFeedResultsPage"; import ProfileFeedItemsPage from "./pages/profile/ProfileFeedItemsPage"; import SearchCommunitiesPage from "./pages/search/results/SearchCommunitiesPage"; -import TermsPage from "./pages/settings/TermsPage"; import BoxesPage from "./pages/inbox/BoxesPage"; import MentionsPage from "./pages/inbox/MentionsPage"; import RepliesPage from "./pages/inbox/RepliesPage"; @@ -327,9 +326,6 @@ export default function TabbedRoutes() { - - - diff --git a/src/features/auth/PageContext.tsx b/src/features/auth/PageContext.tsx index 2d5066bdd..bb64ba551 100644 --- a/src/features/auth/PageContext.tsx +++ b/src/features/auth/PageContext.tsx @@ -9,7 +9,6 @@ import React, { useState, } from "react"; import { CommentReplyItem } from "../comment/compose/reply/CommentReply"; -import Login from "../auth/Login"; import { useAppDispatch, useAppSelector } from "../../store"; import { changeAccount } from "../auth/authSlice"; import CommentReplyModal from "../comment/compose/reply/CommentReplyModal"; @@ -31,6 +30,7 @@ import AccountSwitcher from "./AccountSwitcher"; import { jwtSelector } from "./authSelectors"; import BanUserModal from "../moderation/ban/BanUserModal"; import CreateCrosspostDialog from "../post/crosspost/create/CreateCrosspostDialog"; +import LoginModal from "./login/LoginModal"; export interface BanUserPayload { user: Person; @@ -104,9 +104,6 @@ interface PageContextProvider { export function PageContextProvider({ value, children }: PageContextProvider) { const dispatch = useAppDispatch(); const jwt = useAppSelector(jwtSelector); - const [presentLogin, onDismissLogin] = useIonModal(Login, { - onDismiss: (data: string, role: string) => onDismissLogin(data, role), - }); const reportRef = useRef(null); const shareAsImageDataRef = useRef(null); @@ -119,14 +116,14 @@ export function PageContextProvider({ value, children }: PageContextProvider) { }, ); + const [isLoginOpen, setIsLoginOpen] = useState(false); + const presentLoginIfNeeded = useCallback(() => { if (jwt) return false; - presentLogin({ - presentingElement: value.pageRef?.current ?? undefined, - }); + setIsLoginOpen(true); return true; - }, [jwt, presentLogin, value.pageRef]); + }, [jwt]); const presentShareAsImage = useCallback( (post: PostView, comment?: CommentView, comments?: CommentView[]) => { @@ -224,10 +221,7 @@ export function PageContextProvider({ value, children }: PageContextProvider) { { onDismiss: (data: string, role: string) => onDismissAccountSwitcher(data, role), - presentLogin: () => - presentLogin({ - presentingElement: value.pageRef?.current ?? undefined, - }), + presentLogin: () => setIsLoginOpen(true), onSelectAccount: (account: string) => dispatch(changeAccount(account)), }, ); @@ -286,6 +280,7 @@ export function PageContextProvider({ value, children }: PageContextProvider) { {children} + async (dispatch: AppDispatch) => { + const client = getClient(baseUrl); + + const res = await client.register(register); + + if (!res.jwt) { + return res; + } + + await dispatch(addJwt(baseUrl, res.jwt)); + + return true; + }; + +const addJwt = + (baseUrl: string, jwt: string) => async (dispatch: AppDispatch) => { + const authenticatedClient = getClient(baseUrl, jwt); const site = await authenticatedClient.getSite(); const myUser = site.my_user?.local_user_view?.person; @@ -168,8 +189,8 @@ export const login = if (!myUser) throw new Error("broke"); dispatch(receivedSite(site)); - dispatch(addAccount({ jwt: res.jwt, handle: getRemoteHandle(myUser) })); - dispatch(updateConnectedInstance(parseJWT(res.jwt).iss)); + dispatch(addAccount({ jwt, handle: getRemoteHandle(myUser) })); + dispatch(updateConnectedInstance(parseJWT(jwt).iss)); }; const resetAccountSpecificStoreData = () => async (dispatch: AppDispatch) => { diff --git a/src/features/auth/login/LearnMore.tsx b/src/features/auth/login/LearnMore.tsx new file mode 100644 index 000000000..899fe4018 --- /dev/null +++ b/src/features/auth/login/LearnMore.tsx @@ -0,0 +1,156 @@ +import styled from "@emotion/styled"; +import { + IonBackButton, + IonButtons, + IonContent, + IonHeader, + IonText, + IonToolbar, +} from "@ionic/react"; + +const HelpIonContent = styled(IonContent)` + line-height: 1.4; +`; + +const List = styled.ul` + li:not(:last-of-type) { + margin-bottom: 1rem; + } +`; + +const Compare = styled.div` + display: flex; + text-align: center; + align-items: center; + justify-content: space-around; + + line-height: 1.5; + + margin: 1rem 0; + + > div { + display: flex; + flex-direction: column; + } +`; + +export default function LearnMore() { + return ( + <> + + + + + + + + +

How does this app work?

+

+ Lemmy is a decentralized network of communities where + people can submit content such as links, text posts, + images and videos. These posts are then{" "} + up and down voted by other people. Posts contain{" "} + comments to discuss the post further. +

+ +

Voyager is one of many apps built for Lemmy.

+ +

Decentralized?

+

+ + Lemmy + {" "} + is a decentralized service. Another decentralized service you probably + are familiar with is{" "} + + E-Mail + + . +

+ +
  • + + Lemmy + + , like{" "} + + E-Mail + + , has a common set of features. + +
    + + Create posts + + + Upvote stuff + +
    +
    vs
    +
    + + Send mail + + + Receive mail + +
    +
    +
  • +
  • + Your{" "} + + Lemmy account + {" "} + is like your{" "} + + E-Mail account + + : it’s hosted by a particular provider. + +
    + + lemmy.world + + + lemm.ee + +
    +
    vs
    +
    + + gmail.com + + + hotmail.com + +
    +
    +
    + Like{" "} + + E-Mail + + , you can interact with people on other providers. +
    +
  • +
  • + + Voyager + {" "} + is like your{" "} + + Mail app + + : it has a particular layout and style you use to access your{" "} + + Lemmy account + + . +
  • +
    +
    + + ); +} diff --git a/src/features/auth/login/LoginModal.tsx b/src/features/auth/login/LoginModal.tsx new file mode 100644 index 000000000..5ac69203d --- /dev/null +++ b/src/features/auth/login/LoginModal.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import LoginNav from "./LoginNav"; +import { DynamicDismissableModal } from "../../shared/DynamicDismissableModal"; +import styled from "@emotion/styled"; + +const StyledDynamicDismissableModal = styled(DynamicDismissableModal)` + --max-width: 500px; + + @media (min-width: 600px) { + --max-height: 750px; + } +`; + +interface LoginModalProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +export default function LoginModal({ isOpen, setIsOpen }: LoginModalProps) { + return ( + + + + ); +} diff --git a/src/features/auth/login/LoginNav.tsx b/src/features/auth/login/LoginNav.tsx new file mode 100644 index 000000000..6fcec3b1c --- /dev/null +++ b/src/features/auth/login/LoginNav.tsx @@ -0,0 +1,44 @@ +import { IonNav, IonSpinner } from "@ionic/react"; +import Welcome from "./welcome/Welcome"; +import styled from "@emotion/styled"; +import { useCallback, useContext } from "react"; +import { IonNavCustomEvent } from "@ionic/core"; +import { DynamicDismissableModalContext } from "../../shared/DynamicDismissableModal"; + +export const Spinner = styled(IonSpinner)` + width: 1.5rem; +`; + +export const Centered = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +`; + +function blurDocument() { + (document.activeElement as HTMLElement)?.blur(); +} + +export default function LoginNav() { + const { setCanDismiss } = useContext(DynamicDismissableModalContext); + + const onIonNavDidChange = useCallback( + (event: IonNavCustomEvent) => { + // If swiped back to root, allow swipe to dismiss + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((event.target as any).getLength() === 1) { + setCanDismiss(true); + } + }, + [setCanDismiss], + ); + + return ( + } + onIonNavWillChange={blurDocument} + onIonNavDidChange={onIonNavDidChange} + /> + ); +} diff --git a/src/features/auth/login/data/README.md b/src/features/auth/login/data/README.md new file mode 100644 index 000000000..87d3d0da3 --- /dev/null +++ b/src/features/auth/login/data/README.md @@ -0,0 +1,15 @@ +# Voyager curated servers policy + +[servers.ts](./servers.ts) contains whitelisted and categorized instances. + +Please feel free to open an issue or PR to add an instance to, provided the instance meets the following criteria: + +1. Server uptime of at least 3 months +2. At least 50 MAU, **UNLESS:** + 1. a regional or niche category instance, or + 2. added only to `ADDITIONAL_LOGIN_INSTANCES` (only shown for login, not signup) +3. Commitment to provide 2 months lead time before shut down +4. Active moderation against harassment, bullying, racism, discrimination, transphobia, hate speech, violation of privacy and threats of violence. +5. Privacy Policy and Terms of Use set up on `/legal` page + +The above policy can be bypassed for instances added by me (aeharding). I reserve the right to refuse to add an instance for any reason without explanation. diff --git a/src/features/auth/login/data/servers.ts b/src/features/auth/login/data/servers.ts new file mode 100644 index 000000000..5fdb40e53 --- /dev/null +++ b/src/features/auth/login/data/servers.ts @@ -0,0 +1,114 @@ +import { concat, uniq } from "lodash"; + +/** + * 🚨 Want to add a server to this list? + * Please read the [curated servers policy](./README.md) first. + */ +export const SERVERS_BY_CATEGORY = { + general: [ + "lemmy.world", + "lemm.ee", + "sh.itjust.works", + "sopuli.xyz", + "reddthat.com", + "lemmy.zip", + "lemmings.world", + "discuss.online", + "lemmus.org", + "lemmy.wtf", + "lemy.lol", + "thelemmy.club", + "lemmy.cafe", + "endlesstalk.org", + ], + regional: [ + "feddit.de", + "lemmy.ca", + "aussie.zone", + "feddit.nl", + "midwest.social", + "feddit.it", + "lemmy.eco.br", + "szmer.info", + "feddit.ch", + "jlai.lu", + "feddit.dk", + "lemmy.nz", + "feddit.nu", + "feddit.cl", + "lemmy.pt", + "dmv.social", + "suppo.fi", + "yall.theatl.social", + "feddit.ro", + "baraza.africa", + "tucson.social", + "real.lemmy.fan", + "lemy.nl", + "lemmy.eus", + "dubvee.org", + "lemmy.id", + "lemmy.bleh.au", + "feddit.uk", + ], + games: [ + "ttrpg.network", + "mtgzone.com", + "fanaticus.social", + "dormi.zone", + "eviltoast.org", + "preserve.games", + "derpzilla.net", + ], + tech: [ + "futurology.today", + "programming.dev", + "discuss.tchncs.de", + "lemmy.dbzer0.com", + "eviltoast.org", + "lemmy.kde.social", + "lemmy.sdf.org", + "lemmyhub.com", + "linux.community", + "infosec.pub", + "iusearchlinux.fyi", + "derpzilla.net", + "lemdro.id", + ], + niche: [ + "sub.wetshaving.social", + "startrek.website", + "bookwormstory.social", + "retrolemmy.com", + "sffa.community", + "lemmy.radio", + "futurology.today", + "adultswim.fan", + "lemmy.radio", + "psychedelia.ink", + "ani.social", + ], + activism: [ + "rblind.com", + "badatbeing.social", + "beehaw.org", + "sirpnk.net", + "merv.news", + ], + lgbt: ["femboys.bar", "transfem.space", "lemmy.blahaj.zone"], + academia: ["mander.xyz", "literature.cafe", "futurology.today"], + furry: ["pawb.social", "yiffit.net"], +}; + +export const WHITELISTED_SERVERS = uniq( + concat(...Object.values(SERVERS_BY_CATEGORY)), +); + +const ADDITIONAL_LOGIN_INSTANCES = ["lemmy.ml", "lemmygrad.ml", "hexbear.net"]; + +export const LOGIN_SERVERS = uniq([ + ...WHITELISTED_SERVERS, + ...ADDITIONAL_LOGIN_INSTANCES, +]); + +export type ServerCategory = keyof typeof SERVERS_BY_CATEGORY | "recommended"; diff --git a/src/features/auth/login/join/Captcha.tsx b/src/features/auth/login/join/Captcha.tsx new file mode 100644 index 000000000..289d0db31 --- /dev/null +++ b/src/features/auth/login/join/Captcha.tsx @@ -0,0 +1,205 @@ +import { + IonIcon, + IonInput, + IonItem, + IonList, + IonSpinner, + IonText, +} from "@ionic/react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react"; +import { getClient } from "../../../../services/lemmy"; +import { GetCaptchaResponse, Register } from "lemmy-js-client"; +import styled from "@emotion/styled"; +import { refresh, volumeHigh, volumeHighOutline } from "ionicons/icons"; +import { b64ToBlob } from "../../../../helpers/blob"; +import { PlainButton } from "../../../shared/PlainButton"; + +const CaptchaIonList = styled(IonList)` + position: relative; + + height: 100px; +`; + +const CaptchaIonItem = styled(IonItem)` + --background: none; +`; + +const CaptchaImg = styled.img` + margin: 0 auto; + height: 100px; +`; + +const CaptchaBg = styled.img` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + filter: blur(20px); +`; + +const Actions = styled.div` + position: absolute; + top: 0; + right: 0; + font-size: 1.3rem; + padding: 12px; + + z-index: 1; + + display: flex; + gap: 12px; + + background: rgba(0, 0, 0, 0.4); + border-bottom-left-radius: 12px; +`; + +const SpinnerContainer = styled.div` + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.3); + + z-index: 1; +`; + +const Spinner = styled(IonSpinner)` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +export interface CaptchaHandle { + getResult: () => Pick; +} + +interface CaptchaProps { + url: string; +} + +export default forwardRef(function Captcha( + { url }, + ref, +) { + const [captcha, setCaptcha] = useState(); + const [answer, setAnswer] = useState(""); + const [playing, setPlaying] = useState(false); + const [audioUrl, setAudioUrl] = useState(""); + const [loading, setLoading] = useState(false); + + useImperativeHandle(ref, () => ({ + getResult, + })); + + const getResult = useCallback( + () => ({ captcha_answer: answer, captcha_uuid: captcha?.ok?.uuid }), + [answer, captcha], + ); + + const getCaptcha = useCallback(async () => { + setLoading(true); + + let res; + + try { + res = await getClient(url).getCaptcha(); + } finally { + setLoading(false); + } + + setCaptcha(res); + }, [url]); + + useEffect(() => { + if (!captcha?.ok) return; + + // Safari doesn't support playing b64 data URIs, so we gotta createObjectURL + const blob = b64ToBlob(captcha.ok.wav, "audio/wav"); + const newUrl = URL.createObjectURL(blob); + setAudioUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + }, [captcha]); + + useEffect(() => { + getCaptcha(); + }, [getCaptcha]); + + async function play() { + if (playing) return; + if (!captcha?.ok) return; + + const audio = new Audio(audioUrl); + + setPlaying(true); + + audio.onended = () => { + setPlaying(false); + }; + audio.play(); + } + + return ( + <> + + {captcha?.ok && ( + <> + + + + + + )} + + + { + if (loading || playing) return; + + getCaptcha(); + }} + > + + + + + + + + {loading && ( + + + + )} + + + + setAnswer(e.detail.value || "")} + > +
    + Captcha Answer (Required) +
    +
    +
    +
    + + ); +}); diff --git a/src/features/auth/login/join/Join.tsx b/src/features/auth/login/join/Join.tsx new file mode 100644 index 000000000..94083ee8a --- /dev/null +++ b/src/features/auth/login/join/Join.tsx @@ -0,0 +1,255 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonList, + IonSpinner, + IonText, + IonTitle, + IonToggle, + IonToolbar, +} from "@ionic/react"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { useContext, useEffect, useRef, useState } from "react"; +import Joined from "./Joined"; +import Captcha, { CaptchaHandle } from "./Captcha"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; +import useAppToast from "../../../../helpers/useAppToast"; +import { loginSuccess } from "../../../../helpers/toastMessages"; +import { register } from "../../authSlice"; +import { LoginResponse } from "lemmy-js-client"; +import { startCase } from "lodash"; + +interface JoinProps { + answer?: string; +} + +export default function Join({ answer }: JoinProps) { + const dispatch = useAppDispatch(); + const presentToast = useAppToast(); + + const { setCanDismiss, dismiss } = useContext(DynamicDismissableModalContext); + const { site, url } = useAppSelector((state) => state.join); + + // eslint-disable-next-line no-undef + const ref = useRef(null); + + // eslint-disable-next-line no-undef + const emailRef = useRef(null); + + const [loading, setLoading] = useState(false); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [passwordVerify, setPasswordVerify] = useState(""); + const [nsfw, setNsfw] = useState(false); + const [email, setEmail] = useState(""); + const [honeypot, setHoneypot] = useState(""); + + const captchaRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + emailRef.current?.setFocus(); + }, 300); + }, []); + + useEffect(() => { + setCanDismiss(false); + }, [username, password, passwordVerify, nsfw, email, setCanDismiss]); + + async function submit() { + if (!url) return; + + setLoading(true); + + let response: LoginResponse | true; + + try { + response = await dispatch( + register(url, { + username, + password, + password_verify: passwordVerify, + show_nsfw: nsfw, + email: email || undefined, + honeypot: honeypot || undefined, + answer: answer || undefined, + ...captchaRef.current?.getResult(), + }), + ); + } catch (error) { + if (!(error instanceof Error)) throw error; + + presentToast({ + message: `Registration error: ${startCase(error.message)}`, + color: "danger", + position: "top", + fullscreen: true, + }); + + throw error; + } finally { + setLoading(false); + } + + // Logged in, so bail + if (response === true) { + setCanDismiss(true); + dismiss(); + + presentToast(loginSuccess); + + return; + } + + const { verify_email_sent } = response; + + const nav = ref.current?.closest("ion-nav"); + if (!nav) return; + + nav.push( + () => , + null, + null, + async (hasCompleted, requiresTransition, entering) => { + // Remove signup steps from stack (everything between root and current nav view) + // (user should not be able to navigate back after signup) + + // TODO open bug for missing ionic type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (entering) nav.removeIndex(1, (nav as any).getLength() - 2); + }, + ); + } + + return ( + <> + + + + + + Account Details + + {loading ? ( + + ) : ( + + Submit + + )} + + + + + + + Account Details + + + + + + setEmail(e.detail.value || "")} + > +
    + Email{" "} + {site?.site_view.local_site.require_email_verification && ( + (Required) + )} +
    +
    +
    +
    + + + + setUsername(e.detail.value || "")} + > +
    + Username (Required) +
    + + @ + + + @{url} + +
    +
    +
    + + + + setPassword(e.detail.value || "")} + clearOnEdit={false} + > +
    + Password (Required) +
    +
    +
    +
    + + + + setPasswordVerify(e.detail.value || "")} + clearOnEdit={false} + > +
    + Confirm Password (Required) +
    +
    +
    +
    + + + + setNsfw(e.detail.checked)} + > + Show NSFW + + + + + {site?.site_view.local_site.captcha_enabled && url && ( + + )} + + setHoneypot(e.target.value)} + className="ion-hide" + /> +
    + + ); +} diff --git a/src/features/auth/login/join/Joined.tsx b/src/features/auth/login/join/Joined.tsx new file mode 100644 index 000000000..81466f23b --- /dev/null +++ b/src/features/auth/login/join/Joined.tsx @@ -0,0 +1,76 @@ +import React, { useContext, useEffect } from "react"; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonNavLink, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import Login from "../login/Login"; +import { useAppSelector } from "../../../../store"; +import useHapticFeedback from "../../../../helpers/useHapticFeedback"; +import { NotificationType } from "@capacitor/haptics"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; + +interface JoinedProps { + verifyEmailSent: boolean; +} + +export default function Joined({ verifyEmailSent }: JoinedProps) { + const vibrate = useHapticFeedback(); + const { setCanDismiss } = useContext(DynamicDismissableModalContext); + const { url, site } = useAppSelector((state) => state.join); + + useEffect(() => { + vibrate({ type: NotificationType.Success }); + setCanDismiss(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + + + + βœ… Success! + + + + + + βœ… Success! + + + +
    +

    + ✨ Your request for an account was successfully submitted!{" "} + + {verifyEmailSent + ? "Keep an eye out for an email to activate your account." + : "Please wait for your account to be approved before you can log in."} + +

    +
    +
    + + + + + url && + } + > + Login + + + + + ); +} diff --git a/src/features/auth/login/join/Legal.tsx b/src/features/auth/login/join/Legal.tsx new file mode 100644 index 000000000..90c91196b --- /dev/null +++ b/src/features/auth/login/join/Legal.tsx @@ -0,0 +1,102 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonList, + IonNavLink, + IonText, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import { useAppSelector } from "../../../../store"; +import Markdown from "../../../shared/Markdown"; +import Question from "./Question"; +import Join from "./Join"; +import { useInterceptHrefWithInAppBrowserIfNeeded } from "../../../shared/InAppExternalLink"; +import { VOYAGER_PRIVACY, VOYAGER_TERMS } from "../../../../helpers/voyager"; + +export default function Legal() { + const { url, site } = useAppSelector((state) => state.join); + const interceptHrefWithInAppBrowserIfNeeded = + useInterceptHrefWithInAppBrowserIfNeeded(); + + return ( + <> + + + + + + Privacy & Terms + + { + if ( + site?.site_view.local_site.application_question && + site?.site_view.local_site.registration_mode === + "RequireApplication" + ) + return ; + + return ; + }} + > + I Agree + + + + + + + + Privacy & Terms + + + +

    + The Voyager app does not collect any data, but the server you sign up + with may have a different policy. Take a moment to review and agree to + the Voyager App policies as well as your server's policies. +

    + + + + Voyager App β€” Privacy Policy + + + Voyager App β€” Terms of Use + + + +

    + The server {url} has the following legal information below: +

    + + + {site?.site_view.local_site.legal_information ? ( + + {site.site_view.local_site.legal_information} + + ) : ( + + This server ({url}) does not have any terms set up. + + )} + +
    + + ); +} diff --git a/src/features/auth/login/join/Question.tsx b/src/features/auth/login/join/Question.tsx new file mode 100644 index 000000000..5a8005861 --- /dev/null +++ b/src/features/auth/login/join/Question.tsx @@ -0,0 +1,86 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonList, + IonNavLink, + IonText, + IonTextarea, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import { useAppSelector } from "../../../../store"; +import Markdown from "../../../shared/Markdown"; +import Join from "./Join"; +import { useContext, useState } from "react"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; + +export default function Question() { + const { site, url } = useAppSelector((state) => state.join); + const { setCanDismiss } = useContext(DynamicDismissableModalContext); + const [answer, setAnswer] = useState(""); + + return ( + <> + + + + + + Account application + + : undefined} + > + + Next + + + + + + + + + Account application + + + +

    + + {url} manually reviews account requests. + {" "} + Please read and enter your application answer below. +

    + + + + {site?.site_view.local_site.application_question} + + + + + + { + setAnswer(e.detail.value || ""); + setCanDismiss(false); + }} + value={answer} + > +
    + Application Answer (Required) +
    +
    +
    +
    +
    + + ); +} diff --git a/src/features/auth/login/join/joinSlice.ts b/src/features/auth/login/join/joinSlice.ts new file mode 100644 index 000000000..b0972547d --- /dev/null +++ b/src/features/auth/login/join/joinSlice.ts @@ -0,0 +1,70 @@ +import { PayloadAction, createSelector, createSlice } from "@reduxjs/toolkit"; +import { AppDispatch, RootState } from "../../../../store"; +import { GetSiteResponse } from "lemmy-js-client"; +import { getClient } from "../../../../services/lemmy"; + +interface JoinState { + site: GetSiteResponse | undefined; + url: string | undefined; + loading: boolean; +} + +const initialState: JoinState = { + site: undefined, + url: undefined, + loading: false, +}; + +export const joinSlice = createSlice({ + name: "join", + initialState, + reducers: { + selectedServer: (state, action: PayloadAction) => { + state.url = action.payload; + state.site = undefined; + state.loading = true; + }, + received: (state, action: PayloadAction) => { + state.site = action.payload; + state.loading = false; + }, + failed: (state) => { + state.loading = false; + }, + }, +}); + +const { selectedServer, received, failed } = joinSlice.actions; + +const urlSelector = (state: RootState) => state.join.url; + +export const joinClientSelector = createSelector([urlSelector], (url) => { + if (!url) return; + + return getClient(url); +}); + +export const requestJoinSiteData = + (url: string) => async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(selectedServer(url)); + + let site; + + try { + site = await joinClientSelector(getState())?.getSite(); + } catch (error) { + dispatch(failed()); + throw error; + } + + if (!site) { + dispatch(failed()); + return; + } + + dispatch(received(site)); + + return site; + }; + +export default joinSlice.reducer; diff --git a/src/features/auth/login/lemmyLogo.svg b/src/features/auth/login/lemmyLogo.svg new file mode 100644 index 000000000..47ff26a07 --- /dev/null +++ b/src/features/auth/login/lemmyLogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/features/auth/login/login/Login.tsx b/src/features/auth/login/login/Login.tsx new file mode 100644 index 000000000..43a2a89e7 --- /dev/null +++ b/src/features/auth/login/login/Login.tsx @@ -0,0 +1,204 @@ +import React, { useContext, useEffect, useRef, useState } from "react"; +import { + IonAvatar, + IonBackButton, + IonButton, + IonButtons, + IonChip, + IonContent, + IonHeader, + IonInput, + IonItem, + IonLabel, + IonList, + IonSpinner, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import useAppToast from "../../../../helpers/useAppToast"; +import { useAppDispatch } from "../../../../store"; +import { login } from "../../authSlice"; +import { + OldLemmyErrorValue, + getLoginErrorMessage, + isLemmyError, +} from "../../../../helpers/lemmy"; +import Totp from "./Totp"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; +import InAppExternalLink from "../../../shared/InAppExternalLink"; +import { HelperText } from "../../../settings/shared/formatting"; +import { getImageSrc } from "../../../../services/lemmy"; +import { loginSuccess } from "../../../../helpers/toastMessages"; +import lemmyLogo from "../lemmyLogo.svg"; +import styled from "@emotion/styled"; +import { VOYAGER_TERMS } from "../../../../helpers/voyager"; + +const SiteImg = styled.img` + object-fit: contain; +`; + +interface LoginProps { + url: string; + siteIcon: string | undefined; +} + +export default function Login({ url, siteIcon }: LoginProps) { + const presentToast = useAppToast(); + const dispatch = useAppDispatch(); + + const { dismiss, setCanDismiss } = useContext(DynamicDismissableModalContext); + + // eslint-disable-next-line no-undef + const usernameRef = useRef(null); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const [loading, setLoading] = useState(false); + + useEffect(() => { + setTimeout(() => { + usernameRef.current?.setFocus(); + }, 300); + }, []); + + async function submit() { + if (!username || !password) { + presentToast({ + message: "Please fill out username and password fields", + color: "danger", + fullscreen: true, + }); + return; + } + + setLoading(true); + + try { + await dispatch(login(url, username, password)); + } catch (error) { + if (isLemmyError(error, "missing_totp_token")) { + usernameRef.current + ?.closest("ion-nav") + ?.push(() => ( + + )); + return; + } + + if ( + isLemmyError(error, "password_incorrect" as OldLemmyErrorValue) || // TODO lemmy v0.18 support + isLemmyError(error, "incorrect_login") + ) { + setPassword(""); + } + + presentToast({ + message: getLoginErrorMessage(error, url), + color: "danger", + fullscreen: true, + }); + + throw error; + } finally { + setLoading(false); + } + + presentToast(loginSuccess); + + setCanDismiss(true); + dismiss(); + } + return ( + <> + + + + + + Log in + + {loading ? ( + + ) : ( + + Confirm + + )} + + + + +
    + You are logging in to{" "} + + + + + + {url} + + +
    + +
    { + event.preventDefault(); + submit(); + }} + > + {/* Hack */} + + + setUsername(e.detail.value || "")} + /> + + + + + setPassword(e.detail.value || "")} + enterkeyhint="done" + /> + + +
    + + + By using Voyager, you agree to the{" "} + + Terms of Use + + +
    + + ); +} diff --git a/src/features/auth/login/login/PickLoginServer.tsx b/src/features/auth/login/login/PickLoginServer.tsx new file mode 100644 index 000000000..46b3b1eba --- /dev/null +++ b/src/features/auth/login/login/PickLoginServer.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonList, + IonSearchbar, + IonSpinner, + IonText, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import { VList } from "virtua"; +import styled from "@emotion/styled"; +import { LOGIN_SERVERS } from "../data/servers"; +import { getClient } from "../../../../services/lemmy"; +import Login from "./Login"; +import useAppToast from "../../../../helpers/useAppToast"; +import { isValidHostname } from "../../../../helpers/url"; +import { GetSiteResponse } from "lemmy-js-client"; +import { uniq } from "lodash"; +import { getCustomServers } from "../../../../services/app"; + +const Container = styled.div` + height: 100%; + + display: flex; + flex-direction: column; +`; + +const StyledIonList = styled(IonList)` + flex: 1; + + --ion-item-background: none; +`; + +export default function PickLoginServer() { + const presentToast = useAppToast(); + const [search, setSearch] = useState(""); + const [dirty, setDirty] = useState(false); + const instances = useMemo( + () => + uniq([...getCustomServers(), ...LOGIN_SERVERS]).filter((server) => + server.includes(search.toLowerCase()), + ), + [search], + ); + const [loading, setLoading] = useState(false); + const ref = useRef(null); + // eslint-disable-next-line no-undef + const searchbarRef = useRef(null); + + const searchInvalid = useMemo( + () => + !( + isValidHostname(search) && + search.includes(".") && + !search.endsWith(".") + ), + [search], + ); + + useEffect(() => { + setTimeout(() => { + searchbarRef.current?.setFocus(); + }, 300); + }, []); + + async function submit() { + if (loading) return; + + setLoading(true); + + const potentialServer = search.toLowerCase(); + + let site: GetSiteResponse; + + try { + site = await getClient(potentialServer).getSite(); + } catch (error) { + presentToast({ + message: `Problem connecting to ${potentialServer}. Please try again`, + color: "danger", + fullscreen: true, + }); + + throw error; + } finally { + setLoading(false); + } + + ref.current + ?.closest("ion-nav") + ?.push(() => ( + + )); + } + + return ( + <> + + + + + + Welcome back + + {loading ? ( + + ) : ( + + Next + + )} + + + + + +
    + + Pick the server you created your account on + +
    + + { + if (e.key !== "Enter") return; + + // Already selected a server + if (!dirty && search) return submit(); + + // Valid with TLD (for autocomplete search) + if (!searchInvalid) { + setDirty(false); + submit(); + return; + } + + // Dirty input with candidate + if (instances[0]) { + setDirty(false); + setSearch(instances[0]); + return; + } + + presentToast({ + message: `β€œ${search}” is not a valid server.`, + color: "danger", + fullscreen: true, + }); + }} + value={search} + onIonInput={(e) => { + setDirty(true); + setSearch(e.detail.value || ""); + }} + /> + + {dirty && ( + + + {(i) => { + const instance = instances[i]!; + + return ( + { + setSearch(instance); + setDirty(false); + searchbarRef.current?.setFocus(); + }} + > + {instance} + + ); + }} + + + )} +
    +
    + + ); +} diff --git a/src/features/auth/login/login/Totp.tsx b/src/features/auth/login/login/Totp.tsx new file mode 100644 index 000000000..7d2d1a348 --- /dev/null +++ b/src/features/auth/login/login/Totp.tsx @@ -0,0 +1,133 @@ +import React, { useContext, useEffect, useRef, useState } from "react"; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonList, + IonSpinner, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import useAppToast from "../../../../helpers/useAppToast"; +import { useAppDispatch } from "../../../../store"; +import { login } from "../../authSlice"; +import { getLoginErrorMessage, isLemmyError } from "../../../../helpers/lemmy"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; +import { loginSuccess } from "../../../../helpers/toastMessages"; + +interface TotpProps { + url: string; + username: string; + password: string; +} + +export default function Totp({ url, username, password }: TotpProps) { + const presentToast = useAppToast(); + const dispatch = useAppDispatch(); + const { setCanDismiss, dismiss } = useContext(DynamicDismissableModalContext); + + // eslint-disable-next-line no-undef + const totpRef = useRef(null); + + const [loading, setLoading] = useState(false); + const [totp, setTotp] = useState(""); + + useEffect(() => { + setTimeout(() => { + totpRef.current?.setFocus(); + }, 300); + }, []); + + async function submit() { + if (!totp) { + presentToast({ + message: "Please enter 2fa code", + color: "danger", + fullscreen: true, + }); + return; + } + + setLoading(true); + + try { + await dispatch(login(url, username, password, totp)); + } catch (error) { + if ( + isLemmyError(error, "incorrect_totp_token") || + isLemmyError(error, "incorrect_login") + ) { + setTotp(""); + } + + presentToast({ + message: getLoginErrorMessage(error, url), + color: "danger", + fullscreen: true, + }); + + throw error; + } finally { + setLoading(false); + } + + presentToast(loginSuccess); + + setCanDismiss(true); + dismiss(); + } + + return ( + <> + + + + + + 2fa code + + {loading ? ( + + ) : ( + + Confirm + + )} + + + + +
    + Enter 2nd factor auth code for {username}@{url} +
    + +
    { + event.preventDefault(); + submit(); + }} + > + {/* Hack */} + + + setTotp(e.detail.value || "")} + /> + + +
    +
    + + ); +} diff --git a/src/features/auth/login/pickJoinServer/Filters.tsx b/src/features/auth/login/pickJoinServer/Filters.tsx new file mode 100644 index 000000000..dd9504025 --- /dev/null +++ b/src/features/auth/login/pickJoinServer/Filters.tsx @@ -0,0 +1,74 @@ +import styled from "@emotion/styled"; +import { IonChip } from "@ionic/react"; +import { SERVERS_BY_CATEGORY, ServerCategory } from "../data/servers"; +import { css } from "@emotion/react"; + +const Container = styled.div` + display: flex; + + overflow: auto; + + padding-left: 8px; + padding-right: 8px; + + > * { + flex-shrink: 0; + } + + &::-webkit-scrollbar { + display: none; + } +`; + +const Chip = styled(IonChip)<{ selected?: boolean }>` + ${({ selected }) => + selected && + css` + --background: var(--ion-color-primary); + --color: var(--ion-color-primary-contrast); + `} +`; + +const CATEGORIES = Object.keys(SERVERS_BY_CATEGORY) as ServerCategory[]; + +interface FiltersProps { + hasRecommended: boolean; + category: ServerCategory; + setCategory: (category: ServerCategory) => void; +} + +export default function Filters({ + hasRecommended, + category, + setCategory, +}: FiltersProps) { + return ( + { + // Prevent page swipes + e.stopPropagation(); + return true; + }} + > + {hasRecommended && ( + setCategory("recommended")} + > + recommended + + )} + {CATEGORIES.map((c) => ( + setCategory(c)} + > + {c} + + ))} + + ); +} diff --git a/src/features/auth/login/pickJoinServer/PickJoinServer.tsx b/src/features/auth/login/pickJoinServer/PickJoinServer.tsx new file mode 100644 index 000000000..b28b718e8 --- /dev/null +++ b/src/features/auth/login/pickJoinServer/PickJoinServer.tsx @@ -0,0 +1,315 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonItem, + IonLabel, + IonRadio, + IonRadioGroup, + IonSearchbar, + IonSpinner, + IonText, + IonThumbnail, + IonTitle, + IonToolbar, +} from "@ionic/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { getInstances } from "./pickJoinServerSlice"; +import { VList } from "virtua"; +import styled from "@emotion/styled"; +import { getClient, getImageSrc } from "../../../../services/lemmy"; +import { GetSiteResponse } from "lemmy-js-client"; +import { isValidHostname } from "../../../../helpers/url"; +import useStartJoinFlow from "./useStartJoinFlow"; +import { uniqBy } from "lodash"; +import { LVInstance } from "../../../../services/lemmyverse"; +import { css } from "@emotion/react"; +import lemmyLogo from "../lemmyLogo.svg"; +import Filters from "./Filters"; +import { SERVERS_BY_CATEGORY, ServerCategory } from "../data/servers"; +import { + defaultServersUntouched, + getCustomServers, + getDefaultServer, +} from "../../../../services/app"; + +const spacing = css` + margin: 2.5rem 0; + width: 100%; +`; + +const CenteredSpinner = styled(IonSpinner)` + ${spacing} +`; + +const Empty = styled.div` + ${spacing} + + color: var(--ion-color-medium); + text-align: center; +`; + +const ServerThumbnail = styled(IonThumbnail)` + --size: 32px; + --border-radius: 6px; + margin: 16px 16px 16px 0; + pointer-events: none; +`; + +const ServerItem = styled(IonItem)` + --background: none; +`; + +const NextMessage = styled.p` + font-size: 0.8em; +`; + +const ServerImg = styled.img` + object-fit: contain; +`; + +const StyledIonSearchbar = styled(IonSearchbar)` + padding-bottom: 5px !important; + min-height: 40px !important; +`; + +const FiltersToolbar = styled(IonToolbar)` + --ion-safe-area-left: -8px; + --ion-safe-area-right: -8px; + --padding-start: 0; + --padding-end: 0; +`; + +export default function PickJoinServer() { + const dispatch = useAppDispatch(); + const instances = useAppSelector((state) => state.pickJoinServer.instances); + // eslint-disable-next-line no-undef + const contentRef = useRef(null); + + const [selection, setSelection] = useState(); + const [search, setSearch] = useState(""); + + const [loadingInstances, setLoadingInstances] = useState(false); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [customInstance, setCustomInstance] = useState< + GetSiteResponse | undefined + >(); + + const startJoinFlow = useStartJoinFlow(contentRef); + + const hasRecommended = !defaultServersUntouched(); + + const [category, setCategory] = useState( + hasRecommended ? "recommended" : "general", + ); + + const matchingInstances = useMemo( + () => + instances?.filter((instance) => + instance.baseurl.includes(search.toLowerCase()), + ) || [], + [instances, search], + ); + + const allInstances = useMemo(() => { + const matches = matchingInstances.map(normalize).filter((instance) => { + if (category === "recommended") + return getCustomServers().includes(instance.url); + + return SERVERS_BY_CATEGORY[category].includes(instance.url); + }); + + const all = customInstance + ? [normalize(customInstance), ...matches] + : matches; + + return uniqBy(all, ({ url }) => url); + }, [customInstance, matchingInstances, category]); + + const customSearchHostnameInvalid = useMemo( + () => + !( + isValidHostname(search) && + search.includes(".") && + !search.endsWith(".") + ), + [search], + ); + + const fetchCustomSite = useCallback(async () => { + setCustomInstance(undefined); + + if (customSearchHostnameInvalid) return; + + const potentialServer = search.toLowerCase(); + + setLoading(true); + + let site; + + try { + site = await getClient(potentialServer).getSite(); + } finally { + setLoading(false); + } + + // User changed search before request resolved + if (site.site_view.site.actor_id !== `https://${search.toLowerCase()}/`) + return; + + setCustomInstance(site); + }, [customSearchHostnameInvalid, search]); + + useEffect(() => { + fetchCustomSite(); + }, [fetchCustomSite]); + + useEffect(() => { + if (!customSearchHostnameInvalid) return; + + setLoading(false); + }, [customSearchHostnameInvalid]); + + useEffect(() => { + (async () => { + setLoadingInstances(true); + + try { + await dispatch(getInstances()); + } finally { + setLoadingInstances(false); + } + })(); + }, [dispatch]); + + async function submit() { + const server = selection || getDefaultServer(); + + setSubmitting(true); + + try { + await startJoinFlow(server); + } finally { + setSubmitting(false); + } + } + + const content = (() => { + if (allInstances.length) { + return ( + + {(i) => { + const { url, icon, description } = allInstances[i]!; + + return ( + + + + + +

    {url}

    +

    {description}

    +
    + +
    + ); + }} +
    + ); + } + + if (loading || loadingInstances) return ; + + return No results; + })(); + + return ( + <> + + + + + + Pick Server + + + setSearch(e.detail.value || "")} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + + if (e.target instanceof HTMLElement) e.target.blur(); + }} + inputMode="url" + enterkeyhint="go" + /> + + + + + setSelection(e.detail.value)} + allowEmptySelection + > + {content} + + + + + + {submitting ? : "Next"} + + + + We‘ll pick a server for you if you don‘t make a + selection. + + + + + + ); +} + +interface Instance { + url: string; + description?: string; + icon?: string; + open: boolean; +} + +function normalize(instance: GetSiteResponse | LVInstance): Instance { + if ("baseurl" in instance) { + return { + url: instance.baseurl, + icon: instance.icon, + description: instance.desc, + open: instance.open, + }; + } + + return { + url: new URL(instance.site_view.site.actor_id).hostname, + icon: instance.site_view.site.icon, + description: instance.site_view.site.description, + open: instance.site_view.local_site.registration_mode === "Open", + }; +} diff --git a/src/features/auth/login/pickJoinServer/pickJoinServerSlice.ts b/src/features/auth/login/pickJoinServer/pickJoinServerSlice.ts new file mode 100644 index 000000000..27026f88b --- /dev/null +++ b/src/features/auth/login/pickJoinServer/pickJoinServerSlice.ts @@ -0,0 +1,47 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { AppDispatch } from "../../../../store"; +import * as lemmyverse from "../../../../services/lemmyverse"; +import { intersectionWith, sortBy, uniq } from "lodash"; +import { WHITELISTED_SERVERS } from "../data/servers"; +import { getCustomServers } from "../../../../services/app"; +import { buildPrioritizeAndSortFn } from "../../../../helpers/array"; + +interface PickJoinServerState { + instances: lemmyverse.LVInstance[] | undefined; +} + +const initialState: PickJoinServerState = { + instances: undefined, +}; + +export const pickJoinServerSlice = createSlice({ + name: "pickJoinServer", + initialState, + reducers: { + received: (state, action: PayloadAction) => { + state.instances = action.payload; + }, + }, +}); + +const { received } = pickJoinServerSlice.actions; + +export const getInstances = () => async (dispatch: AppDispatch) => { + const instances = await lemmyverse.getFullList(); + + const serverWhitelist = uniq([...getCustomServers(), ...WHITELISTED_SERVERS]); + + const unorderedInstances = sortBy( + intersectionWith(instances, serverWhitelist, (a, b) => a.baseurl === b), + (instance) => -instance.trust.score, + ); + + const customSortFn = buildPrioritizeAndSortFn( + getCustomServers(), + ({ baseurl }: lemmyverse.LVInstance) => baseurl, + ); + + dispatch(received(unorderedInstances.sort(customSortFn))); +}; + +export default pickJoinServerSlice.reducer; diff --git a/src/features/auth/login/pickJoinServer/useStartJoinFlow.tsx b/src/features/auth/login/pickJoinServer/useStartJoinFlow.tsx new file mode 100644 index 000000000..b02369c9b --- /dev/null +++ b/src/features/auth/login/pickJoinServer/useStartJoinFlow.tsx @@ -0,0 +1,40 @@ +import { MutableRefObject } from "react"; +import { useAppDispatch } from "../../../../store"; +import { requestJoinSiteData } from "../join/joinSlice"; +import Legal from "../join/Legal"; +import { useIonAlert } from "@ionic/react"; +import useAppToast from "../../../../helpers/useAppToast"; +import { GetSiteResponse } from "lemmy-js-client"; + +export default function useStartJoinFlow( + ref: MutableRefObject, +) { + const presentToast = useAppToast(); + const [presentAlert] = useIonAlert(); + const dispatch = useAppDispatch(); + + return async function go(url: string) { + let site: GetSiteResponse | undefined; + + try { + site = await dispatch(requestJoinSiteData(url)); + } catch (error) { + presentToast({ + message: `Problem connecting to ${url}. Please try again later.`, + position: "top", + color: "danger", + fullscreen: true, + }); + + throw error; + } + + if (site?.site_view.local_site.registration_mode === "Closed") { + presentAlert(`Registration closed for ${url}`); + + return; + } + + ref.current?.closest("ion-nav")?.push(() => ); + }; +} diff --git a/src/features/auth/login/welcome/AndroidClose.tsx b/src/features/auth/login/welcome/AndroidClose.tsx new file mode 100644 index 000000000..e5732e8a9 --- /dev/null +++ b/src/features/auth/login/welcome/AndroidClose.tsx @@ -0,0 +1,20 @@ +import styled from "@emotion/styled"; +import { IonButton, IonButtons } from "@ionic/react"; +import { useContext } from "react"; +import { DynamicDismissableModalContext } from "../../../shared/DynamicDismissableModal"; + +const AndroidIonButtons = styled(IonButtons)` + .ios & { + display: none; + } +`; + +export default function AndroidClose() { + const { dismiss } = useContext(DynamicDismissableModalContext); + + return ( + + Close + + ); +} diff --git a/src/features/auth/login/welcome/Buttons.tsx b/src/features/auth/login/welcome/Buttons.tsx new file mode 100644 index 000000000..9e3f131d4 --- /dev/null +++ b/src/features/auth/login/welcome/Buttons.tsx @@ -0,0 +1,101 @@ +import styled from "@emotion/styled"; +import { useAppSelector } from "../../../../store"; +import { useRef } from "react"; +import { IonButton, IonNavLink, IonSpinner } from "@ionic/react"; +import PickJoinServer from "../pickJoinServer/PickJoinServer"; +import LearnMore from "../LearnMore"; +import PickLoginServer from "../login/PickLoginServer"; +import useStartJoinFlow from "../pickJoinServer/useStartJoinFlow"; +import { getDefaultServer } from "../../../../services/app"; + +const TopSpacer = styled.div` + flex: 10; +`; + +const BottomSpacer = styled.div` + flex: 7; +`; + +const Container = styled.div` + display: flex; + flex-direction: column; + + gap: 0.5rem; + margin: 2rem; + + margin-top: auto; + + /* position: absolute; + left: 0; + right: 0; + bottom: 25vh; */ +`; + +const Or = styled.div` + font-size: 0.8em; + position: relative; + + display: flex; + gap: 6px; + + margin: 6px 0 -6px; + + hr { + flex: 1; + background: var(--ion-color-medium); + opacity: 0.6; + } +`; + +const ButtonLine = styled.div` + display: flex; + + > * { + flex: 1; + } +`; + +export default function Buttons() { + const loadingJoin = useAppSelector((state) => state.join.loading); + const ref = useRef(null); + const startJoinFlow = useStartJoinFlow(ref); + + return ( + <> + + + startJoinFlow(getDefaultServer())} + disabled={loadingJoin} + > + {loadingJoin ? : `Join ${getDefaultServer()}`} + + }> + + Pick another server + + + +
    + OR +
    +
    + + + }> + + Learn More + + + }> + + Log In + + + +
    + + + ); +} diff --git a/src/features/auth/login/welcome/Welcome.tsx b/src/features/auth/login/welcome/Welcome.tsx new file mode 100644 index 000000000..2dce13cb2 --- /dev/null +++ b/src/features/auth/login/welcome/Welcome.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { IonContent, IonHeader, IonTitle, IonToolbar } from "@ionic/react"; +import styled from "@emotion/styled"; +import { css } from "@emotion/react"; +import BaseSvg from "./assets/base.svg?react"; +import Buttons from "./Buttons"; +import AndroidClose from "./AndroidClose"; + +// slot attribute not allowed for some reason?? +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyComponent = any; + +const StyledIonContent = styled(IonContent)` + &::part(scroll) { + z-index: 1; + + display: flex; + flex-direction: column; + } + + ${({ theme }) => + theme.dark + ? css` + --background: linear-gradient(0deg, #001233ff, #000a1c 33%, #0000); + ` + : css` + --background: linear-gradient(0deg, #bfd5ff, #e3edff 33%, #ffff); + `} +`; + +const StyledBaseSvg = styled(BaseSvg)` + opacity: 0.4; + margin: 0 -2rem; + position: absolute; + bottom: 0; + + pointer-events: none; + + ${({ theme }) => + !theme.dark && + css` + filter: brightness(2.7); + `} +` as AnyComponent; + +export default function Welcome() { + return ( + <> + + + Welcome + + + + + + + + Welcome. + + + + + + + + + ); +} diff --git a/src/features/auth/login/welcome/assets/base.svg b/src/features/auth/login/welcome/assets/base.svg new file mode 100644 index 000000000..fdfc4b9ca --- /dev/null +++ b/src/features/auth/login/welcome/assets/base.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/features/comment/Comment.tsx b/src/features/comment/Comment.tsx index 37aaafc77..482190ac7 100644 --- a/src/features/comment/Comment.tsx +++ b/src/features/comment/Comment.tsx @@ -7,7 +7,6 @@ import React, { MouseEvent } from "react"; import Ago from "../labels/Ago"; import { maxWidthCss } from "../shared/AppContent"; import PersonLink from "../labels/links/PersonLink"; -import { ignoreSsrFlag } from "../../helpers/emotion"; import Vote from "../labels/Vote"; import AnimateHeight from "react-animate-height"; import CommentContent from "./CommentContent"; @@ -134,6 +133,12 @@ const StyledPersonLabel = styled(PersonLink)` overflow: hidden; `; +const CommentVote = styled(Vote)` + // Increase tap target + padding: 6px 3px; + margin: -6px -3px; +`; + const Content = styled.div` padding-top: 0.35em; @@ -142,19 +147,6 @@ const Content = styled.div` } line-height: 1.25; - - > *:first-child ${ignoreSsrFlag} { - &, - > p:first-child ${ignoreSsrFlag} { - margin-top: 0; - } - } - > *:last-child { - &, - > p:last-child { - margin-bottom: 0; - } - } `; const CollapsedIcon = styled(IonIcon)` @@ -254,7 +246,7 @@ export default function Comment({ distinguished={comment.distinguished} showBadge={!context} /> - +
    { if (!(e.target instanceof HTMLElement)) return; if (e.target.nodeName === "A") e.stopPropagation(); diff --git a/src/features/comment/CommentMarkdown.tsx b/src/features/comment/CommentMarkdown.tsx index 470ff096a..40e7839de 100644 --- a/src/features/comment/CommentMarkdown.tsx +++ b/src/features/comment/CommentMarkdown.tsx @@ -1,19 +1,18 @@ import { useAppSelector } from "../../store"; import InAppExternalLink from "../shared/InAppExternalLink"; -import Markdown from "../shared/Markdown"; +import Markdown, { MarkdownProps } from "../shared/Markdown"; import MarkdownImg from "../shared/MarkdownImg"; -interface CommentMarkdownProps { - children: string; -} - -export default function CommentMarkdown({ children }: CommentMarkdownProps) { +export default function CommentMarkdown( + props: Omit, +) { const { showCommentImages } = useAppSelector( (state) => state.settings.general.comments, ); return ( !showCommentImages ? ( @@ -32,8 +31,6 @@ export default function CommentMarkdown({ children }: CommentMarkdownProps) { /> ), }} - > - {children} - + /> ); } diff --git a/src/features/comment/compose/edit/CommentEdit.tsx b/src/features/comment/compose/edit/CommentEdit.tsx index e2d9db969..88f83dc3c 100644 --- a/src/features/comment/compose/edit/CommentEdit.tsx +++ b/src/features/comment/compose/edit/CommentEdit.tsx @@ -8,7 +8,7 @@ import { import { Comment } from "lemmy-js-client"; import { useEffect, useState } from "react"; import { useAppDispatch } from "../../../../store"; -import { Centered, Spinner } from "../../../auth/Login"; +import { Centered, Spinner } from "../../../auth/login/LoginNav"; import { editComment } from "../../commentSlice"; import { DismissableProps } from "../../../shared/DynamicDismissableModal"; import CommentContent from "../shared"; diff --git a/src/features/comment/compose/reply/CommentReply.tsx b/src/features/comment/compose/reply/CommentReply.tsx index 72c7db4f9..7ffebd372 100644 --- a/src/features/comment/compose/reply/CommentReply.tsx +++ b/src/features/comment/compose/reply/CommentReply.tsx @@ -19,7 +19,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import ItemReplyingTo from "./ItemReplyingTo"; import useClient from "../../../../helpers/useClient"; import { useAppDispatch, useAppSelector } from "../../../../store"; -import { Centered, Spinner } from "../../../auth/Login"; +import { Centered, Spinner } from "../../../auth/login/LoginNav"; import { handleSelector } from "../../../auth/authSelectors"; import { receivedComments } from "../../commentSlice"; import CommentContent from "../shared"; diff --git a/src/features/community/MoreActions.tsx b/src/features/community/MoreActions.tsx index 6a3898e59..0e913ce9b 100644 --- a/src/features/community/MoreActions.tsx +++ b/src/features/community/MoreActions.tsx @@ -15,6 +15,8 @@ import { useState } from "react"; import useHidePosts from "../feed/useHidePosts"; import useCommunityActions from "./useCommunityActions"; import { Community, CommunityView } from "lemmy-js-client"; +import { useAppSelector } from "../../store"; +import { compact } from "lodash"; interface MoreActionsProps { community: CommunityView | undefined; @@ -68,23 +70,25 @@ function MoreActionsActionSheet({ } = useCommunityActions(community); const hidePosts = useHidePosts(); + const showHiddenInCommunities = useAppSelector( + (state) => state.settings.general.posts.showHiddenInCommunities, + ); + return ( { post(); }, }, - { + !showHiddenInCommunities && { text: "Hide Read Posts", - data: "hide-read", icon: eyeOffOutline, handler: () => { hidePosts(); @@ -92,7 +96,6 @@ function MoreActionsActionSheet({ }, { text: !isSubscribed ? "Subscribe" : "Unsubscribe", - data: "subscribe", icon: !isSubscribed ? heartOutline : heartDislikeOutline, handler: () => { subscribe(); @@ -100,7 +103,6 @@ function MoreActionsActionSheet({ }, { text: !isFavorite ? "Favorite" : "Unfavorite", - data: "favorite", icon: !isFavorite ? starOutline : starSharp, handler: () => { favorite(); @@ -108,7 +110,6 @@ function MoreActionsActionSheet({ }, { text: "Sidebar", - data: "sidebar", icon: tabletPortraitOutline, handler: () => { sidebar(); @@ -116,7 +117,6 @@ function MoreActionsActionSheet({ }, { text: "Share", - data: "share", icon: shareOutline, handler: () => { share(); @@ -125,7 +125,6 @@ function MoreActionsActionSheet({ { text: !isBlocked ? "Block Community" : "Unblock Community", role: !isBlocked ? "destructive" : undefined, - data: "block", icon: removeCircleOutline, handler: () => { block(); @@ -135,7 +134,7 @@ function MoreActionsActionSheet({ text: "Cancel", role: "cancel", }, - ]} + ])} onDidDismiss={() => setOpen(false)} /> ); diff --git a/src/features/community/migrationSlice.ts b/src/features/community/migrationSlice.ts index 265f01b1d..7fdb71617 100644 --- a/src/features/community/migrationSlice.ts +++ b/src/features/community/migrationSlice.ts @@ -1,5 +1,5 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import { AppDispatch, RootState } from "../../store"; +import { AppDispatch } from "../../store"; import { db } from "../../services/db"; import { uniq } from "lodash"; @@ -18,53 +18,24 @@ export const migrationSlice = createSlice({ setMigrationLinks: (state, action: PayloadAction) => { state.links = action.payload; }, + addMigrationLink: (state, action: PayloadAction) => { + state.links = uniq([action.payload, ...state.links]); + db.setSetting("migration_links", state.links); + }, + resetMigrationLinks: (state) => { + state.links = []; + db.setSetting("migration_links", state.links); + }, }, }); export default migrationSlice.reducer; -export const addMigrationLink = - (link: string) => - async (dispatch: AppDispatch, getState: () => RootState) => { - const userHandle = getState().auth.accountData?.activeHandle; - const links = uniq([link, ...getState().migration.links]); - - if (!userHandle) return; - - dispatch(setMigrationLinks(links)); - - db.setSetting("migration_links", links, { - user_handle: userHandle, - }); - }; - -export const getMigrationLinks = - () => async (dispatch: AppDispatch, getState: () => RootState) => { - const userHandle = getState().auth.accountData?.activeHandle; - - if (!userHandle) { - dispatch(setMigrationLinks([])); - return; - } +export const getMigrationLinks = () => async (dispatch: AppDispatch) => { + const links = await db.getSetting("migration_links"); - const links = await db.getSetting("migration_links", { - user_handle: userHandle, - }); - - dispatch(setMigrationLinks(links || [])); - }; - -export const resetMigrationLinks = - () => async (dispatch: AppDispatch, getState: () => RootState) => { - const userHandle = getState().auth.accountData?.activeHandle; - - if (!userHandle) return; - - dispatch(setMigrationLinks([])); - - db.setSetting("migration_links", [], { - user_handle: userHandle, - }); - }; + dispatch(setMigrationLinks(links || [])); +}; -const { setMigrationLinks } = migrationSlice.actions; +export const { setMigrationLinks, addMigrationLink, resetMigrationLinks } = + migrationSlice.actions; diff --git a/src/features/community/titleSearch/TitleSearchProvider.tsx b/src/features/community/titleSearch/TitleSearchProvider.tsx index dd376fa04..59224052a 100644 --- a/src/features/community/titleSearch/TitleSearchProvider.tsx +++ b/src/features/community/titleSearch/TitleSearchProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useState } from "react"; +import React, { createContext, useMemo, useState } from "react"; type TitleSearchContext = { search: string; @@ -26,17 +26,20 @@ export function TitleSearchProvider({ children }: TitleSearchProviderProps) { const [searching, setSearching] = useState(false); const [onSubmit, setOnSubmit] = useState(() => () => {}); + const value = useMemo( + () => ({ + search, + setSearch, + searching, + setSearching, + onSubmit, + setOnSubmit: (fn: () => void) => setOnSubmit(() => fn), + }), + [onSubmit, search, searching], + ); + return ( - setOnSubmit(() => fn), - }} - > + {children} ); diff --git a/src/features/inbox/inboxSlice.ts b/src/features/inbox/inboxSlice.ts index 89d585aab..ccf41049b 100644 --- a/src/features/inbox/inboxSlice.ts +++ b/src/features/inbox/inboxSlice.ts @@ -1,10 +1,16 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { GetUnreadCountResponse, PrivateMessageView } from "lemmy-js-client"; import { AppDispatch, RootState } from "../../store"; +import { logoutAccount } from "../auth/authSlice"; import { InboxItemView } from "./InboxItem"; import { differenceBy, uniqBy } from "lodash"; import { receivedUsers } from "../user/userSlice"; -import { clientSelector, jwtSelector } from "../auth/authSelectors"; +import { isLemmyError } from "../../helpers/lemmy"; +import { + clientSelector, + handleSelector, + jwtSelector, +} from "../auth/authSelectors"; interface PostState { counts: { @@ -108,7 +114,31 @@ export const getInboxCounts = if (Date.now() - lastUpdatedCounts < 3_000) return; - const result = await clientSelector(getState()).getUnreadCount(); + let result; + const initialHandle = handleSelector(getState()); + + try { + result = await clientSelector(getState()).getUnreadCount(); + } catch (error) { + // Get inbox counts is a good place to check if token is valid, + // because it runs quite often (when returning from background, + // every 60 seconds, etc) + // + // If API rejects jwt, check if initial handle used to make the request + // is the same as the handle at this moment (e.g. something else didn't + // log the user out). If match, then proceed to log the user out + if ( + isLemmyError(error, "not_logged_in") || + isLemmyError(error, "incorrect_login") + ) { + const handle = handleSelector(getState()); + if (handle && handle === initialHandle) { + dispatch(logoutAccount(handle)); + } + } + + throw error; + } if (result) dispatch(receivedInboxCounts(result)); }; diff --git a/src/features/labels/Vote.tsx b/src/features/labels/Vote.tsx index c1f3748c7..f529d53d2 100644 --- a/src/features/labels/Vote.tsx +++ b/src/features/labels/Vote.tsx @@ -43,9 +43,13 @@ const Container = styled.div<{ interface VoteProps { item: PostView | CommentView; + className?: string; } -export default function Vote({ item }: VoteProps): React.ReactElement { +export default function Vote({ + item, + className, +}: VoteProps): React.ReactElement { const presentToast = useAppToast(); const dispatch = useAppDispatch(); const votesById = useAppSelector((state) => @@ -100,6 +104,7 @@ export default function Vote({ item }: VoteProps): React.ReactElement { return ( <> { @@ -109,6 +114,7 @@ export default function Vote({ item }: VoteProps): React.ReactElement { {formatNumber(upvotes)} { @@ -123,6 +129,7 @@ export default function Vote({ item }: VoteProps): React.ReactElement { case OVoteDisplayMode.Hide: return ( { await onVote(e, myVote ? 0 : 1); @@ -136,6 +143,7 @@ export default function Vote({ item }: VoteProps): React.ReactElement { const score = calculateTotalScore(item, votesById); return ( { await onVote(e, myVote ? 0 : 1); diff --git a/src/features/moderation/ban/BanUser.tsx b/src/features/moderation/ban/BanUser.tsx index 6b6a24986..b69722b6d 100644 --- a/src/features/moderation/ban/BanUser.tsx +++ b/src/features/moderation/ban/BanUser.tsx @@ -21,7 +21,7 @@ import { preventPhotoswipeGalleryFocusTrap } from "../../gallery/GalleryImg"; import { getHandle } from "../../../helpers/lemmy"; import AddRemoveButtons from "../../share/asImage/AddRemoveButtons"; import { banUser } from "../../user/userSlice"; -import { Centered, Spinner } from "../../auth/Login"; +import { Centered, Spinner } from "../../auth/login/LoginNav"; import { buildBanFailed, buildBanned } from "../../../helpers/toastMessages"; const Title = styled.span` diff --git a/src/features/post/crosspost/create/CreateCrosspostDialog.tsx b/src/features/post/crosspost/create/CreateCrosspostDialog.tsx index 74132b5ca..e026fa9ea 100644 --- a/src/features/post/crosspost/create/CreateCrosspostDialog.tsx +++ b/src/features/post/crosspost/create/CreateCrosspostDialog.tsx @@ -161,7 +161,24 @@ export default function CreateCrosspostDialog({ placeholder="Title" value={title} onIonInput={(e) => setTitle(e.detail.value || "")} - /> + // clearInput // TODO add once below bug fixed + > + {/* https://github.com/ionic-team/ionic-framework/issues/28855 */} +
    + {!title && ( + { + setTitle(post.post.name); + e.preventDefault(); + }} + > + Autofill + + )} +
    + - + ))} - - Add Keyword - + + + Add Keyword + + ); diff --git a/src/features/settings/general/hiding/HidingSettings.tsx b/src/features/settings/general/hiding/HidingSettings.tsx index 3f6f9ca60..3da085e0b 100644 --- a/src/features/settings/general/hiding/HidingSettings.tsx +++ b/src/features/settings/general/hiding/HidingSettings.tsx @@ -6,6 +6,7 @@ import ShowHideReadButton from "./ShowHideReadButton"; import { HelperText, ListHeader } from "../../shared/formatting"; import AutoHideRead from "./autoHide/AutoHideRead"; import DisableInCommunities from "./autoHide/DisableInCommunities"; +import ShowHiddenInCommunities from "./ShowHiddenInCommunities"; export default function HidingSettings() { const disableMarkingRead = useAppSelector( @@ -20,6 +21,7 @@ export default function HidingSettings() { <> + )} diff --git a/src/features/settings/general/hiding/ShowHiddenInCommunities.tsx b/src/features/settings/general/hiding/ShowHiddenInCommunities.tsx new file mode 100644 index 000000000..fd22096cb --- /dev/null +++ b/src/features/settings/general/hiding/ShowHiddenInCommunities.tsx @@ -0,0 +1,24 @@ +import { IonToggle } from "@ionic/react"; +import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { setShowHiddenInCommunities } from "../../settingsSlice"; + +export default function ShowHiddenInCommunities() { + const dispatch = useAppDispatch(); + const showHiddenInCommunities = useAppSelector( + (state) => state.settings.general.posts.showHiddenInCommunities, + ); + + return ( + + + dispatch(setShowHiddenInCommunities(e.detail.checked)) + } + > + Show Hidden in Communities + + + ); +} diff --git a/src/features/settings/settingsSlice.tsx b/src/features/settings/settingsSlice.tsx index 5083e6fbc..64a483101 100644 --- a/src/features/settings/settingsSlice.tsx +++ b/src/features/settings/settingsSlice.tsx @@ -98,6 +98,7 @@ interface SettingsState { disableMarkingRead: boolean; markReadOnScroll: boolean; showHideReadButton: boolean; + showHiddenInCommunities: boolean; autoHideRead: boolean; disableAutoHideInCommunities: boolean; infiniteScrolling: boolean; @@ -176,6 +177,7 @@ const initialState: SettingsState = { disableMarkingRead: false, markReadOnScroll: true, showHideReadButton: true, + showHiddenInCommunities: false, autoHideRead: true, disableAutoHideInCommunities: false, infiniteScrolling: true, @@ -370,6 +372,11 @@ export const appearanceSlice = createSlice({ db.setSetting("show_hide_read_button", action.payload); }, + setShowHiddenInCommunities(state, action: PayloadAction) { + state.general.posts.showHiddenInCommunities = action.payload; + + db.setSetting("show_hidden_in_communities", action.payload); + }, setAutoHideRead(state, action: PayloadAction) { state.general.posts.autoHideRead = action.payload; @@ -537,6 +544,9 @@ export const fetchSettingsFromDatabase = createAsyncThunk( const show_hide_read_button = await db.getSetting( "show_hide_read_button", ); + const show_hidden_in_communities = await db.getSetting( + "show_hidden_in_communities", + ); const auto_hide_read = await db.getSetting("auto_hide_read"); const disable_auto_hide_in_communities = await db.getSetting( "disable_auto_hide_in_communities", @@ -626,6 +636,9 @@ export const fetchSettingsFromDatabase = createAsyncThunk( showHideReadButton: show_hide_read_button ?? initialState.general.posts.showHideReadButton, + showHiddenInCommunities: + show_hidden_in_communities ?? + initialState.general.posts.showHiddenInCommunities, autoHideRead: auto_hide_read ?? initialState.general.posts.autoHideRead, disableAutoHideInCommunities: @@ -694,6 +707,7 @@ export const { setDisableMarkingPostsRead, setMarkPostsReadOnScroll, setShowHideReadButton, + setShowHiddenInCommunities, setAutoHideRead, setDisableAutoHideInCommunities, setInfiniteScrolling, diff --git a/src/features/settings/terms/Terms.tsx b/src/features/settings/terms/Terms.tsx deleted file mode 100644 index 9c99b5b5c..000000000 --- a/src/features/settings/terms/Terms.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { isNative } from "../../../helpers/device"; - -export default function Terms() { - const appName = isNative() ? "Voyager" : location.hostname; - - return ( -
    -

    {appName} Privacy Policy

    - -

    Last updated: June 24, 2023

    - -

    - We respect your privacy. When you use {appName}, we proxy information to - your Lemmy instance in order to overcome CORS restrictions with Lemmy. - However, we want to assure you that we do not log, sell, or inspect any - of the data proxied. -

    - -

    - In order to maintain {appName} functionality, we may collect aggregated - and anonymized analytics. This data is solely used for the purpose of - improving and enhancing our services. -

    - -

    - Your privacy is of utmost importance, and we are committed to keeping - your information secure. If you have any concerns or questions regarding - our privacy practices, please contact us. -

    - -

    - Please note that your Lemmy instance has its own privacy policy. We - recommend reviewing their privacy policy to understand how they handle - your data. -

    - -

    {appName} Terms of Use

    - -

    Last updated: June 24, 2023

    - -

    - THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS - OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -

    -
    - ); -} diff --git a/src/features/settings/terms/TermsSheet.tsx b/src/features/settings/terms/TermsSheet.tsx deleted file mode 100644 index 50c6dcf6c..000000000 --- a/src/features/settings/terms/TermsSheet.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { - IonButton, - IonButtons, - IonContent, - IonHeader, - IonPage, - IonTitle, - IonToolbar, -} from "@ionic/react"; -import Terms from "./Terms"; - -interface TermsSheetProps { - onDismiss: (data?: string, role?: string) => void; -} - -export default function TermsSheet({ onDismiss }: TermsSheetProps) { - return ( - - - - - onDismiss()}>Cancel - - Terms - - - - - - - ); -} diff --git a/src/features/shared/DynamicDismissableModal.tsx b/src/features/shared/DynamicDismissableModal.tsx index 0dbf83e41..7a6095e51 100644 --- a/src/features/shared/DynamicDismissableModal.tsx +++ b/src/features/shared/DynamicDismissableModal.tsx @@ -1,5 +1,12 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { useIonActionSheet } from "@ionic/react"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { IonModal, useIonActionSheet } from "@ionic/react"; import { PageContext } from "../auth/PageContext"; import { Prompt, useLocation } from "react-router"; import IonModalAutosizedForOnScreenKeyboard from "./IonModalAutosizedForOnScreenKeyboard"; @@ -7,6 +14,7 @@ import { useAppSelector } from "../../store"; import { jwtIssSelector } from "../auth/authSelectors"; import { clearRecoveredText } from "../../helpers/useTextRecovery"; import useStateRef from "../../helpers/useStateRef"; +import { isNative } from "../../helpers/device"; export interface DismissableProps { dismiss: () => void; @@ -17,7 +25,9 @@ interface DynamicDismissableModalProps { setIsOpen: (open: boolean) => void; isOpen: boolean; - children: (props: DismissableProps) => React.ReactElement; + children: + | React.ReactElement + | ((props: DismissableProps) => React.ReactElement); className?: string; dismissClassName?: string; @@ -94,6 +104,33 @@ export function DynamicDismissableModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname]); + const dismiss = useCallback(() => { + if (canDismissRef.current) { + setIsOpen(false); + return; + } + + onDismissAttemptCb(); + }, [canDismissRef, onDismissAttemptCb, setIsOpen]); + + const context = useMemo( + () => ({ dismiss, setCanDismiss }), + [dismiss, setCanDismiss], + ); + + const content = useMemo( + () => + typeof renderModalContents === "function" + ? renderModalContents({ + setCanDismiss, + dismiss, + }) + : renderModalContents, + [dismiss, renderModalContents, setCanDismiss], + ); + + const Modal = isNative() ? IonModal : IonModalAutosizedForOnScreenKeyboard; + return ( <> {isOpen && ( @@ -107,7 +144,7 @@ export function DynamicDismissableModal({ }} /> )} - - {renderModalContents({ - setCanDismiss, - dismiss: () => { - if (canDismissRef.current) { - setIsOpen(false); - return; - } - - onDismissAttemptCb(); - }, - })} - + + {content} + + ); } @@ -148,3 +177,8 @@ const useUnload = (fn: (e: BeforeUnloadEvent) => void) => { }; }, [cb]); }; + +export const DynamicDismissableModalContext = createContext<{ + dismiss: () => void; + setCanDismiss: (canDismiss: boolean) => void; +}>({ dismiss: () => {}, setCanDismiss: () => {} }); diff --git a/src/features/shared/InAppExternalLink.tsx b/src/features/shared/InAppExternalLink.tsx index f6ab856fb..dee918acb 100644 --- a/src/features/shared/InAppExternalLink.tsx +++ b/src/features/shared/InAppExternalLink.tsx @@ -1,4 +1,4 @@ -import React, { HTMLProps, MouseEventHandler } from "react"; +import React, { HTMLProps, MouseEvent, MouseEventHandler } from "react"; import { isNative } from "../../helpers/device"; import { useAppSelector } from "../../store"; import { OLinkHandlerType } from "../../services/db"; @@ -40,22 +40,28 @@ function useOnClick( href: string | undefined, _onClick: MouseEventHandler | undefined, ) { + const interceptHrefWithInAppBrowserIfNeeded = + useInterceptHrefWithInAppBrowserIfNeeded(); + + return interceptHrefWithInAppBrowserIfNeeded(href, _onClick); +} + +export function useInterceptHrefWithInAppBrowserIfNeeded() { const linkHandler = useAppSelector( (state) => state.settings.general.linkHandler, ); const openNativeBrowser = useNativeBrowser(); - const onClick: MouseEventHandler = (e) => { - _onClick?.(e); - - if (e.defaultPrevented) return; + return (href: string | undefined, onClick?: MouseEventHandler) => + (e: MouseEvent) => { + onClick?.(e); - if (isNative() && href && linkHandler === OLinkHandlerType.InApp) { - e.preventDefault(); - e.stopPropagation(); - openNativeBrowser(href); - } - }; + if (e.defaultPrevented) return; - return onClick; + if (isNative() && href && linkHandler === OLinkHandlerType.InApp) { + e.preventDefault(); + e.stopPropagation(); + openNativeBrowser(href); + } + }; } diff --git a/src/features/shared/IonModalAutosizedForOnScreenKeyboard.tsx b/src/features/shared/IonModalAutosizedForOnScreenKeyboard.tsx index 83f65648d..471c323ca 100644 --- a/src/features/shared/IonModalAutosizedForOnScreenKeyboard.tsx +++ b/src/features/shared/IonModalAutosizedForOnScreenKeyboard.tsx @@ -26,6 +26,9 @@ const StyledIonModal = styled(IonModal)<{ viewportHeight: number }>` `} `; +/** + * This component is only needed for Safari PWAs. It is not necessary for native. + */ export default function IonModalAutosizedForOnScreenKeyboard( props: React.ComponentProps, ) { diff --git a/src/features/shared/Markdown.tsx b/src/features/shared/Markdown.tsx index 4988e8557..87ca13310 100644 --- a/src/features/shared/Markdown.tsx +++ b/src/features/shared/Markdown.tsx @@ -4,6 +4,7 @@ import LinkInterceptor from "./markdown/LinkInterceptor"; import customRemarkGfm from "./markdown/customRemarkGfm"; import MarkdownImg from "./MarkdownImg"; import { css } from "@emotion/react"; +import InAppExternalLink from "./InAppExternalLink"; const markdownCss = css` @media (max-width: 700px) { @@ -55,7 +56,15 @@ const TableContainer = styled.div` } `; -export default function Markdown(props: ReactMarkdownOptions) { +export interface MarkdownProps + extends Omit { + disableInternalLinkRouting?: boolean; +} + +export default function Markdown({ + disableInternalLinkRouting, + ...props +}: MarkdownProps) { return ( ), - a: (props) => , + a: disableInternalLinkRouting + ? (props) => ( + + ) + : (props) => , ...props.components, }} remarkPlugins={[customRemarkGfm]} diff --git a/src/features/shared/PlainButton.tsx b/src/features/shared/PlainButton.tsx new file mode 100644 index 000000000..e208a6886 --- /dev/null +++ b/src/features/shared/PlainButton.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; + +// https://gist.github.com/MoOx/9137295 +export const PlainButton = styled.button` + border: none; + margin: 0; + padding: 0; + width: auto; + overflow: visible; + + background: transparent; + + /* inherit font & color from ancestor */ + color: inherit; + font: inherit; + + /* Normalize line-height. Cannot be changed from normal in Firefox 4+. */ + line-height: normal; + + /* Corrects font smoothing for webkit */ + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + + /* Corrects inability to style clickable input types in iOS */ + -webkit-appearance: none; + appearance: none; +`; diff --git a/src/features/shared/markdown/editing/MarkdownToolbar.tsx b/src/features/shared/markdown/editing/MarkdownToolbar.tsx index 197e1d9df..984c134ef 100644 --- a/src/features/shared/markdown/editing/MarkdownToolbar.tsx +++ b/src/features/shared/markdown/editing/MarkdownToolbar.tsx @@ -1,38 +1,12 @@ import styled from "@emotion/styled"; -import { - IonIcon, - IonLoading, - useIonActionSheet, - useIonModal, -} from "@ionic/react"; -import { - ellipsisHorizontal, - glassesOutline, - happyOutline, - image, - link, -} from "ionicons/icons"; import "@github/markdown-toolbar-element"; -import PreviewModal from "./PreviewModal"; -import { - Dispatch, - MouseEvent, - RefObject, - SetStateAction, - TouchEvent, - useEffect, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { css } from "@emotion/react"; -import { uploadImage } from "../../../../services/lemmy"; -import { useAppSelector } from "../../../../store"; -import { jwtSelector, urlSelector } from "../../../auth/authSelectors"; -import { insert } from "../../../../helpers/string"; import useKeyboardOpen from "../../../../helpers/useKeyboardOpen"; -import textFaces from "./textFaces.txt?raw"; -import useAppToast from "../../../../helpers/useAppToast"; -import { bold, italic, quote } from "../../../icons"; + +import DefaultMode, { SharedModeProps } from "./modes/DefaultMode"; +import UsernameAutocompleteMode from "./modes/autocomplete/UsernameAutocompleteMode"; +import CommunityAutocomplete from "./modes/autocomplete/CommunityAutocompleteMode"; export const TOOLBAR_TARGET_ID = "toolbar-target"; export const TOOLBAR_HEIGHT = "50px"; @@ -98,250 +72,112 @@ const Toolbar = styled.div<{ keyboardOpen: boolean }>` } `; -const Button = styled.button` - padding: 0; - font-size: 1.5rem; - - appearance: none; - background: none; - border: 0; -`; +type MarkdownToolbarMode = + | { + type: "default"; + } + | { + type: "username"; + match: string; + index: number; + } + | { + type: "community"; + match: string; + index: number; + }; -interface MarkdownToolbarProps { - type: "comment" | "post"; - text: string; - setText: Dispatch>; - textareaRef: RefObject; +interface MarkdownToolbarProps extends SharedModeProps { slot?: string; } export default function MarkdownToolbar({ - type, - text, - setText, - textareaRef, slot, + ...rest }: MarkdownToolbarProps) { - const [presentActionSheet] = useIonActionSheet(); - const [presentTextFaceActionSheet] = useIonActionSheet(); - const presentToast = useAppToast(); + const { text, textareaRef } = rest; + const keyboardOpen = useKeyboardOpen(); - const [imageUploading, setImageUploading] = useState(false); - const jwt = useAppSelector(jwtSelector); - const instanceUrl = useAppSelector(urlSelector); const toolbarRef = useRef(null); - const [presentPreview, onDismissPreview] = useIonModal(PreviewModal, { - onDismiss: (data: string, role: string) => onDismissPreview(data, role), - type, - text, - }); - const selectionLocation = useRef(0); - const replySelectionRef = useRef(""); - - useEffect(() => { - const onChange = () => { - selectionLocation.current = textareaRef.current?.selectionStart ?? 0; - replySelectionRef.current = window.getSelection()?.toString() || ""; - }; - - document.addEventListener("selectionchange", onChange); - - return () => { - document.removeEventListener("selectionchange", onChange); - }; - }, [textareaRef]); - - function presentMoreOptions(e: MouseEvent) { - e.preventDefault(); - - presentActionSheet({ - cssClass: "left-align-buttons", - buttons: [ - { - text: "Preview", - icon: glassesOutline, - handler: presentPreview, - }, - { - text: "Text Faces", - icon: happyOutline, - handler: presentTextFaces, - }, - { - text: "Cancel", - role: "cancel", - }, - ], - }); - } - - async function receivedImage(image: File) { - if (!jwt) return; - - setImageUploading(true); - - let imageUrl: string; - - try { - imageUrl = await uploadImage(instanceUrl, jwt, image); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - - presentToast({ - message: `Problem uploading image: ${message}. Please try again.`, - color: "danger", - fullscreen: true, - }); - - throw error; - } finally { - setImageUploading(false); - } + const [mode, setMode] = useState({ type: "default" }); + const calculateMode = useCallback(() => { if (!textareaRef.current) return; - setText((text) => - insert(text, selectionLocation.current, `\n![](${imageUrl})\n`), - ); - } - - function presentTextFaces() { - presentTextFaceActionSheet({ - cssClass: "left-align-buttons action-sheet-height-fix", - keyboardClose: false, - buttons: [ - ...textFaces.split("\n").map((face) => ({ - text: formatTextFace(face), - data: face, - })), - { - text: "Cancel", - role: "cancel", - }, - ], - onWillDismiss: (event) => { - if (!event.detail.data) return; - - const currentSelectionLocation = - selectionLocation.current + event.detail.data.length; - - setText((text) => - insert(text, selectionLocation.current, event.detail.data), - ); - - if (textareaRef.current) { - textareaRef.current.focus(); - - setTimeout(() => { - if (!textareaRef.current) return; - - textareaRef.current.selectionEnd = currentSelectionLocation; - }, 10); + const text = textareaRef.current.value; + const cursorPosition = textareaRef.current.selectionStart; + + // Use a regex to check if the entered text matches the pattern "@username@domain.com" + const TYPEAHEAD_HANDLE_REGEX = /(?:^|\s|\(|\[)(?:@|!)(\w*(@[\w.]*)?)/g; + const BEGINNING_SPACE_REGEX = /\s|\(|\[/; + + /** + * Say cursor is at the following position: + * ``` + * ! h e l l o b o b + * ^ + * ``` + * Then match should only match against partial string `"@hello"`. + * But, with: + * ``` + * ! h e l l o b o b + * ^ + * ``` + * Match against entire string `"@hellobob"` + */ + const textToMatch = /@|!/.test(text[cursorPosition - 1] || "") + ? text + : text.slice(0, cursorPosition); + + let match; + while ((match = TYPEAHEAD_HANDLE_REGEX.exec(textToMatch)) !== null) { + if ( + cursorPosition >= match.index && + cursorPosition <= TYPEAHEAD_HANDLE_REGEX.lastIndex + ) { + if (match[1] != null) { + // if match starts with a @ then mention is at the very beginning of the comment/post + const index = BEGINNING_SPACE_REGEX.test(text[match.index] || "") + ? match.index + 1 + : match.index; + + setMode({ + type: text[index] === "@" ? "username" : "community", + match: match[1], + index, + }); + return; } - }, - }); - } - - function onQuote(e: MouseEvent | TouchEvent) { - if (!replySelectionRef.current) return; - if ( - !textareaRef.current || - textareaRef.current?.selectionStart - textareaRef.current?.selectionEnd - ) - return; - - e.stopPropagation(); - e.preventDefault(); - - const currentSelectionLocation = selectionLocation.current; - - let insertedText = `> ${replySelectionRef.current - .trim() - .split("\n") - .join("\n> ")}\n\n`; - - if ( - text[currentSelectionLocation - 2] && - text[currentSelectionLocation - 2] !== "\n" - ) { - insertedText = `\n${insertedText}`; + // Do something with the detected handle + } } - setText((text) => insert(text, currentSelectionLocation, insertedText)); - - if (textareaRef.current) { - textareaRef.current.focus(); - - setTimeout(() => { - if (!textareaRef.current) return; + setMode({ type: "default" }); + }, [textareaRef]); - textareaRef.current.selectionEnd = - currentSelectionLocation + insertedText.length; - }, 10); + useEffect(() => { + calculateMode(); + }, [calculateMode, text]); + + const toolbar = (() => { + switch (mode.type) { + case "default": + return ; + case "username": + return ; + case "community": + return ; } - - return false; - } + })(); return ( <> - - - - - - - - - - - - - - - - - - + {toolbar} ); } - -// Rudimentary parsing to remove recurring back slashes for display -function formatTextFace(input: string): string { - return input.replace(/(?:\\(.))/g, "$1"); -} diff --git a/src/features/shared/markdown/editing/PreviewModal.tsx b/src/features/shared/markdown/editing/PreviewModal.tsx index 90179afd3..c64b16356 100644 --- a/src/features/shared/markdown/editing/PreviewModal.tsx +++ b/src/features/shared/markdown/editing/PreviewModal.tsx @@ -22,11 +22,17 @@ export default function PreviewModal({ onDismiss, }: PreviewModalProps) { const content = (() => { + // disableInternalLinkRouting to prevent internal links (like @test@example.com) + // from taking user away from current page (since user is in a modal) + // + // TODO future - push internal routes onto the `ion-nav`? might be cool! switch (type) { case "comment": - return {text}; + return ( + {text} + ); case "post": - return {text}; + return {text}; } })(); diff --git a/src/features/shared/markdown/editing/modes/DefaultMode.tsx b/src/features/shared/markdown/editing/modes/DefaultMode.tsx new file mode 100644 index 000000000..25a466cae --- /dev/null +++ b/src/features/shared/markdown/editing/modes/DefaultMode.tsx @@ -0,0 +1,319 @@ +import { + IonIcon, + IonLoading, + useIonActionSheet, + useIonModal, +} from "@ionic/react"; +import useAppToast from "../../../../../helpers/useAppToast"; +import { + ellipsisHorizontal, + glassesOutline, + happyOutline, + image, + link, + peopleOutline, + personOutline, +} from "ionicons/icons"; +import { + Dispatch, + MouseEvent, + RefObject, + SetStateAction, + useEffect, + useRef, + useState, +} from "react"; +import { useAppSelector } from "../../../../../store"; +import { jwtSelector, urlSelector } from "../../../../auth/authSelectors"; +import PreviewModal from "../PreviewModal"; +import styled from "@emotion/styled"; +import { uploadImage } from "../../../../../services/lemmy"; +import { insert } from "../../../../../helpers/string"; +import textFaces from "./textFaces.txt?raw"; +import { bold, italic, quote } from "../../../../icons"; +import { TOOLBAR_TARGET_ID } from "../MarkdownToolbar"; +import { css } from "@emotion/react"; + +const Button = styled.button` + padding: 0; + font-size: 1.5rem; + + appearance: none; + background: none; + border: 0; +`; + +export interface SharedModeProps { + type: "comment" | "post"; + text: string; + setText: Dispatch>; + textareaRef: RefObject; +} + +interface DefaultModeProps extends SharedModeProps { + calculateMode: () => void; +} + +export default function DefaultMode({ + type, + text, + setText, + textareaRef, + calculateMode, +}: DefaultModeProps) { + const [presentActionSheet] = useIonActionSheet(); + const [presentTextFaceActionSheet] = useIonActionSheet(); + const presentToast = useAppToast(); + const jwt = useAppSelector(jwtSelector); + const instanceUrl = useAppSelector(urlSelector); + + const [presentPreview, onDismissPreview] = useIonModal(PreviewModal, { + onDismiss: (data: string, role: string) => onDismissPreview(data, role), + type, + text, + }); + + const [imageUploading, setImageUploading] = useState(false); + + const selectionLocation = useRef(0); + const replySelectionRef = useRef(""); + + useEffect(() => { + const onChange = () => { + selectionLocation.current = textareaRef.current?.selectionStart ?? 0; + replySelectionRef.current = window.getSelection()?.toString() || ""; + }; + + document.addEventListener("selectionchange", onChange); + + return () => { + document.removeEventListener("selectionchange", onChange); + }; + }, [textareaRef]); + + function presentMoreOptions(e: MouseEvent) { + e.preventDefault(); + + presentActionSheet({ + cssClass: "left-align-buttons", + buttons: [ + { + text: "Preview", + icon: glassesOutline, + handler: presentPreview, + }, + { + text: "Mention user", + icon: personOutline, + handler: () => { + insertAutocomplete("@"); + }, + }, + { + text: "Link a community", + icon: peopleOutline, + handler: () => { + insertAutocomplete("!"); + }, + }, + { + text: "Text Faces", + icon: happyOutline, + handler: presentTextFaces, + }, + { + text: "Cancel", + role: "cancel", + }, + ], + }); + } + + function insertAutocomplete(prefix: "@" | "!") { + const index = selectionLocation.current; + + // Test previous character to see if separator needed + const needsSpace = !/^$|\s|\(|\[/.test(text[index - 1] || ""); + const space = needsSpace ? " " : ""; + + const toInsert = `${space}${prefix}`; + + setText((text) => insert(text, index, toInsert)); + + textareaRef.current?.focus(); + + setTimeout(() => { + const location = index + toInsert.length; + + textareaRef.current?.setSelectionRange(location, location); + + calculateMode(); + }); + } + + async function receivedImage(image: File) { + if (!jwt) return; + + setImageUploading(true); + + let imageUrl: string; + + try { + imageUrl = await uploadImage(instanceUrl, jwt, image); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + presentToast({ + message: `Problem uploading image: ${message}. Please try again.`, + color: "danger", + fullscreen: true, + }); + + throw error; + } finally { + setImageUploading(false); + } + + if (!textareaRef.current) return; + + setText((text) => + insert(text, selectionLocation.current, `\n![](${imageUrl})\n`), + ); + } + + function presentTextFaces() { + presentTextFaceActionSheet({ + cssClass: "left-align-buttons action-sheet-height-fix", + keyboardClose: false, + buttons: [ + ...textFaces.split("\n").map((face) => ({ + text: formatTextFace(face), + data: face, + })), + { + text: "Cancel", + role: "cancel", + }, + ], + onWillDismiss: (event) => { + if (!event.detail.data) return; + + const currentSelectionLocation = + selectionLocation.current + event.detail.data.length; + + setText((text) => + insert(text, selectionLocation.current, event.detail.data), + ); + + if (textareaRef.current) { + textareaRef.current.focus(); + + setTimeout(() => { + textareaRef.current?.setSelectionRange( + currentSelectionLocation, + currentSelectionLocation, + ); + }); + } + }, + }); + } + + function onQuote(e: MouseEvent | TouchEvent) { + if (!replySelectionRef.current) return; + if ( + !textareaRef.current || + textareaRef.current?.selectionStart - textareaRef.current?.selectionEnd + ) + return; + + e.stopPropagation(); + e.preventDefault(); + + const currentSelectionLocation = selectionLocation.current; + + let insertedText = `> ${replySelectionRef.current + .trim() + .split("\n") + .join("\n> ")}\n\n`; + + if ( + text[currentSelectionLocation - 2] && + text[currentSelectionLocation - 2] !== "\n" + ) { + insertedText = `\n${insertedText}`; + } + + setText((text) => insert(text, currentSelectionLocation, insertedText)); + + if (textareaRef.current) { + textareaRef.current.focus(); + + setTimeout(() => { + if (!textareaRef.current) return; + + textareaRef.current.selectionEnd = + currentSelectionLocation + insertedText.length; + }, 10); + } + + return false; + } + + return ( + <> + + + + + + + + + + + + + + + + + + + + ); +} + +// Rudimentary parsing to remove recurring back slashes for display +function formatTextFace(input: string): string { + return input.replace(/(?:\\(.))/g, "$1"); +} diff --git a/src/features/shared/markdown/editing/modes/autocomplete/CommunityAutocompleteMode.tsx b/src/features/shared/markdown/editing/modes/autocomplete/CommunityAutocompleteMode.tsx new file mode 100644 index 000000000..203639d82 --- /dev/null +++ b/src/features/shared/markdown/editing/modes/autocomplete/CommunityAutocompleteMode.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import useClient from "../../../../../../helpers/useClient"; +import GenericAutocompleteMode, { + AutocompleteModeProps, +} from "./GenericAutocompleteMode"; +import { Community } from "lemmy-js-client"; +import { getRemoteHandle } from "../../../../../../helpers/lemmy"; + +export default function CommunityAutocomplete(props: AutocompleteModeProps) { + const client = useClient(); + + const fetchFn = useCallback( + async (q: string) => { + const { communities } = await client.search({ + q, + type_: "Communities", + sort: "TopAll", + }); + + return communities.map((u) => u.community); + }, + [client], + ); + + const buildMd = useCallback((item: Community) => { + return `[!${getRemoteHandle(item)}](${item.actor_id})`; + }, []); + + return ( + + ); +} diff --git a/src/features/shared/markdown/editing/modes/autocomplete/GenericAutocompleteMode.tsx b/src/features/shared/markdown/editing/modes/autocomplete/GenericAutocompleteMode.tsx new file mode 100644 index 000000000..9df08ec65 --- /dev/null +++ b/src/features/shared/markdown/editing/modes/autocomplete/GenericAutocompleteMode.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from "react"; +import { getHandle } from "../../../../../../helpers/lemmy"; +import { Community } from "lemmy-js-client"; +import useDebounceFn from "../../../../../../helpers/useDebounceFn"; +import styled from "@emotion/styled"; +import { SharedModeProps as GenericModeProps } from "../DefaultMode"; +import { insert } from "../../../../../../helpers/string"; + +const Container = styled.div` + display: flex; + gap: 16px; + padding: 0 16px; + overflow: auto; + height: 100%; +`; + +const Item = styled.div` + display: flex; + align-items: center; + justify-content: center; + + white-space: nowrap; +`; + +const EmptyItem = styled(Item)` + color: var(--ion-color-medium); +`; + +export interface AutocompleteModeProps extends GenericModeProps { + match: string; + index: number; +} + +interface GenericAutocompleteModeProps extends AutocompleteModeProps { + /** + * Return a search with candidates for a given query + * + * @param q Search query + * @returns Matches for the search + */ + fetchFn: (q: string) => Promise; + + /** + * Builds the markdown to replace incomplete user input with + * + * e.g. a markdown link to user/community etc + */ + buildMd: (item: I) => string; +} + +export default function GenericAutocompleteMode< + I extends Pick, +>({ + fetchFn, + buildMd, + match, + index, + text, + setText, + textareaRef, +}: GenericAutocompleteModeProps) { + const [items, setItems] = useState([]); + + const debouncedFetchItems = useDebounceFn(() => { + fetchItems(); + }, 500); + + const fetchItems = useCallback(async () => { + if (!match) { + setItems([]); + return; + } + + const items = await fetchFn(match); + + setItems(items); + }, [match, fetchFn]); + + useEffect(() => { + debouncedFetchItems(); + }, [match, debouncedFetchItems]); + + function select(item: I) { + const md = `${buildMd(item)} `; + const newText = insert(text, index, md, match.length + 1); + + setText(newText); + + setTimeout(() => { + if (!textareaRef.current) return; + + textareaRef.current.focus(); + + const cursorPosition = index + md.length; + textareaRef.current.setSelectionRange(cursorPosition, cursorPosition); + }); + } + + return ( + + {items.length ? ( + items.map((item) => ( + select(item)}> + {getHandle(item)} + + )) + ) : ( + {match ? "No results" : "Type for suggestions"} + )} + + ); +} diff --git a/src/features/shared/markdown/editing/modes/autocomplete/UsernameAutocompleteMode.tsx b/src/features/shared/markdown/editing/modes/autocomplete/UsernameAutocompleteMode.tsx new file mode 100644 index 000000000..48a63d835 --- /dev/null +++ b/src/features/shared/markdown/editing/modes/autocomplete/UsernameAutocompleteMode.tsx @@ -0,0 +1,31 @@ +import { useCallback } from "react"; +import useClient from "../../../../../../helpers/useClient"; +import GenericAutocompleteMode, { + AutocompleteModeProps, +} from "./GenericAutocompleteMode"; +import { Person } from "lemmy-js-client"; + +export default function UsernameAutocompleteMode(props: AutocompleteModeProps) { + const client = useClient(); + + const fetchFn = useCallback( + async (q: string) => { + const { users } = await client.search({ + q, + type_: "Users", + sort: "TopAll", + }); + + return users.map((u) => u.person); + }, + [client], + ); + + const buildMd = useCallback((item: Person) => { + return `[@${item.name}](${item.actor_id})`; + }, []); + + return ( + + ); +} diff --git a/src/features/shared/markdown/editing/textFaces.txt b/src/features/shared/markdown/editing/modes/textFaces.txt similarity index 100% rename from src/features/shared/markdown/editing/textFaces.txt rename to src/features/shared/markdown/editing/modes/textFaces.txt diff --git a/src/helpers/array.ts b/src/helpers/array.ts index 55b32fcf9..6d38d09b1 100644 --- a/src/helpers/array.ts +++ b/src/helpers/array.ts @@ -19,3 +19,37 @@ export function moveItem(array: T[], from: number, to: number): T[] { clonedArray.splice(to, 0, itemToMove); return clonedArray; } + +/** + * This function provides a sort function that will pull referenceArray values to the front, + * in the order of referenceArray, if the values exists. + * + * @param referenceArray Array of values that should be pulled to front of array (in order), if exist + * @param by Map function to compare elements of array to sort to referenceArray values + * @returns Sort function + */ +export function buildPrioritizeAndSortFn( + referenceArray: A[], + by: (el: B) => A, +) { + const customSort = (a: A, b: A) => { + const indexA = referenceArray.indexOf(a); + const indexB = referenceArray.indexOf(b); + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + if (indexA !== -1) { + return -1; + } + + if (indexB !== -1) { + return 1; + } + + return 0; + }; + + return (a: B, b: B) => customSort(by(a), by(b)); +} diff --git a/src/helpers/blob.ts b/src/helpers/blob.ts index ce63fce36..8ffec87f7 100644 --- a/src/helpers/blob.ts +++ b/src/helpers/blob.ts @@ -34,3 +34,23 @@ export function blobToString(blob: Blob): Promise { reader.readAsBinaryString(blob); }); } + +export function b64ToBlob(b64Data: string, contentType = "", sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + const blob = new Blob(byteArrays, { type: contentType }); + return blob; +} diff --git a/src/helpers/lemmy.ts b/src/helpers/lemmy.ts index 2cc68f607..fdbe0b546 100644 --- a/src/helpers/lemmy.ts +++ b/src/helpers/lemmy.ts @@ -318,3 +318,33 @@ export function buildCrosspostBody(post: Post): string { return `${header}\n>\n${quote(post.body)}`; } + +export function getLoginErrorMessage( + error: unknown, + instanceActorId: string, +): string { + if (!(error instanceof Error)) + return "Unknown error occurred, please try again."; + + switch (error.message as LemmyErrorValue) { + // TODO old lemmy support + case "incorrect_totp token" as OldLemmyErrorValue: + case "incorrect_totp_token": + return "Incorrect 2nd factor code. Please try again."; + // TODO old lemmy support + case "couldnt_find_that_username_or_email" as OldLemmyErrorValue: + case "couldnt_find_person": + return `User not found. Is your account on ${instanceActorId}?`; + case "password_incorrect" as OldLemmyErrorValue: + case "incorrect_login": + return `Incorrect login credentials for ${instanceActorId}. Please try again.`; + case "email_not_verified": + return `Email not verified. Please check your inbox. Request a new verification email from https://${instanceActorId}.`; + case "site_ban": + return "You have been banned."; + case "deleted": + return "Account deleted."; + default: + return "Connection error, please try again."; + } +} diff --git a/src/helpers/string.ts b/src/helpers/string.ts index 5b2f745fd..16e793d5e 100644 --- a/src/helpers/string.ts +++ b/src/helpers/string.ts @@ -1,3 +1,8 @@ -export function insert(str: string, index: number, value: string) { - return str.substr(0, index) + value + str.substr(index); +export function insert( + str: string, + index: number, + insertedText: string, + removeLength = 0, +) { + return str.slice(0, index) + insertedText + str.slice(index + removeLength); } diff --git a/src/helpers/toastMessages.ts b/src/helpers/toastMessages.ts index 2958f8cbf..74e2bc766 100644 --- a/src/helpers/toastMessages.ts +++ b/src/helpers/toastMessages.ts @@ -168,3 +168,10 @@ export function buildBanFailed(banned: boolean): AppToastOptions { centerText: true, }; } + +export const loginSuccess: AppToastOptions = { + message: "Login successful", + color: "success", + centerText: true, + icon: checkmark, +}; diff --git a/src/helpers/url.ts b/src/helpers/url.ts index 3fba4beb4..389354e5f 100644 --- a/src/helpers/url.ts +++ b/src/helpers/url.ts @@ -33,3 +33,37 @@ export function isUrlVideo(url: string): boolean { export function isUrlMedia(url: string): boolean { return isUrlImage(url) || isUrlVideo(url); } + +// https://github.com/miguelmota/is-valid-hostname +export function isValidHostname(value: string) { + if (typeof value !== "string") return false; + + const validHostnameChars = /^[a-zA-Z0-9-.]{1,253}\.?$/g; + if (!validHostnameChars.test(value)) { + return false; + } + + if (value.endsWith(".")) { + value = value.slice(0, value.length - 1); + } + + if (value.length > 253) { + return false; + } + + const labels = value.split("."); + + const isValid = labels.every(function (label) { + const validLabelChars = /^([a-zA-Z0-9-]+)$/g; + + const validLabel = + validLabelChars.test(label) && + label.length < 64 && + !label.startsWith("-") && + !label.endsWith("-"); + + return validLabel; + }); + + return isValid; +} diff --git a/src/helpers/voyager.ts b/src/helpers/voyager.ts new file mode 100644 index 000000000..4b74d8764 --- /dev/null +++ b/src/helpers/voyager.ts @@ -0,0 +1,2 @@ +export const VOYAGER_PRIVACY = "https://getvoyager.app/privacy.html"; +export const VOYAGER_TERMS = "https://getvoyager.app/terms.html"; diff --git a/src/index.css b/src/index.scss similarity index 95% rename from src/index.css rename to src/index.scss index 404ae1d69..69aa81815 100644 --- a/src/index.css +++ b/src/index.scss @@ -149,12 +149,12 @@ ion-action-sheet ion-icon { margin-inline-end: 16px !important; } -.action-sheet-button:not(.action-sheet-cancel) { +.ios .action-sheet-button:not(.action-sheet-cancel) { padding-inline-end: 0 !important; padding-inline-start: 0; } -.left-align-buttons .action-sheet-button:not(.action-sheet-cancel) { +.ios .left-align-buttons .action-sheet-button:not(.action-sheet-cancel) { padding-inline-start: 14px; } @@ -279,3 +279,18 @@ ion-modal.save-as-image-modal { #share-as-image-root video { display: none; } + +.collapse-md-margins { + > *:first-child { + &, + > p:first-child { + margin-top: 0; + } + } + > *:last-child { + &, + > p:last-child { + margin-bottom: 0; + } + } +} diff --git a/src/index.tsx b/src/index.tsx index 20f3f8eac..d0b6517ef 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; -import "./index.css"; +import "./index.scss"; import { getAndroidNavMode, isNative } from "./helpers/device"; import "./features/icons"; diff --git a/src/pages/settings/RedditDataMigratePage.tsx b/src/pages/settings/RedditDataMigratePage.tsx index 741b40303..4daac21ab 100644 --- a/src/pages/settings/RedditDataMigratePage.tsx +++ b/src/pages/settings/RedditDataMigratePage.tsx @@ -5,6 +5,7 @@ import { IonHeader, IonInput, IonItem, + IonLabel, IonList, IonPage, IonTitle, diff --git a/src/pages/settings/TermsPage.tsx b/src/pages/settings/TermsPage.tsx deleted file mode 100644 index 6b2bb2c66..000000000 --- a/src/pages/settings/TermsPage.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - IonBackButton, - IonButtons, - IonHeader, - IonPage, - IonTitle, - IonToolbar, -} from "@ionic/react"; -import AppContent from "../../features/shared/AppContent"; -import Terms from "../../features/settings/terms/Terms"; -import { useRef } from "react"; -import { useSetActivePage } from "../../features/auth/AppContext"; - -export default function TermsPage() { - const pageRef = useRef(null); - - useSetActivePage(pageRef); - - return ( - - - - - - - - Terms - - - - - - - ); -} diff --git a/src/pages/settings/about/AboutPage.tsx b/src/pages/settings/about/AboutPage.tsx index 105fd0e8a..e2e7cad83 100644 --- a/src/pages/settings/about/AboutPage.tsx +++ b/src/pages/settings/about/AboutPage.tsx @@ -33,6 +33,7 @@ import styled from "@emotion/styled"; import { IonItemInAppExternalLink } from "../../../features/shared/InAppExternalLink"; import { isAndroid, isNative } from "../../../helpers/device"; import { useSetActivePage } from "../../../features/auth/AppContext"; +import { VOYAGER_PRIVACY, VOYAGER_TERMS } from "../../../helpers/voyager"; export const InsetIonItem = styled(IonItemInAppExternalLink)` --background: var(--ion-tab-bar-background, var(--ion-color-step-50, #fff)); @@ -135,7 +136,7 @@ export default function AboutPage() { @@ -146,7 +147,7 @@ export default function AboutPage() { diff --git a/src/pages/shared/CommunityPage.tsx b/src/pages/shared/CommunityPage.tsx index 70a9efdd5..1747e8214 100644 --- a/src/pages/shared/CommunityPage.tsx +++ b/src/pages/shared/CommunityPage.tsx @@ -147,6 +147,11 @@ const CommunityPageContent = memo(function CommunityPageContent({ const connectedInstance = useAppSelector( (state) => state.auth.connectedInstance, ); + + const showHiddenInCommunities = useAppSelector( + (state) => state.settings.general.posts.showHiddenInCommunities, + ); + const [sort, setSort] = useFeedSort({ remoteCommunityHandle: getRemoteHandleFromHandle( community, @@ -206,6 +211,7 @@ const CommunityPageContent = memo(function CommunityPageContent({ communityName={community} sortDuration={getSortDuration(sort)} header={header} + filterHiddenPosts={!showHiddenInCommunities} /> @@ -275,7 +281,7 @@ const CommunityPageContent = memo(function CommunityPageContent({ {renderFeed()} - + {!showHiddenInCommunities && } diff --git a/src/pages/shared/SelectTextModal.tsx b/src/pages/shared/SelectTextModal.tsx index 007c57a1a..3384fec44 100644 --- a/src/pages/shared/SelectTextModal.tsx +++ b/src/pages/shared/SelectTextModal.tsx @@ -7,7 +7,7 @@ import { IonContent, IonModal, } from "@ionic/react"; -import { Centered } from "../../features/auth/Login"; +import { Centered } from "../../features/auth/login/LoginNav"; import styled from "@emotion/styled"; import TextareaAutosize from "react-textarea-autosize"; import { css } from "@emotion/react"; diff --git a/src/services/app.ts b/src/services/app.ts index 536fffe1c..37ac8eeec 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -1,12 +1,8 @@ import React, { useEffect, useState } from "react"; import { isNative } from "../helpers/device"; +import { isEqual } from "lodash"; -const DEFAULT_LEMMY_SERVERS = [ - "lemmy.world", - "lemmy.ml", - "beehaw.org", - "sh.itjust.works", -]; +const DEFAULT_LEMMY_SERVERS = ["lemmy.world"]; let _customServers = DEFAULT_LEMMY_SERVERS; @@ -18,6 +14,10 @@ export function getDefaultServer() { return _customServers[0]!; } +export function defaultServersUntouched() { + return isEqual(DEFAULT_LEMMY_SERVERS, getCustomServers()); +} + async function getConfig() { if (isNative()) return; diff --git a/src/services/db.ts b/src/services/db.ts index daa846712..bafa6b5fd 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -263,6 +263,7 @@ export type SettingValueTypes = { disable_marking_posts_read: boolean; mark_read_on_scroll: boolean; show_hide_read_button: boolean; + show_hidden_in_communities: boolean; auto_hide_read: boolean; disable_auto_hide_in_communities: boolean; gesture_swipe_post: SwipeActions; diff --git a/src/services/lemmyverse.ts b/src/services/lemmyverse.ts new file mode 100644 index 000000000..b55a1a97f --- /dev/null +++ b/src/services/lemmyverse.ts @@ -0,0 +1,33 @@ +// Incomplete +export interface LVInstance { + baseurl: string; + url: string; + name: string; + desc: string; + downvotes: boolean; + nsfw: boolean; + create_admin: boolean; + private: boolean; + fed: boolean; + version: string; + open: boolean; + langs: string[]; + date: string; + published: number; + time: number; + score: number; + tags: string[]; + icon?: string; + banner?: string; + trust: { + score: number; + }; +} + +export async function getFullList(): Promise { + const data = await fetch( + "https://data.lemmyverse.net/data/instance.full.json", + ); + + return await data.json(); +} diff --git a/src/store.tsx b/src/store.tsx index 1cb423e94..2a748aa04 100644 --- a/src/store.tsx +++ b/src/store.tsx @@ -41,6 +41,8 @@ import imageSlice from "./features/post/inFeed/large/imageSlice"; import feedSortSlice from "./features/feed/sort/feedSortSlice"; import siteSlice from "./features/auth/siteSlice"; import { handleSelector } from "./features/auth/authSelectors"; +import pickJoinServerSlice from "./features/auth/login/pickJoinServer/pickJoinServerSlice"; +import joinSlice from "./features/auth/login/join/joinSlice"; const store = configureStore({ reducer: { @@ -60,6 +62,8 @@ const store = configureStore({ mod: modSlice, image: imageSlice, feedSort: feedSortSlice, + pickJoinServer: pickJoinServerSlice, + join: joinSlice, migration: migrationSlice, }, }); @@ -78,6 +82,9 @@ export default store; let lastActiveHandle: string | undefined = undefined; const activeHandleChange = () => { const state = store.getState(); + + store.dispatch(getMigrationLinks()); + const activeHandle = handleSelector(state); if (activeHandle === lastActiveHandle) return; @@ -85,7 +92,6 @@ const activeHandleChange = () => { lastActiveHandle = activeHandle; store.dispatch(getFavoriteCommunities()); - store.dispatch(getMigrationLinks()); store.dispatch(getBlurNsfw()); store.dispatch(getFilteredKeywords()); store.dispatch(getDefaultFeed());