diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3efb9ddd4e..ceca6aa7ba 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -33,5 +33,11 @@ module.exports = { 'react/prop-types': 'off', 'react/display-name': 'warn', 'no-useless-escape': 'warn', + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: '(useAnimatedStyle|useDerivedValue|useAnimatedProps)', + }, + ], }, }; diff --git a/.github/helpers/deploy.sh b/.github/helpers/deploy.sh index 304ac3903c..3b7bd0188b 100755 --- a/.github/helpers/deploy.sh +++ b/.github/helpers/deploy.sh @@ -9,7 +9,7 @@ ship=$3 zone=$4 project=$5 ref=${6:-"develop"} -[ "$desk" == "talk" ] && from="talk" || from="desk" +[ "$desk" == "tm-alpha" ] && from="tm-alpha-desk" || from="desk" folder=$ship/$desk echo "Deploying $desk from $ref of $repo to $ship in $zone of $project" @@ -54,4 +54,4 @@ gcloud compute \ --zone "$zone" --verbosity info \ urb@"$ship" < "$cmdfile" -echo "OTA performed for $desk on $ship" \ No newline at end of file +echo "OTA performed for $desk on $ship" diff --git a/.github/helpers/glob.sh b/.github/helpers/glob.sh index aeff3f8cac..f629c48f2a 100755 --- a/.github/helpers/glob.sh +++ b/.github/helpers/glob.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# this script globs a folder of files, then subsequently uploads the +# this script globs a folder of files, then subsequently uploads the # glob to bootstrap.urbit.org and replaces the hash in the docket file. # assumes gcloud credentials are loaded and gsutil installed. @@ -8,15 +8,15 @@ # $2: the location of the docket file # globber is a prebooted and docked fakezod -curl https://bootstrap.urbit.org/globberv3.tgz | tar xzk +curl https://storage.googleapis.com/bootstrap.urbit.org/globberv4.tar.gz | tar xzk ./zod/.run -d dojo () { - curl -s --data '{"source":{"dojo":"'"$1"'"},"sink":{"stdout":null}}' http://localhost:12321 + curl -s --data '{"source":{"dojo":"'"$1"'"},"sink":{"stdout":null}}' http://localhost:12321 } hood () { - curl -s --data '{"source":{"dojo":"+hood/'"$1"'"},"sink":{"app":"hood"}}' http://localhost:12321 + curl -s --data '{"source":{"dojo":"+hood/'"$1"'"},"sink":{"app":"hood"}}' http://localhost:12321 } rsync -avL $1 zod/work/glob @@ -31,4 +31,4 @@ echo "hash=$(echo $hash)" >> $GITHUB_OUTPUT hood "exit" sleep 5s -rm -rf zod \ No newline at end of file +rm -rf zod diff --git a/.github/workflows/deploy-canary.yml b/.github/workflows/deploy-canary.yml index 9586d724aa..13e1f6c47f 100644 --- a/.github/workflows/deploy-canary.yml +++ b/.github/workflows/deploy-canary.yml @@ -36,10 +36,33 @@ jobs: with: name: 'ui-dist' path: apps/tlon-web/dist + build-new-frontend: + runs-on: ubuntu-latest + name: 'Build New Frontend' + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + run_install: | + - recursive: true + args: [--frozen-lockfile] + - working-directory: ./apps/tlon-web-new + run: + pnpm build:alpha + - uses: actions/upload-artifact@v3 + with: + name: 'ui-dist-new' + path: apps/tlon-web-new/dist glob: runs-on: ubuntu-latest name: 'Make a glob' - needs: build-frontend + needs: build-new-frontend steps: - uses: actions/checkout@v3 with: @@ -69,6 +92,39 @@ jobs: BRANCH=${INPUT:-"staging"} git pull origin $BRANCH --rebase --autostash git push + glob-new: + runs-on: ubuntu-latest + name: 'Make a glob for new frontend' + needs: build-frontend-new + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - uses: actions/download-artifact@v3 + with: + name: 'ui-dist-new' + path: apps/tlon-web-new/dist + - id: 'auth' + uses: 'google-github-actions/auth@v1' + with: + credentials_json: '${{ secrets.GCP_SERVICE_KEY }}' + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@v1' + - name: 'glob' + uses: ./.github/actions/glob + with: + folder: 'apps/tlon-web-new/dist/*' + docket: "tm-alpha-desk/desk.docket-0" + - name: Commit and Push Glob + run: | + git config --global user.name github-actions + git config --global user.email github-actions@github.com + git add desk/desk.docket-0 + git commit -n -m "update glob: ${{ steps.glob.outputs.hash }} [skip actions]" || echo "No changes to commit" + INPUT=${{ env.tag }} + BRANCH=${INPUT:-"staging"} + git pull origin $BRANCH --rebase --autostash + git push deploy: runs-on: ubuntu-latest name: "Release to ~binnec-dozzod-marnus (canary)" @@ -90,4 +146,26 @@ jobs: env: SSH_SEC_KEY: ${{ secrets.GCP_SSH_SEC_KEY }} SSH_PUB_KEY: ${{ secrets.GCP_SSH_PUB_KEY }} - URBIT_REPO_TAG: ${{ vars.URBIT_REPO_TAG }} \ No newline at end of file + URBIT_REPO_TAG: ${{ vars.URBIT_REPO_TAG }} + deploy-new: + runs-on: ubuntu-latest + name: "Release new frontend to ~binnec-dozzod-marnus (canary)" + needs: glob-new + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - id: 'auth' + uses: 'google-github-actions/auth@v1' + with: + credentials_json: '${{ secrets.GCP_SERVICE_KEY }}' + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@v1' + - id: deploy + name: Deploy + run: + ./.github/helpers/deploy.sh tloncorp/tlon-apps tm-alpha binnec-dozzod-marnus us-central1-a mainnet-tlon-other-2d ${{ env.tag }} + env: + SSH_SEC_KEY: ${{ secrets.GCP_SSH_SEC_KEY }} + SSH_PUB_KEY: ${{ secrets.GCP_SSH_PUB_KEY }} + URBIT_REPO_TAG: ${{ vars.URBIT_REPO_TAG }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0bcf131f12..d2801ee227 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,6 +39,28 @@ jobs: with: name: "ui-dist" path: apps/tlon-web/dist + build-new-frontend: + runs-on: ubuntu-latest + name: "Build New Frontend" + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + run_install: | + - recursive: true + args: [--frozen-lockfile] + - working-directory: ./apps/tlon-web-new + run: pnpm build:alpha + - uses: actions/upload-artifact@v3 + with: + name: "ui-dist-new" + path: apps/tlon-web-new/dist glob: runs-on: ubuntu-latest name: "Make a glob" @@ -72,6 +94,39 @@ jobs: BRANCH=${INPUT:-"develop"} git pull origin $BRANCH --rebase --autostash git push + glob-new: + runs-on: ubuntu-latest + name: "Make a glob for new frontend" + needs: build-new-frontend + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - uses: actions/download-artifact@v3 + with: + name: "ui-dist-new" + path: apps/tlon-web/dist + - id: "auth" + uses: "google-github-actions/auth@v1" + with: + credentials_json: "${{ secrets.GCP_SERVICE_KEY }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v1" + - name: "glob" + uses: ./.github/actions/glob + with: + folder: "apps/tlon-web-new/dist/*" + docket: "tm-alpha-desk/desk.docket-0" + - name: Commit and Push Glob + run: | + git config --global user.name github-actions + git config --global user.email github-actions@github.com + git add desk/desk.docket-0 + git commit -n -m "update new frontend glob: ${{ steps.glob.outputs.hash }} [skip actions]" || echo "No changes to commit" + INPUT=${{ env.tag }} + BRANCH=${INPUT:-"develop"} + git pull origin $BRANCH --rebase --autostash + git push deploy: runs-on: ubuntu-latest needs: glob @@ -96,3 +151,27 @@ jobs: SSH_SEC_KEY: ${{ secrets.GCP_SSH_SEC_KEY }} SSH_PUB_KEY: ${{ secrets.GCP_SSH_PUB_KEY }} URBIT_REPO_TAG: ${{ vars.URBIT_REPO_TAG }} + deploy-new: + runs-on: ubuntu-latest + needs: glob-new + name: "Deploy a new frontend glob to ~wannec-dozzod-marnus (devstream)" + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ env.tag }} + - id: "auth" + uses: "google-github-actions/auth@v1" + with: + credentials_json: "${{ secrets.GCP_SERVICE_KEY }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v1" + - id: deploy + name: Deploy + run: + ./.github/helpers/deploy.sh tloncorp/tlon-apps tm-alpha + wannec-dozzod-marnus us-central1-a mainnet-tlon-other-2d ${{ env.tag + }} + env: + SSH_SEC_KEY: ${{ secrets.GCP_SSH_SEC_KEY }} + SSH_PUB_KEY: ${{ secrets.GCP_SSH_PUB_KEY }} + URBIT_REPO_TAG: ${{ vars.URBIT_REPO_TAG }} diff --git a/apps/tlon-mobile/.env.sample b/apps/tlon-mobile/.env.sample index 7818da8b98..7141de8b6f 100644 --- a/apps/tlon-mobile/.env.sample +++ b/apps/tlon-mobile/.env.sample @@ -14,6 +14,14 @@ DEFAULT_SHIP_LOGIN_ACCESS_CODE= API_URL=https://test.tlon.systems SHIP_URL_PATTERN=https://{shipId}.test.tlon.systems +# Paths used when dumping / restoring SQLite database via dev menu, relative to +# workspace root. +# Set these to the same value to easily dump and then restore the dump. +# Defaults to `dump.sqlite3` and `restore.sqlite3`. +# Recommended to add these paths to your `.git/info/exclude` file. +SQLITE_DUMP_PATH=dump.db +SQLITE_RESTORE_PATH=$SQLITE_DUMP_PATH + # Other env vars that are set in production, use as needed # for local testing POST_HOG_API_KEY= @@ -27,4 +35,4 @@ ENABLED_LOGGERS=query,sync IGNORE_COSMOS=false TLON_EMPLOYEE_GROUP= BRANCH_KEY= -BRANCH_DOMAIN= \ No newline at end of file +BRANCH_DOMAIN= diff --git a/apps/tlon-mobile/assets/images/welcome_blocks.jpg b/apps/tlon-mobile/assets/images/welcome_blocks.jpg new file mode 100644 index 0000000000..5f697134fa Binary files /dev/null and b/apps/tlon-mobile/assets/images/welcome_blocks.jpg differ diff --git a/apps/tlon-mobile/cosmos.imports.ts b/apps/tlon-mobile/cosmos.imports.ts index 5ebbb8b7da..a5a05f2536 100644 --- a/apps/tlon-mobile/cosmos.imports.ts +++ b/apps/tlon-mobile/cosmos.imports.ts @@ -1,68 +1,67 @@ // This file is automatically generated by Cosmos. Add it to .gitignore and // only edit if you know what you're doing. - import { RendererConfig, UserModuleWrappers } from 'react-cosmos-core'; import * as fixture0 from './src/App.fixture'; -import * as fixture1 from './src/fixtures/VideoEmbed.fixture'; -import * as fixture2 from './src/fixtures/TrimmedText.fixture'; -import * as fixture3 from './src/fixtures/TlonButton.fixture'; -import * as fixture4 from './src/fixtures/SearchBar.fixture'; -import * as fixture5 from './src/fixtures/ScreenHeader.fixture'; -import * as fixture6 from './src/fixtures/ReferenceSkeleton.fixture'; -import * as fixture7 from './src/fixtures/ProfileWidget.fixture'; -import * as fixture8 from './src/fixtures/ProfileSheet.fixture'; -import * as fixture9 from './src/fixtures/ProfileBlock.fixture'; -import * as fixture10 from './src/fixtures/PostScreen.fixture'; -import * as fixture11 from './src/fixtures/PostReference.fixture'; -import * as fixture12 from './src/fixtures/OutsideEmbed.fixture'; -import * as fixture13 from './src/fixtures/MetaEditorScreen.fixture'; -import * as fixture14 from './src/fixtures/MessageInput.fixture'; -import * as fixture15 from './src/fixtures/MessageActions.fixture'; -import * as fixture16 from './src/fixtures/InputToolbar.fixture'; -import * as fixture17 from './src/fixtures/Input.fixture'; +import * as fixture47 from './src/fixtures/ActionSheet/AddGalleryPostSheet.fixture'; +import * as fixture46 from './src/fixtures/ActionSheet/AttachmentSheet.fixture'; +import * as fixture45 from './src/fixtures/ActionSheet/ChannelSortActionsSheet.fixture'; +import * as fixture44 from './src/fixtures/ActionSheet/CreateChannelSheet.fixture'; +import * as fixture43 from './src/fixtures/ActionSheet/DeleteSheet.fixture'; +import * as fixture42 from './src/fixtures/ActionSheet/EditSectionNameSheet.fixture'; +import * as fixture41 from './src/fixtures/ActionSheet/GenericActionSheet.fixture'; +import * as fixture40 from './src/fixtures/ActionSheet/GroupJoinRequestSheet.fixture'; +import * as fixture39 from './src/fixtures/ActionSheet/GroupPreviewSheet.fixture'; +import * as fixture38 from './src/fixtures/ActionSheet/ProfileSheet.fixture'; +import * as fixture37 from './src/fixtures/ActionSheet/SendPostRetrySheet.fixture'; +import * as fixture33 from './src/fixtures/AttachmentPreviewList.fixture'; +import * as fixture32 from './src/fixtures/AudioEmbed.fixture'; +import * as fixture31 from './src/fixtures/Avatar.fixture'; +import * as fixture30 from './src/fixtures/BlockSectionList.fixture'; +import * as fixture29 from './src/fixtures/Button.fixture'; +import * as fixture28 from './src/fixtures/Channel.fixture'; +import * as fixture27 from './src/fixtures/ChannelDivider.fixture'; +import * as fixture26 from './src/fixtures/ChannelHeader.fixture'; +import * as fixture25 from './src/fixtures/ChannelSwitcherSheet.fixture'; +import * as fixture24 from './src/fixtures/ChatMessage.fixture'; +import * as fixture23 from './src/fixtures/ContactList.fixture'; +import * as fixture36 from './src/fixtures/DetailView/ChatDetailView.fixture'; +import * as fixture35 from './src/fixtures/DetailView/GalleryDetailView.fixture'; +import * as fixture34 from './src/fixtures/DetailView/NotebookDetailView.fixture'; +import * as fixture22 from './src/fixtures/Form.fixture'; +import * as fixture21 from './src/fixtures/GalleryPost.fixture'; +import * as fixture20 from './src/fixtures/GroupList.fixture'; +import * as fixture19 from './src/fixtures/GroupListItem.fixture'; import * as fixture18 from './src/fixtures/ImageViewer.fixture'; -import * as fixture19 from './src/fixtures/HeaderButton.fixture'; -import * as fixture20 from './src/fixtures/GroupListItem.fixture'; -import * as fixture21 from './src/fixtures/GroupList.fixture'; -import * as fixture22 from './src/fixtures/GalleryPost.fixture'; -import * as fixture23 from './src/fixtures/Form.fixture'; -import * as fixture24 from './src/fixtures/DetailView.fixture'; -import * as fixture25 from './src/fixtures/ContactList.fixture'; -import * as fixture26 from './src/fixtures/ChatMessage.fixture'; -import * as fixture27 from './src/fixtures/ChannelSwitcherSheet.fixture'; -import * as fixture28 from './src/fixtures/ChannelHeader.fixture'; -import * as fixture29 from './src/fixtures/ChannelDivider.fixture'; -import * as fixture30 from './src/fixtures/Channel.fixture'; -import * as fixture31 from './src/fixtures/Button.fixture'; -import * as fixture32 from './src/fixtures/BlockSectionList.fixture'; -import * as fixture33 from './src/fixtures/Avatar.fixture'; -import * as fixture34 from './src/fixtures/AudioEmbed.fixture'; -import * as fixture35 from './src/fixtures/AttachmentPreviewList.fixture'; -import * as fixture36 from './src/fixtures/ActionSheet/SendPostRetrySheet.fixture'; -import * as fixture37 from './src/fixtures/ActionSheet/ProfileSheet.fixture'; -import * as fixture38 from './src/fixtures/ActionSheet/GroupPreviewSheet.fixture'; -import * as fixture39 from './src/fixtures/ActionSheet/GroupJoinRequestSheet.fixture'; -import * as fixture40 from './src/fixtures/ActionSheet/GenericActionSheet.fixture'; -import * as fixture41 from './src/fixtures/ActionSheet/EditSectionNameSheet.fixture'; -import * as fixture42 from './src/fixtures/ActionSheet/DeleteSheet.fixture'; -import * as fixture43 from './src/fixtures/ActionSheet/CreateChannelSheet.fixture'; -import * as fixture44 from './src/fixtures/ActionSheet/ChannelSortActionsSheet.fixture'; -import * as fixture45 from './src/fixtures/ActionSheet/AttachmentSheet.fixture'; -import * as fixture46 from './src/fixtures/ActionSheet/AddGalleryPostSheet.fixture'; - +import * as fixture17 from './src/fixtures/Input.fixture'; +import * as fixture16 from './src/fixtures/InputToolbar.fixture'; +import * as fixture15 from './src/fixtures/MessageActions.fixture'; +import * as fixture14 from './src/fixtures/MessageInput.fixture'; +import * as fixture13 from './src/fixtures/MetaEditorScreen.fixture'; +import * as fixture12 from './src/fixtures/OutsideEmbed.fixture'; +import * as fixture11 from './src/fixtures/PostReference.fixture'; +import * as fixture10 from './src/fixtures/PostScreen.fixture'; +import * as fixture9 from './src/fixtures/ProfileBlock.fixture'; +import * as fixture8 from './src/fixtures/ProfileSheet.fixture'; +import * as fixture7 from './src/fixtures/ProfileWidget.fixture'; +import * as fixture6 from './src/fixtures/ReferenceSkeleton.fixture'; +import * as fixture5 from './src/fixtures/ScreenHeader.fixture'; +import * as fixture4 from './src/fixtures/SearchBar.fixture'; +import * as fixture3 from './src/fixtures/Text.fixture'; +import * as fixture2 from './src/fixtures/VideoEmbed.fixture'; +import * as fixture1 from './src/fixtures/ViewReactionsSheet.fixture'; import * as decorator0 from './src/fixtures/cosmos.decorator'; export const rendererConfig: RendererConfig = { - "playgroundUrl": "http://localhost:5001", - "rendererUrl": null + playgroundUrl: 'http://localhost:5001', + rendererUrl: null, }; const fixtures = { 'src/App.fixture.tsx': { module: fixture0 }, - 'src/fixtures/VideoEmbed.fixture.tsx': { module: fixture1 }, - 'src/fixtures/TrimmedText.fixture.tsx': { module: fixture2 }, - 'src/fixtures/TlonButton.fixture.tsx': { module: fixture3 }, + 'src/fixtures/ViewReactionsSheet.fixture.tsx': { module: fixture1 }, + 'src/fixtures/VideoEmbed.fixture.tsx': { module: fixture2 }, + 'src/fixtures/Text.fixture.tsx': { module: fixture3 }, 'src/fixtures/SearchBar.fixture.tsx': { module: fixture4 }, 'src/fixtures/ScreenHeader.fixture.tsx': { module: fixture5 }, 'src/fixtures/ReferenceSkeleton.fixture.tsx': { module: fixture6 }, @@ -78,42 +77,63 @@ const fixtures = { 'src/fixtures/InputToolbar.fixture.tsx': { module: fixture16 }, 'src/fixtures/Input.fixture.tsx': { module: fixture17 }, 'src/fixtures/ImageViewer.fixture.tsx': { module: fixture18 }, - 'src/fixtures/HeaderButton.fixture.tsx': { module: fixture19 }, - 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture20 }, - 'src/fixtures/GroupList.fixture.tsx': { module: fixture21 }, - 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture22 }, - 'src/fixtures/Form.fixture.tsx': { module: fixture23 }, - 'src/fixtures/DetailView.fixture.tsx': { module: fixture24 }, - 'src/fixtures/ContactList.fixture.tsx': { module: fixture25 }, - 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture26 }, - 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture27 }, - 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture28 }, - 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture29 }, - 'src/fixtures/Channel.fixture.tsx': { module: fixture30 }, - 'src/fixtures/Button.fixture.tsx': { module: fixture31 }, - 'src/fixtures/BlockSectionList.fixture.tsx': { module: fixture32 }, - 'src/fixtures/Avatar.fixture.tsx': { module: fixture33 }, - 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture34 }, - 'src/fixtures/AttachmentPreviewList.fixture.tsx': { module: fixture35 }, - 'src/fixtures/ActionSheet/SendPostRetrySheet.fixture.tsx': { module: fixture36 }, - 'src/fixtures/ActionSheet/ProfileSheet.fixture.tsx': { module: fixture37 }, - 'src/fixtures/ActionSheet/GroupPreviewSheet.fixture.tsx': { module: fixture38 }, - 'src/fixtures/ActionSheet/GroupJoinRequestSheet.fixture.tsx': { module: fixture39 }, - 'src/fixtures/ActionSheet/GenericActionSheet.fixture.tsx': { module: fixture40 }, - 'src/fixtures/ActionSheet/EditSectionNameSheet.fixture.tsx': { module: fixture41 }, - 'src/fixtures/ActionSheet/DeleteSheet.fixture.tsx': { module: fixture42 }, - 'src/fixtures/ActionSheet/CreateChannelSheet.fixture.tsx': { module: fixture43 }, - 'src/fixtures/ActionSheet/ChannelSortActionsSheet.fixture.tsx': { module: fixture44 }, - 'src/fixtures/ActionSheet/AttachmentSheet.fixture.tsx': { module: fixture45 }, - 'src/fixtures/ActionSheet/AddGalleryPostSheet.fixture.tsx': { module: fixture46 } + 'src/fixtures/GroupListItem.fixture.tsx': { module: fixture19 }, + 'src/fixtures/GroupList.fixture.tsx': { module: fixture20 }, + 'src/fixtures/GalleryPost.fixture.tsx': { module: fixture21 }, + 'src/fixtures/Form.fixture.tsx': { module: fixture22 }, + 'src/fixtures/ContactList.fixture.tsx': { module: fixture23 }, + 'src/fixtures/ChatMessage.fixture.tsx': { module: fixture24 }, + 'src/fixtures/ChannelSwitcherSheet.fixture.tsx': { module: fixture25 }, + 'src/fixtures/ChannelHeader.fixture.tsx': { module: fixture26 }, + 'src/fixtures/ChannelDivider.fixture.tsx': { module: fixture27 }, + 'src/fixtures/Channel.fixture.tsx': { module: fixture28 }, + 'src/fixtures/Button.fixture.tsx': { module: fixture29 }, + 'src/fixtures/BlockSectionList.fixture.tsx': { module: fixture30 }, + 'src/fixtures/Avatar.fixture.tsx': { module: fixture31 }, + 'src/fixtures/AudioEmbed.fixture.tsx': { module: fixture32 }, + 'src/fixtures/AttachmentPreviewList.fixture.tsx': { module: fixture33 }, + 'src/fixtures/DetailView/NotebookDetailView.fixture.tsx': { + module: fixture34, + }, + 'src/fixtures/DetailView/GalleryDetailView.fixture.tsx': { + module: fixture35, + }, + 'src/fixtures/DetailView/ChatDetailView.fixture.tsx': { module: fixture36 }, + 'src/fixtures/ActionSheet/SendPostRetrySheet.fixture.tsx': { + module: fixture37, + }, + 'src/fixtures/ActionSheet/ProfileSheet.fixture.tsx': { module: fixture38 }, + 'src/fixtures/ActionSheet/GroupPreviewSheet.fixture.tsx': { + module: fixture39, + }, + 'src/fixtures/ActionSheet/GroupJoinRequestSheet.fixture.tsx': { + module: fixture40, + }, + 'src/fixtures/ActionSheet/GenericActionSheet.fixture.tsx': { + module: fixture41, + }, + 'src/fixtures/ActionSheet/EditSectionNameSheet.fixture.tsx': { + module: fixture42, + }, + 'src/fixtures/ActionSheet/DeleteSheet.fixture.tsx': { module: fixture43 }, + 'src/fixtures/ActionSheet/CreateChannelSheet.fixture.tsx': { + module: fixture44, + }, + 'src/fixtures/ActionSheet/ChannelSortActionsSheet.fixture.tsx': { + module: fixture45, + }, + 'src/fixtures/ActionSheet/AttachmentSheet.fixture.tsx': { module: fixture46 }, + 'src/fixtures/ActionSheet/AddGalleryPostSheet.fixture.tsx': { + module: fixture47, + }, }; const decorators = { - 'src/fixtures/cosmos.decorator.tsx': { module: decorator0 } + 'src/fixtures/cosmos.decorator.tsx': { module: decorator0 }, }; export const moduleWrappers: UserModuleWrappers = { lazy: false, fixtures, - decorators + decorators, }; diff --git a/apps/tlon-mobile/eas.json b/apps/tlon-mobile/eas.json index 10f1fc73c6..568281dd4a 100644 --- a/apps/tlon-mobile/eas.json +++ b/apps/tlon-mobile/eas.json @@ -18,6 +18,7 @@ "NOTIFY_SERVICE": "tlon-preview-release" }, "android": { + "resourceClass": "large", "gradleCommand": ":app:bundlePreviewRelease" }, "ios": { diff --git a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj index 6891178fad..98a9fcb2a3 100644 --- a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj +++ b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj @@ -158,6 +158,8 @@ 70F99AAC2B2D338E00D77256 /* UrbitModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EAEAB42A57A99100FE96E4 /* UrbitModule.swift */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; C184662C2ABBDFF1008EA8C0 /* GoogleService-Info-io.tlon.groups.plist in Resources */ = {isa = PBXBuildFile; fileRef = C184662B2ABBDFF1008EA8C0 /* GoogleService-Info-io.tlon.groups.plist */; }; + C83014822C7BA74C00D9A5CA /* UIFont+SystemDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = C83014812C7BA74C00D9A5CA /* UIFont+SystemDesign.m */; }; + C83014832C7BA74C00D9A5CA /* UIFont+SystemDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = C83014812C7BA74C00D9A5CA /* UIFont+SystemDesign.m */; }; D0A57BA86BAF5327C2EECEE4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61ADD28CFDBC6EFBD36E3DA /* ExpoModulesProvider.swift */; }; E757508CA162BD3456643B33 /* Pods_Landscape.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79BDD5DCE15A385BB361AED1 /* Pods_Landscape.framework */; }; F901307AF87F28F061D5E649 /* Pods_Landscape_preview.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C87F249B7EFCDBC3E54B1447 /* Pods_Landscape_preview.framework */; }; @@ -226,6 +228,7 @@ 630DE0EB2C51AC840053603B /* UserDefaults+appGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+appGroup.swift"; sourceTree = ""; }; 630DE0F02C51AE870053603B /* Info-preview.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-preview.plist"; sourceTree = ""; }; 632793BD2C4AE4B500F942B1 /* Error+isAFTimeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+isAFTimeout.swift"; sourceTree = ""; }; + 636788082C90DC2600F5331E /* Notifications-preview.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notifications-preview.entitlements"; sourceTree = ""; }; 6374ACDF2C49DA9600E637C0 /* Notifications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Notifications.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6374ACE12C49DA9600E637C0 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 6374ACE32C49DA9600E637C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -271,6 +274,8 @@ B35AC6CF758CAFC9D112A69F /* Pods-Landscape.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Landscape.debug.xcconfig"; path = "Target Support Files/Pods-Landscape/Pods-Landscape.debug.xcconfig"; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; C184662B2ABBDFF1008EA8C0 /* GoogleService-Info-io.tlon.groups.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-io.tlon.groups.plist"; sourceTree = ""; }; + C83014812C7BA74C00D9A5CA /* UIFont+SystemDesign.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "UIFont+SystemDesign.m"; path = "Landscape/UIFont+SystemDesign.m"; sourceTree = ""; }; + C83014842C7BA7A800D9A5CA /* UIFont+SystemDesign.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+SystemDesign.h"; sourceTree = ""; }; C87F249B7EFCDBC3E54B1447 /* Pods_Landscape_preview.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Landscape_preview.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; EE042EA448A634B1DCCA2092 /* Pods-Landscape-preview.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Landscape-preview.debug.xcconfig"; path = "Target Support Files/Pods-Landscape-preview/Pods-Landscape-preview.debug.xcconfig"; sourceTree = ""; }; @@ -342,6 +347,8 @@ AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, 70EAEAB72A57AB8400FE96E4 /* Landscape-Bridging-Header.h */, 630DE0B82C51A1500053603B /* LandscapePreview-Bridging-Header.h */, + C83014812C7BA74C00D9A5CA /* UIFont+SystemDesign.m */, + C83014842C7BA7A800D9A5CA /* UIFont+SystemDesign.h */, 70EAEAB42A57A99100FE96E4 /* UrbitModule.swift */, 70EAEAB62A57AAF200FE96E4 /* UrbitModule.m */, 63E27E122C5AF5B8008ACB45 /* HTTPCookieStorage+forwardChanges.swift */, @@ -372,6 +379,7 @@ isa = PBXGroup; children = ( 6374ACEB2C49DAB600E637C0 /* Notifications.entitlements */, + 636788082C90DC2600F5331E /* Notifications-preview.entitlements */, 6374ACE12C49DA9600E637C0 /* NotificationService.swift */, 6374ACE32C49DA9600E637C0 /* Info.plist */, 630DE0F02C51AE870053603B /* Info-preview.plist */, @@ -1165,6 +1173,7 @@ 632793BE2C4AE4B500F942B1 /* Error+isAFTimeout.swift in Sources */, 7036E3542ACD0FC90020A9FB /* UserDefaultsStore.swift in Sources */, 70D3865A2A609BFC00AFB46E /* PocketNotificationsAPI.swift in Sources */, + C83014822C7BA74C00D9A5CA /* UIFont+SystemDesign.m in Sources */, 70D3865B2A609BFC00AFB46E /* PocketAPI.swift in Sources */, 70D386582A609BFC00AFB46E /* PushNotificationManager.swift in Sources */, 63E27E0B2C5AF26C008ACB45 /* Alamofire+sessionWithSharedCookieStorage.swift in Sources */, @@ -1275,6 +1284,7 @@ 70DBBFF62B7C60B50021EA96 /* SettingsStore.swift in Sources */, 70DBBFF72B7C60B50021EA96 /* UIColor+Extension.swift in Sources */, 70DBBFF82B7C60B50021EA96 /* PocketUserAPI.swift in Sources */, + C83014832C7BA74C00D9A5CA /* UIFont+SystemDesign.m in Sources */, 6374ACFE2C4ACD7500E637C0 /* Login.swift in Sources */, 70DBBFF92B7C60B50021EA96 /* ClubStore.swift in Sources */, 70DBBFFA2B7C60B50021EA96 /* PocketChatAPI.swift in Sources */, @@ -1376,10 +1386,12 @@ PRODUCT_NAME = Landscape; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; "SWIFT_OBJC_BRIDGING_HEADER[arch=*]" = "$(SRCROOT)/$(PROJECT_NAME)/$(SWIFT_MODULE_NAME)-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -1412,9 +1424,11 @@ PRODUCT_NAME = Landscape; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/$(PROJECT_NAME)/$(SWIFT_MODULE_NAME)-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -1430,7 +1444,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = Notifications/Notifications.entitlements; + CODE_SIGN_ENTITLEMENTS = "Notifications/Notifications-preview.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; @@ -1478,7 +1492,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = Notifications/Notifications.entitlements; + CODE_SIGN_ENTITLEMENTS = "Notifications/Notifications-preview.entitlements"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; @@ -1849,11 +1863,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -1946,11 +1956,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/apps/tlon-mobile/ios/Landscape/UIFont+SystemDesign.m b/apps/tlon-mobile/ios/Landscape/UIFont+SystemDesign.m new file mode 100644 index 0000000000..8f2a6462b0 --- /dev/null +++ b/apps/tlon-mobile/ios/Landscape/UIFont+SystemDesign.m @@ -0,0 +1,59 @@ +// UIFont+SystemDesign.m + +#import +#import "UIFont+SystemDesign.h" + +@implementation UIFont (SystemDesign) + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class class = object_getClass((id)self); + + SEL originalSelector = @selector(fontWithName:size:); + Method originalMethod = class_getClassMethod(class, originalSelector); + + SEL swizzledSelector = @selector(_fontWithName:size:); + Method swizzledMethod = class_getClassMethod(class, swizzledSelector); + + BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); + if (didAddMethod) { + class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); + } + else { + method_exchangeImplementations(originalMethod, swizzledMethod); + } + }); +} + +#pragma mark - Method Swizzling + ++ (UIFont *)_fontWithName:(NSString *)fontName size:(CGFloat)fontSize { + NSString* const systemRounded = @"System-Rounded"; + NSString* const systemSerif = @"System-Serif"; + NSString* const systemMonospaced = @"System-Monospaced"; + + NSArray* fonts = @[systemRounded, systemSerif, systemMonospaced]; + + if ([fonts containsObject:fontName]) { + if (@available(iOS 13.0, *)) { + NSDictionary* designs = @{systemRounded : UIFontDescriptorSystemDesignRounded, + systemSerif : UIFontDescriptorSystemDesignSerif, + systemMonospaced : UIFontDescriptorSystemDesignMonospaced + }; + + UIFontDescriptor *fontDescriptor ; + + fontDescriptor = [UIFont systemFontOfSize:fontSize].fontDescriptor; + fontDescriptor = [fontDescriptor fontDescriptorWithDesign: designs[fontName]]; + return [UIFont fontWithDescriptor:fontDescriptor size:fontSize]; + } + else { + return [UIFont systemFontOfSize:fontSize]; + } + } + + return [self _fontWithName:fontName size:fontSize]; +} + +@end diff --git a/apps/tlon-mobile/ios/Notifications/NotificationService.swift b/apps/tlon-mobile/ios/Notifications/NotificationService.swift index ce66d4a96b..f611b8c619 100644 --- a/apps/tlon-mobile/ios/Notifications/NotificationService.swift +++ b/apps/tlon-mobile/ios/Notifications/NotificationService.swift @@ -31,13 +31,29 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(mutatedContent) return - default: + case let .failedFetchContents(err): + packErrorOnNotification(err) + contentHandler(bestAttemptContent!) + return + + case .invalid: + fallthrough + + case .dismiss: contentHandler(bestAttemptContent!) return } } } + /** Appends an error onto the `bestAttemptContent` payload; does *not* attempt to complete the notification request. */ + func packErrorOnNotification(_ error: Error) { + guard let bestAttemptContent else { return } + var errorList = (bestAttemptContent.userInfo["notificationServiceExtensionErrors"] as? [String]) ?? [String]() + errorList.append(error.localizedDescription) + bestAttemptContent.userInfo["notificationServiceExtensionErrors"] = errorList + } + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. diff --git a/apps/tlon-mobile/ios/Notifications/Notifications-preview.entitlements b/apps/tlon-mobile/ios/Notifications/Notifications-preview.entitlements new file mode 100644 index 0000000000..5e9e91c5d8 --- /dev/null +++ b/apps/tlon-mobile/ios/Notifications/Notifications-preview.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.io.tlon.groups.preview + + + diff --git a/apps/tlon-mobile/ios/Notifications/Notifications.entitlements b/apps/tlon-mobile/ios/Notifications/Notifications.entitlements index 5e9e91c5d8..72816185d8 100644 --- a/apps/tlon-mobile/ios/Notifications/Notifications.entitlements +++ b/apps/tlon-mobile/ios/Notifications/Notifications.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.io.tlon.groups.preview + group.io.tlon.groups diff --git a/apps/tlon-mobile/ios/Podfile.lock b/apps/tlon-mobile/ios/Podfile.lock index 1a7e46abb3..8207fd9ee5 100644 --- a/apps/tlon-mobile/ios/Podfile.lock +++ b/apps/tlon-mobile/ios/Podfile.lock @@ -1386,7 +1386,7 @@ PODS: - sqlite3 (3.42.0): - sqlite3/common (= 3.42.0) - sqlite3/common (3.42.0) - - tentap (0.5.13): + - tentap (0.5.11): - glog - RCT-Folly (= 2022.05.16.00) - React-Core @@ -1872,9 +1872,9 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: af09429398d99d524cae2fe00f6f0f6e491ed102 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 sqlite3: f163dbbb7aa3339ad8fc622782c2d9d7b72f7e9c - tentap: fc7669734b4dea745d6e56f57f5c7dc4fc2977bc + tentap: f86059c95e32751ffdab4c513494b477b760c609 UMAppLoader: 5df85360d65cabaef544be5424ac64672e648482 - Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 + Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 PODFILE CHECKSUM: 0cb7a78e5777e69c86c1bf4bb5135fd660376dbe diff --git a/apps/tlon-mobile/ios/UIFont+SystemDesign.h b/apps/tlon-mobile/ios/UIFont+SystemDesign.h new file mode 100644 index 0000000000..46221f4f83 --- /dev/null +++ b/apps/tlon-mobile/ios/UIFont+SystemDesign.h @@ -0,0 +1,16 @@ +// +// UIFont+SystemDesign.h +// Landscape +// +// Created by Daniel Brewster on 8/25/24. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIFont () + +@end + +NS_ASSUME_NONNULL_END diff --git a/apps/tlon-mobile/metro.config.js b/apps/tlon-mobile/metro.config.js index 3f519ab7e3..69bab1a6cf 100644 --- a/apps/tlon-mobile/metro.config.js +++ b/apps/tlon-mobile/metro.config.js @@ -2,6 +2,7 @@ const { getDefaultConfig } = require('expo/metro-config'); const { mergeConfig } = require('@react-native/metro-config'); const path = require('path'); +const fs = require('fs'); const connect = require('connect'); const { spawn } = require('child_process'); @@ -28,15 +29,64 @@ module.exports = mergeConfig(config, { }, server: { enhanceMiddleware: (metroMiddleware) => { - return connect() - .use(metroMiddleware) - .use('/open-sqlite', (req, res) => { - const dbPath = new URL('http://localhost' + req.url).searchParams.get( - 'path' - ); - openDrizzleStudio(dbPath); - res.end('ok'); - }); + return ( + connect() + .use(metroMiddleware) + .use('/open-sqlite', (req, res) => { + const dbPath = new URL( + 'http://localhost' + req.url + ).searchParams.get('path'); + openDrizzleStudio(dbPath); + res.end('ok'); + }) + /** + * Dumps SQLite database from simulator to Metro host. + * - databaseSourcePath: path to the SQLite database in the simulator. + * - outputPath: path for output file, relative to workspace root + */ + .use('/dump-sqlite', (req, res) => { + const params = new URL('http://localhost' + req.url).searchParams; + const dbPath = params.get('databaseSourcePath'); + const outputPath = params.get('outputPath'); + const dest = path.resolve(workspaceRoot, outputPath); + try { + fs.copyFileSync(dbPath, dest); + console.log('/dump-sqlite', 'Copied', dbPath, 'to', dest); + res.end('ok'); + } catch (err) { + console.error('/dump-sqlite', err); + res.statusCode = 500; + res.end(err.message); + } + }) + /** + * Replaces SQLite on simulator with a database from Metro host. + * - sourcePath: path of database file on Metro host + * - targetPath: path of database on simulator that will be overwritten + * with contents of file at `sourcePath` + */ + .use('/restore-sqlite', (req, res) => { + const params = new URL('http://localhost' + req.url).searchParams; + const sourcePath = params.get('sourcePath'); + const targetPath = params.get('targetPath'); + + try { + fs.copyFileSync(sourcePath, targetPath); + console.log( + '/restore-sqlite', + 'Copied', + sourcePath, + 'to', + targetPath + ); + res.end('ok'); + } catch (err) { + console.error('/restore-sqlite', err); + res.statusCode = 500; + res.end(err.message); + } + }) + ); }, }, resolver: { diff --git a/apps/tlon-mobile/package.json b/apps/tlon-mobile/package.json index 713d6c0853..4803052e9a 100644 --- a/apps/tlon-mobile/package.json +++ b/apps/tlon-mobile/package.json @@ -109,12 +109,14 @@ "react-native-svg": "^15.0.0", "react-native-url-polyfill": "^2.0.0", "react-native-webview": "13.6.4", + "seedrandom": "^3.0.5", "tailwind-rn": "^4.2.0", "text-encoding": "^0.7.0", "web-streams-polyfill": "^3.3.3", "zustand": "^3.7.2" }, "devDependencies": { + "@faker-js/faker": "^8.4.1", "@jest/globals": "^29.7.0", "@react-native/metro-config": "^0.73.5", "@tamagui/babel-plugin": "1.101.3", @@ -122,6 +124,7 @@ "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/react": "^18.2.55", "@types/tmp": "^0.2.6", + "@types/seedrandom": "^3.0.5", "babel-plugin-inline-import": "^3.0.0", "babel-preset-expo": "^10.0.0", "better-sqlite3": "~9.4.3", diff --git a/apps/tlon-mobile/src/App.main.tsx b/apps/tlon-mobile/src/App.main.tsx index 184db22243..d814ffb80f 100644 --- a/apps/tlon-mobile/src/App.main.tsx +++ b/apps/tlon-mobile/src/App.main.tsx @@ -20,32 +20,30 @@ import { Provider as TamaguiProvider } from '@tloncorp/app/provider'; import { FeatureFlagConnectedInstrumentationProvider } from '@tloncorp/app/utils/perf'; import { posthogAsync } from '@tloncorp/app/utils/posthog'; import { QueryClientProvider, queryClient } from '@tloncorp/shared/dist/api'; -import { PortalProvider } from '@tloncorp/ui'; +import { LoadingSpinner, PortalProvider, Text, View } from '@tloncorp/ui'; import { usePreloadedEmojis } from '@tloncorp/ui'; import { PostHogProvider } from 'posthog-react-native'; import type { PropsWithChildren } from 'react'; import { useEffect, useState } from 'react'; -import { StatusBar, Text, View } from 'react-native'; +import { StatusBar } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { useTailwind } from 'tailwind-rn'; import AuthenticatedApp from './components/AuthenticatedApp'; -import { LoadingSpinner } from './components/LoadingSpinner'; -import { CheckVerifyScreen } from './screens/CheckVerifyScreen'; -import { EULAScreen } from './screens/EULAScreen'; -import { JoinWaitListScreen } from './screens/JoinWaitListScreen'; -import { RequestPhoneVerifyScreen } from './screens/RequestPhoneVerifyScreen'; -import { ReserveShipScreen } from './screens/ReserveShipScreen'; -import { ResetPasswordScreen } from './screens/ResetPasswordScreen'; -import { SetNicknameScreen } from './screens/SetNicknameScreen'; -import { SetNotificationsScreen } from './screens/SetNotificationsScreen'; -import { SetTelemetryScreen } from './screens/SetTelemetryScreen'; -import { ShipLoginScreen } from './screens/ShipLoginScreen'; -import { SignUpEmailScreen } from './screens/SignUpEmailScreen'; -import { SignUpPasswordScreen } from './screens/SignUpPasswordScreen'; -import { TlonLoginScreen } from './screens/TlonLoginScreen'; -import { WelcomeScreen } from './screens/WelcomeScreen'; +import { CheckVerifyScreen } from './screens/Onboarding/CheckVerifyScreen'; +import { EULAScreen } from './screens/Onboarding/EULAScreen'; +import { InventoryCheckScreen } from './screens/Onboarding/InventoryCheckScreen'; +import { JoinWaitListScreen } from './screens/Onboarding/JoinWaitListScreen'; +import { RequestPhoneVerifyScreen } from './screens/Onboarding/RequestPhoneVerifyScreen'; +import { ReserveShipScreen } from './screens/Onboarding/ReserveShipScreen'; +import { ResetPasswordScreen } from './screens/Onboarding/ResetPasswordScreen'; +import { SetNicknameScreen } from './screens/Onboarding/SetNicknameScreen'; +import { SetTelemetryScreen } from './screens/Onboarding/SetTelemetryScreen'; +import { ShipLoginScreen } from './screens/Onboarding/ShipLoginScreen'; +import { SignUpEmailScreen } from './screens/Onboarding/SignUpEmailScreen'; +import { SignUpPasswordScreen } from './screens/Onboarding/SignUpPasswordScreen'; +import { TlonLoginScreen } from './screens/Onboarding/TlonLoginScreen'; +import { WelcomeScreen } from './screens/Onboarding/WelcomeScreen'; import type { OnboardingStackParamList } from './types'; type Props = { @@ -61,11 +59,17 @@ const App = ({ channelId: notificationChannelId, }: Props) => { const isDarkMode = useIsDarkMode(); - const tailwind = useTailwind(); + const { isLoading, isAuthenticated } = useShip(); const [connected, setConnected] = useState(true); const { lure, priorityToken } = useBranch(); const screenOptions = useScreenOptions(); + + const onboardingScreenOptions = { + ...screenOptions, + headerShown: false, + }; + usePreloadedEmojis(); useEffect(() => { @@ -81,10 +85,10 @@ const App = ({ }, []); return ( - + {connected ? ( isLoading ? ( - + ) : isAuthenticated ? ( @@ -97,45 +101,34 @@ const App = ({ ) : ( - + + - ) ) : ( - - + + You are offline. Please connect to the internet and try again. @@ -209,7 +190,6 @@ function MigrationCheck({ children }: PropsWithChildren) { export default function ConnectedApp(props: Props) { const isDarkMode = useIsDarkMode(); - const tailwind = useTailwind(); const navigationContainerRef = useNavigationContainerRef(); return ( @@ -229,7 +209,7 @@ export default function ConnectedApp(props: Props) { enable: process.env.NODE_ENV !== 'test', }} > - + diff --git a/apps/tlon-mobile/src/__uitests__/HeaderButton.test.tsx b/apps/tlon-mobile/src/__uitests__/HeaderButton.test.tsx deleted file mode 100644 index d4b72c59a4..0000000000 --- a/apps/tlon-mobile/src/__uitests__/HeaderButton.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; -import { render, screen, userEvent } from '@testing-library/react-native'; - -import { HeaderButton } from '../components/HeaderButton'; - -describe('HeaderButton', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('renders', async () => { - const user = userEvent.setup(); - - const onPress = jest.fn(); - render(); - await user.press(await screen.findByText('Test')); - expect(onPress).toHaveBeenCalled(); - }); -}); diff --git a/apps/tlon-mobile/src/components/AddGroupSheet.tsx b/apps/tlon-mobile/src/components/AddGroupSheet.tsx index 2903b73ca4..1c6ba22e95 100644 --- a/apps/tlon-mobile/src/components/AddGroupSheet.tsx +++ b/apps/tlon-mobile/src/components/AddGroupSheet.tsx @@ -7,6 +7,8 @@ import { NativeStackScreenProps, createNativeStackNavigator, } from '@react-navigation/native-stack'; +import { BRANCH_DOMAIN, BRANCH_KEY } from '@tloncorp/app/constants'; +import { useCurrentUserId } from '@tloncorp/app/hooks/useCurrentUser'; import { QueryClientProvider, queryClient } from '@tloncorp/shared/dist/api'; import * as db from '@tloncorp/shared/dist/db'; import { @@ -16,6 +18,7 @@ import { CreateGroupWidget, GroupPreviewPane, Icon, + InviteUsersWidget, Sheet, View, ViewUserGroupsWidget, @@ -55,6 +58,10 @@ type StackParamList = { Home: undefined; Root: undefined; CreateGroup: undefined; + InviteUsers: { + group: db.Group; + onInviteComplete: () => void; + }; ViewContactGroups: { contactId: string; }; @@ -138,6 +145,10 @@ export default function AddGroupSheet({ name="CreateGroup" component={CreateGroupScreen} /> + { - dismiss(); - onCreatedGroup(args); + props.navigation.push('InviteUsers', { + group: args.group, + onInviteComplete: () => { + dismiss(); + onCreatedGroup(args); + }, + }); }, - [dismiss, onCreatedGroup] + [dismiss, onCreatedGroup, props.navigation] ); return ( @@ -277,3 +293,26 @@ function CreateGroupScreen( ); } + +function InviteUsersScreen( + props: NativeStackScreenProps +) { + const { contacts } = useContext(ActionContext); + const currentUserId = useCurrentUserId(); + + return ( + + + + + + ); +} diff --git a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx index 20835c2867..9aca190538 100644 --- a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx +++ b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx @@ -6,6 +6,7 @@ import { useNavigationLogging } from '@tloncorp/app/hooks/useNavigationLogger'; import { useNetworkLogger } from '@tloncorp/app/hooks/useNetworkLogger'; import { configureClient } from '@tloncorp/app/lib/api'; import { PlatformState } from '@tloncorp/app/lib/platformHelpers'; +import { AppDataProvider } from '@tloncorp/app/provider/AppDataProvider'; import { initializeCrashReporter, sync } from '@tloncorp/shared'; import * as store from '@tloncorp/shared/dist/store'; import { ZStack } from '@tloncorp/ui'; @@ -15,6 +16,7 @@ import { useDeepLinkListener } from '../hooks/useDeepLinkListener'; import useNotificationListener, { type Props as NotificationListenerProps, } from '../hooks/useNotificationListener'; +import { refreshHostingAuth } from '../lib/refreshHostingAuth'; import { RootStack } from '../navigation/RootStack'; export interface AuthenticatedAppProps { @@ -37,6 +39,7 @@ function AuthenticatedApp({ shipName: ship ?? '', shipUrl: shipUrl ?? '', onReset: () => sync.syncStart(), + verbose: __DEV__, onChannelReset: () => sync.handleDiscontinuity(), }); @@ -57,6 +60,8 @@ function AuthenticatedApp({ sync.syncUnreads({ priority: sync.SyncPriority.High }); sync.syncPinnedItems({ priority: sync.SyncPriority.High }); } + + refreshHostingAuth(); }); return ( @@ -69,5 +74,9 @@ function AuthenticatedApp({ export default function ConnectedAuthenticatedApp( props: AuthenticatedAppProps ) { - return ; + return ( + + + + ); } diff --git a/apps/tlon-mobile/src/components/HeaderButton.tsx b/apps/tlon-mobile/src/components/HeaderButton.tsx deleted file mode 100644 index e6c0520c5d..0000000000 --- a/apps/tlon-mobile/src/components/HeaderButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import cn from 'classnames'; -import { Pressable, Text } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -type Props = { - title: string; - isSubmit?: boolean; - onPress: () => void; -}; - -export const HeaderButton = ({ title, isSubmit = false, onPress }: Props) => { - const tailwind = useTailwind(); - return ( - - {({ pressed }) => ( - - {title} - - )} - - ); -}; diff --git a/apps/tlon-mobile/src/components/LoadingSpinner.tsx b/apps/tlon-mobile/src/components/LoadingSpinner.tsx deleted file mode 100644 index c213c32750..0000000000 --- a/apps/tlon-mobile/src/components/LoadingSpinner.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { IS_ANDROID, IS_IOS } from '@tloncorp/app/constants'; -import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; -import classNames from 'classnames'; -import React, { useEffect, useRef } from 'react'; -import { ActivityIndicator, Animated, Easing, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -type Props = { - height?: number; - durationMs?: number; -}; - -export const LoadingSpinner = ({ height = 24, durationMs = 1000 }: Props) => { - const rotationDegree = useRef(new Animated.Value(0)).current; - const tailwind = useTailwind(); - const isDarkMode = useIsDarkMode(); - - useEffect(() => { - if (IS_IOS) { - Animated.loop( - Animated.timing(rotationDegree, { - toValue: 360, - duration: durationMs, - easing: Easing.linear, - useNativeDriver: true, - }) - ).start(); - } - }, [durationMs, rotationDegree]); - - // Android styling is off, use default spinner which looks better - if (IS_ANDROID) { - return ( - = 24 ? 'large' : 'small'} - color={isDarkMode ? '#fff' : '#000'} - /> - ); - } - - return ( - - - - - ); -}; diff --git a/apps/tlon-mobile/src/components/TlonButton.tsx b/apps/tlon-mobile/src/components/TlonButton.tsx deleted file mode 100644 index dda6959355..0000000000 --- a/apps/tlon-mobile/src/components/TlonButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import cn from 'classnames'; -import type { PressableProps } from 'react-native'; -import { Pressable, Text } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -type Props = PressableProps & { - title: string; - variant?: 'primary' | 'secondary' | 'minimal'; - align?: 'left' | 'center' | 'right'; - roundedFull?: boolean; -}; - -export const TlonButton = ({ - title, - variant = 'primary', - align = 'left', - roundedFull = false, - ...pressableProps -}: Props) => { - const tailwind = useTailwind(); - return ( - [ - tailwind( - cn( - 'py-4 px-6', - variant === 'primary' && 'bg-tlon-black-80', - variant === 'secondary' && - 'border border-tlon-black-10 dark:border-tlon-black-80', - variant === 'minimal' && - pressed && - 'bg-tlon-black-10 dark:bg-tlon-black-90', - roundedFull ? 'rounded-full' : 'rounded-xl', - pressed ? 'opacity-90' : 'opacity-100' - ) - ), - ]} - > - - {title} - - - ); -}; diff --git a/apps/tlon-mobile/src/controllers/AppInfoScreenController.tsx b/apps/tlon-mobile/src/controllers/AppInfoScreenController.tsx new file mode 100644 index 0000000000..bca3f16253 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/AppInfoScreenController.tsx @@ -0,0 +1,20 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { AppInfoScreen } from '@tloncorp/app/features/settings/AppInfoScreen'; +import { useCallback } from 'react'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function AppInfoScreenController(props: Props) { + const onPressPreviewFeatures = useCallback(() => { + props.navigation.navigate('FeatureFlags'); + }, [props.navigation]); + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/AppSettingsScreenController.tsx b/apps/tlon-mobile/src/controllers/AppSettingsScreenController.tsx new file mode 100644 index 0000000000..4584323e61 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/AppSettingsScreenController.tsx @@ -0,0 +1,35 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { AppSettingsScreen } from '@tloncorp/app/features/settings/AppSettingsScreen'; +import { useCallback } from 'react'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function AppSettingsScreenController(props: Props) { + const onManageAccountPressed = useCallback(() => { + props.navigation.navigate('ManageAccount'); + }, [props.navigation]); + + const onAppInfoPressed = useCallback(() => { + props.navigation.navigate('AppInfo'); + }, [props.navigation]); + + const onPushNotifPressed = useCallback(() => { + props.navigation.navigate('PushNotificationSettings'); + }, [props.navigation]); + + const onBlockedUsersPressed = useCallback(() => { + props.navigation.navigate('BlockedUsers'); + }, [props.navigation]); + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/BlockedUsersScreenController.tsx b/apps/tlon-mobile/src/controllers/BlockedUsersScreenController.tsx new file mode 100644 index 0000000000..e2ae9a1dd2 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/BlockedUsersScreenController.tsx @@ -0,0 +1,10 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BlockedUsersScreen } from '@tloncorp/app/features/settings/BlockedUsersScreen'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function BlockedUsersScreenController(props: Props) { + return props.navigation.goBack()} />; +} diff --git a/apps/tlon-mobile/src/controllers/ChannelMembersScreenController.tsx b/apps/tlon-mobile/src/controllers/ChannelMembersScreenController.tsx new file mode 100644 index 0000000000..98033a8aba --- /dev/null +++ b/apps/tlon-mobile/src/controllers/ChannelMembersScreenController.tsx @@ -0,0 +1,22 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { ChannelMembersScreen } from '@tloncorp/app/features/channels/ChannelMembersScreen'; + +import { RootStackParamList } from '../types'; + +type ChannelMembersScreenProps = NativeStackScreenProps< + RootStackParamList, + 'ChannelMembers' +>; + +export function ChannelMembersScreenController( + props: ChannelMembersScreenProps +) { + const { channelId } = props.route.params; + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/ChannelMetaScreenController.tsx b/apps/tlon-mobile/src/controllers/ChannelMetaScreenController.tsx new file mode 100644 index 0000000000..fa6acfc91c --- /dev/null +++ b/apps/tlon-mobile/src/controllers/ChannelMetaScreenController.tsx @@ -0,0 +1,20 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { ChannelMetaScreen } from '@tloncorp/app/features/channels/ChannelMetaScreen'; + +import { RootStackParamList } from '../types'; + +type ChannelMetaScreenProps = NativeStackScreenProps< + RootStackParamList, + 'ChannelMeta' +>; + +export function ChannelMetaScreenController(props: ChannelMetaScreenProps) { + const { channelId } = props.route.params; + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/EditChannelScreenController.tsx b/apps/tlon-mobile/src/controllers/EditChannelScreenController.tsx new file mode 100644 index 0000000000..139998e48f --- /dev/null +++ b/apps/tlon-mobile/src/controllers/EditChannelScreenController.tsx @@ -0,0 +1,21 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { EditChannelScreen } from '@tloncorp/app/features/groups/EditChannelScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type ManageChannelsScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'EditChannel' +>; + +export function EditChannelScreenController(props: ManageChannelsScreenProps) { + const { groupId, channelId } = props.route.params; + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/EditProfileScreenController.tsx b/apps/tlon-mobile/src/controllers/EditProfileScreenController.tsx new file mode 100644 index 0000000000..9cf43ecfbb --- /dev/null +++ b/apps/tlon-mobile/src/controllers/EditProfileScreenController.tsx @@ -0,0 +1,15 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { EditProfileScreen } from '@tloncorp/app/features/settings/EditProfileScreen'; +import { useCallback } from 'react'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function EditProfileScreenController(props: Props) { + const onGoBack = useCallback(() => { + props.navigation.goBack(); + }, [props.navigation]); + + return ; +} diff --git a/apps/tlon-mobile/src/controllers/FeatureFlagScreenController.tsx b/apps/tlon-mobile/src/controllers/FeatureFlagScreenController.tsx new file mode 100644 index 0000000000..a6e07421f2 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/FeatureFlagScreenController.tsx @@ -0,0 +1,15 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { FeatureFlagScreen } from '@tloncorp/app/features/settings/FeatureFlagScreen'; + +import type { RootStackParamList } from '../types'; + +type FeatureFlagScreenProps = NativeStackScreenProps< + RootStackParamList, + 'FeatureFlags' +>; + +export function FeatureFlagScreenController({ + navigation, +}: FeatureFlagScreenProps) { + return navigation.goBack()} />; +} diff --git a/apps/tlon-mobile/src/controllers/GroupMembersScreenController.tsx b/apps/tlon-mobile/src/controllers/GroupMembersScreenController.tsx new file mode 100644 index 0000000000..2f257bdd9c --- /dev/null +++ b/apps/tlon-mobile/src/controllers/GroupMembersScreenController.tsx @@ -0,0 +1,20 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GroupMembersScreen } from '@tloncorp/app/features/groups/GroupMembersScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type GroupMembersScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'GroupMembers' +>; + +export function GroupMembersScreenController(props: GroupMembersScreenProps) { + const { groupId } = props.route.params; + + return ( + props.navigation.goBack()} + groupId={groupId} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/GroupMetaScreenController.tsx b/apps/tlon-mobile/src/controllers/GroupMetaScreenController.tsx new file mode 100644 index 0000000000..000f03b3ab --- /dev/null +++ b/apps/tlon-mobile/src/controllers/GroupMetaScreenController.tsx @@ -0,0 +1,20 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GroupMetaScreen } from '@tloncorp/app/features/groups/GroupMetaScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type GroupMetaScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'GroupMeta' +>; + +export function GroupMetaScreenController(props: GroupMetaScreenProps) { + const { groupId } = props.route.params; + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/GroupPrivacyScreenController.tsx b/apps/tlon-mobile/src/controllers/GroupPrivacyScreenController.tsx new file mode 100644 index 0000000000..375fb27f4b --- /dev/null +++ b/apps/tlon-mobile/src/controllers/GroupPrivacyScreenController.tsx @@ -0,0 +1,22 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GroupPrivacyScreen } from '@tloncorp/app/features/groups/GroupPrivacyScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type InvitesAndPrivacyScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'Privacy' +>; + +export function GroupPrivacyScreenController( + props: InvitesAndPrivacyScreenProps +) { + const { groupId } = props.route.params; + + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/GroupRolesScreenController.tsx b/apps/tlon-mobile/src/controllers/GroupRolesScreenController.tsx new file mode 100644 index 0000000000..6616b4f642 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/GroupRolesScreenController.tsx @@ -0,0 +1,18 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GroupRolesScreen } from '@tloncorp/app/features/groups/GroupRolesScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type GroupRolesScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'GroupRoles' +>; + +export function GroupRolesScreenController(props: GroupRolesScreenProps) { + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/ImageViewerScreenController.tsx b/apps/tlon-mobile/src/controllers/ImageViewerScreenController.tsx new file mode 100644 index 0000000000..0735772989 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/ImageViewerScreenController.tsx @@ -0,0 +1,24 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import ImageViewerScreen from '@tloncorp/app/features/top/ImageViewerScreen'; + +import type { RootStackParamList } from '../types'; + +type ImagePreviewScreenControllerProps = NativeStackScreenProps< + RootStackParamList, + 'ImageViewer' +>; + +export default function ImageViewerScreenController( + props: ImagePreviewScreenControllerProps +) { + const postParam = props.route.params.post; + const uriParam = props.route.params.uri; + + return ( + props.navigation.pop()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/ManageAccountScreenController.tsx b/apps/tlon-mobile/src/controllers/ManageAccountScreenController.tsx new file mode 100644 index 0000000000..c2318007bd --- /dev/null +++ b/apps/tlon-mobile/src/controllers/ManageAccountScreenController.tsx @@ -0,0 +1,18 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { ManageAccountScreen } from '@tloncorp/app/features/settings/ManageAccountScreen'; + +import { useWebView } from '../hooks/useWebView'; +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function ManageAccountScreenController(props: Props) { + const webview = useWebView(); + + return ( + props.navigation.goBack()} + webview={webview} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/ManageChannelsScreenController.tsx b/apps/tlon-mobile/src/controllers/ManageChannelsScreenController.tsx new file mode 100644 index 0000000000..aaefaa5428 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/ManageChannelsScreenController.tsx @@ -0,0 +1,25 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { ManageChannelsScreen } from '@tloncorp/app/features/groups/ManageChannelsScreen'; + +import { GroupSettingsStackParamList } from '../types'; + +type ManageChannelsScreenProps = NativeStackScreenProps< + GroupSettingsStackParamList, + 'ManageChannels' +>; + +export function ManageChannelsScreenController( + props: ManageChannelsScreenProps +) { + const { groupId } = props.route.params; + + return ( + props.navigation.goBack()} + onGoToEditChannel={(channelId) => { + props.navigation.navigate('EditChannel', { groupId, channelId }); + }} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/PostScreenController.tsx b/apps/tlon-mobile/src/controllers/PostScreenController.tsx index d278a660fc..22da2750b0 100644 --- a/apps/tlon-mobile/src/controllers/PostScreenController.tsx +++ b/apps/tlon-mobile/src/controllers/PostScreenController.tsx @@ -1,5 +1,6 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import PostScreen from '@tloncorp/app/features/top/PostScreen'; +import { useCallback } from 'react'; import type { RootStackParamList } from '../types'; @@ -9,8 +10,16 @@ type PostScreenControllerProps = NativeStackScreenProps< >; export function PostScreenController(props: PostScreenControllerProps) { + const handleGoToUserProfile = useCallback( + (userId: string) => { + props.navigation.push('UserProfile', { userId }); + }, + [props.navigation] + ); + return ( diff --git a/apps/tlon-mobile/src/controllers/ProfileScreenController.tsx b/apps/tlon-mobile/src/controllers/ProfileScreenController.tsx index 15c98d0f92..551780927b 100644 --- a/apps/tlon-mobile/src/controllers/ProfileScreenController.tsx +++ b/apps/tlon-mobile/src/controllers/ProfileScreenController.tsx @@ -1,11 +1,15 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import ProfileScreen from '@tloncorp/app/features/settings/ProfileScreen'; +import { useHandleLogout } from '@tloncorp/app/hooks/useHandleLogout'; +import { resetDb } from '@tloncorp/app/lib/nativeDb'; import { RootStackParamList } from '../types'; type Props = NativeStackScreenProps; export function ProfileScreenController(props: Props) { + const handleLogout = useHandleLogout({ resetDb }); + return ( props.navigation.navigate('AppSettings')} @@ -17,6 +21,7 @@ export function ProfileScreenController(props: Props) { navigateToHome={() => props.navigation.navigate('ChatList')} navigateToNotifications={() => props.navigation.navigate('Activity')} navigateToSettings={() => props.navigation.navigate('Profile')} + handleLogout={handleLogout} /> ); } diff --git a/apps/tlon-mobile/src/controllers/PushNotificationSettingsScreenController.tsx b/apps/tlon-mobile/src/controllers/PushNotificationSettingsScreenController.tsx new file mode 100644 index 0000000000..8414e22762 --- /dev/null +++ b/apps/tlon-mobile/src/controllers/PushNotificationSettingsScreenController.tsx @@ -0,0 +1,14 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { PushNotificationSettingsScreen } from '@tloncorp/app/features/settings/PushNotificationSettingsScreen'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function PushNotificationSettingsScreenController(props: Props) { + return ( + props.navigation.goBack()} + /> + ); +} diff --git a/apps/tlon-mobile/src/controllers/UserBugReportScreenController.tsx b/apps/tlon-mobile/src/controllers/UserBugReportScreenController.tsx new file mode 100644 index 0000000000..1afbc2dcad --- /dev/null +++ b/apps/tlon-mobile/src/controllers/UserBugReportScreenController.tsx @@ -0,0 +1,15 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { UserBugReportScreen } from '@tloncorp/app/features/settings/UserBugReportScreen'; +import { useCallback } from 'react'; + +import { RootStackParamList } from '../types'; + +type Props = NativeStackScreenProps; + +export function UserBugReportScreenController(props: Props) { + const onGoBack = useCallback(() => { + props.navigation.goBack(); + }, [props.navigation]); + + return ; +} diff --git a/apps/tlon-mobile/src/screens/UserProfileScreen.tsx b/apps/tlon-mobile/src/controllers/UserProfileScreenController.tsx similarity index 50% rename from apps/tlon-mobile/src/screens/UserProfileScreen.tsx rename to apps/tlon-mobile/src/controllers/UserProfileScreenController.tsx index 60ac2172b5..158de5cf61 100644 --- a/apps/tlon-mobile/src/screens/UserProfileScreen.tsx +++ b/apps/tlon-mobile/src/controllers/UserProfileScreenController.tsx @@ -1,23 +1,14 @@ import { CommonActions } from '@react-navigation/native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useCurrentUserId } from '@tloncorp/app/hooks/useCurrentUser'; +import { UserProfileScreen } from '@tloncorp/app/features/top/UserProfileScreen'; import * as store from '@tloncorp/shared/dist/store'; -import { - AppDataContextProvider, - NavigationProvider, - UserProfileScreenView, - View, -} from '@tloncorp/ui'; import { useCallback } from 'react'; import { RootStackParamList } from '../types'; type Props = NativeStackScreenProps; -export default function UserProfileScreen(props: Props) { - const currentUserId = useCurrentUserId(); - const { data: contacts } = store.useContacts(); - +export default function UserProfileScreenController(props: Props) { const handleGoToDm = useCallback( async (participants: string[]) => { const dmChannel = await store.upsertDmChannel({ @@ -37,18 +28,10 @@ export default function UserProfileScreen(props: Props) { ); return ( - - - - props.navigation.goBack()} - /> - - - + props.navigation.goBack()} + onPressGoToDm={handleGoToDm} + /> ); } diff --git a/apps/tlon-mobile/src/fixtures/AttachmentPreviewList.fixture.tsx b/apps/tlon-mobile/src/fixtures/AttachmentPreviewList.fixture.tsx index 9be48eaf79..c56ebf10ae 100644 --- a/apps/tlon-mobile/src/fixtures/AttachmentPreviewList.fixture.tsx +++ b/apps/tlon-mobile/src/fixtures/AttachmentPreviewList.fixture.tsx @@ -1,31 +1,55 @@ import { Attachment, AttachmentProvider } from '@tloncorp/ui'; +import { AppDataContextProvider, RequestsProvider } from '@tloncorp/ui/src'; import { AttachmentPreviewList } from '@tloncorp/ui/src/components/MessageInput/AttachmentPreviewList'; -import React from 'react'; import { FixtureWrapper } from './FixtureWrapper'; -import { createFakePosts } from './fakeData'; +import { + exampleContacts, + referencedChatPost, + referencedGalleryPost, + referencedNotebookPost, + useApp, + useChannel, + useGroup, + usePost, + usePostReference, +} from './contentHelpers'; -const posts = createFakePosts(100); - -const attachment: Attachment = { - type: 'reference', - path: '/1/chan/~nibset-napwyn/intros/msg/~solfer-magfed-3mct56', - reference: { +const attachments: Attachment[] = [ + referencedChatPost, + referencedGalleryPost, + referencedNotebookPost, +].map((p) => { + return { type: 'reference', - referenceType: 'channel', - channelId: posts[0].channelId, - postId: posts[0].id, - }, -}; + path: '', + reference: { + type: 'reference', + referenceType: 'channel', + channelId: p.channelId, + postId: p.id, + }, + } as const; +}); export default ( - {}} - canUpload={true} - > - - + + + {}} + canUpload={true} + > + + + + ); diff --git a/apps/tlon-mobile/src/fixtures/Channel.fixture.tsx b/apps/tlon-mobile/src/fixtures/Channel.fixture.tsx index 1ceb4fb75a..300a596862 100644 --- a/apps/tlon-mobile/src/fixtures/Channel.fixture.tsx +++ b/apps/tlon-mobile/src/fixtures/Channel.fixture.tsx @@ -6,12 +6,19 @@ import { } from '@tloncorp/shared/dist'; import type { Upload } from '@tloncorp/shared/dist/api'; import type * as db from '@tloncorp/shared/dist/db'; -import { Channel, ChannelSwitcherSheet } from '@tloncorp/ui'; +import { + AppDataContextProvider, + Channel, + ChannelSwitcherSheet, +} from '@tloncorp/ui'; +import { range } from 'lodash'; import type { ComponentProps, PropsWithChildren } from 'react'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, SafeAreaView, View } from 'react-native'; import { FixtureWrapper } from './FixtureWrapper'; import { + createFakePost, createFakePosts, group, initialContacts, @@ -66,9 +73,11 @@ const ChannelFixtureWrapper = ({ theme, }: PropsWithChildren<{ theme?: 'light' | 'dark' }>) => { return ( - - {children} - + + + {children} + + ); }; @@ -76,8 +85,6 @@ const baseProps: ComponentProps = { headerMode: 'default', posts: posts, channel: tlonLocalIntros, - currentUserId: '~zod', - contacts: initialContacts, negotiationMatch: true, isLoadingPosts: false, group: group, @@ -110,18 +117,26 @@ export const ChannelFixture = (props: { theme?: 'light' | 'dark'; negotiationMatch?: boolean; headerMode?: 'default' | 'next'; + passedProps?: ( + baseProps: ComponentProps + ) => Partial>; }) => { const switcher = useChannelSwitcher(tlonLocalIntros); + const channelProps = useMemo( + () => ({ + ...baseProps, + headerModel: props.headerMode, + channel: switcher.activeChannel, + negotiationMatch: props.negotiationMatch ?? true, + goToChannels: () => switcher.open(), + }), + [props.headerMode, props.negotiationMatch, switcher] + ); + return ( - switcher.open()} - /> + ); @@ -235,11 +250,217 @@ function SwitcherFixture({ switcher.setActiveChannel(channel); switcher.close(); }} - contacts={initialContacts} /> ); } +function useSimulatedPostsQuery({ + getPostAt = () => createFakePost(), +}: Partial<{ + getPostAt: (index: number) => db.Post; +}> = {}) { + const postIndex = useRef(0); + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const loadMore = useCallback( + async ({ + limit = 10, + simulateLoadMs = 200, + insertionPoint = 'end', + getPostAtOverride, + }: Partial<{ + limit: number; + simulateLoadMs: number; + insertionPoint: 'start' | 'end'; + getPostAtOverride?: typeof getPostAt; + }>) => { + if (isLoading) { + return; + } + setIsLoading(true); + const page = range(postIndex.current, postIndex.current + limit).map( + (i) => (getPostAtOverride ?? getPostAt)(i) + ); + postIndex.current = postIndex.current + limit; + await new Promise((resolve) => setTimeout(resolve, simulateLoadMs)); + setPosts((prev) => + insertionPoint === 'start' ? [...page, ...prev] : [...prev, ...page] + ); + setIsLoading(false); + }, + [isLoading, getPostAt, postIndex] + ); + + return { + posts, + loadMore, + isLoading, + }; +} + +function FixtureToolbar({ + children, +}: { + children: (opts: { + doBusyWork: (fn: () => Promise) => Promise; + }) => React.ReactNode; +}) { + const [isBusy, setIsBusy] = useState(false); + const doBusyWork = useCallback(async (fn: () => Promise) => { + setIsBusy(true); + try { + await fn(); + } finally { + setIsBusy(false); + } + }, []); + + return ( + + + {children({ doBusyWork })} + + + ); +} + +function ChannelWithControlledPostLoading() { + const anchorPost = useMemo(() => createFakePost(), []); + const { posts, loadMore, isLoading } = useSimulatedPostsQuery({ + getPostAt: (index) => { + // Insert anchor post near start, but enough to warrant scroll + if (index === 8) { + return anchorPost; + } + return createFakePost(); + }, + }); + + return ( + <> + ({ + posts, + isLoading, + initialChannelUnread: createTestChannelUnread({ + channel: baseProps.channel, + post: anchorPost, + }), + hasNewerPosts: true, + })} + /> + + {({ doBusyWork }) => ( + <> + + ); }; diff --git a/apps/tlon-mobile/src/screens/Onboarding/EULAScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/EULAScreen.tsx new file mode 100644 index 0000000000..576a5fdf82 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/EULAScreen.tsx @@ -0,0 +1,545 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { GenericHeader, SizableText, View, YStack } from '@tloncorp/ui'; +import { ScrollView } from 'react-native'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +export const EULAScreen = ({ navigation }: Props) => { + return ( + + navigation.goBack()} + /> + + + + End-User License Agreement (“Agreement”) + + + Our EULA was last updated on August 29, 2024 + + + + Please read this End-User License Agreement carefully before + checking the “I have read and agree to the End User License + Agreement” checkbox, downloading or using Tlon. + + + Interpretation and Definitions + + Interpretation + + + The words of which the initial letter is capitalized have meanings + defined under the following conditions. The following definitions + shall have the same meaning regardless of whether they appear in + singular or in plural. + + + Definitions + + + For the purposes of this End-User License Agreement: + + + + “Agreement” means this End-User License Agreement that + forms the entire agreement between You and the Company regarding the + use of the Application. + + + + “Application” means the software program provided by the + Company downloaded by You through an Application Store’s + account to a Device, named Tlon + + + + “Application Store” means the digital distribution + service operated and developed by Apple Inc. (Apple App Store) or + Google Inc. (Google Play Store) by which the Application has been + downloaded to your Device. + + + + “Company” (referred to as either “the + Company”, “We”, “Us” or + “Our” in this Agreement) refers to Tlon Corporation, + 2325 3rd St, San Francisco, CA 94107 + + + “Content” refers to content such as text, images, or + other information that can be posted, uploaded, linked to or + otherwise made available by You, regardless of the form of that + content. + + + “Country” refers to: United States + + + “Device” means any device that can access the + Application such as a computer, a cellphone or a digital tablet. + + + “Family Sharing / Family Group” permits You to share + applications downloaded through the Application Store with other + family members by allowing them to view and download each + others’ eligible Applications to their associated Devices. + + + “Third-Party Services” means any services or content + (including data, information, applications and other products + services) provided by a third-party that may be displayed, included + or made available by the Application. + + + “You” means the individual accessing or using the + Application or the company, or other legal entity on behalf of which + such individual is accessing or using the Application, as + applicable. + + + Acknowledgment + + + By clicking the “I Agree” button, downloading or using + the Application, You are agreeing to be bound by the terms and + conditions of this Agreement. If You do not agree to the terms of + this Agreement, do not click on the “I Agree” button, do + not download or do not use the Application. + + + + This Agreement is a legal document between You and the Company and + it governs your use of the Application made available to You by the + Company. + + + + This Agreement is between You and the Company only and not with the + Application Store. Therefore, the Company is solely responsible for + the Application and its content. Although the Application Store is + not a party to this Agreement, it has the right to enforce it + against You as a third party beneficiary relating to your use of the + Application. + + + + Since the Application can be accessed and used by other users via, + for example, Family Sharing / Family Group or volume purchasing, the + use of the Application by those users is expressly subject to this + Agreement. + + + + The Application is licensed, not sold, to You by the Company for use + strictly in accordance with the terms of this Agreement. + + + License + + Scope of License + + + The Company grants You a revocable, non-exclusive, non-transferable, + limited license to download, install and use the Application + strictly in accordance with the terms of this Agreement. + + + + You may only use the Application on a Device that You own or control + and as permitted by the Application Store’s terms and + conditions. + + + + The license that is granted to You by the Company is solely for your + personal, non-commercial purposes strictly in accordance with the + terms of this Agreement. + + + License Restrictions + + + You agree not to, and You will not permit others to: + + + + License, sell, rent, lease, assign, distribute, transmit, host, + outsource, disclose or otherwise commercially exploit the + Application or make the Application available to any third party. + + + Remove, alter or obscure any proprietary notice (including any + notice of copyright or trademark) of the Company or its affiliates, + partners, suppliers or the licensors of the Application. + + + + No Tolerance for Objectionable Content or Abusive Users + + + + Definition of Objectionable Content: For purposes of this EULA, + “objectionable content” shall mean any information, + data, text, images, videos, sounds, or other material that: + + + + a. Is defamatory, obscene, pornographic, vulgar, or offensive; + + + b. Promotes discrimination, bigotry, racism, hatred, harassment, or + harm against any individual or group; + + + c. Is violent or threatening, or promotes violence or actions that + are threatening to any other person; + + + d. Promotes illegal or harmful activities or substances. + + + + No Tolerance Policy: The User acknowledges and agrees that we have a + zero-tolerance policy regarding objectionable content and abusive + behavior. Any user found to be uploading, posting, sharing, or + disseminating objectionable content, or engaging in abusive behavior + towards other users or representatives of the Company, may face + immediate suspension or termination of their account, at the sole + discretion of the Company, without prior notice. + + + + Reporting: Users encountering objectionable content or abusive + behavior are encouraged to report such instances to the Company + immediately through the in-app reporting feature. The Company will + review all reports and take appropriate action, which may include + removal of content, warning the offending user, or escalating to law + enforcement if necessary. + + + + Indemnity: The User agrees to indemnify and hold harmless The + Company and its affiliates, officers, agents, and employees from any + claim or demand, including reasonable attorneys’ fees, made by + any third party due to or arising out of the User’s violation + of this section, or the User’s violation of any law or the + rights of a third party related to objectionable content or abusive + behavior. + + + + Revisions: The Company reserves the right to revise the criteria for + objectionable content or abusive behavior at any time and will + notify users of any changes to this policy. + + + Intellectual Property + + + The Application, including without limitation all copyrights, + patents, trademarks, trade secrets and other intellectual property + rights are, and shall remain, the sole and exclusive property of the + Company. + + + + The Company shall not be obligated to indemnify or defend You with + respect to any third party claim arising out of or relating to the + Application. To the extent the Company is required to provide + indemnification by applicable law, the Company, not the Application + Store, shall be solely responsible for the investigation, defense, + settlement and discharge of any claim that the Application or your + use of it infringes any third party intellectual property rights. + + + Modifications to the Application + + + The Company reserves the right to modify, suspend or discontinue, + temporarily or permanently, the Application or any service to which + it connects, with or without notice and without liability to You. + + + Updates to the Application + + + The Company may from time to time provide enhancements or + improvements to the features/functionality of the Application, which + may include patches, bug fixes, updates, upgrades and other + modifications. + + + + Updates may modify or delete certain features and/or functionalities + of the Application. You agree that the Company has no obligation to + (i) provide any Updates, or (ii) continue to provide or enable any + particular features and/or functionalities of the Application to + You. + + + + You further agree that all updates or any other modifications will + be (i) deemed to constitute an integral part of the Application, and + (ii) subject to the terms and conditions of this Agreement. + + + Maintenance and Support + + + The Company does not provide any maintenance or support for the + download and use of the Application. To the extent that any + maintenance or support is required by applicable law, the Company, + not the Application Store, shall be obligated to furnish any such + maintenance or support. + + + Third-Party Services + + + The Application may display, include or make available third-party + content (including data, information, applications and other + products services) or provide links to third-party websites or + services. + + + + You acknowledge and agree that the Company shall not be responsible + for any Third-party Services, including their accuracy, + completeness, timeliness, validity, copyright compliance, legality, + decency, quality or any other aspect thereof. The Company does not + assume and shall not have any liability or responsibility to You or + any other person or entity for any Third-party Services. + + + + You must comply with applicable Third parties’ Terms of + agreement when using the Application. Third-party Services and links + thereto are provided solely as a convenience to You and You access + and use them entirely at your own risk and subject to such third + parties’ Terms and conditions. + + + Term and Termination + + + This Agreement shall remain in effect until terminated by You or the + Company. The Company may, in its sole discretion, at any time and + for any or no reason, suspend or terminate this Agreement with or + without prior notice. + + + + This Agreement will terminate immediately, without prior notice from + the Company, in the event that you fail to comply with any provision + of this Agreement. You may also terminate this Agreement by deleting + the Application and all copies thereof from your Device or from your + computer. + + + + Upon termination of this Agreement, You shall cease all use of the + Application and delete all copies of the Application from your + Device. + + + + Termination of this Agreement will not limit any of the + Company’s rights or remedies at law or in equity in case of + breach by You (during the term of this Agreement) of any of your + obligations under the present Agreement. + + + Indemnification + + + You agree to indemnify and hold the Company and its parents, + subsidiaries, affiliates, officers, employees, agents, partners and + licensors (if any) harmless from any claim or demand, including + reasonable attorneys’ fees, due to or arising out of your: (a) + use of the Application; (b) violation of this Agreement or any law + or regulation; or (c) violation of any right of a third party. + + + No Warranties + + + The Application is provided to You “AS IS&lrdquo; and + “AS AVAILABLE” and with all faults and defects without + warranty of any kind. To the maximum extent permitted under + applicable law, the Company, on its own behalf and on behalf of its + affiliates and its and their respective licensors and service + providers, expressly disclaims all warranties, whether express, + implied, statutory or otherwise, with respect to the Application, + including all implied warranties of merchantability, fitness for a + particular purpose, title and non-infringement, and warranties that + may arise out of course of dealing, course of performance, usage or + trade practice. Without limitation to the foregoing, the Company + provides no warranty or undertaking, and makes no representation of + any kind that the Application will meet your requirements, achieve + any intended results, be compatible or work with any other software, + applications, systems or services, operate without interruption, + meet any performance or reliability standards or be error free or + that any errors or defects can or will be corrected. + + + + Without limiting the foregoing, neither the Company nor any of the + company’s provider makes any representation or warranty of any + kind, express or implied: (i) as to the operation or availability of + the Application, or the information, content, and materials or + products included thereon; (ii) that the Application will be + uninterrupted or error-free; (iii) as to the accuracy, reliability, + or currency of any information or content provided through the + Application; or (iv) that the Application, its servers, the content, + or e-mails sent from or on behalf of the Company are free of + viruses, scripts, trojan horses, worms, malware, timebombs or other + harmful components. + + + + Some jurisdictions do not allow the exclusion of certain types of + warranties or limitations on applicable statutory rights of a + consumer, so some or all of the above exclusions and limitations may + not apply. But in such a case the exclusions and limitations set + forth in this section shall be applied to the greatest extent + enforceable under applicable law. To the extent any warranty exists + under law that cannot be disclaimed, the Company, not the + Application Store, shall be solely responsible for such warranty. + + + Limitation of Liability + + + Notwithstanding any damages that You might incur, the entire + liability of the Company and any of its suppliers under any + provision of this Agreement and your exclusive remedy for all of the + foregoing shall be limited to the amount actually paid by You for + the Application or through the Application or 100 USD if You + haven’t purchased anything through the Application. + + + + To the maximum extent permitted by applicable law, in no event shall + the Company or its suppliers be liable for any special, incidental, + indirect, or consequential damages whatsoever (including, but not + limited to, damages for loss of profits, loss of data or other + information, for business interruption, for personal injury, loss of + privacy arising out of or in any way related to the use of or + inability to use the Application, third-party software and/or + third-party hardware used with the Application, or otherwise in + connection with any provision of this Agreement), even if the + Company or any supplier has been advised of the possibility of such + damages and even if the remedy fails of its essential purpose. + + + + Some states/jurisdictions do not allow the exclusion or limitation + of incidental or consequential damages, so the above limitation or + exclusion may not apply. + + + + You expressly understand and agree that the Application Store, its + subsidiaries and affiliates, and its licensors shall not be liable + to You under any theory of liability for any direct, indirect, + incidental, special consequential or exemplary damages that may be + incurred by You, including any loss of data, whether or not the + Application Store or its representatives have been advised of or + should have been aware of the possibility of any such losses + arising. + + + Severability and Waiver + + + If any provision of this Agreement is held to be unenforceable or + invalid, such provision will be changed and interpreted to + accomplish the objectives of such provision to the greatest extent + possible under applicable law and the remaining provisions will + continue in full force and effect. + + + Waiver + + + Except as provided herein, the failure to exercise a right or to + require performance of an obligation under this Agreement shall not + affect a party’s ability to exercise such right or require + such performance at any time thereafter nor shall the waiver of a + breach constitute a waiver of any subsequent breach. + + + Product Claims + + + The Company does not make any warranties concerning the Application. + To the extent You have any claim arising from or relating to your + use of the Application, the Company, not the Application Store, is + responsible for addressing any such claims, which may include, but + not limited to: (i) any product liability claims; (ii) any claim + that the Application fails to conform to any applicable legal or + regulatory requirement; and (iii) any claim arising under consumer + protection, or similar legislation. + + + United States Legal Compliance + + + You represent and warrant that (i) You are not located in a country + that is subject to the United States government embargo, or that has + been designated by the United States government as a + “terrorist supporting” country, and (ii) You are not + listed on any United States government list of prohibited or + restricted parties. + + + Changes to this Agreement + + + The Company reserves the right, at its sole discretion, to modify or + replace this Agreement at any time. If a revision is material we + will provide at least 30 days’ notice prior to any new terms + taking effect. What constitutes a material change will be determined + at the sole discretion of the Company. + + + + By continuing to access or use the Application after any revisions + become effective, You agree to be bound by the revised terms. If You + do not agree to the new terms, You are no longer authorized to use + the Application. + + + Governing Law + + + This agreement shall be governed by and construed in accordance with + the laws of the State of California. Your use of the Application may + also be subject to other local, state, national, or international + laws. + + + Entire Agreement + + + The Agreement constitutes the entire agreement between You and the + Company regarding your use of the Application and supersedes all + prior and contemporaneous written or oral agreements between You and + the Company. + + + + If you have any questions about this Agreement, You can contact us + by sending us an email: into@tlon.io + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/InventoryCheckScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/InventoryCheckScreen.tsx new file mode 100644 index 0000000000..ae86e1672a --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/InventoryCheckScreen.tsx @@ -0,0 +1,142 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { DEFAULT_LURE, DEFAULT_PRIORITY_TOKEN } from '@tloncorp/app/constants'; +import { getHostingAvailability } from '@tloncorp/app/lib/hostingApi'; +import { trackError } from '@tloncorp/app/utils/posthog'; +import { + GenericHeader, + Icon, + PrimaryButton, + SizableText, + Text, + View, + XStack, + YStack, +} from '@tloncorp/ui'; +import { useState } from 'react'; +import { Image } from 'react-native'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +export const InventoryCheckScreen = ({ + navigation, + route: { + params: { + lure = DEFAULT_LURE, + priorityToken = DEFAULT_PRIORITY_TOKEN, + } = {}, + }, +}: Props) => { + const [isChecking, setIsChecking] = useState(false); + + const checkAvailability = async () => { + setIsChecking(true); + + try { + const { enabled } = await getHostingAvailability({ + lure, + priorityToken, + }); + if (enabled) { + navigation.navigate('SignUpEmail', { lure, priorityToken }); + } else { + navigation.navigate('JoinWaitList', { lure }); + } + } catch (err) { + console.error('Error checking hosting availability:', err); + if (err instanceof Error) { + trackError(err); + } + } + + setIsChecking(false); + }; + + return ( + + navigation.goBack()} + showSpinner={isChecking} + /> + + + + + + + + Pain-free P2P + + + + + + + + + + + Tlon operates on a peer-to-peer network. + + + Practically, this means your free account is a cloud computer. You + can run it yourself, or we can run it for you. + + + + + + + + + + + + Hassle-free messaging you can trust. + + We'll make sure your computer is online and up-to-date. Interested + in self-hosting? You can always change your mind. + + + + + + + + + + + + Sign up with your email address. + + We'll ask you a few questions to get you set up. + + + + + + Get Started + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/JoinWaitListScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/JoinWaitListScreen.tsx new file mode 100644 index 0000000000..3f3570a983 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/JoinWaitListScreen.tsx @@ -0,0 +1,106 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { EMAIL_REGEX } from '@tloncorp/app/constants'; +import { addUserToWaitlist } from '@tloncorp/app/lib/hostingApi'; +import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; +import { + Field, + GenericHeader, + PrimaryButton, + SizableText, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + email: string; +}; + +export const JoinWaitListScreen = ({ + navigation, + route: { + params: { lure }, + }, +}: Props) => { + const [remoteError, setRemoteError] = useState(); + const { + control, + handleSubmit, + formState: { errors, isValid }, + } = useForm(); + + const onSubmit = async (data: FormData) => { + try { + await addUserToWaitlist({ email: data.email, lure }); + trackOnboardingAction({ + actionName: 'Waitlist Joined', + }); + navigation.popToTop(); + } catch (err) { + console.error('Error joining waitlist:', err); + if (err instanceof Error) { + setRemoteError(err.message); + trackError(err); + } + } + }; + + return ( + + navigation.goBack()} + /> + + + We’ve given out all available accounts for today, but + we’ll have more soon. If you’d like, we can let you know + via email when they’re ready. + + ( + + + + )} + /> + {remoteError ? ( + + {remoteError} + + ) : null} + + {isValid && ( + + Notify Me + + )} + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/RequestPhoneVerifyScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/RequestPhoneVerifyScreen.tsx new file mode 100644 index 0000000000..8e2b960e44 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/RequestPhoneVerifyScreen.tsx @@ -0,0 +1,182 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; +import { requestPhoneVerify } from '@tloncorp/app/lib/hostingApi'; +import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; +import { + Button, + Field, + GenericHeader, + SizableText, + Text, + View, + YStack, + useTheme, +} from '@tloncorp/ui'; +import { useRef, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { CountryPicker } from 'react-native-country-codes-picker'; +import PhoneInput from 'react-native-phone-input'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps< + OnboardingStackParamList, + 'RequestPhoneVerify' +>; + +type FormData = { + phoneNumber: string; +}; + +export const RequestPhoneVerifyScreen = ({ + navigation, + route: { + params: { user }, + }, +}: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [remoteError, setRemoteError] = useState(); + + const [showCountryPicker, setShowCountryPicker] = useState(false); + const phoneInputRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + const theme = useTheme(); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm(); + + const onSubmit = handleSubmit(async ({ phoneNumber }) => { + setIsSubmitting(true); + try { + await requestPhoneVerify(user.id, phoneNumber); + trackOnboardingAction({ + actionName: 'Phone Verification Requested', + }); + navigation.navigate('CheckVerify', { + user: { + ...user, + phoneNumber, + }, + }); + } catch (err) { + console.error('Error verifiying phone number:', err); + if (err instanceof SyntaxError) { + setRemoteError('Invalid phone number, please contact support@tlon.io'); + trackError({ message: 'Invalid phone number' }); + } else if (err instanceof Error) { + setRemoteError(err.message); + trackError(err); + } + } + + setIsSubmitting(false); + }); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + + } + /> + + + Tlon is a platform for humans. We want to make sure you’re one + too. We’ll send you a verification code to the phone number you + enter below. + + {remoteError ? ( + + {remoteError} + + ) : null} + + ( + + setShowCountryPicker(true)} + onChangePhoneNumber={onChange} + style={{ + padding: 16, + borderWidth: 1, + borderColor: theme.border.val, + borderRadius: 8, + }} + textStyle={{ + color: theme.primaryText.val, + }} + initialCountry="us" + autoFormat={true} + /> + + )} + /> + + + + { + phoneInputRef.current?.selectCountry(item.code.toLowerCase()); + setShowCountryPicker(false); + }} + style={{ + modal: { + flex: 0.8, + backgroundColor: isDarkMode + ? theme.background.val + : theme.background.val, + }, + countryButtonStyles: { + backgroundColor: isDarkMode + ? theme.background.val + : theme.background.val, + }, + dialCode: { + color: theme.primaryText.val, + }, + countryName: { + color: theme.primaryText.val, + }, + textInput: { + backgroundColor: isDarkMode + ? theme.background.val + : theme.background.val, + color: theme.primaryText.val, + borderWidth: 1, + borderColor: theme.border.val, + padding: 16, + }, + line: { + backgroundColor: isDarkMode + ? theme.background.val + : theme.background.val, + }, + }} + onBackdropPress={() => setShowCountryPicker(false)} + /> + + ); +}; diff --git a/apps/tlon-mobile/src/screens/ReserveShipScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx similarity index 81% rename from apps/tlon-mobile/src/screens/ReserveShipScreen.tsx rename to apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx index 3c8afd160a..55be542102 100644 --- a/apps/tlon-mobile/src/screens/ReserveShipScreen.tsx +++ b/apps/tlon-mobile/src/screens/Onboarding/ReserveShipScreen.tsx @@ -18,13 +18,12 @@ import { updateNickname, updateTelemetrySetting, } from '@tloncorp/shared/dist/api'; +import { Spinner, Text, View, YStack } from '@tloncorp/ui'; import { preSig } from '@urbit/aura'; import { useCallback, useEffect, useState } from 'react'; -import { Alert, Text, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; +import { Alert } from 'react-native'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; +import type { OnboardingStackParamList } from '../../types'; type Props = NativeStackScreenProps; @@ -41,7 +40,6 @@ export const ReserveShipScreen = ({ state: 'loading', }); const { setShip } = useShip(); - const tailwind = useTailwind(); const { clearLure } = useBranch(); const startShip = useCallback( @@ -51,7 +49,7 @@ export const ReserveShipScreen = ({ if (!shipsWithStatus) { return setState({ state: 'error', - error: "Sorry, we couldn't find an active Urbit ID for your account.", + error: "Sorry, we couldn't find an active ship for your account.", }); } @@ -80,7 +78,7 @@ export const ReserveShipScreen = ({ if (!authCookie) { return setState({ state: 'error', - error: "Sorry, we couldn't log you into your Urbit ID.", + error: "Sorry, we couldn't log you into your ship.", }); } @@ -150,7 +148,7 @@ export const ReserveShipScreen = ({ return setState({ state: 'error', error: - 'Sorry, we could no longer find an Urbit ID for you. Please try again later.', + 'Sorry, we could no longer find a ship for you. Please try again later.', }); } @@ -176,7 +174,7 @@ export const ReserveShipScreen = ({ return setState({ state: 'error', error: - 'We were not able to reserve your Urbit ID. Please try again later.', + 'We were not able to reserve your ship. Please try again later.', }); } } @@ -192,7 +190,7 @@ export const ReserveShipScreen = ({ return setState({ state: 'error', - error: "Sorry, we couldn't boot your Urbit. Please try again later.", + error: "Sorry, we couldn't boot your ship. Please try again later.", }); } }, @@ -241,40 +239,24 @@ export const ReserveShipScreen = ({ ); return ( - + {state === 'loading' ? ( - <> - - - Getting your Urbit ready... + + + + Getting your ship ready... - + ) : state === 'booting' ? ( - <> - - - Booting your Urbit... + + + + Booting your ship... - + This may take a few minutes. - + ) : null} ); diff --git a/apps/tlon-mobile/src/screens/Onboarding/ResetPasswordScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/ResetPasswordScreen.tsx new file mode 100644 index 0000000000..d8e06b5cc1 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/ResetPasswordScreen.tsx @@ -0,0 +1,122 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { EMAIL_REGEX } from '@tloncorp/app/constants'; +import { requestPasswordReset } from '@tloncorp/app/lib/hostingApi'; +import { trackError } from '@tloncorp/app/utils/posthog'; +import { + Button, + Field, + GenericHeader, + KeyboardAvoidingView, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + email: string; +}; + +export const ResetPasswordScreen = ({ + navigation, + route: { + params: { email: emailParam }, + }, +}: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + control, + handleSubmit, + formState: { errors, isValid }, + setError, + trigger, + } = useForm({ + defaultValues: { + email: emailParam, + }, + }); + + const onSubmit = handleSubmit(async ({ email }) => { + setIsSubmitting(true); + + try { + await requestPasswordReset(email); + navigation.goBack(); + } catch (err) { + console.error('Error resetting password:', err); + if (err instanceof Error) { + setError('email', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + } + + setIsSubmitting(false); + }); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + isValid && ( + + ) + } + /> + + + + Enter the email associated with your Tlon account. We’ll send + you a link to reset your password. + + ( + + { + onBlur(); + trigger('email'); + }} + onChangeText={onChange} + onSubmitEditing={onSubmit} + value={value} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + enablesReturnKeyAutomatically + /> + + )} + /> + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/SetNicknameScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/SetNicknameScreen.tsx new file mode 100644 index 0000000000..45f3daa8c2 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/SetNicknameScreen.tsx @@ -0,0 +1,118 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { requestNotificationToken } from '@tloncorp/app/lib/notifications'; +import { trackError } from '@tloncorp/app/utils/posthog'; +import { + Button, + Field, + GenericHeader, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + nickname?: string; + notificationToken?: string | undefined; +}; + +export const SetNicknameScreen = ({ + navigation, + route: { + params: { user, signUpExtras }, + }, +}: Props) => { + const { + control, + handleSubmit, + formState: { errors }, + setValue, + } = useForm({ + defaultValues: { + nickname: signUpExtras.nickname, + notificationToken: undefined, + }, + }); + + const onSubmit = handleSubmit(({ nickname, notificationToken }) => { + navigation.navigate('SetTelemetry', { + user, + signUpExtras: { + ...signUpExtras, + nickname, + notificationToken, + }, + }); + }); + + // Disable back button + useEffect( + () => + navigation.addListener('beforeRemove', (e) => { + e.preventDefault(); + }), + [navigation] + ); + + useEffect(() => { + async function getNotificationToken() { + let token: string | undefined; + try { + token = await requestNotificationToken(); + setValue('notificationToken', token); + } catch (err) { + console.error('Error enabling notifications:', err); + if (err instanceof Error) { + trackError(err); + } + } + } + getNotificationToken(); + }, [setValue]); + + return ( + + + Next + + } + /> + + + Choose the nickname you want to use on the Tlon network. By default, + you will use a pseudonymous identifier. + + ( + + + + )} + /> + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/SetTelemetryScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/SetTelemetryScreen.tsx new file mode 100644 index 0000000000..13f3fa7c06 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/SetTelemetryScreen.tsx @@ -0,0 +1,75 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { + Button, + GenericHeader, + SizableText, + Text, + View, + XStack, + YStack, +} from '@tloncorp/ui'; +import { usePostHog } from 'posthog-react-native'; +import { useCallback, useState } from 'react'; +import { Switch } from 'react-native'; +import branch from 'react-native-branch'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +export const SetTelemetryScreen = ({ + navigation, + route: { + params: { user, signUpExtras }, + }, +}: Props) => { + const [isEnabled, setIsEnabled] = useState(true); + const postHog = usePostHog(); + + const handleNext = useCallback(() => { + if (!isEnabled) { + postHog?.optOut(); + branch.disableTracking(true); + } + + navigation.push('ReserveShip', { + user, + signUpExtras: { ...signUpExtras, telemetry: isEnabled }, + }); + }, [isEnabled, user, postHog, navigation, signUpExtras]); + + return ( + + + Next + + } + /> + + + We’re trying to make the app better and knowing how people use + the app really helps. + + + + Enable anonymous usage stats + + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx new file mode 100644 index 0000000000..3514f3059f --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/ShipLoginScreen.tsx @@ -0,0 +1,246 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { + ACCESS_CODE_REGEX, + DEFAULT_SHIP_LOGIN_ACCESS_CODE, + DEFAULT_SHIP_LOGIN_URL, +} from '@tloncorp/app/constants'; +import { useShip } from '@tloncorp/app/contexts/ship'; +import { isEulaAgreed, setEulaAgreed } from '@tloncorp/app/utils/eula'; +import { getShipFromCookie } from '@tloncorp/app/utils/ship'; +import { transformShipURL } from '@tloncorp/app/utils/string'; +import { getLandscapeAuthCookie } from '@tloncorp/shared/dist/api'; +import { + Button, + CheckboxInput, + Field, + GenericHeader, + Icon, + KeyboardAvoidingView, + ListItem, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useCallback, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + shipUrl: string; + accessCode: string; + eulaAgreed: boolean; +}; + +export const ShipLoginScreen = ({ navigation }: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [formattedShipUrl, setFormattedShipUrl] = useState< + string | undefined + >(); + const [remoteError, setRemoteError] = useState(); + const { + control, + setFocus, + handleSubmit, + formState: { errors, isValid }, + setValue, + trigger, + watch, + } = useForm({ + defaultValues: { + shipUrl: DEFAULT_SHIP_LOGIN_URL, + accessCode: DEFAULT_SHIP_LOGIN_ACCESS_CODE, + eulaAgreed: false, + }, + }); + const { setShip } = useShip(); + + const isValidUrl = useCallback((url: string) => { + const urlPattern = + /^(https?:\/\/)?(localhost|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|[\w.-]+\.([a-z]{2,}))(:\d+)?$/i; + const hostedPattern = /tlon\.network/i; + if (!urlPattern.test(url)) { + return false; + } + if (hostedPattern.test(url)) { + return 'hosted'; + } + return true; + }, []); + + const handleEula = () => { + navigation.navigate('EULA'); + }; + + const onSubmit = handleSubmit(async (params) => { + const { shipUrl: rawShipUrl, accessCode } = params; + setIsSubmitting(true); + + if (params.eulaAgreed) { + await setEulaAgreed(); + } + + const shipUrl = transformShipURL(rawShipUrl); + setFormattedShipUrl(shipUrl); + try { + const authCookie = await getLandscapeAuthCookie( + shipUrl, + accessCode.trim() + ); + if (authCookie) { + const shipId = getShipFromCookie(authCookie); + if (await isEulaAgreed()) { + setShip({ + ship: shipId, + shipUrl, + authCookie, + }); + } else { + setRemoteError( + 'Please agree to the End User License Agreement to continue.' + ); + } + } else { + setRemoteError( + "Sorry, we couldn't log in to your ship. It may be busy or offline." + ); + } + } catch (err) { + setRemoteError((err as Error).message); + } + + setIsSubmitting(false); + }); + + useEffect(() => { + if (errors.shipUrl && formattedShipUrl) { + setFocus('shipUrl'); + setValue('shipUrl', formattedShipUrl); + } + }, [errors.shipUrl, formattedShipUrl, setFocus, setValue]); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + isValid && + watch('eulaAgreed') && ( + + ) + } + /> + + + + Connect a self-hosted ship by entering its URL and access code. + + {remoteError ? ( + {remoteError} + ) : null} + + { + const urlValidation = isValidUrl(value); + if (urlValidation === false) { + return 'Please enter a valid URL.'; + } + if (urlValidation === 'hosted') { + return 'Please log in to your hosted Tlon ship using email and password.'; + } + return true; + }, + }} + render={({ field: { onChange, onBlur, value } }) => ( + + { + onBlur(); + trigger('shipUrl'); + }} + onChangeText={onChange} + onSubmitEditing={() => setFocus('accessCode')} + value={value} + keyboardType="url" + autoCapitalize="none" + autoCorrect={false} + returnKeyType="next" + enablesReturnKeyAutomatically + /> + + )} + /> + ( + + { + onBlur(); + trigger('accessCode'); + }} + onChangeText={onChange} + onSubmitEditing={onSubmit} + value={value} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + enablesReturnKeyAutomatically + /> + + )} + /> + ( + onChange(!value)} + /> + )} + /> + + + End User License Agreement + + + + + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/SignUpEmailScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/SignUpEmailScreen.tsx new file mode 100644 index 0000000000..8696ecb79f --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/SignUpEmailScreen.tsx @@ -0,0 +1,147 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { + DEFAULT_LURE, + DEFAULT_PRIORITY_TOKEN, + EMAIL_REGEX, +} from '@tloncorp/app/constants'; +import { getHostingAvailability } from '@tloncorp/app/lib/hostingApi'; +import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; +import { + Button, + GenericHeader, + KeyboardAvoidingView, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { Field } from '@tloncorp/ui'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + email: string; +}; + +export const SignUpEmailScreen = ({ + navigation, + route: { + params: { + lure = DEFAULT_LURE, + priorityToken = DEFAULT_PRIORITY_TOKEN, + } = {}, + }, +}: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + control, + handleSubmit, + formState: { errors, isValid }, + setError, + trigger, + } = useForm(); + + const onSubmit = handleSubmit(async ({ email }) => { + setIsSubmitting(true); + + try { + const { enabled, validEmail } = await getHostingAvailability({ + email, + lure, + priorityToken, + }); + + if (!enabled) { + navigation.navigate('JoinWaitList', { email, lure }); + } else if (!validEmail) { + setError('email', { + type: 'custom', + message: + 'This email address is ineligible for signup. Please contact support@tlon.io.', + }); + trackError({ message: 'Ineligible email address' }); + } else { + trackOnboardingAction({ + actionName: 'Email submitted', + email, + lure, + }); + navigation.navigate('SignUpPassword', { email, lure, priorityToken }); + } + } catch (err) { + console.error('Error getting hosting availability:', err); + if (err instanceof Error) { + setError('email', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + } + + setIsSubmitting(false); + }); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + isValid && ( + + ) + } + /> + + + + Enter your email address. You’ll use it to log in to Tlon and + we’ll email you the occasional service update. + + ( + + { + onBlur(); + trigger('email'); + }} + onChangeText={onChange} + onSubmitEditing={onSubmit} + value={value} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + enablesReturnKeyAutomatically + /> + + )} + /> + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/SignUpPasswordScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/SignUpPasswordScreen.tsx new file mode 100644 index 0000000000..604029dadc --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/SignUpPasswordScreen.tsx @@ -0,0 +1,281 @@ +import { + RecaptchaAction, + execute, + initClient, +} from '@google-cloud/recaptcha-enterprise-react-native'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RECAPTCHA_SITE_KEY } from '@tloncorp/app/constants'; +import { + logInHostingUser, + signUpHostingUser, +} from '@tloncorp/app/lib/hostingApi'; +import { isEulaAgreed, setEulaAgreed } from '@tloncorp/app/utils/eula'; +import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; +import { + Button, + CheckboxInput, + Field, + GenericHeader, + Icon, + KeyboardAvoidingView, + ListItem, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + password: string; + confirmPassword: string; + eulaAgreed: boolean; +}; + +export const SignUpPasswordScreen = ({ + navigation, + route: { + params: { email, lure, priorityToken }, + }, +}: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const { + control, + setFocus, + handleSubmit, + formState: { errors, isValid }, + setError, + trigger, + watch, + } = useForm({ + defaultValues: { + eulaAgreed: false, + }, + mode: 'onChange', + }); + + const handleEula = () => { + navigation.navigate('EULA'); + }; + + const onSubmit = handleSubmit(async (params) => { + const { password } = params; + setIsSubmitting(true); + + let recaptchaToken: string | undefined; + try { + recaptchaToken = await execute(RecaptchaAction.LOGIN(), 10_000); + } catch (err) { + console.error('Error executing reCAPTCHA:', err); + if (err instanceof Error) { + setError('password', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + } + + if (params.eulaAgreed) { + await setEulaAgreed(); + } + + if (!recaptchaToken) { + setIsSubmitting(false); + return; + } + + if (!isEulaAgreed()) { + setError('eulaAgreed', { + type: 'custom', + message: 'Please agree to the End User License Agreement to continue.', + }); + setIsSubmitting(false); + return; + } + + try { + await signUpHostingUser({ + email, + password, + recaptchaToken, + lure, + priorityToken, + }); + } catch (err) { + console.error('Error signing up user:', err); + if (err instanceof Error) { + setError('password', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + setIsSubmitting(false); + return; + } + + trackOnboardingAction({ + actionName: 'Account Created', + email, + lure, + }); + + try { + const user = await logInHostingUser({ + email, + password, + }); + if (user.requirePhoneNumberVerification) { + navigation.navigate('RequestPhoneVerify', { user }); + } else { + navigation.navigate('CheckVerify', { user }); + } + } catch (err) { + console.error('Error logging in user:', err); + if (err instanceof Error) { + setError('password', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + } + + setIsSubmitting(false); + }); + + // Initialize reCAPTCHA client + useEffect(() => { + (async () => { + try { + await initClient(RECAPTCHA_SITE_KEY, 10_000); + } catch (err) { + console.error('Error initializing reCAPTCHA client:', err); + if (err instanceof Error) { + setError('password', { + type: 'custom', + message: err.message, + }); + trackError(err); + } + } + })(); + }, []); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + isValid && + watch('eulaAgreed') && ( + + ) + } + /> + + + + Please set a strong password with at least 8 characters. + + ( + + { + onBlur(); + trigger('password'); + }} + onChangeText={onChange} + onSubmitEditing={() => setFocus('confirmPassword')} + value={value} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + returnKeyType="next" + enablesReturnKeyAutomatically + /> + + )} + /> + + value === password || 'Passwords must match.', + }} + render={({ field: { onChange, onBlur, value } }) => ( + + { + onBlur(); + trigger('confirmPassword'); + }} + onChangeText={onChange} + onSubmitEditing={onSubmit} + value={value} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + enablesReturnKeyAutomatically + /> + + )} + /> + ( + onChange(!value)} + /> + )} + /> + + + End User License Agreement + + + + + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/TlonLoginScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/TlonLoginScreen.tsx new file mode 100644 index 0000000000..1ebcc91ee9 --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/TlonLoginScreen.tsx @@ -0,0 +1,268 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { + DEFAULT_TLON_LOGIN_EMAIL, + DEFAULT_TLON_LOGIN_PASSWORD, + EMAIL_REGEX, +} from '@tloncorp/app/constants'; +import { useShip } from '@tloncorp/app/contexts/ship'; +import { + getShipAccessCode, + getShipsWithStatus, + logInHostingUser, + requestPhoneVerify, +} from '@tloncorp/app/lib/hostingApi'; +import { isEulaAgreed, setEulaAgreed } from '@tloncorp/app/utils/eula'; +import { getShipUrl } from '@tloncorp/app/utils/ship'; +import { getLandscapeAuthCookie } from '@tloncorp/shared/dist/api'; +import { + Button, + CheckboxInput, + Field, + GenericHeader, + Icon, + KeyboardAvoidingView, + ListItem, + SizableText, + Text, + TextInput, + View, + YStack, +} from '@tloncorp/ui'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +type FormData = { + email: string; + password: string; + eulaAgreed: boolean; +}; + +export const TlonLoginScreen = ({ navigation }: Props) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [remoteError, setRemoteError] = useState(); + const { + control, + setFocus, + handleSubmit, + formState: { errors, isValid }, + getValues, + trigger, + watch, + } = useForm({ + defaultValues: { + email: DEFAULT_TLON_LOGIN_EMAIL, + password: DEFAULT_TLON_LOGIN_PASSWORD, + eulaAgreed: false, + }, + mode: 'onChange', + }); + const { setShip } = useShip(); + + const handleForgotPassword = () => { + const { email } = getValues(); + navigation.navigate('ResetPassword', { email }); + }; + + const handleEula = () => { + navigation.navigate('EULA'); + }; + + const onSubmit = handleSubmit(async (params) => { + setIsSubmitting(true); + + if (params.eulaAgreed) { + await setEulaAgreed(); + } + + try { + const user = await logInHostingUser(params); + if (user.verified) { + if (user.ships.length > 0) { + const shipsWithStatus = await getShipsWithStatus(user.ships); + if (shipsWithStatus) { + const { status, shipId } = shipsWithStatus; + if (status === 'Ready') { + const { code: accessCode } = await getShipAccessCode(shipId); + const shipUrl = getShipUrl(shipId); + const authCookie = await getLandscapeAuthCookie( + shipUrl, + accessCode + ); + if (authCookie) { + if (await isEulaAgreed()) { + setShip( + { + ship: shipId, + shipUrl, + authCookie, + }, + authCookie + ); + } else { + setRemoteError( + 'Please agree to the End User License Agreement to continue.' + ); + } + } else { + setRemoteError( + "Sorry, we couldn't log you into your Tlon account." + ); + } + } else { + navigation.navigate('ReserveShip', { user }); + } + } else { + setRemoteError( + "Sorry, we couldn't find an active Tlon ship for your account." + ); + } + } else { + navigation.navigate('ReserveShip', { user }); + } + } else if (user.requirePhoneNumberVerification && !user.phoneNumber) { + navigation.navigate('RequestPhoneVerify', { user }); + } else { + if (user.requirePhoneNumberVerification) { + await requestPhoneVerify(user.id, user.phoneNumber ?? ''); + } + + navigation.navigate('CheckVerify', { + user, + }); + } + } catch (err: any) { + if ('name' in err && err.name === 'AbortError') { + setRemoteError( + 'Sorry, we could not connect to the server. Please try again later.' + ); + } else { + setRemoteError((err as Error).message); + } + } + + setIsSubmitting(false); + }); + + return ( + + navigation.goBack()} + showSpinner={isSubmitting} + rightContent={ + isValid && + watch('eulaAgreed') && ( + + ) + } + /> + + + + Enter the email and password associated with your Tlon account. + + {remoteError ? ( + {remoteError} + ) : null} + + ( + + { + onBlur(); + trigger('email'); + }} + onChangeText={onChange} + onSubmitEditing={() => setFocus('password')} + value={value} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + returnKeyType="next" + enablesReturnKeyAutomatically + /> + + )} + name="email" + /> + ( + + { + onBlur(); + trigger('password'); + }} + onChangeText={onChange} + onSubmitEditing={onSubmit} + value={value} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + returnKeyType="send" + enablesReturnKeyAutomatically + /> + + )} + name="password" + /> + ( + onChange(!value)} + /> + )} + /> + + + + End User License Agreement + + + + + + + + Forgot password? + + + + + + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx b/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx new file mode 100644 index 0000000000..30fe36252c --- /dev/null +++ b/apps/tlon-mobile/src/screens/Onboarding/WelcomeScreen.tsx @@ -0,0 +1,82 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; +import { + ActionSheet, + PrimaryButton, + SizableText, + View, + YStack, +} from '@tloncorp/ui'; +import { useState } from 'react'; +import { ImageBackground, Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import type { OnboardingStackParamList } from '../../types'; + +type Props = NativeStackScreenProps; + +export const WelcomeScreen = ({ navigation }: Props) => { + const isDarkMode = useIsDarkMode(); + const { bottom } = useSafeAreaInsets(); + const [open, setOpen] = useState(false); + + const bgSource = isDarkMode + ? require('../../../assets/images/welcome-bg-dark.png') + : require('../../../assets/images/welcome-bg.png'); + + return ( + + + + { + navigation.navigate('InventoryCheck'); + }} + > + Sign Up with Email + + setOpen(true)}> + + Have an account? Log in + + + + + + + + { + setOpen(false); + navigation.navigate('TlonLogin'); + }, + }} + /> + { + setOpen(false); + navigation.navigate('ShipLogin'); + }, + }} + /> + + + + + ); +}; diff --git a/apps/tlon-mobile/src/screens/RequestPhoneVerifyScreen.tsx b/apps/tlon-mobile/src/screens/RequestPhoneVerifyScreen.tsx deleted file mode 100644 index bd767fd2b5..0000000000 --- a/apps/tlon-mobile/src/screens/RequestPhoneVerifyScreen.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; -import { requestPhoneVerify } from '@tloncorp/app/lib/hostingApi'; -import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { Text, View } from 'react-native'; -import { CountryPicker } from 'react-native-country-codes-picker'; -import PhoneInput from 'react-native-phone-input'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps< - OnboardingStackParamList, - 'RequestPhoneVerify' ->; - -type FormData = { - phoneNumber: string; -}; - -export const RequestPhoneVerifyScreen = ({ - navigation, - route: { - params: { user }, - }, -}: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const [remoteError, setRemoteError] = useState(); - - const [showCountryPicker, setShowCountryPicker] = useState(false); - const phoneInputRef = useRef(null); - - const isDarkMode = useIsDarkMode(); - const tailwind = useTailwind(); - const { - control, - handleSubmit, - formState: { errors }, - } = useForm(); - - const onSubmit = handleSubmit(async ({ phoneNumber }) => { - setIsSubmitting(true); - try { - await requestPhoneVerify(user.id, phoneNumber); - trackOnboardingAction({ - actionName: 'Phone Verification Requested', - }); - navigation.navigate('CheckVerify', { - user: { - ...user, - phoneNumber, - }, - }); - } catch (err) { - console.error('Error verifiying phone number:', err); - if (err instanceof SyntaxError) { - // Handle HTML response with 500 error from hosting API - // generates exception when trying to JSON.parse - // Assumed here to be caused primarily by an already in use phone number - setRemoteError('Invalid phone number, please contact support@tlon.io'); - trackError({ message: 'Invalid phone number' }); - } else if (err instanceof Error) { - setRemoteError(err.message); - trackError(err); - } - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting]); - - return ( - - - Phone Number - - - Tlon is a platform for humans. We want to make sure you're one too. - - {remoteError ? ( - {remoteError} - ) : null} - - ( - setShowCountryPicker(true)} - onChangePhoneNumber={onChange} - style={tailwind( - 'flex-1 px-4 py-3 border border-tlon-black-20 rounded-lg' - )} - textStyle={tailwind( - 'font-medium text-tlon-black-80 dark:text-white' - )} - initialCountry="us" - autoFormat={true} - /> - )} - name="phoneNumber" - /> - - {errors.phoneNumber ? ( - - {errors.phoneNumber.message} - - ) : null} - - { - phoneInputRef.current?.selectCountry(item.code.toLowerCase()); - setShowCountryPicker(false); - }} - style={{ - modal: { - flex: 0.8, - backgroundColor: isDarkMode ? '#333' : '#fff', - }, - countryButtonStyles: { - backgroundColor: isDarkMode ? '#000' : '#e5e5e5', - }, - dialCode: { - color: isDarkMode ? '#fff' : '#000', - }, - countryName: { - color: isDarkMode ? '#fff' : '#000', - }, - textInput: { - backgroundColor: isDarkMode ? '#000' : '#fff', - color: isDarkMode ? '#fff' : '#000', - borderWidth: 1, - borderColor: '#ccc', - }, - line: { - backgroundColor: isDarkMode ? '#000' : '#fff', - }, - }} - onBackdropPress={() => setShowCountryPicker(false)} - /> - - ); -}; diff --git a/apps/tlon-mobile/src/screens/ResetPasswordScreen.tsx b/apps/tlon-mobile/src/screens/ResetPasswordScreen.tsx deleted file mode 100644 index ed137a6f15..0000000000 --- a/apps/tlon-mobile/src/screens/ResetPasswordScreen.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { requestPasswordReset } from '@tloncorp/app/lib/hostingApi'; -import { useLayoutEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { Text, TextInput, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - email: string; -}; - -export const ResetPasswordScreen = ({ - navigation, - route: { - params: { email: emailParam }, - }, -}: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const [remoteError, setRemoteError] = useState(); - const tailwind = useTailwind(); - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - defaultValues: { - email: emailParam, - }, - }); - - const onSubmit = handleSubmit(async ({ email }) => { - setIsSubmitting(true); - - try { - await requestPasswordReset(email); - navigation.goBack(); - } catch (err) { - return setRemoteError((err as Error).message); - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting]); - - return ( - - - Enter the email associated with your Tlon account. - - - - Email - - ( - - )} - name="email" - /> - {remoteError ?? errors.email ? ( - - {remoteError ?? errors.email?.message} - - ) : null} - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/SetNicknameScreen.tsx b/apps/tlon-mobile/src/screens/SetNicknameScreen.tsx deleted file mode 100644 index f56db7749c..0000000000 --- a/apps/tlon-mobile/src/screens/SetNicknameScreen.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useEffect, useLayoutEffect } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { Text, TextInput, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - nickname?: string; -}; - -export const SetNicknameScreen = ({ - navigation, - route: { - params: { user, signUpExtras }, - }, -}: Props) => { - const tailwind = useTailwind(); - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - defaultValues: { - nickname: signUpExtras.nickname, - }, - }); - - const onSubmit = handleSubmit(({ nickname }) => { - navigation.navigate('SetNotifications', { - user, - signUpExtras: { - ...signUpExtras, - nickname, - }, - }); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => , - }); - }, [navigation]); - - // Disable back button - useEffect( - () => - navigation.addListener('beforeRemove', (e) => { - e.preventDefault(); - }), - [navigation] - ); - - return ( - - - Name - - - Choose the name you want to use on the network. - - ( - - )} - name="nickname" - /> - {errors.nickname ? ( - - {errors.nickname.message} - - ) : null} - - ); -}; diff --git a/apps/tlon-mobile/src/screens/SetNotificationsScreen.tsx b/apps/tlon-mobile/src/screens/SetNotificationsScreen.tsx deleted file mode 100644 index 991f5ddc10..0000000000 --- a/apps/tlon-mobile/src/screens/SetNotificationsScreen.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { requestNotificationToken } from '@tloncorp/app/lib/notifications'; -import { trackError } from '@tloncorp/app/utils/posthog'; -import { Text, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { TlonButton } from '../components/TlonButton'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps< - OnboardingStackParamList, - 'SetNotifications' ->; - -export const SetNotificationsScreen = ({ - navigation, - route: { - params: { user, signUpExtras }, - }, -}: Props) => { - const tailwind = useTailwind(); - - const goToNext = (notificationToken?: string) => { - navigation.navigate('SetTelemetry', { - user, - signUpExtras: { - ...signUpExtras, - notificationToken, - }, - }); - }; - - return ( - - - Enable notifications so you're alerted when you receive new messages. - - - { - let token: string | undefined; - try { - token = await requestNotificationToken(); - } catch (err) { - console.error('Error enabling notifications:', err); - if (err instanceof Error) { - trackError(err); - } - } - - goToNext(token); - }} - align="center" - roundedFull - /> - - goToNext()} - roundedFull - /> - - ); -}; diff --git a/apps/tlon-mobile/src/screens/SetTelemetryScreen.tsx b/apps/tlon-mobile/src/screens/SetTelemetryScreen.tsx deleted file mode 100644 index 344f8b5f51..0000000000 --- a/apps/tlon-mobile/src/screens/SetTelemetryScreen.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { usePostHog } from 'posthog-react-native'; -import { useCallback, useLayoutEffect, useState } from 'react'; -import { Switch, Text, View } from 'react-native'; -import branch from 'react-native-branch'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -export const SetTelemetryScreen = ({ - navigation, - route: { - params: { user, signUpExtras }, - }, -}: Props) => { - const [isEnabled, setIsEnabled] = useState(true); - const tailwind = useTailwind(); - const postHog = usePostHog(); - - const handleNext = useCallback(() => { - if (!isEnabled) { - postHog?.optOut(); - branch.disableTracking(true); - } - - navigation.push('ReserveShip', { - user, - signUpExtras: { ...signUpExtras, telemetry: isEnabled }, - }); - }, [isEnabled, user, postHog]); - - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => , - }); - }, [navigation, handleNext]); - - return ( - - - We're trying to make this thing great, and knowing how people use the - app really helps. - - - - Enable Telemetry - - - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx b/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx deleted file mode 100644 index 0221c61b9f..0000000000 --- a/apps/tlon-mobile/src/screens/ShipLoginScreen.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { - ACCESS_CODE_REGEX, - DEFAULT_SHIP_LOGIN_ACCESS_CODE, - DEFAULT_SHIP_LOGIN_URL, -} from '@tloncorp/app/constants'; -import { useShip } from '@tloncorp/app/contexts/ship'; -import { isEulaAgreed } from '@tloncorp/app/utils/eula'; -import { getShipFromCookie } from '@tloncorp/app/utils/ship'; -import { transformShipURL } from '@tloncorp/app/utils/string'; -import { getLandscapeAuthCookie } from '@tloncorp/shared/dist/api'; -import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { Text, TextInput, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - shipUrl: string; - accessCode: string; -}; - -export const ShipLoginScreen = ({ navigation }: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const [formattedShipUrl, setFormattedShipUrl] = useState< - string | undefined - >(); - const [remoteError, setRemoteError] = useState(); - const tailwind = useTailwind(); - const { - control, - setFocus, - handleSubmit, - formState: { errors }, - setValue, - } = useForm({ - defaultValues: { - shipUrl: DEFAULT_SHIP_LOGIN_URL, - accessCode: DEFAULT_SHIP_LOGIN_ACCESS_CODE, - }, - }); - const { setShip } = useShip(); - - const isValidUrl = useCallback((url: string) => { - const urlPattern = - /^(https?:\/\/)?(localhost|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|[\w.-]+\.([a-z]{2,}))(:\d+)?$/i; - const hostedPattern = /tlon\.network/i; - if (!urlPattern.test(url)) { - return false; - } - if (hostedPattern.test(url)) { - return 'hosted'; - } - return true; - }, []); - - const onSubmit = handleSubmit(async ({ shipUrl: rawShipUrl, accessCode }) => { - setIsSubmitting(true); - - const shipUrl = transformShipURL(rawShipUrl); - setFormattedShipUrl(shipUrl); - try { - const authCookie = await getLandscapeAuthCookie( - shipUrl, - accessCode.trim() - ); - if (authCookie) { - const shipId = getShipFromCookie(authCookie); - if (await isEulaAgreed()) { - setShip({ - ship: shipId, - shipUrl, - authCookie, - }); - } else { - navigation.navigate('EULA', { shipId, shipUrl, authCookie }); - } - } else { - setRemoteError( - "Sorry, we couldn't log in to your ship. It may be busy or offline." - ); - } - } catch (err) { - setRemoteError((err as Error).message); - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerLeft: () => ( - navigation.goBack()} /> - ), - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting, tailwind, onSubmit]); - - useEffect(() => { - if (errors.shipUrl && formattedShipUrl) { - setFocus('shipUrl'); - setValue('shipUrl', formattedShipUrl); - } - }, [errors.shipUrl, formattedShipUrl, setFocus, setValue]); - - return ( - - - Connect a self-hosted ship by entering its URL and access code. - - {remoteError ? ( - {remoteError} - ) : null} - - - Ship URL - - { - const urlValidation = isValidUrl(value); - if (urlValidation === false) { - return 'Please enter a valid URL.'; - } - if (urlValidation === 'hosted') { - return 'Please log in to your hosted Tlon ship using email and password.'; - } - return true; - }, - }} - render={({ field: { onChange, onBlur, value } }) => ( - setFocus('accessCode')} - value={value} - keyboardType="url" - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - enablesReturnKeyAutomatically - /> - )} - name="shipUrl" - /> - {errors.shipUrl ? ( - - {errors.shipUrl.message} - - ) : null} - - - - Access Code - - ( - - )} - name="accessCode" - /> - {errors.accessCode ? ( - - {errors.accessCode.message} - - ) : null} - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/SignUpEmailScreen.tsx b/apps/tlon-mobile/src/screens/SignUpEmailScreen.tsx deleted file mode 100644 index 94b9072b63..0000000000 --- a/apps/tlon-mobile/src/screens/SignUpEmailScreen.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { - DEFAULT_LURE, - DEFAULT_PRIORITY_TOKEN, - EMAIL_REGEX, -} from '@tloncorp/app/constants'; -import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; -import { getHostingAvailability } from '@tloncorp/app/lib/hostingApi'; -import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; -import { useLayoutEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { - KeyboardAvoidingView, - ScrollView, - Text, - TextInput, - View, -} from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - email: string; -}; - -export const SignUpEmailScreen = ({ - navigation, - route: { - params: { - lure = DEFAULT_LURE, - priorityToken = DEFAULT_PRIORITY_TOKEN, - } = {}, - }, -}: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const tailwind = useTailwind(); - const isDarkMode = useIsDarkMode(); - - const { - control, - handleSubmit, - formState: { errors }, - setError, - } = useForm(); - - const onSubmit = handleSubmit(async ({ email }) => { - setIsSubmitting(true); - - try { - const { enabled, validEmail } = await getHostingAvailability({ - email, - lure, - priorityToken, - }); - - if (!enabled) { - navigation.navigate('JoinWaitList', { email, lure }); - } else if (!validEmail) { - setError('email', { - type: 'custom', - message: - 'This email address is ineligible for signup. Please contact support@tlon.io', - }); - trackError({ message: 'Ineligible email address' }); - } else { - trackOnboardingAction({ - actionName: 'Email submitted', - email, - lure, - }); - navigation.navigate('EULA', { email, lure, priorityToken }); - } - } catch (err) { - console.error('Error getting hosting availability:', err); - if (err instanceof Error) { - setError('email', { - type: 'custom', - message: err.message, - }); - trackError(err); - } - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerLeft: () => ( - navigation.goBack()} /> - ), - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting, isDarkMode]); - - return ( - - - - Hosting with Tlon makes running your Urbit easy and reliable. Sign up - for a free account and your very own Urbit ID. - - - - Email - - ( - - )} - name="email" - /> - {errors.email ? ( - - {errors.email.message} - - ) : null} - - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/SignUpPasswordScreen.tsx b/apps/tlon-mobile/src/screens/SignUpPasswordScreen.tsx deleted file mode 100644 index 3a924f9735..0000000000 --- a/apps/tlon-mobile/src/screens/SignUpPasswordScreen.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { - RecaptchaAction, - execute, - initClient, -} from '@google-cloud/recaptcha-enterprise-react-native'; -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { RECAPTCHA_SITE_KEY } from '@tloncorp/app/constants'; -import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; -import { - logInHostingUser, - signUpHostingUser, -} from '@tloncorp/app/lib/hostingApi'; -import { trackError, trackOnboardingAction } from '@tloncorp/app/utils/posthog'; -import { useEffect, useLayoutEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { - KeyboardAvoidingView, - ScrollView, - Text, - TextInput, - View, -} from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - password: string; - confirmPassword: string; -}; - -export const SignUpPasswordScreen = ({ - navigation, - route: { - params: { email, lure, priorityToken }, - }, -}: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const tailwind = useTailwind(); - const isDarkMode = useIsDarkMode(); - const { - control, - setFocus, - handleSubmit, - formState: { errors }, - setError, - } = useForm(); - - const onSubmit = handleSubmit(async ({ password }) => { - setIsSubmitting(true); - - let recaptchaToken: string | undefined; - try { - recaptchaToken = await execute(RecaptchaAction.LOGIN(), 10_000); - } catch (err) { - console.error('Error executing reCAPTCHA:', err); - if (err instanceof Error) { - setError('password', { - type: 'custom', - message: err.message, - }); - trackError(err); - } - } - - if (!recaptchaToken) { - setIsSubmitting(false); - return; - } - - try { - await signUpHostingUser({ - email, - password, - recaptchaToken, - lure, - priorityToken, - }); - } catch (err) { - console.error('Error signing up user:', err); - if (err instanceof Error) { - setError('password', { - type: 'custom', - message: err.message, - }); - trackError(err); - } - - setIsSubmitting(false); - return; - } - - trackOnboardingAction({ - actionName: 'Account Created', - email, - lure, - }); - - try { - const user = await logInHostingUser({ - email, - password, - }); - if (user.requirePhoneNumberVerification) { - navigation.navigate('RequestPhoneVerify', { user }); - } else { - navigation.navigate('CheckVerify', { user }); - } - } catch (err) { - console.error('Error logging in user:', err); - if (err instanceof Error) { - setError('password', { - type: 'custom', - message: err.message, - }); - trackError(err); - } - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting, isDarkMode]); - - // Initialize reCAPTCHA client - useEffect(() => { - (async () => { - try { - await initClient(RECAPTCHA_SITE_KEY, 10_000); - } catch (err) { - console.error('Error initializing reCAPTCHA client:', err); - if (err instanceof Error) { - setError('password', { - type: 'custom', - message: err.message, - }); - trackError(err); - } - } - })(); - }, []); - - return ( - - - - Password - - ( - setFocus('confirmPassword')} - value={value} - secureTextEntry - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - enablesReturnKeyAutomatically - /> - )} - name="password" - /> - - value === password || 'Passwords must match.', - }} - render={({ field: { ref, onChange, onBlur, value } }) => ( - - )} - name="confirmPassword" - /> - {errors.password || errors.confirmPassword ? ( - - {errors.password?.message ?? errors.confirmPassword?.message} - - ) : null} - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx b/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx deleted file mode 100644 index b92ad0049b..0000000000 --- a/apps/tlon-mobile/src/screens/TlonLoginScreen.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { - DEFAULT_TLON_LOGIN_EMAIL, - DEFAULT_TLON_LOGIN_PASSWORD, -} from '@tloncorp/app/constants'; -import { useShip } from '@tloncorp/app/contexts/ship'; -import { - getShipAccessCode, - getShipsWithStatus, - logInHostingUser, - requestPhoneVerify, -} from '@tloncorp/app/lib/hostingApi'; -import { isEulaAgreed } from '@tloncorp/app/utils/eula'; -import { getShipUrl } from '@tloncorp/app/utils/ship'; -import { getLandscapeAuthCookie } from '@tloncorp/shared/dist/api'; -import { useLayoutEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { Text, TextInput, View } from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { HeaderButton } from '../components/HeaderButton'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -type FormData = { - email: string; - password: string; -}; - -export const TlonLoginScreen = ({ navigation }: Props) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const [remoteError, setRemoteError] = useState(); - const tailwind = useTailwind(); - const { - control, - setFocus, - handleSubmit, - formState: { errors }, - getValues, - } = useForm({ - defaultValues: { - email: DEFAULT_TLON_LOGIN_EMAIL, - password: DEFAULT_TLON_LOGIN_PASSWORD, - }, - }); - const { setShip } = useShip(); - - const handleForgotPassword = () => { - const { email } = getValues(); - navigation.navigate('ResetPassword', { email }); - }; - - const onSubmit = handleSubmit(async (params) => { - setIsSubmitting(true); - - try { - const user = await logInHostingUser(params); - if (user.verified) { - if (user.ships.length > 0) { - const shipsWithStatus = await getShipsWithStatus(user.ships); - if (shipsWithStatus) { - const { status, shipId } = shipsWithStatus; - if (status === 'Ready') { - const { code: accessCode } = await getShipAccessCode(shipId); - const shipUrl = getShipUrl(shipId); - const authCookie = await getLandscapeAuthCookie( - shipUrl, - accessCode - ); - if (authCookie) { - if (await isEulaAgreed()) { - setShip( - { - ship: shipId, - shipUrl, - authCookie, - }, - authCookie - ); - } else { - navigation.navigate('EULA', { shipId, shipUrl, authCookie }); - } - } else { - setRemoteError( - "Sorry, we couldn't log you into your Urbit ID." - ); - } - } else { - navigation.navigate('ReserveShip', { user }); - } - } else { - setRemoteError( - "Sorry, we couldn't find an active Urbit ID for your account." - ); - } - } else { - navigation.navigate('ReserveShip', { user }); - } - } else if (user.requirePhoneNumberVerification && !user.phoneNumber) { - navigation.navigate('RequestPhoneVerify', { user }); - } else { - if (user.requirePhoneNumberVerification) { - await requestPhoneVerify(user.id, user.phoneNumber ?? ''); - } - - navigation.navigate('CheckVerify', { - user, - }); - } - } catch (err: any) { - if ('name' in err && err.name === 'AbortError') { - setRemoteError( - 'Sorry, we could not connect to the server. Please try again later.' - ); - } else { - setRemoteError((err as Error).message); - } - } - - setIsSubmitting(false); - }); - - useLayoutEffect(() => { - navigation.setOptions({ - headerLeft: () => ( - navigation.goBack()} /> - ), - headerRight: () => - isSubmitting ? ( - - - - ) : ( - - ), - }); - }, [navigation, isSubmitting]); - - return ( - - - Enter the email and password associated with your Tlon account. - - {remoteError ? ( - {remoteError} - ) : null} - - - Email - - ( - setFocus('password')} - value={value} - keyboardType="email-address" - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - enablesReturnKeyAutomatically - /> - )} - name="email" - /> - {errors.email ? ( - - {errors.email.message} - - ) : null} - - - - Password - - ( - - )} - name="password" - /> - {errors.password ? ( - - {errors.password.message} - - ) : null} - - - - Forgot password? - - - - ); -}; diff --git a/apps/tlon-mobile/src/screens/WelcomeScreen.tsx b/apps/tlon-mobile/src/screens/WelcomeScreen.tsx deleted file mode 100644 index 8a0f51d931..0000000000 --- a/apps/tlon-mobile/src/screens/WelcomeScreen.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import BottomSheet from '@gorhom/bottom-sheet'; -import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useIsDarkMode } from '@tloncorp/app/hooks/useIsDarkMode'; -import cn from 'classnames'; -import { useRef } from 'react'; -import { - Animated, - ImageBackground, - Pressable, - StyleSheet, - Text, - View, -} from 'react-native'; -import { useTailwind } from 'tailwind-rn'; - -import { TlonButton } from '../components/TlonButton'; -import type { OnboardingStackParamList } from '../types'; - -type Props = NativeStackScreenProps; - -export const WelcomeScreen = ({ navigation }: Props) => { - const bottomSheetRef = useRef(null); - const tailwind = useTailwind(); - const isDarkMode = useIsDarkMode(); - const overlayOpacity = new Animated.Value(0); - - const bgSource = isDarkMode - ? require('../../assets/images/welcome-bg-dark.png') - : require('../../assets/images/welcome-bg.png'); - - return ( - - - - - [ - tailwind( - cn( - 'bg-tlon-blue rounded-lg py-3 w-3/4 mx-auto', - pressed && 'bg-tlon-blue-active' - ) - ), - shadowStyles.button, - ]} - onPress={() => navigation.navigate('SignUpEmail')} - > - - Sign Up with Email - - - bottomSheetRef.current?.expand()} - > - {({ pressed }) => ( - - Have an account? Log in - - )} - - - - - - - Animated.spring(overlayOpacity, { - toValue: toIndex === -1 ? 0 : 0.3, - useNativeDriver: true, - }).start() - } - enablePanDownToClose - > - - { - navigation.navigate('TlonLogin'); - bottomSheetRef.current?.close(); - }} - /> - - { - navigation.navigate('ShipLogin'); - bottomSheetRef.current?.close(); - }} - /> - - - - ); -}; - -const shadowStyles = StyleSheet.create({ - button: { - shadowColor: '#000', - shadowOffset: { - width: 5, - height: 20, - }, - shadowOpacity: 0.1, - shadowRadius: 7, - elevation: 6, - }, -}); diff --git a/apps/tlon-mobile/src/types.ts b/apps/tlon-mobile/src/types.ts index 51e90bbf5a..4b1a5790b1 100644 --- a/apps/tlon-mobile/src/types.ts +++ b/apps/tlon-mobile/src/types.ts @@ -77,7 +77,7 @@ export type GroupSettingsStackParamList = { ManageChannels: { groupId: string; }; - InvitesAndPrivacy: { + Privacy: { groupId: string; }; GroupRoles: { @@ -92,12 +92,11 @@ export type SettingsStackParamList = { export type OnboardingStackParamList = { Welcome: undefined; + InventoryCheck: { lure?: string; priorityToken?: string } | undefined; SignUpEmail: { lure?: string; priorityToken?: string } | undefined; - EULA: - | { shipId: string; shipUrl: string; authCookie: string } - | { email: string; lure: string; priorityToken?: string }; + EULA: undefined; SignUpPassword: { email: string; lure: string; priorityToken?: string }; - JoinWaitList: { email: string; lure?: string }; + JoinWaitList: { email?: string; lure?: string }; RequestPhoneVerify: { user: User }; CheckVerify: { user: User }; ReserveShip: { user: User; signUpExtras?: SignUpExtras }; diff --git a/apps/tlon-mobile/src/window.d.ts b/apps/tlon-mobile/src/window.d.ts new file mode 100644 index 0000000000..f5774ed598 --- /dev/null +++ b/apps/tlon-mobile/src/window.d.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + our: string; + } +} + +export {}; diff --git a/apps/tlon-web-new/.env b/apps/tlon-web-new/.env new file mode 100644 index 0000000000..f8f1ae7935 --- /dev/null +++ b/apps/tlon-web-new/.env @@ -0,0 +1,6 @@ +# Change manually to clear local storage once +VITE_LAST_WIPE=2022-05-13 +VITE_ENABLE_WDYR=false +VITE_POSTHOG_KEY=phc_o25oAii2Hz9tIDe2SXyr0fvnL73qXxoP21NCOLfs40O +VITE_BRANCH_KEY=key_live_hubypwhuxR6vkwKfdozyRoamErouusXi +VITE_BRANCH_DOMAIN=join.tlon.io \ No newline at end of file diff --git a/apps/tlon-web-new/.env.profile b/apps/tlon-web-new/.env.profile new file mode 100644 index 0000000000..995fca4af2 --- /dev/null +++ b/apps/tlon-web-new/.env.profile @@ -0,0 +1 @@ +NODE_ENV=production \ No newline at end of file diff --git a/apps/tlon-web-new/.env.staging b/apps/tlon-web-new/.env.staging new file mode 100644 index 0000000000..cbde1ccac5 --- /dev/null +++ b/apps/tlon-web-new/.env.staging @@ -0,0 +1 @@ +NODE_ENV=production diff --git a/apps/tlon-web-new/.gitignore b/apps/tlon-web-new/.gitignore new file mode 100644 index 0000000000..63fbf81eb2 --- /dev/null +++ b/apps/tlon-web-new/.gitignore @@ -0,0 +1,15 @@ +node_modules +.DS_Store +dist +dev-dist +dist-ssr +*.local +stats.html +.eslintcache +.vercel +.tamagui +/test-results/ +/playwright-report/ +/playwright/.cache/ + +/e2e/.auth/ diff --git a/apps/tlon-web-new/.prettierrc.js b/apps/tlon-web-new/.prettierrc.js new file mode 100644 index 0000000000..5f35efaa58 --- /dev/null +++ b/apps/tlon-web-new/.prettierrc.js @@ -0,0 +1,7 @@ +const baseConfig = require('../../.prettierrc.json'); + +module.exports = { + ...baseConfig, + plugins: ['prettier-plugin-tailwindcss', ...(baseConfig.plugins || [])], + tailwindConfig: './tailwind.config.js', +}; diff --git a/apps/tlon-web-new/.tool-versions b/apps/tlon-web-new/.tool-versions new file mode 100644 index 0000000000..557ea46d25 --- /dev/null +++ b/apps/tlon-web-new/.tool-versions @@ -0,0 +1 @@ +nodejs 20.11.0 diff --git a/apps/tlon-web-new/index.html b/apps/tlon-web-new/index.html new file mode 100644 index 0000000000..2660c10b3e --- /dev/null +++ b/apps/tlon-web-new/index.html @@ -0,0 +1,23 @@ + + + + + + Tlon + + + + + + + +
+ + + + diff --git a/apps/tlon-web-new/package.json b/apps/tlon-web-new/package.json new file mode 100644 index 0000000000..2005edb863 --- /dev/null +++ b/apps/tlon-web-new/package.json @@ -0,0 +1,224 @@ +{ + "name": "tlon-web-new", + "version": "5.7.0", + "private": true, + "scripts": { + "build": "tsc && vite build", + "build:mock": "tsc && vite build --mode staging", + "build:profile": "tsc && vite build --mode profile", + "build:alpha": "tsc && vite build --mode alpha", + "dev": "cross-env SSL=true vite", + "dev:alpha": "cross-env SSL=true vite --mode alpha", + "dev:native": "cross-env APP=chat vite --mode native --host", + "dev-no-ssl": "vite", + "dev2": "cross-env SSL=true vite --mode dev2", + "multi": "cross-env SSL=true concurrently \"vite\" \"vite --mode dev2\"", + "dev-sw": "vite --mode sw", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix --quiet", + "lint:format": "prettier src/ --write", + "lint:all": "pnpm lint:format && pnpm run lint:fix", + "lint:staged": "lint-staged", + "mock": "vite --mode mock", + "serve": "vite preview", + "serve-sw": "vite preview --mode sw", + "tsc": "tsc --noEmit", + "cosmos": "cosmos" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "pnpm lint:fix", + "pnpm lint:format" + ] + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.190.0", + "@aws-sdk/s3-request-presigner": "^3.190.0", + "@emoji-mart/data": "^1.0.6", + "@emoji-mart/react": "^1.0.1", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.5", + "@radix-ui/react-popover": "^1.0.2", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.0.0", + "@radix-ui/react-toggle": "^1.0.2", + "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.0", + "@tamagui/vite-plugin": "1.101.3", + "@tanstack/react-query": "^4.28.0", + "@tanstack/react-query-devtools": "^4.28.0", + "@tanstack/react-query-persist-client": "^4.28.0", + "@tanstack/react-virtual": "^3.0.0-beta.60", + "@tiptap/core": "^2.6.6", + "@tiptap/extension-blockquote": "^2.6.6", + "@tiptap/extension-bold": "^2.6.6", + "@tiptap/extension-bullet-list": "^2.6.6", + "@tiptap/extension-code": "^2.6.6", + "@tiptap/extension-code-block": "^2.6.6", + "@tiptap/extension-document": "^2.6.6", + "@tiptap/extension-floating-menu": "^2.6.6", + "@tiptap/extension-hard-break": "^2.6.6", + "@tiptap/extension-heading": "^2.6.6", + "@tiptap/extension-history": "^2.6.6", + "@tiptap/extension-horizontal-rule": "^2.6.6", + "@tiptap/extension-italic": "^2.6.6", + "@tiptap/extension-link": "^2.6.6", + "@tiptap/extension-list-item": "^2.6.6", + "@tiptap/extension-mention": "^2.6.6", + "@tiptap/extension-ordered-list": "^2.6.6", + "@tiptap/extension-paragraph": "^2.6.6", + "@tiptap/extension-placeholder": "^2.6.6", + "@tiptap/extension-strike": "^2.6.6", + "@tiptap/extension-task-item": "^2.6.6", + "@tiptap/extension-task-list": "^2.6.6", + "@tiptap/extension-text": "^2.6.6", + "@tiptap/pm": "^2.6.6", + "@tiptap/react": "^2.6.6", + "@tiptap/suggestion": "^2.6.6", + "@tloncorp/app": "workspace:*", + "@tloncorp/mock-http-api": "^1.2.0", + "@tloncorp/shared": "workspace:*", + "@tloncorp/ui": "workspace:*", + "@types/marked": "^4.3.0", + "@urbit/api": "^2.2.0", + "@urbit/aura": "^1.0.0", + "@urbit/http-api": "3.2.0-dev", + "@urbit/sigil-js": "^2.2.0", + "any-ascii": "^0.3.1", + "big-integer": "^1.6.51", + "browser-cookies": "^1.2.0", + "browser-image-compression": "^2.0.2", + "classnames": "^2.3.1", + "clipboard-copy": "^4.0.1", + "color2k": "^2.0.0", + "cross-fetch": "^3.1.5", + "date-fns": "^2.28.0", + "dompurify": "^3.0.6", + "emoji-mart": "^5.2.2", + "emoji-regex": "^10.2.1", + "fast-average-color": "^9.1.1", + "framer-motion": "^6.5.1", + "fuzzy": "^0.1.3", + "get-youtube-id": "^1.0.1", + "hast-to-hyperscript": "^10.0.1", + "history": "^5.3.0", + "idb-keyval": "^6.2.0", + "immer": "^9.0.12", + "lodash": "^4.17.21", + "marked": "^4.3.0", + "masonic": "^3.6.5", + "moment": "^2.29.4", + "posthog-js": "^1.68.2", + "prismjs": "^1.29.0", + "prosemirror-commands": "~1.2.2", + "prosemirror-history": "~1.2.0", + "prosemirror-keymap": "~1.1.5", + "prosemirror-markdown": "^1.11.1", + "prosemirror-model": "~1.16.1", + "prosemirror-schema-list": "~1.1.6", + "prosemirror-state": "~1.3.4", + "prosemirror-transform": "~1.4.2", + "prosemirror-view": "~1.23.13", + "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", + "react-colorful": "^5.5.1", + "react-dnd": "^15.1.1", + "react-dnd-html5-backend": "^15.1.2", + "react-dnd-touch-backend": "^15.1.1", + "react-dom": "^18.2.0", + "react-error-boundary": "^3.1.4", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.30.0", + "react-image-size": "^2.0.0", + "react-intersection-observer": "^9.4.0", + "react-native-web": "0.19.12", + "react-oembed-container": "github:stefkampen/react-oembed-container", + "react-qr-code": "^2.0.12", + "react-router": "^6.22.1", + "react-router-dom": "^6.22.1", + "react-select": "^5.3.2", + "react-tweet": "^3.0.4", + "react-virtuoso": "^4.5.1", + "refractor": "^4.8.0", + "slugify": "^1.6.6", + "sorted-btree": "^1.8.1", + "sqlocal": "^0.11.1", + "tailwindcss-opentype": "^1.1.0", + "tailwindcss-scoped-groups": "^2.0.0", + "tippy.js": "^6.3.7", + "urbit-ob": "^5.0.1", + "use-pwa-install": "^1.0.1", + "usehooks-ts": "^2.6.0", + "uuid": "^9.0.0", + "validator": "^13.7.0", + "vaul": "github:latter-bolden/vaul", + "video-react": "^0.16.0", + "vite-plugin-svgr": "^4.2.0", + "workbox-precaching": "^6.5.4", + "zustand": "^3.7.2" + }, + "devDependencies": { + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@faker-js/faker": "^8.4.1", + "@playwright/test": "^1.33.0", + "@tailwindcss/aspect-ratio": "^0.4.0", + "@tailwindcss/container-queries": "^0.1.0", + "@tailwindcss/typography": "^0.5.7", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.2.0", + "@types/dompurify": "^3.0.5", + "@types/fs-extra": "^11.0.1", + "@types/lodash": "4.14.183", + "@types/node": "^20.10.8", + "@types/node-fetch": "^2.6.4", + "@types/portscanner": "^2.1.1", + "@types/prismjs": "^1.26.0", + "@types/qrcode": "^1.5.2", + "@types/react": "^18.2.46", + "@types/react-beautiful-dnd": "^13.1.2", + "@types/react-dom": "^18.2.7", + "@types/react-helmet": "^6.1.5", + "@types/react-test-renderer": "^18.0.0", + "@types/tar-fs": "^2.0.1", + "@types/uuid": "^9.0.2", + "@types/validator": "^13.7.2", + "@types/video-react": "^0.15.1", + "@types/ws": "^8.5.3", + "@urbit/vite-plugin-urbit": "^2.0.1", + "@vitejs/plugin-basic-ssl": "^1.1.0", + "@vitejs/plugin-react": "^4.2.1", + "@welldone-software/why-did-you-render": "^7.0.1", + "autoprefixer": "^10.4.4", + "concurrently": "^8.0.1", + "cross-env": "^7.0.3", + "flow-remove-types": "^2.241.0", + "fs-extra": "^11.1.1", + "jsdom": "^23.2.0", + "lint-staged": "^15.0.0", + "msw": "^0.44.2", + "node-fetch": "^2.6.12", + "postcss": "^8.4.12", + "postcss-import": "^14.1.0", + "prettier": "^3.1.1", + "react-cosmos": "6.1.1", + "react-cosmos-plugin-vite": "6.1.1", + "react-test-renderer": "^18.2.0", + "rollup-plugin-analyzer": "^4.0.0", + "rollup-plugin-visualizer": "^5.6.0", + "tailwindcss": "^3.2.7", + "tailwindcss-theme-swapper": "^0.7.3", + "tar-fs": "^3.0.4", + "tsc-files": "^1.1.4", + "vite": "^5.1.6", + "vite-plugin-pwa": "^0.17.5", + "vitest": "^0.34.1", + "workbox-window": "^7.0.0" + }, + "msw": { + "workerDirectory": "./public" + } +} diff --git a/apps/tlon-web-new/postcss.config.js b/apps/tlon-web-new/postcss.config.js new file mode 100644 index 0000000000..1e7269f15a --- /dev/null +++ b/apps/tlon-web-new/postcss.config.js @@ -0,0 +1,9 @@ +// postcss.config.js +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/tlon-web-new/public/apple-touch-icon.png b/apps/tlon-web-new/public/apple-touch-icon.png new file mode 100644 index 0000000000..7a41283415 Binary files /dev/null and b/apps/tlon-web-new/public/apple-touch-icon.png differ diff --git a/apps/tlon-web-new/public/favicon.ico b/apps/tlon-web-new/public/favicon.ico new file mode 100644 index 0000000000..22ce875a41 Binary files /dev/null and b/apps/tlon-web-new/public/favicon.ico differ diff --git a/apps/tlon-web-new/public/icon-192-maskable.png b/apps/tlon-web-new/public/icon-192-maskable.png new file mode 100644 index 0000000000..f75b700d9b Binary files /dev/null and b/apps/tlon-web-new/public/icon-192-maskable.png differ diff --git a/apps/tlon-web-new/public/icon-192.png b/apps/tlon-web-new/public/icon-192.png new file mode 100644 index 0000000000..7272b30b66 Binary files /dev/null and b/apps/tlon-web-new/public/icon-192.png differ diff --git a/apps/tlon-web-new/public/icon-512-maskable.png b/apps/tlon-web-new/public/icon-512-maskable.png new file mode 100644 index 0000000000..f98f123f72 Binary files /dev/null and b/apps/tlon-web-new/public/icon-512-maskable.png differ diff --git a/apps/tlon-web-new/public/icon-512.png b/apps/tlon-web-new/public/icon-512.png new file mode 100644 index 0000000000..83b79886f1 Binary files /dev/null and b/apps/tlon-web-new/public/icon-512.png differ diff --git a/apps/tlon-web-new/public/icon.svg b/apps/tlon-web-new/public/icon.svg new file mode 100644 index 0000000000..20faa0c7f6 --- /dev/null +++ b/apps/tlon-web-new/public/icon.svg @@ -0,0 +1,10 @@ + + + + diff --git a/apps/tlon-web-new/public/manifest.json b/apps/tlon-web-new/public/manifest.json new file mode 100644 index 0000000000..74ebfdff51 --- /dev/null +++ b/apps/tlon-web-new/public/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Tlon", + "short_name": "Tlon", + "description": "Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a peer-to-peer collaboration tool built on Urbit that provides a few simple basics that communities can shape into something unique to their needs.", + "start_url": "/apps/groups/", + "scope": "/apps/groups/", + "id": "/apps/groups/", + "icons": [ + { + "src": "./icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "./icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/tlon-web-new/public/mockServiceWorker.js b/apps/tlon-web-new/public/mockServiceWorker.js new file mode 100644 index 0000000000..7b5aeb2ea9 --- /dev/null +++ b/apps/tlon-web-new/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.44.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995'; +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + const accept = request.headers.get('accept') || ''; + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return; + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2); + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url + ); + return; + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}` + ); + }) + ); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const clonedResponse = response.clone(); + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + const clonedRequest = request.clone(); + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the cilent). + const headers = Object.fromEntries(clonedRequest.headers.entries()); + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass']; + + return fetch(clonedRequest, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'MOCK_NOT_FOUND': { + return passthrough(); + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data; + const networkError = new Error(message); + networkError.name = name; + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError; + } + } + + return passthrough(); +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2]); + }); +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs); + }); +} + +async function respondWithMock(response) { + await sleep(response.delay); + return new Response(response.body, response); +} diff --git a/apps/tlon-web-new/public/welcome_flowers.jpg b/apps/tlon-web-new/public/welcome_flowers.jpg new file mode 100644 index 0000000000..0c9ef76110 Binary files /dev/null and b/apps/tlon-web-new/public/welcome_flowers.jpg differ diff --git a/apps/tlon-web-new/reactNativeWebPlugin.ts b/apps/tlon-web-new/reactNativeWebPlugin.ts new file mode 100644 index 0000000000..1b3972d9a1 --- /dev/null +++ b/apps/tlon-web-new/reactNativeWebPlugin.ts @@ -0,0 +1,99 @@ +// ref: https://github.com/Bram-dc/vite-plugin-react-native-web +import type { Plugin as ESBuildPlugin } from 'esbuild'; +// @ts-expect-error - flow-remove-types is not typed +import flowRemoveTypes from 'flow-remove-types'; +import fs from 'fs/promises'; +import { transformWithEsbuild } from 'vite'; +import type { Plugin as VitePlugin } from 'vite'; + +// import type { ViteReactNativeWebOptions } from '../types' + +const development = process.env.NODE_ENV === 'development'; + +const extensions = [ + '.web.mjs', + '.mjs', + '.web.js', + '.js', + + '.web.mts', + '.mts', + '.web.ts', + '.ts', + + '.web.jsx', + '.jsx', + + '.web.tsx', + '.tsx', + + '.json', +]; + +const loader = { + '.js': 'jsx', +} as const; + +const filter = /\.(js|flow)$/; +const nativeFilter = /\.native\.(js|jsx|ts|tsx)$/; + +const esbuildPlugin = (): ESBuildPlugin => ({ + name: 'react-native-web', + setup: (build) => { + build.onLoad({ filter }, async ({ path }) => { + const src = await fs.readFile(path, 'utf-8'); + return { + contents: flowRemoveTypes(src).toString(), + loader: loader['.js'], + }; + }); + }, +}); + +const reactNativeWeb = + (/*options: ViteReactNativeWebOptions = {}*/): VitePlugin => ({ + enforce: 'pre', + name: 'react-native-web', + + config: () => ({ + define: { + // this is necessary for sqlocal and @tamagui/animations-moti to work + global: 'globalThis', + __DEV__: JSON.stringify(development), + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + }, + resolve: { + extensions, + alias: [{ find: 'react-native', replacement: 'react-native-web' }], + }, + optimizeDeps: { + esbuildOptions: { + plugins: [esbuildPlugin()], + resolveExtensions: extensions, + }, + }, + }), + + async transform(code, id) { + if (!filter.test(id)) return code; + + if (nativeFilter.test(id)) { + return null; + } + + if (code.includes('@flow')) code = flowRemoveTypes(code).toString(); + + return ( + await transformWithEsbuild(code, id, { + loader: loader['.js'], + tsconfigRaw: { + compilerOptions: { + jsx: 'react-jsx', + }, + }, + }) + ).code; + }, + }); + +export default reactNativeWeb; diff --git a/apps/tlon-web-new/src/api.ts b/apps/tlon-web-new/src/api.ts new file mode 100644 index 0000000000..ca64c731c4 --- /dev/null +++ b/apps/tlon-web-new/src/api.ts @@ -0,0 +1,357 @@ +import type UrbitMock from '@tloncorp/mock-http-api'; +import UrbitBase, { + Message, + Poke, + PokeInterface, + Scry, + SubscriptionRequestInterface, + Thread, + UrbitHttpApiEvent, + UrbitHttpApiEventType, +} from '@urbit/http-api'; +import _ from 'lodash'; + +import { useLocalState } from '@/state/local'; + +import { actionDrill, isHosted, parseKind } from './logic/utils'; +import { useEyreState } from './state/eyre'; +import useSchedulerStore from './state/scheduler'; + +export const IS_MOCK = + import.meta.env.MODE === 'mock' || import.meta.env.MODE === 'staging'; +const URL = (import.meta.env.VITE_MOCK_URL || + import.meta.env.VITE_VERCEL_URL) as string; + +type Hook = (event: any, mark: string) => boolean; + +interface Watcher { + id: string; + hook: Hook; + resolve: (value: void | PromiseLike) => void; + reject: (reason?: any) => void; +} + +interface SubscriptionId { + app: string; + path: string; +} + +function subPath(id: SubscriptionId) { + return `${id.app}${id.path}`; +} + +type EyrePayload = (Message & + (Poke | Thread | SubscriptionRequestInterface | Scry))[]; + +function hostingUrl(url: string, messages: EyrePayload) { + if (!isHosted || messages.length !== 1) { + return url; + } + + const json = messages[0]; + + if (json.action === 'poke' && 'mark' in json) { + const base = `${url}?mark=${json.mark}`; + const actions = json.json ? actionDrill(json.json).join(',') : []; + const kind = parseKind(json.json); + const ret = + actions.length > 0 + ? `${base}&actions=${actions}${kind ? `&kind=${kind}` : ''}` + : base; + return ret; + } + + return url; +} + +const Urbit = UrbitBase as new ( + url: string, + code?: string, + desk?: string, + fetch?: typeof window.fetch, + urlTransformer?: (someUrl: string, json: EyrePayload) => string +) => UrbitBase; + +class API { + private client: UrbitBase | UrbitMock | undefined; + + subscriptions: Set; + + subscriptionMap: Map; + + watchers: Record>; + + constructor() { + this.subscriptions = new Set(); + this.subscriptionMap = new Map(); + this.watchers = {}; + } + + private async setup() { + const { showDevTools } = useLocalState.getState(); + + if (this.client && this.client.verbose === showDevTools) { + return this.client; + } + + if (IS_MOCK) { + window.ship = 'finned-palmer'; + window.our = `~${window.ship}`; + window.desk = 'groups'; + + const MockUrbit = (await import('@tloncorp/mock-http-api')).default; + const mockHandlers = (await import('./mocks/handlers')).default; + + this.client = new MockUrbit(mockHandlers, URL || '', ''); + this.client.ship = window.ship; + this.client.verbose = true; + + return this.client; + } + + this.client = new Urbit('', '', window.desk, undefined, hostingUrl); + this.client.ship = window.ship; + this.client.verbose = showDevTools; + + (this.client as UrbitBase).onReconnect = () => { + const { onReconnect } = useLocalState.getState(); + if (onReconnect) { + onReconnect(); + } + }; + + this.client.onRetry = () => { + useLocalState.setState((state) => ({ + subscription: 'reconnecting', + errorCount: state.errorCount + 1, + })); + }; + + this.client.onError = () => { + (async () => { + useLocalState.setState((state) => ({ + airLockErrorCount: state.airLockErrorCount + 1, + subscription: 'disconnected', + })); + })(); + }; + + this.client.on('status-update', ({ status }) => { + useLocalState.getState().log(`http-api status: ${status}`); + }); + + this.client.on('error', (error) => { + useLocalState.getState().log(`http-api error: ${error.msg}`); + }); + + this.client.on('fact', (msg) => { + useLocalState + .getState() + .log( + `http-api msg [${msg.id}]\n\t${JSON.stringify(JSON.parse(msg.data), null, 2).replace(/\n/g, '\n\t')}` + ); + }); + + this.client.on('reset', () => { + useLocalState.getState().log('http-api reset'); + }); + + this.client.on('seamless-reset', () => { + useLocalState.getState().log('http-api seamless-reset'); + }); + + return this.client; + } + + private async withClient(cb: (client: UrbitBase | UrbitMock) => T) { + const { showDevTools } = useLocalState.getState(); + + if (!this.client || this.client.verbose !== showDevTools) { + const client = await this.setup(); + return cb(client); + } + + return cb(this.client); + } + + private async withErrorHandling( + cb: (client: UrbitBase | UrbitMock) => Promise + ) { + try { + const result = await this.withClient(cb); + useLocalState.setState({ subscription: 'connected', errorCount: 0 }); + + return result; + } catch (e) { + useLocalState.setState((state) => ({ errorCount: state.errorCount + 1 })); + throw e; + } + } + + async scry(params: Scry) { + return this.withClient((client) => { + useLocalState.getState().log(`scry ${params.app} ${params.path}`); + return client.scry(params); + }); + } + + async poke(params: PokeInterface) { + return this.withErrorHandling((client) => { + useLocalState + .getState() + .log( + `poke ${params.app} ${params.mark}: ${JSON.stringify(params.json)}` + ); + return client.poke(params); + }); + } + + /** + * A function to track a subscription for a specific event + * + * @param subscription Subscription to listen to + * @param hook Function to call to check if the event is the one we're waiting for + */ + async track(subscription: SubscriptionId, hook: (event: R) => boolean) { + const path = subPath(subscription); + return new Promise((resolve, reject) => { + const subWatchers = this.watchers[path] || new Map(); + const id = _.uniqueId(); + + this.watchers[path] = subWatchers.set(id, { + id, + hook, + resolve, + reject, + }); + }); + } + + async trackedPoke( + params: PokeInterface, + subscription: SubscriptionId, + validator?: (event: R) => boolean + ) { + return this.withErrorHandling( + (client) => + new Promise((resolve, reject) => { + useLocalState + .getState() + .log( + `poke ${params.app} ${params.mark}: ${JSON.stringify(params.json)}` + ); + client + .poke({ + ...params, + onError: (e) => { + params.onError?.(e); + reject(); + }, + onSuccess: async () => { + params.onSuccess?.(); + const defaultValidator = (event: any) => + _.isEqual(params.json, event); + await this.track(subscription, validator || defaultValidator); + resolve(); + }, + }) + .catch(reject); + }) + ); + } + + async subscribe(params: SubscriptionRequestInterface, priority = 5) { + const subId = subPath(params); + if (this.subscriptions.has(subId)) { + const [id] = [...this.subscriptionMap.entries()].find( + ([k, v]) => v === subId + ) || [0, '']; + return Promise.resolve(id); + } + + useLocalState.getState().log(`subscribe ${params.app} ${params.path}`); + this.subscriptions.add(subId); + + const eventListener = + (listener?: (event: any, mark: string, id: number) => void) => + (event: any, mark: string, id?: number) => { + const path = params.app + params.path; + const relevantWatchers = this.watchers[path]; + + if (relevantWatchers) { + relevantWatchers.forEach((w) => { + if (w.hook(event, mark)) { + w.resolve(); + relevantWatchers.delete(w.id); + } + }); + } + + if (listener) { + listener(event, mark, id || 0); + } + }; + + const id = await useSchedulerStore.getState().wait( + () => + this.withErrorHandling((client) => + client.subscribe({ + ...params, + event: eventListener(params.event), + quit: () => { + // should only happen once since we call this each invocation + // and onReconnect will set the lastReconnect time + const { lastReconnect, onReconnect } = useLocalState.getState(); + const threshold = import.meta.env.DEV + ? 60 * 1000 + : 12 * 60 * 60 * 1000; // 12 hours + if (Date.now() - lastReconnect >= threshold && onReconnect) { + onReconnect(); + } + }, + }) + ), + priority + ); + + this.subscriptionMap.set(id, subId); + return id; + } + + async subscribeOnce(app: string, path: string, timeout?: number) { + return this.withErrorHandling(() => { + useLocalState.getState().log(`subscribe once ${app} ${path}`); + return this.client!.subscribeOnce(app, path, timeout); + }); + } + + async thread(params: Thread) { + return this.withErrorHandling(() => this.client!.thread(params)); + } + + async unsubscribe(id: number) { + const subId = this.subscriptionMap.get(id); + if (subId) { + this.subscriptions.delete(subId); + this.subscriptionMap.delete(id); + } + + return this.withErrorHandling(() => this.client!.unsubscribe(id)); + } + + reset() { + this.withClient((client) => client.reset()); + } + + on( + event: T, + callback: (data: UrbitHttpApiEvent[T]) => void + ) { + this.withClient((client) => (client as UrbitBase).on(event, callback)); + } +} + +const api = new API(); +useEyreState.getState().start({ api }); + +export default api; diff --git a/apps/tlon-web-new/src/app.tsx b/apps/tlon-web-new/src/app.tsx new file mode 100644 index 0000000000..4687a18a05 --- /dev/null +++ b/apps/tlon-web-new/src/app.tsx @@ -0,0 +1,362 @@ +// Copyright 2024, Tlon Corporation +import { TooltipProvider } from '@radix-ui/react-tooltip'; +import { Provider as TamaguiProvider } from '@tloncorp/app/provider'; +import { AppDataProvider } from '@tloncorp/app/provider/AppDataProvider'; +import { sync } from '@tloncorp/shared'; +import * as api from '@tloncorp/shared/dist/api'; +import cookies from 'browser-cookies'; +import { usePostHog } from 'posthog-js/react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { + NavigateFunction, + Route, + BrowserRouter as Router, + Routes, + useNavigate, +} from 'react-router-dom'; + +import { ActivityScreenController } from '@/controllers/ActivityScreenController'; +import { ChannelScreenController } from '@/controllers/ChannelScreenController'; +import { ChatListScreenController } from '@/controllers/ChatListScreenController'; +import { GroupChannelsScreenController } from '@/controllers/GroupChannelsScreenController'; +import ImageViewerScreenController from '@/controllers/ImageViewerScreenController'; +import { PostScreenController } from '@/controllers/PostScreenController'; +import { ProfileScreenController } from '@/controllers/ProfileScreenController'; +import EyrieMenu from '@/eyrie/EyrieMenu'; +import { useMigrations } from '@/lib/webDb'; +import { ANALYTICS_DEFAULT_PROPERTIES } from '@/logic/analytics'; +import useAppUpdates, { AppUpdateContext } from '@/logic/useAppUpdates'; +import useErrorHandler from '@/logic/useErrorHandler'; +import useIsStandaloneMode from '@/logic/useIsStandaloneMode'; +import { useIsDark } from '@/logic/useMedia'; +import { preSig } from '@/logic/utils'; +import { toggleDevTools, useLocalState, useShowDevTools } from '@/state/local'; +import { useAnalyticsId, useLogActivity, useTheme } from '@/state/settings'; + +import { AppInfoScreenController } from './controllers/AppInfoScreenController'; +import { AppSettingsScreenController } from './controllers/AppSettingsScreenController'; +import { BlockedUsersScreenController } from './controllers/BlockedUsersScreenController'; +import { ChannelMembersScreenController } from './controllers/ChannelMembersScreenController'; +import { ChannelSearchScreenController } from './controllers/ChannelSearchScreenController'; +import { EditChannelScreenController } from './controllers/EditChannelScreenController'; +import { EditProfileScreenController } from './controllers/EditProfileScreenController'; +import { FeatureFlagScreenController } from './controllers/FeatureFlagScreenController'; +import { GroupMembersScreenController } from './controllers/GroupMembersScreenController'; +import { GroupMetaScreenController } from './controllers/GroupMetaScreenController'; +import { GroupPrivacyScreenController } from './controllers/GroupPrivacyScreenController'; +import { GroupRolesScreenController } from './controllers/GroupRolesScreenController'; +import { ManageAccountScreenController } from './controllers/ManageAccountScreenController'; +import { ManageChannelsScreenController } from './controllers/ManageChannelsScreenController'; +import { PushNotificationSettingsScreenController } from './controllers/PushNotificationSettingsScreenController'; +import { UserBugReportScreenController } from './controllers/UserBugReportScreenController'; +import UserProfileScreenController from './controllers/UserProfileScreenController'; + +const ReactQueryDevtoolsProduction = React.lazy(() => + import('@tanstack/react-query-devtools/build/lib/index.prod.js').then( + (d) => ({ + default: d.ReactQueryDevtools, + }) + ) +); + +function authRedirect() { + document.location = `${document.location.protocol}//${document.location.host}`; +} + +function checkIfLoggedIn() { + if (!('ship' in window)) { + authRedirect(); + } + + const session = cookies.get(`urbauth-~${window.ship}`); + if (!session) { + fetch('/~/name') + .then((res) => res.text()) + .then((name) => { + if (name !== preSig(window.ship)) { + authRedirect(); + } + }) + .catch(() => { + authRedirect(); + }); + } +} + +function handleGridRedirect(navigate: NavigateFunction) { + const query = new URLSearchParams(window.location.search); + + if (query.has('landscape-note')) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigate(decodeURIComponent(query.get('landscape-note')!)); + } else if (query.has('grid-link')) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigate(decodeURIComponent(query.get('landscape-link')!)); + } +} + +function AppRoutes() { + return ( + + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + + ); +} + +function MigrationCheck({ children }: PropsWithChildren) { + const { success, error } = useMigrations(); + if (!success && !error) { + return null; + } + if (error) { + throw error; + } + return <>{children}; +} + +const App = React.memo(function AppComponent() { + const navigate = useNavigate(); + const handleError = useErrorHandler(); + const isDarkMode = useIsDark(); + + useEffect(() => { + handleError(() => { + checkIfLoggedIn(); + handleGridRedirect(navigate); + })(); + }, [handleError, navigate]); + + useEffect(() => { + api.configureClient({ + shipName: window.our, + shipUrl: '', + onReset: () => sync.syncStart(), + onChannelReset: () => sync.handleDiscontinuity(), + }); + sync.syncStart(); + }, []); + + return ( +
+ + + + + + + + + +
+ ); +}); + +function RoutedApp() { + const mode = import.meta.env.MODE; + const [userThemeColor, setUserThemeColor] = useState('#ffffff'); + const showDevTools = useShowDevTools(); + const isStandAlone = useIsStandaloneMode(); + const logActivity = useLogActivity(); + const posthog = usePostHog(); + const analyticsId = useAnalyticsId(); + const { needsUpdate, triggerUpdate } = useAppUpdates(); + const body = document.querySelector('body'); + const colorSchemeFromNative = + window.nativeOptions?.colorScheme ?? window.colorscheme; + + const appUpdateContextValue = useMemo( + () => ({ needsUpdate, triggerUpdate }), + [needsUpdate, triggerUpdate] + ); + + const basename = () => { + if (mode === 'mock' || mode === 'staging') { + return '/'; + } + + if (mode === 'alpha') { + return '/apps/tm-alpha'; + } + + return '/apps/groups'; + }; + + const theme = useTheme(); + const isDarkMode = useIsDark(); + + useEffect(() => { + const onFocus = () => { + useLocalState.setState({ inFocus: true }); + }; + window.addEventListener('focus', onFocus); + + const onBlur = () => { + useLocalState.setState({ inFocus: false }); + }; + window.addEventListener('blur', onBlur); + + return () => { + window.removeEventListener('focus', onFocus); + window.removeEventListener('blur', onBlur); + }; + }, []); + + useEffect(() => { + window.toggleDevTools = () => toggleDevTools(); + }, []); + + useEffect(() => { + if ( + (isDarkMode && theme === 'auto') || + theme === 'dark' || + colorSchemeFromNative === 'dark' + ) { + document.body.classList.add('dark'); + useLocalState.setState({ currentTheme: 'dark' }); + setUserThemeColor('#000000'); + } else { + document.body.classList.remove('dark'); + useLocalState.setState({ currentTheme: 'light' }); + setUserThemeColor('#ffffff'); + } + }, [isDarkMode, theme, colorSchemeFromNative]); + + useEffect(() => { + if (isStandAlone) { + // this is necessary for the desktop PWA to not have extra padding at the bottom. + body?.style.setProperty('padding-bottom', '0px'); + } + }, [isStandAlone, body]); + + useEffect(() => { + if (posthog && analyticsId !== '' && logActivity) { + posthog.identify(analyticsId, ANALYTICS_DEFAULT_PROPERTIES); + } + }, [posthog, analyticsId, logActivity]); + + useEffect(() => { + if (posthog) { + if (showDevTools) { + posthog.debug(); + } else { + posthog.debug(false); + } + } + }, [posthog, showDevTools]); + + return ( + + + Tlon + + + + + + + + {showDevTools && ( + <> + + + +
+ +
+ + )} +
+ ); +} + +export default RoutedApp; diff --git a/apps/tlon-web-new/src/components/Layout/Layout.css b/apps/tlon-web-new/src/components/Layout/Layout.css new file mode 100644 index 0000000000..61286663c2 --- /dev/null +++ b/apps/tlon-web-new/src/components/Layout/Layout.css @@ -0,0 +1,31 @@ +:root { + --aside-width: 240px; +} + +.layout { + position: relative; + display: grid; + grid-template-areas: + 'header header' + 'main aside' + 'footer aside'; + grid-template-columns: 1fr auto; /** collapsible aside, main expands to fill */ + grid-template-rows: auto minmax(0, 1fr) auto; /** collapsible header, main expands to fill, collapsible footer */ + height: 100%; +} + +.aside { + grid-area: aside; +} + +.header { + grid-area: header; +} + +.footer { + grid-area: footer; +} + +.main { + grid-area: main; +} diff --git a/apps/tlon-web-new/src/constants.ts b/apps/tlon-web-new/src/constants.ts new file mode 100644 index 0000000000..fe068dccfa --- /dev/null +++ b/apps/tlon-web-new/src/constants.ts @@ -0,0 +1,50 @@ +export const STANDARD_MESSAGE_FETCH_PAGE_SIZE = 100; +export const LARGE_MESSAGE_FETCH_PAGE_SIZE = 300; +export const FETCH_BATCH_SIZE = 3; +export const MAX_DISPLAYED_OPTIONS = 40; +export const NOTE_REF_DISPLAY_LIMIT = 600; +export const LEAP_DESCRIPTION_TRUNCATE_LENGTH = 48; +export const LEAP_RESULT_TRUNCATE_SIZE = 5; +export const LEAP_RESULT_SCORE_THRESHOLD = 10; +export const CURIO_PAGE_SIZE = 250; +export const CHANNEL_SEARCH_RESULT_SIZE = 20; + +export const PASTEABLE_MEDIA_TYPES = [ + 'image/gif', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/svg', + 'image/tif', + 'image/webp', + 'video/mp4', + 'video/webm', + 'video/ogg', + 'video/quicktime', +]; + +export const AUTHORS = [ + '~nocsyx-lassul', + '~finned-palmer', + '~hastuc-dibtux', + '~datder-sonnet', + '~rilfun-lidlen', + '~ravmel-ropdyl', + '~fabled-faster', + '~fallyn-balfus', + '~riprud-tidmel', + '~wicdev-wisryt', + '~rovnys-ricfer', + '~mister-dister-dozzod-dozzod', +]; + +export const lsDesk = 'landscape'; + +export const ALPHABETICAL_SORT = 'A → Z'; +export const DEFAULT_SORT = 'Arranged'; +export const RECENT_SORT = 'Recent'; + +export type SortMode = + | typeof ALPHABETICAL_SORT + | typeof DEFAULT_SORT + | typeof RECENT_SORT; diff --git a/apps/tlon-web-new/src/controllers/ActivityScreenController.tsx b/apps/tlon-web-new/src/controllers/ActivityScreenController.tsx new file mode 100644 index 0000000000..a592e3cc41 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ActivityScreenController.tsx @@ -0,0 +1,46 @@ +import { ActivityScreen } from '@tloncorp/app/features/top/ActivityScreen'; +import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +export function ActivityScreenController() { + const navigate = useNavigate(); + const handleGoToChannel = useCallback( + (channel: db.Channel, selectedPostId?: string) => { + navigate(`/group/${channel.groupId}/channel/${channel.id}`); + }, + [navigate] + ); + + // TODO: if diary or gallery, figure out a way to pop open the comment + // sheet + const handleGoToThread = useCallback( + (post: db.Post) => { + // TODO: we have no way to route to specific thread message rn + navigate( + `/group/${post.groupId}/channel/${post.channelId}/post/${post.authorId}/${post.id}` + ); + }, + [navigate] + ); + + const handleGoToGroup = useCallback( + (group: db.Group) => { + store.markGroupRead(group); + navigate(`/group/members/${group.id}`); + }, + [navigate] + ); + + return ( + navigate('/')} + navigateToActivity={() => '/activity'} + navigateToProfile={() => navigate('/profile')} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/AppInfoScreenController.tsx b/apps/tlon-web-new/src/controllers/AppInfoScreenController.tsx new file mode 100644 index 0000000000..c5568e58b4 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/AppInfoScreenController.tsx @@ -0,0 +1,17 @@ +import { AppInfoScreen } from '@tloncorp/app/features/settings/AppInfoScreen'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +export function AppInfoScreenController() { + const navigate = useNavigate(); + const onPressPreviewFeatures = useCallback(() => { + navigate('/settings/feature-flags'); + }, [navigate]); + + return ( + navigate(-1)} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/AppSettingsScreenController.tsx b/apps/tlon-web-new/src/controllers/AppSettingsScreenController.tsx new file mode 100644 index 0000000000..de9eda25d3 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/AppSettingsScreenController.tsx @@ -0,0 +1,32 @@ +import { AppSettingsScreen } from '@tloncorp/app/features/settings/AppSettingsScreen'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +export function AppSettingsScreenController() { + const navigate = useNavigate(); + const onManageAccountPressed = useCallback(() => { + navigate('/settings/manage-account'); + }, [navigate]); + + const onAppInfoPressed = useCallback(() => { + navigate('/settings/app-info'); + }, [navigate]); + + const onPushNotifPressed = useCallback(() => { + navigate('/settings/push-notifications'); + }, [navigate]); + + const onBlockedUsersPressed = useCallback(() => { + navigate('/settings/blocked-users'); + }, [navigate]); + + return ( + navigate(-1)} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/BlockedUsersScreenController.tsx b/apps/tlon-web-new/src/controllers/BlockedUsersScreenController.tsx new file mode 100644 index 0000000000..bda8e50abe --- /dev/null +++ b/apps/tlon-web-new/src/controllers/BlockedUsersScreenController.tsx @@ -0,0 +1,7 @@ +import { BlockedUsersScreen } from '@tloncorp/app/features/settings/BlockedUsersScreen'; +import { useNavigate } from 'react-router'; + +export function BlockedUsersScreenController() { + const navigate = useNavigate(); + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/ChannelMembersScreenController.tsx b/apps/tlon-web-new/src/controllers/ChannelMembersScreenController.tsx new file mode 100644 index 0000000000..6fe36c9644 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ChannelMembersScreenController.tsx @@ -0,0 +1,15 @@ +import { ChannelMembersScreen } from '@tloncorp/app/features/channels/ChannelMembersScreen'; +import { useNavigate, useParams } from 'react-router'; + +export function ChannelMembersScreenController() { + const { chShip: channelId } = useParams<{ chShip: string }>(); + const navigate = useNavigate(); + + if (!channelId) { + return null; + } + + return ( + navigate(-1)} /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ChannelMetaScreenController.tsx b/apps/tlon-web-new/src/controllers/ChannelMetaScreenController.tsx new file mode 100644 index 0000000000..98ac797c85 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ChannelMetaScreenController.tsx @@ -0,0 +1,15 @@ +import { ChannelMetaScreen } from '@tloncorp/app/features/channels/ChannelMetaScreen'; +import { useNavigate, useParams } from 'react-router'; + +export function ChannelMetaScreenController() { + const { chShip: channelId } = useParams<{ chShip: string }>(); + const navigate = useNavigate(); + + if (!channelId) { + return null; + } + + return ( + navigate(-1)} /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ChannelScreenController.tsx b/apps/tlon-web-new/src/controllers/ChannelScreenController.tsx new file mode 100644 index 0000000000..fbd54e38f7 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ChannelScreenController.tsx @@ -0,0 +1,43 @@ +import ChannelScreen from '@tloncorp/app/features/top/ChannelScreen'; +import * as db from '@tloncorp/shared/dist/db'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +import useChannelMeta from '@/logic/useChannelMeta'; + +export function ChannelScreenController() { + const navigate = useNavigate(); + const { channel, group, postId, isDm } = useChannelMeta(); + const handleGoToDm = useCallback( + async (dmChannel: db.Channel) => { + navigate(`/dm/${dmChannel.id}`); + }, + [navigate] + ); + + const handleGoToUserProfile = useCallback( + (userId: string) => { + navigate(`/profile/${userId}`); + }, + [navigate] + ); + + const handleGoBack = useCallback(() => { + navigate('..'); + }, [navigate]); + + if (!channel || (!isDm && !group)) { + return null; + } + + return ( + + ); +} diff --git a/apps/tlon-web-new/src/controllers/ChannelSearchScreenController.tsx b/apps/tlon-web-new/src/controllers/ChannelSearchScreenController.tsx new file mode 100644 index 0000000000..68007d50d8 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ChannelSearchScreenController.tsx @@ -0,0 +1,50 @@ +import ChannelSearchScreen from '@tloncorp/app/features/top/ChannelSearchScreen'; +import { isGroupChannelId } from '@tloncorp/shared/dist'; +import { useNavigate } from 'react-router'; + +import useChannelMeta from '@/logic/useChannelMeta'; + +export function ChannelSearchScreenController() { + const navigate = useNavigate(); + const { channel: channelData } = useChannelMeta(); + + if (!channelData) { + return null; + } + return ( + { + if (channel.type === 'dm' || channel.type === 'groupDm') { + if (selectedPostId) { + navigate(`/dm/${channel.id}/post/${selectedPostId}`); + return; + } + navigate(`/dm/${channel.id}`); + return; + } + if (selectedPostId) { + navigate( + `/group/${channel.group?.id}/channel/${channel.id}/${selectedPostId}` + ); + return; + } + + navigate(`/group/${channel.group?.id}/channel/${channel.id}`); + }} + navigateToReply={({ id, authorId, channelId }) => { + if (isGroupChannelId(channelId)) { + navigate( + `/group/${channelData.group?.id}/channel/${channelId}/post/${authorId}/${id}` + ); + return; + } + + navigate(`/dm/${channelId}/post/${authorId}/${id}`); + }} + cancelSearch={() => { + navigate(-1); + }} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ChatListScreenController.tsx b/apps/tlon-web-new/src/controllers/ChatListScreenController.tsx new file mode 100644 index 0000000000..69b19b4b02 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ChatListScreenController.tsx @@ -0,0 +1,88 @@ +import ChatListScreen from '@tloncorp/app/features/top/ChatListScreen'; +import { isDmChannelId } from '@tloncorp/shared/dist'; +import * as db from '@tloncorp/shared/dist/db'; +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router'; + +// import AddGroupSheet from '../components/AddGroupSheet'; + +export function ChatListScreenController() { + const navigate = useNavigate(); + const [addGroupOpen, setAddGroupOpen] = useState(false); + const [startDmOpen, setStartDmOpen] = useState(false); + + const handleAddGroupOpenChange = useCallback((open: boolean) => { + if (!open) { + setAddGroupOpen(false); + } + }, []); + + const goToChannel = useCallback( + ({ channel }: { channel: db.Channel }) => { + setStartDmOpen(false); + setAddGroupOpen(false); + setTimeout( + () => navigate(`/group/${channel.groupId}/channel/${channel.id}`), + 150 + ); + }, + [navigate] + ); + + const handleGroupCreated = useCallback( + ({ channel }: { channel: db.Channel }) => goToChannel({ channel }), + [goToChannel] + ); + + const handleNavigateToChannel = useCallback( + (channel: db.Channel, postId?: string | null) => { + if (isDmChannelId(channel.id)) { + if (postId) { + navigate(`/dm/${channel.id}/${postId}`); + } else { + navigate(`/dm/${channel.id}`); + } + } else { + if (postId) { + navigate(`/group/${channel.groupId}/channel/${channel.id}/${postId}`); + } else { + navigate(`/group/${channel.groupId}/channel/${channel.id}`); + } + } + }, + [navigate] + ); + + return ( + <> + { + navigate(`/dm/${channel.id}`); + }} + navigateToGroupChannels={(group) => { + navigate(`/group/${group.id}`); + }} + navigateToSelectedPost={handleNavigateToChannel} + navigateToHome={() => { + navigate('/'); + }} + navigateToNotifications={() => { + navigate('/activity'); + }} + navigateToProfile={() => { + navigate('/profile'); + }} + /> + {/* + + */} + + ); +} diff --git a/apps/tlon-web-new/src/controllers/EditChannelScreenController.tsx b/apps/tlon-web-new/src/controllers/EditChannelScreenController.tsx new file mode 100644 index 0000000000..4edf40674f --- /dev/null +++ b/apps/tlon-web-new/src/controllers/EditChannelScreenController.tsx @@ -0,0 +1,17 @@ +import { EditChannelScreen } from '@tloncorp/app/features/groups/EditChannelScreen'; +import { useNavigate } from 'react-router'; + +import useChannelMeta from '@/logic/useChannelMeta'; + +export function EditChannelScreenController() { + const { groupId, channelId } = useChannelMeta(); + const navigate = useNavigate(); + + return ( + navigate(-1)} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/EditProfileScreenController.tsx b/apps/tlon-web-new/src/controllers/EditProfileScreenController.tsx new file mode 100644 index 0000000000..c6b89b16c4 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/EditProfileScreenController.tsx @@ -0,0 +1,12 @@ +import { EditProfileScreen } from '@tloncorp/app/features/settings/EditProfileScreen'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +export function EditProfileScreenController() { + const navigate = useNavigate(); + const onGoBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + return ; +} diff --git a/apps/tlon-web-new/src/controllers/FeatureFlagScreenController.tsx b/apps/tlon-web-new/src/controllers/FeatureFlagScreenController.tsx new file mode 100644 index 0000000000..9286fee314 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/FeatureFlagScreenController.tsx @@ -0,0 +1,7 @@ +import { FeatureFlagScreen } from '@tloncorp/app/features/settings/FeatureFlagScreen'; +import { useNavigate } from 'react-router'; + +export function FeatureFlagScreenController() { + const navigate = useNavigate(); + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/GroupChannelsScreenController.tsx b/apps/tlon-web-new/src/controllers/GroupChannelsScreenController.tsx new file mode 100644 index 0000000000..4da5031d10 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/GroupChannelsScreenController.tsx @@ -0,0 +1,27 @@ +import { GroupChannelsScreen } from '@tloncorp/app/features/top/GroupChannelsScreen'; +import { useGroup } from '@tloncorp/shared/dist'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function GroupChannelsScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + const { data: group } = useGroup({ id: groupId }); + + if (!group) { + return null; + } + + return ( + { + navigate(`/group/${groupId}/channel/${channel.id}`); + }} + goBack={() => { + navigate(-1); + }} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/GroupMembersScreenController.tsx b/apps/tlon-web-new/src/controllers/GroupMembersScreenController.tsx new file mode 100644 index 0000000000..e2186576d5 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/GroupMembersScreenController.tsx @@ -0,0 +1,11 @@ +import { GroupMembersScreen } from '@tloncorp/app/features/groups/GroupMembersScreen'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function GroupMembersScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + + return navigate(-1)} groupId={groupId} />; +} diff --git a/apps/tlon-web-new/src/controllers/GroupMetaScreenController.tsx b/apps/tlon-web-new/src/controllers/GroupMetaScreenController.tsx new file mode 100644 index 0000000000..f6bfb82796 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/GroupMetaScreenController.tsx @@ -0,0 +1,11 @@ +import { GroupMetaScreen } from '@tloncorp/app/features/groups/GroupMetaScreen'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function GroupMetaScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/GroupPrivacyScreenController.tsx b/apps/tlon-web-new/src/controllers/GroupPrivacyScreenController.tsx new file mode 100644 index 0000000000..b282f4382a --- /dev/null +++ b/apps/tlon-web-new/src/controllers/GroupPrivacyScreenController.tsx @@ -0,0 +1,11 @@ +import { GroupPrivacyScreen } from '@tloncorp/app/features/groups/GroupPrivacyScreen'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function GroupPrivacyScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/GroupRolesScreenController.tsx b/apps/tlon-web-new/src/controllers/GroupRolesScreenController.tsx new file mode 100644 index 0000000000..5f0a276768 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/GroupRolesScreenController.tsx @@ -0,0 +1,11 @@ +import { GroupRolesScreen } from '@tloncorp/app/features/groups/GroupRolesScreen'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function GroupRolesScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/ImageViewerScreenController.tsx b/apps/tlon-web-new/src/controllers/ImageViewerScreenController.tsx new file mode 100644 index 0000000000..675a00c981 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ImageViewerScreenController.tsx @@ -0,0 +1,16 @@ +import ImageViewerScreen from '@tloncorp/app/features/top/ImageViewerScreen'; +import { useNavigate, useParams } from 'react-router'; + +export default function ImageViewerScreenController() { + const navigate = useNavigate(); + const { postId, uri } = useParams(); + const decodedUri = decodeURIComponent(uri ?? ''); + + return ( + navigate(-1)} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ManageAccountScreenController.tsx b/apps/tlon-web-new/src/controllers/ManageAccountScreenController.tsx new file mode 100644 index 0000000000..0deb771930 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ManageAccountScreenController.tsx @@ -0,0 +1,12 @@ +import { ManageAccountScreen } from '@tloncorp/app/features/settings/ManageAccountScreen'; +import { useNavigate } from 'react-router'; + +import { resetDb } from '@/lib/webDb'; + +export function ManageAccountScreenController() { + const navigate = useNavigate(); + + return ( + navigate(-1)} /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ManageChannelsScreenController.tsx b/apps/tlon-web-new/src/controllers/ManageChannelsScreenController.tsx new file mode 100644 index 0000000000..7f1286a156 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ManageChannelsScreenController.tsx @@ -0,0 +1,19 @@ +import { ManageChannelsScreen } from '@tloncorp/app/features/groups/ManageChannelsScreen'; +import { useNavigate } from 'react-router'; + +import useGroupIdFromRoute from '@/logic/useGroupIdFromRoute'; + +export function ManageChannelsScreenController() { + const groupId = useGroupIdFromRoute(); + const navigate = useNavigate(); + + return ( + navigate(-1)} + onGoToEditChannel={(channelId) => { + navigate(`/group/${groupId}/channel/${channelId}/edit`); + }} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/PostScreenController.tsx b/apps/tlon-web-new/src/controllers/PostScreenController.tsx new file mode 100644 index 0000000000..9c9a8445dc --- /dev/null +++ b/apps/tlon-web-new/src/controllers/PostScreenController.tsx @@ -0,0 +1,27 @@ +import PostScreen from '@tloncorp/app/features/top/PostScreen'; +import { usePostWithThreadUnreads } from '@tloncorp/shared/dist'; +import { useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +export function PostScreenController() { + const { postId } = useParams(); + const navigate = useNavigate(); + const { data: post } = usePostWithThreadUnreads({ id: postId ?? '' }); + + const handleGoToUserProfile = useCallback((userId: string) => { + // TODO: Implement profile on web. + // props.navigation.push('UserProfile', { userId }); + }, []); + + if (!post) { + return null; + } + + return ( + navigate(-1)} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/ProfileScreenController.tsx b/apps/tlon-web-new/src/controllers/ProfileScreenController.tsx new file mode 100644 index 0000000000..9db8b14af9 --- /dev/null +++ b/apps/tlon-web-new/src/controllers/ProfileScreenController.tsx @@ -0,0 +1,18 @@ +import ProfileScreen from '@tloncorp/app/features/settings/ProfileScreen'; +import { useNavigate } from 'react-router'; + +export function ProfileScreenController() { + const navigate = useNavigate(); + + return ( + navigate('/settings')} + navigateToEditProfile={() => navigate('/profile/edit')} + navigateToErrorReport={() => navigate('/bug-report')} + navigateToProfile={(userId: string) => navigate(`/profile/${userId}`)} + navigateToHome={() => navigate('/')} + navigateToNotifications={() => navigate('/activity')} + navigateToSettings={() => navigate('/profile')} + /> + ); +} diff --git a/apps/tlon-web-new/src/controllers/PushNotificationSettingsScreenController.tsx b/apps/tlon-web-new/src/controllers/PushNotificationSettingsScreenController.tsx new file mode 100644 index 0000000000..620fb171cb --- /dev/null +++ b/apps/tlon-web-new/src/controllers/PushNotificationSettingsScreenController.tsx @@ -0,0 +1,7 @@ +import { PushNotificationSettingsScreen } from '@tloncorp/app/features/settings/PushNotificationSettingsScreen'; +import { useNavigate } from 'react-router'; + +export function PushNotificationSettingsScreenController() { + const navigate = useNavigate(); + return navigate(-1)} />; +} diff --git a/apps/tlon-web-new/src/controllers/UserBugReportScreenController.tsx b/apps/tlon-web-new/src/controllers/UserBugReportScreenController.tsx new file mode 100644 index 0000000000..a445c3086c --- /dev/null +++ b/apps/tlon-web-new/src/controllers/UserBugReportScreenController.tsx @@ -0,0 +1,12 @@ +import { UserBugReportScreen } from '@tloncorp/app/features/settings/UserBugReportScreen'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +export function UserBugReportScreenController() { + const navigate = useNavigate(); + const onGoBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + return ; +} diff --git a/apps/tlon-web-new/src/controllers/UserProfileScreenController.tsx b/apps/tlon-web-new/src/controllers/UserProfileScreenController.tsx new file mode 100644 index 0000000000..ce173af76a --- /dev/null +++ b/apps/tlon-web-new/src/controllers/UserProfileScreenController.tsx @@ -0,0 +1,32 @@ +import { UserProfileScreen } from '@tloncorp/app/features/top/UserProfileScreen'; +import * as store from '@tloncorp/shared/dist/store'; +import { useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +export default function UserProfileScreenController() { + const navigate = useNavigate(); + const { userId } = useParams<{ userId: string }>(); + + const handleGoToDm = useCallback( + async (participants: string[]) => { + const dmChannel = await store.upsertDmChannel({ + participants, + }); + + navigate(`/dm/${dmChannel.id}`); + }, + [navigate] + ); + + if (!userId) { + return null; + } + + return ( + navigate(-1)} + onPressGoToDm={handleGoToDm} + /> + ); +} diff --git a/apps/tlon-web-new/src/env.d.ts b/apps/tlon-web-new/src/env.d.ts new file mode 100644 index 0000000000..d6d1d9a6b8 --- /dev/null +++ b/apps/tlon-web-new/src/env.d.ts @@ -0,0 +1,14 @@ +interface ImportMetaEnv + extends Readonly> { + readonly VITE_LAST_WIPE: string; + readonly VITE_STORAGE_VERSION: string; + readonly VITE_ENABLE_WDYR: 'true' | 'false' | undefined; + readonly VITE_POSTHOG_KEY: string; + readonly VITE_BRANCH_KEY: string; + readonly VITE_BRANCH_DOMAIN: string; + readonly VITE_SHIP_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/tlon-web-new/src/eyrie/Eyrie.tsx b/apps/tlon-web-new/src/eyrie/Eyrie.tsx new file mode 100644 index 0000000000..839c617fa5 --- /dev/null +++ b/apps/tlon-web-new/src/eyrie/Eyrie.tsx @@ -0,0 +1,106 @@ +import * as Accordion from '@radix-ui/react-accordion'; +import cn from 'classnames'; +import React from 'react'; + +import { Fact, useEyreState } from '@/state/eyre'; + +import Subscription from './Subscription'; + +function filterFacts(facts: Fact[], id: number) { + return facts.filter((f) => { + try { + const data = JSON.parse(f.data); + + if ('id' in data) { + return data.id === id; + } + + return false; + } catch (error) { + return false; + } + }); +} + +export default function Eyrie() { + const { channel, status, idStatus, facts, subscriptions, errors, onReset } = + useEyreState(); + + return ( +
+
+ +
+ channel: + {channel} +
+
+

+ {status} +

+
+
+ last sent id: + {idStatus.current} +
+
+ last heard id: + {idStatus.lastHeard} +
+
+ last ackd id: + {idStatus.lastAcknowledged} +
+
+
+ + + + + error log + {errors.length} + + + +
+ {errors.map((e, i) => ( +
+
{new Date(e.time).toLocaleTimeString()}
+
{e.msg}
+
+ ))} +
+
+
+
+
+ + {Object.values(subscriptions).map((sub) => ( + + ))} + +
+
+ ); +} diff --git a/apps/tlon-web-new/src/eyrie/EyrieMenu.tsx b/apps/tlon-web-new/src/eyrie/EyrieMenu.tsx new file mode 100644 index 0000000000..b8f34ca71b --- /dev/null +++ b/apps/tlon-web-new/src/eyrie/EyrieMenu.tsx @@ -0,0 +1,34 @@ +import * as Popover from '@radix-ui/react-popover'; +import React from 'react'; + +import { EyreState, useEyreState } from '@/state/eyre'; + +import Eyrie from './Eyrie'; + +function disableDefault(e: T): void { + e.preventDefault(); +} + +const selMenu = (s: EyreState) => ({ open: s.open, toggle: s.toggle }); +export default function EyrieMenu() { + const { open, toggle } = useEyreState(selMenu); + + return ( + + + + + + + + + ); +} diff --git a/apps/tlon-web-new/src/eyrie/Subscription.tsx b/apps/tlon-web-new/src/eyrie/Subscription.tsx new file mode 100644 index 0000000000..8853fca081 --- /dev/null +++ b/apps/tlon-web-new/src/eyrie/Subscription.tsx @@ -0,0 +1,69 @@ +import * as Accordion from '@radix-ui/react-accordion'; +import cn from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Fact, Subscription as SubscriptionType } from '@/state/eyre'; + +interface SubscriptionProps { + sub: SubscriptionType; + facts: Fact[]; +} + +export default function Subscription({ sub, facts }: SubscriptionProps) { + const firstMount = useRef(true); + const [recentlyReceived, setRecentlyReceived] = useState(false); + + useEffect(() => { + if (firstMount.current) { + return; + } + + const sortedFacts = facts.sort((a, b) => b.time - a.time); + const head = sortedFacts[0] || { time: 0 }; + + if (Math.abs(Date.now() - head.time) <= 150) { + setRecentlyReceived(true); + setTimeout(() => setRecentlyReceived(false), 100); + } + }, [facts]); + + useEffect(() => { + firstMount.current = false; + }, []); + + return ( + + + + {sub.id} + {sub.app} + {sub.path} + {facts.length} + + + +

fact log:

+
+ {facts.map((f, i) => ( +
+
{f.id}
+
+ {new Date(f.time).toLocaleTimeString()} +
+
{JSON.stringify(f.data)}
+
+ ))} +
+
+
+ ); +} diff --git a/apps/tlon-web-new/src/global.d.ts b/apps/tlon-web-new/src/global.d.ts new file mode 100644 index 0000000000..c98746af76 --- /dev/null +++ b/apps/tlon-web-new/src/global.d.ts @@ -0,0 +1,32 @@ +type React = import('react'); + +type Stringified = string & { + [P in keyof T]: { '_ value': T[P] }; +}; + +declare module '@emoji-mart/react'; +declare module 'emoji-mart'; +declare module 'react-oembed-container'; +namespace JSX { + interface IntrinsicElements { + 'em-emoji': React.DetailedHTMLProps< + React.HTMLAttributes & { shortcodes: string; size?: string }, + HTMLElement + >; + 'urbit-sigil': React.DetailedHTMLProps< + React.HTMLAttributes & import('types/sigil').SigilProps + >; + } +} + +declare module 'urbit-ob' { + function isValidPatp(ship: string): boolean; + function clan(ship: string): 'galaxy' | 'star' | 'planet' | 'moon' | 'comet'; +} + +declare module '*.svg' { + const content: React.FC< + React.SVGAttributes & React.RefAttributes + >; + export default content; +} diff --git a/apps/tlon-web-new/src/hooks.ts b/apps/tlon-web-new/src/hooks.ts new file mode 100644 index 0000000000..d60cd577b5 --- /dev/null +++ b/apps/tlon-web-new/src/hooks.ts @@ -0,0 +1,27 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export function useSearchParam( + key: string +): [T | undefined, (update: T) => void] { + const [searchParams, setSearchParams] = useSearchParams(); + + const value = useMemo(() => { + const v = searchParams.get(key); + if (v) { + return JSON.parse(decodeURIComponent(v)); + } + return undefined; + }, [key, searchParams]); + + const setValue = useCallback( + (update: T) => { + searchParams.delete(key); + searchParams.append(key, JSON.stringify(update)); + setSearchParams(searchParams); + }, + [key, searchParams, setSearchParams] + ); + + return [value, setValue]; +} diff --git a/apps/tlon-web-new/src/indexedDBPersistor.ts b/apps/tlon-web-new/src/indexedDBPersistor.ts new file mode 100644 index 0000000000..e5b3529aa1 --- /dev/null +++ b/apps/tlon-web-new/src/indexedDBPersistor.ts @@ -0,0 +1,23 @@ +import { + PersistedClient, + Persister, +} from '@tanstack/react-query-persist-client'; +import { del, get, set } from 'idb-keyval'; + +/** + * Creates an Indexed DB persister + * @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API + */ +export default function createIDBPersister( + idbValidKey: IDBValidKey = window.our +) { + return { + persistClient: async (client: PersistedClient) => { + set(idbValidKey, client); + }, + restoreClient: async () => get(idbValidKey), + removeClient: async () => { + await del(idbValidKey); + }, + } as Persister; +} diff --git a/apps/tlon-web-new/src/keyMap.ts b/apps/tlon-web-new/src/keyMap.ts new file mode 100644 index 0000000000..dda098124c --- /dev/null +++ b/apps/tlon-web-new/src/keyMap.ts @@ -0,0 +1,50 @@ +export default { + leap: { + // not used programattically, but used in future for keyboard shortcuts modal + open: 'Cmd/Ctrl + k', + close: 'Escape', + nextResult: 'ArrowDown', + prevResult: 'ArrowUp', + selectResult: 'Enter', + }, + thread: { + close: 'Escape', + }, + mentionPopup: { + close: 'Escape', + nextItem: 'ArrowDown', + prevItem: 'ArrowUp', + selectItem: 'Enter', + }, + grid: { + close: 'Escape', + nextItem: 'ArrowRight', + prevItem: 'ArrowLeft', + nextItemAlt: 'l', + nextItemAlt2: 'Tab', + prevItemAlt: 'h', + nextRow: 'ArrowDown', + prevRow: 'ArrowUp', + nextRowAlt: 'j', + prevRowAlt: 'k', + open: 'Enter', + }, + curio: { + close: 'Escape', + next: 'ArrowRight', + prev: 'ArrowLeft', + }, + tippy: { + close: 'Escape', + nextItem: 'ArrowUp', + prevItem: 'ArrowDown', + selectItem: 'Enter', + }, + chatInputMenu: { + close: 'Escape', + nextItem: 'ArrowLeft', + prevItem: 'ArrowRight', + nextItemAlt: 'ArrowDown', + prevItemAlt: 'ArrowUp', + }, +}; diff --git a/apps/tlon-web-new/src/lib.ts b/apps/tlon-web-new/src/lib.ts new file mode 100644 index 0000000000..d280c7fb91 --- /dev/null +++ b/apps/tlon-web-new/src/lib.ts @@ -0,0 +1,15 @@ +/** + * min =< ret < max + */ +export function randInt(min: number, max: number) { + const range = max - min; + const rand = Math.random() * range; + return Math.floor(Math.random() * range) + min; +} + +export async function asyncForEach( + array: Array, + callback: (element: T, idx?: number, ary?: Array) => Promise +) { + await Promise.all(array.map((e, i) => callback(e, i, array))); +} diff --git a/apps/tlon-web-new/src/lib/migrator.ts b/apps/tlon-web-new/src/lib/migrator.ts new file mode 100644 index 0000000000..21e057a7f2 --- /dev/null +++ b/apps/tlon-web-new/src/lib/migrator.ts @@ -0,0 +1,145 @@ +import { createDevLogger } from '@tloncorp/shared'; +import { migrations as sharedMigrations } from '@tloncorp/shared/dist/db/migrations'; +import { sql } from 'drizzle-orm'; +import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy'; +import { SQLocalDrizzle } from 'sqlocal/drizzle'; + +const logger = createDevLogger('migrator', false); + +async function runWithRetry( + operation: () => Promise, + retries: number = 3, + delay: number = 1000, + operationName: string +): Promise { + for (let i = 0; i < retries; i++) { + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Operation ${operationName} timed out`)), + 5000 + ) + ), + ]); + } catch (error) { + logger.warn( + `Attempt ${i + 1} failed for operation ${operationName}:`, + error + ); + if (i === retries - 1) throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error(`All retries failed for operation ${operationName}`); +} + +async function testDatabaseConnection(db: SqliteRemoteDatabase) { + logger.log('Testing database connection'); + try { + await runWithRetry( + () => db.run(sql`SELECT 1`), + 3, + 1000, + 'Test database connection' + ); + logger.log('Database connection successful'); + } catch (error) { + logger.error('Database connection test failed:', error); + throw error; + } +} + +async function getDatabaseInfo(sqlocal: SQLocalDrizzle) { + logger.log('Fetching database info'); + try { + const info = await sqlocal.getDatabaseInfo(); + logger.log('Database info:', info); + return info; + } catch (error) { + logger.error('Failed to fetch database info:', error); + throw error; + } +} + +export default async function migrate>( + db: SqliteRemoteDatabase, + migrationConfig: typeof sharedMigrations, + sqlocal: SQLocalDrizzle +) { + const { journal, migrations } = migrationConfig; + + logger.log('Migrating database', { db, journal, migrations }); + + try { + await testDatabaseConnection(db); + await getDatabaseInfo(sqlocal); + logger.log('Creating migrations table if not exists'); + + await runWithRetry( + () => + db.run(sql` + CREATE TABLE IF NOT EXISTS __drizzle_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT, + created_at TEXT + ) + `), + 3, + 1000, + 'Create migrations table' + ); + + logger.log('Migrations table created or already exists'); + + logger.log('Fetching applied migrations'); + const appliedMigrations = await runWithRetry( + () => + db.values<[number, string, string]>( + sql`SELECT id, hash, created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1` + ), + 3, + 1000, + 'Fetch applied migrations' + ); + + logger.log('Applied migrations', appliedMigrations); + const appliedMigrationHashes = new Set(appliedMigrations.map((m) => m[1])); + logger.log('Applied migration hashes', appliedMigrationHashes); + + for (const entry of journal.entries) { + logger.log('Checking migration', entry); + // tag looks like this "0000_swift_yellow_claw" + // we only want the hash part + const migrationHash = `m${entry.tag.split('_').slice(0, 1).join('_')}`; + logger.log('Checking migration hash', migrationHash); + if (!appliedMigrationHashes.has(migrationHash)) { + const migrationSql = migrations[migrationHash]; + if (migrationSql) { + try { + await db.run(sql.raw(migrationSql)); + logger.log(`Applied migration ${migrationHash}`); + + // Record migration as applied + await db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at) + VALUES (${migrationHash}, datetime('now')) + `); + logger.log(`Recorded migration ${migrationHash}`); + } catch (e) { + logger.error(`Error applying migration ${migrationHash}`, e); + // throw e; + } + } else { + console.warn(`Migration SQL not found for hash: ${migrationHash}`); + } + } + } + + logger.log('Database migration complete'); + } catch (e) { + logger.error('Error migrating database', e); + throw e; + } +} diff --git a/apps/tlon-web-new/src/lib/triggers.ts b/apps/tlon-web-new/src/lib/triggers.ts new file mode 100644 index 0000000000..3e9893eae0 --- /dev/null +++ b/apps/tlon-web-new/src/lib/triggers.ts @@ -0,0 +1,122 @@ +export const TRIGGER_SETUP = ` +-- Create the change_log table +CREATE TABLE IF NOT EXISTS __change_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + operation TEXT NOT NULL, + row_id INTEGER, + row_data TEXT, + timestamp INTEGER DEFAULT (strftime('%s', 'now')) +); + +-- Create triggers for posts table +CREATE TRIGGER IF NOT EXISTS after_posts_insert +AFTER INSERT ON posts +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'posts', + 'INSERT', + NEW.id, + json_object('id', NEW.id, 'channel_id', NEW.channel_id, 'parent_id', NEW.parent_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_posts_update +AFTER UPDATE ON posts +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'posts', + 'UPDATE', + NEW.id, + json_object('id', NEW.id, 'channel_id', NEW.channel_id, 'parent_id', NEW.parent_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_posts_delete +AFTER DELETE ON posts +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'posts', + 'DELETE', + OLD.id, + json_object('id', OLD.id, 'channel_id', OLD.channel_id) + ); +END; + +-- Create triggers for post_reactions table +CREATE TRIGGER IF NOT EXISTS after_post_reactions_insert +AFTER INSERT ON post_reactions +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'post_reactions', + 'INSERT', + NEW.id, + json_object('id', NEW.id, 'post_id', NEW.post_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_post_reactions_update +AFTER UPDATE ON post_reactions +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'post_reactions', + 'UPDATE', + NEW.id, + json_object('id', NEW.id, 'post_id', NEW.post_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_post_reactions_delete +AFTER DELETE ON post_reactions +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'post_reactions', + 'DELETE', + OLD.id, + json_object('id', OLD.id, 'post_id', OLD.post_id) + ); +END; + +-- Create triggers for thread_unreads table +CREATE TRIGGER IF NOT EXISTS after_thread_unreads_insert +AFTER INSERT ON thread_unreads +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'thread_unreads', + 'INSERT', + NEW.id, + json_object('id', NEW.id, 'thread_id', NEW.thread_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_thread_unreads_update +AFTER UPDATE ON thread_unreads +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'thread_unreads', + 'UPDATE', + NEW.id, + json_object('id', NEW.id, 'thread_id', NEW.thread_id) + ); +END; + +CREATE TRIGGER IF NOT EXISTS after_thread_unreads_delete +AFTER DELETE ON thread_unreads +BEGIN + INSERT INTO __change_log (table_name, operation, row_id, row_data) + VALUES ( + 'thread_unreads', + 'DELETE', + OLD.id, + json_object('id', OLD.id, 'thread_id', OLD.thread_id) + ); +END; +`; diff --git a/apps/tlon-web-new/src/lib/webDb.ts b/apps/tlon-web-new/src/lib/webDb.ts new file mode 100644 index 0000000000..d51091a576 --- /dev/null +++ b/apps/tlon-web-new/src/lib/webDb.ts @@ -0,0 +1,177 @@ +import { createDevLogger, escapeLog } from '@tloncorp/shared'; +import type { Schema } from '@tloncorp/shared/dist/db'; +import { handleChange, schema, setClient } from '@tloncorp/shared/dist/db'; +import { migrations } from '@tloncorp/shared/dist/db/migrations'; +import { sql } from 'drizzle-orm'; +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import { useEffect, useMemo, useState } from 'react'; +import { SQLocalDrizzle } from 'sqlocal/drizzle'; + +import migrate from './migrator'; +import { TRIGGER_SETUP } from './triggers'; + +const POLL_INTERVAL = 100; + +let sqlocal: SQLocalDrizzle | null = null; +let client: ReturnType> | null = null; + +const enableLogger = false; +const logger = createDevLogger('db', enableLogger); + +export async function setupDb() { + if (sqlocal || client) { + logger.warn('setupDb called multiple times, ignoring'); + return; + } + try { + sqlocal = new SQLocalDrizzle({ + databasePath: 'tlon.sqlite', + verbose: enableLogger, + }); + + logger.log('sqlocal instance created', { sqlocal }); + // Experimental SQLite settings. May cause crashes. More here: + // https://ospfranco.notion.site/Configuration-6b8b9564afcc4ac6b6b377fe34475090 + await sqlocal.sql('PRAGMA mmap_size=268435456'); + // await sqlocal.sql('PRAGMA journal_mode=MEMORY'); + await sqlocal.sql('PRAGMA synchronous=OFF'); + await sqlocal.sql('PRAGMA journal_mode=WAL'); + // await sqlocal.sql(TRIGGER_SETUP); + + const { driver } = sqlocal; + + client = drizzle(driver, { + schema, + logger: enableLogger + ? { + logQuery(query, params) { + logger.log(escapeLog(query), params); + }, + } + : undefined, + }); + + const dbInfo = await sqlocal.getDatabaseInfo(); + logger.log('SQLite database opened:', dbInfo); + + setClient(client); + + // startChangePolling(); + } catch (e) { + logger.error('Failed to setup SQLite db', e); + } +} + +let isPolling = false; + +function startChangePolling() { + if (isPolling) return; + isPolling = true; + pollChanges(); +} + +const changeLogTable = sqliteTable('__change_log', { + id: integer('id').primaryKey(), + table_name: text('table_name').notNull(), + operation: text('operation').notNull(), + row_id: integer('row_id'), + row_data: text('row_data'), + timestamp: integer('timestamp').default(sql`(strftime('%s', 'now'))`), +}); + +async function pollChanges() { + if (!client) return; + + try { + const changes = await client.select().from(changeLogTable).all(); + + for (const change of changes) { + handleChange({ + table: change.table_name, + operation: change.operation as 'INSERT' | 'UPDATE' | 'DELETE', + row: JSON.parse(change.row_data ?? ''), + }); + } + + // Clear processed changes + await client.delete(changeLogTable).run(); + } catch (error) { + console.error('Error polling changes:', error); + } finally { + // Schedule next poll + setTimeout(pollChanges, POLL_INTERVAL); // Poll every second + } +} + +export async function purgeDb() { + if (!sqlocal) { + logger.warn('purgeDb called before setupDb, ignoring'); + return; + } + logger.log('purging sqlite database'); + sqlocal.destroy(); + sqlocal = null; + client = null; + logger.log('purged sqlite database, recreating'); + await setupDb(); +} + +export async function getDbPath() { + return sqlocal?.getDatabaseInfo().then((info) => info.databasePath); +} + +export function useMigrations() { + const [hasSucceeded, setHasSucceeded] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function performMigrations() { + logger.log('running migrations'); + const startTime = Date.now(); + + try { + await runMigrations(); + setHasSucceeded(true); + logger.log('migrations complete in', Date.now() - startTime + 'ms'); + } catch (e) { + logger.log('failed to migrate database', e); + setError(e); + } + } + + performMigrations(); + }, []); + + return useMemo( + () => ({ + success: hasSucceeded, + error: error, + }), + [hasSucceeded, error] + ); +} + +async function runMigrations() { + if (!client || !sqlocal) { + logger.warn('runMigrations called before setupDb, ignoring'); + return; + } + + try { + logger.log('runMigrations: starting migration'); + await migrate(client, migrations, sqlocal); + logger.log('runMigrations: migrations succeeded'); + return; + } catch (e) { + logger.log('migrations failed, purging db and retrying', e); + } + await purgeDb(); + await migrate(client, migrations, sqlocal); + logger.log("migrations succeeded after purge, shouldn't happen often"); +} + +export async function resetDb() { + await purgeDb(); + await migrate(client!, migrations, sqlocal!); +} diff --git a/apps/tlon-web-new/src/logic/analytics.ts b/apps/tlon-web-new/src/logic/analytics.ts new file mode 100644 index 0000000000..96a832fe66 --- /dev/null +++ b/apps/tlon-web-new/src/logic/analytics.ts @@ -0,0 +1,138 @@ +import { PrivacyType } from '@tloncorp/shared/dist/urbit/groups'; +import posthog, { Properties } from 'posthog-js'; + +import { log } from './utils'; + +export type AnalyticsEventName = + | 'app_open' + | 'app_close' + | 'profile_edit' + | 'profile_view' + | 'group_join' + | 'group_exit' + | 'open_group' + | 'leave_group' + | 'open_channel' + | 'leave_channel' + | 'react_item' + | 'comment_item' + | 'post_item' + | 'view_item' + | 'error'; + +export type AnalyticsChannelType = 'chat' | 'diary' | 'heap'; + +export type GroupsAnalyticsEvent = { + name: AnalyticsEventName; + leaveName?: AnalyticsEventName; + groupFlag: string; + chFlag?: string; + channelType?: AnalyticsChannelType; + privacy?: PrivacyType; +}; + +// Configure PostHog with all auto-capturing settings disabled, +// as we will only be tracking specific interactions. +posthog.init(import.meta.env.VITE_POSTHOG_KEY, { + api_host: 'https://eu.posthog.com', + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + disable_session_recording: true, + mask_all_text: true, + mask_all_element_attributes: true, + // this stops all capturing from happening until we manually opt-in. + // this is to prevent accidentally capturing data. all opting is managed + // in the activity checker in ActivityModal. + opt_out_capturing_by_default: true, + advanced_disable_decide: true, +}); + +export const analyticsClient = posthog; + +export const ANALYTICS_DEFAULT_PROPERTIES: Properties = { + // The following default properties stop PostHog from auto-logging the URL, + // which can inadvertently reveal private info on Urbit + $current_url: null, + $pathname: null, + $set_once: null, + $host: null, + $referrer: null, + $initial_current_url: null, + $initial_referrer_url: null, + $referring_domain: null, + $initial_referring_domain: null, + $unset: [ + 'initial_referrer_url', + 'initial_referring_domain', + 'initial_current_url', + 'current_url', + 'pathname', + 'host', + 'referrer', + 'referring_domain', + ], +}; + +// Once someone is opted in this will fire no matter what so we need +// additional guarding here to prevent accidentally capturing data. +export const captureAnalyticsEvent = ( + name: AnalyticsEventName, + properties?: Properties +) => { + log('Attempting to capture analytics event', name); + const captureProperties: Properties = { + ...(properties || {}), + ...ANALYTICS_DEFAULT_PROPERTIES, + }; + + posthog.capture(name, captureProperties, { + $set_once: { + $host: null, + $referrer: null, + $current_url: null, + $pathname: null, + $initial_current_url: null, + $initial_referrer_url: null, + $referring_domain: null, + $initial_referring_domain: null, + }, + }); +}; + +export const captureGroupsAnalyticsEvent = ({ + name, + groupFlag, + chFlag, + channelType, + privacy, +}: GroupsAnalyticsEvent) => { + if (!privacy || privacy === 'secret') { + return; + } + + const properties: Properties = {}; + + if (channelType) { + properties.channel_type = channelType; + } + + if (privacy === 'public') { + properties.group_flag = groupFlag; + if (chFlag) { + properties.channel_flag = chFlag; + } + } + + captureAnalyticsEvent(name, properties); +}; + +export function captureError(source: string, error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + captureAnalyticsEvent('error', { + source, + message, + stack, + }); +} diff --git a/apps/tlon-web-new/src/logic/routing.ts b/apps/tlon-web-new/src/logic/routing.ts new file mode 100644 index 0000000000..c41c5d7984 --- /dev/null +++ b/apps/tlon-web-new/src/logic/routing.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { NavigateOptions, To, useLocation, useNavigate } from 'react-router'; + +export interface ModalLocationState { + backgroundLocation: Location; +} + +/** + * Returns an imperative method for navigating while preserving the navigation + * state underneath the overlay + */ +export function useModalNavigate() { + const navigate = useNavigate(); + const location = useLocation(); + return useCallback( + (to: To, opts?: NavigateOptions) => { + if (location.state) { + navigate(to, { ...(opts || {}), state: location.state }); + return; + } + navigate(to, opts); + }, + [navigate, location.state] + ); +} + +export function useDismissNavigate() { + const navigate = useNavigate(); + const location = useLocation(); + const state = location.state as ModalLocationState | null; + + return useCallback(() => { + if (state?.backgroundLocation) { + // we want to replace the current location with the background location + // so that the user won't navigate back to the modal if they hit the back button + navigate(state.backgroundLocation, { replace: true }); + } + }, [navigate, state]); +} diff --git a/apps/tlon-web-new/src/logic/useAppUpdates.ts b/apps/tlon-web-new/src/logic/useAppUpdates.ts new file mode 100644 index 0000000000..dd81fb9469 --- /dev/null +++ b/apps/tlon-web-new/src/logic/useAppUpdates.ts @@ -0,0 +1,100 @@ +import { createContext, useCallback, useEffect, useState } from 'react'; +import { useRegisterSW } from 'virtual:pwa-register/react'; + +import useKilnState, { usePike } from '@/state/kiln'; + +const CHECK_FOR_UPDATES_INTERVAL = 10 * 60 * 1000; // 10 minutes + +function useServiceWorker() { + const { + needRefresh: [needRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisteredSW(swUrl, r) { + if (!r) { + return; + } + + setInterval(async () => { + if (r.installing || !navigator) { + return; + } + + if ('connection' in navigator && !navigator.onLine) { + return; + } + + const resp = await fetch(swUrl, { + cache: 'no-store', + headers: { + cache: 'no-store', + 'cache-control': 'no-cache', + }, + }); + + if (resp?.status === 200) { + await r.update(); + } + }, CHECK_FOR_UPDATES_INTERVAL); + }, + }); + + return { needRefresh, updateServiceWorker }; +} + +export default function useAppUpdates() { + const { needRefresh, updateServiceWorker } = useServiceWorker(); + const pike = usePike('groups'); + + const [needsUpdate, setNeedsUpdate] = useState(false); + const [initialHash, setInitialHash] = useState(null); + + useEffect(() => { + const interval = setInterval(() => { + useKilnState.getState().fetchPikes(); + }, CHECK_FOR_UPDATES_INTERVAL); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (pike) { + if (!initialHash) { + setInitialHash(pike.hash); + } else if (initialHash !== pike.hash && !needsUpdate) { + // wait 5 minutes before showing the update prompt in case there + // are multiple updates in quick succession + setTimeout(() => setNeedsUpdate(true), 5 * 60 * 1000); + } + } + }, [pike, initialHash, needsUpdate]); + + const triggerUpdate = useCallback( + async (returnToRoot: boolean) => { + const path = returnToRoot + ? `${window.location.origin}/apps/groups/?updatedAt=${Date.now()}` + : `${window.location.href}?updatedAt=${Date.now()}`; + + if (needRefresh) { + try { + await updateServiceWorker(false); + } catch (e) { + console.error('Service worker failed to update:', e); + } + } + + window.location.assign(path); + }, + [needRefresh, updateServiceWorker] + ); + + return { + needsUpdate: needRefresh, + triggerUpdate, + }; +} + +export const AppUpdateContext = createContext<{ + needsUpdate: boolean; + triggerUpdate: (returnToRoot: boolean) => Promise | null; +}>({ needsUpdate: false, triggerUpdate: () => null }); diff --git a/apps/tlon-web-new/src/logic/useChannelMeta.tsx b/apps/tlon-web-new/src/logic/useChannelMeta.tsx new file mode 100644 index 0000000000..cac639bf53 --- /dev/null +++ b/apps/tlon-web-new/src/logic/useChannelMeta.tsx @@ -0,0 +1,17 @@ +import * as store from '@tloncorp/shared/dist/store'; +import { useLocation, useParams } from 'react-router'; + +const useChannelMeta = () => { + const { ship, name, chType, chShip, chName, postId } = useParams(); + const location = useLocation(); + const isDm = location.pathname.includes('/dm/'); + const channelId = isDm && chShip ? chShip : `${chType}/${chShip}/${chName}`; + const groupId = `${ship}/${name}`; + + const { data: channel } = store.useChannel({ id: channelId }); + const { data: group } = store.useGroup({ id: groupId }); + + return { channel, group, postId, isDm, channelId, groupId }; +}; + +export default useChannelMeta; diff --git a/apps/tlon-web-new/src/logic/useErrorHandler.ts b/apps/tlon-web-new/src/logic/useErrorHandler.ts new file mode 100644 index 0000000000..42fe041a4c --- /dev/null +++ b/apps/tlon-web-new/src/logic/useErrorHandler.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { useErrorHandler as useBoundaryHandler } from 'react-error-boundary'; + +export default function useErrorHandler() { + const handle = useBoundaryHandler(); + + return useCallback( + (cb: (...args: any[]) => any) => + (...args: any[]) => { + try { + cb(...args); + } catch (error) { + handle(error); + } + }, + [handle] + ); +} diff --git a/apps/tlon-web-new/src/logic/useGroupIdFromRoute.tsx b/apps/tlon-web-new/src/logic/useGroupIdFromRoute.tsx new file mode 100644 index 0000000000..d18111c188 --- /dev/null +++ b/apps/tlon-web-new/src/logic/useGroupIdFromRoute.tsx @@ -0,0 +1,9 @@ +import { useParams } from 'react-router'; + +const useGroupIdFromRoute = () => { + const { ship, name } = useParams<{ ship: string; name: string }>(); + const groupId = `${ship}/${name}`; + return groupId; +}; + +export default useGroupIdFromRoute; diff --git a/apps/tlon-web-new/src/logic/useIsStandaloneMode.ts b/apps/tlon-web-new/src/logic/useIsStandaloneMode.ts new file mode 100644 index 0000000000..0b96f7402e --- /dev/null +++ b/apps/tlon-web-new/src/logic/useIsStandaloneMode.ts @@ -0,0 +1,5 @@ +export default function useIsStandaloneMode() { + const isStandalone = window.matchMedia('(display-mode: standalone)').matches; + + return isStandalone; +} diff --git a/apps/tlon-web-new/src/logic/useMedia.ts b/apps/tlon-web-new/src/logic/useMedia.ts new file mode 100644 index 0000000000..adca3e8d74 --- /dev/null +++ b/apps/tlon-web-new/src/logic/useMedia.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect } from 'react'; +import create from 'zustand'; + +interface MediaMatchStore { + media: { + [query: string]: boolean; + }; + setQuery: (query: string, data: boolean) => void; +} + +const useMediaMatchStore = create((set, get) => ({ + media: {}, + setQuery: (query, data) => { + set((draft) => { + draft.media[query] = data; + }); + }, +})); + +function useQuery(query: string) { + return useMediaMatchStore( + useCallback( + (s) => { + if (s.media[query]) { + return s.media[query]; + } + + const mQuery = window.matchMedia(query); + return mQuery.matches; + }, + [query] + ) + ); +} + +const queries: string[] = []; + +export default function useMedia(mediaQuery: string) { + const value = useQuery(mediaQuery); + + const update = useCallback( + (e: MediaQueryListEvent) => { + useMediaMatchStore.getState().setQuery(mediaQuery, e.matches); + }, + [mediaQuery] + ); + + useEffect(() => { + if (queries.includes(mediaQuery)) { + return; + } + + const query = window.matchMedia(mediaQuery); + queries.push(mediaQuery); + useMediaMatchStore.getState().setQuery(mediaQuery, query.matches); + + query.addEventListener('change', update); + update({ matches: query.matches } as MediaQueryListEvent); + }, [update, mediaQuery]); + + return value; +} + +export function useIsMobile() { + return useMedia('(max-width: 767px)'); +} + +export function useIsDark() { + return useMedia('(prefers-color-scheme: dark)'); +} diff --git a/apps/tlon-web-new/src/logic/useReactQueryScry.ts b/apps/tlon-web-new/src/logic/useReactQueryScry.ts new file mode 100644 index 0000000000..c91e68531a --- /dev/null +++ b/apps/tlon-web-new/src/logic/useReactQueryScry.ts @@ -0,0 +1,40 @@ +import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import api from '@/api'; +import useSchedulerStore from '@/state/scheduler'; + +export default function useReactQueryScry({ + queryKey, + app, + path, + onScry, + priority = 3, + options, +}: { + queryKey: QueryKey; + app: string; + path: string; + onScry?: (data: T) => T; + priority?: number; + options?: UseQueryOptions; +}) { + const fetchData = useCallback( + async () => + useSchedulerStore.getState().wait(async () => { + const result = await api.scry({ + app, + path, + }); + + return onScry ? onScry(result) : result; + }, priority), + [app, path, priority] + ); + + return useQuery(queryKey, fetchData, { + retryOnMount: false, + refetchOnMount: false, + ...options, + }); +} diff --git a/apps/tlon-web-new/src/logic/useReactQuerySubscribeOnce.tsx b/apps/tlon-web-new/src/logic/useReactQuerySubscribeOnce.tsx new file mode 100644 index 0000000000..70f559ea1d --- /dev/null +++ b/apps/tlon-web-new/src/logic/useReactQuerySubscribeOnce.tsx @@ -0,0 +1,32 @@ +import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import api from '@/api'; + +export default function useReactQuerySubscribeOnce({ + queryKey, + app, + path, + options, + initialData, + timeout = 5000, +}: { + queryKey: QueryKey; + app: string; + path: string; + options?: UseQueryOptions; + initialData?: any; + timeout?: number; +}): ReturnType { + const fetchData = async () => api.subscribeOnce(app, path, timeout); + + const defaultOptions = { + retryOnMount: false, + refetchOnMount: false, + enabled: true, + initialData, + }; + return useQuery(queryKey, fetchData, { + ...defaultOptions, + ...options, + }); +} diff --git a/apps/tlon-web-new/src/logic/useReactQuerySubscription.tsx b/apps/tlon-web-new/src/logic/useReactQuerySubscription.tsx new file mode 100644 index 0000000000..30d9c56edb --- /dev/null +++ b/apps/tlon-web-new/src/logic/useReactQuerySubscription.tsx @@ -0,0 +1,67 @@ +import { + QueryKey, + UseQueryOptions, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import _ from 'lodash'; +import { useEffect, useRef } from 'react'; + +import api from '@/api'; +import useSchedulerStore from '@/state/scheduler'; + +export default function useReactQuerySubscription({ + queryKey, + app, + path, + scry, + scryApp = app, + priority = 3, + onEvent, + onScry, + options, +}: { + queryKey: QueryKey; + app: string; + path: string; + scry: string; + scryApp?: string; + priority?: number; + onEvent?: (data: Event) => void; + onScry?: (data: T) => T; + options?: UseQueryOptions; +}) { + const queryClient = useQueryClient(); + const invalidate = useRef( + _.debounce( + () => { + queryClient.invalidateQueries(queryKey); + }, + 300, + { leading: true, trailing: true } + ) + ); + + const fetchData = async () => + useSchedulerStore.getState().wait(async () => { + const result = await api.scry({ + app: scryApp, + path: scry, + }); + + return onScry ? onScry(result) : result; + }, priority); + + useEffect(() => { + api.subscribe({ + app, + path, + event: onEvent ? onEvent : invalidate.current, + }); + }, [app, path, queryClient, queryKey, onEvent]); + + return useQuery(queryKey, fetchData, { + staleTime: 60 * 1000, + ...options, + }); +} diff --git a/apps/tlon-web-new/src/logic/utils.ts b/apps/tlon-web-new/src/logic/utils.ts new file mode 100644 index 0000000000..6e43e168f9 --- /dev/null +++ b/apps/tlon-web-new/src/logic/utils.ts @@ -0,0 +1,1247 @@ +import { MessageKey } from '@tloncorp/shared/dist/urbit/activity'; +import { + CacheId, + ChatStory, + Cite, + Listing, + Post, + Story, + Verse, + VerseBlock, + VerseInline, +} from '@tloncorp/shared/dist/urbit/channel'; +import { + Bold, + Inline, + Italics, + Strikethrough, +} from '@tloncorp/shared/dist/urbit/content'; +import { + Cabals, + ChannelPrivacyType, + Cordon, + Gang, + Group, + GroupChannel, + GroupPreview, + PrivacyType, + Rank, + Saga, +} from '@tloncorp/shared/dist/urbit/groups'; +import { + BigIntOrderedMap, + Docket, + DocketHref, + Treaty, + udToDec, +} from '@urbit/api'; +import { formatUd, formatUv, unixToDa } from '@urbit/aura'; +import anyAscii from 'any-ascii'; +import bigInt, { BigInteger } from 'big-integer'; +import { hsla, parseToHsla, parseToRgba } from 'color2k'; +import { differenceInDays, endOfToday, format } from 'date-fns'; +import emojiRegex from 'emoji-regex'; +import _ from 'lodash'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router'; +import ob from 'urbit-ob'; +import { useCopyToClipboard } from 'usehooks-ts'; +import isURL from 'validator/es/lib/isURL'; + +export const isStagingHosted = + import.meta.env.DEV || + (import.meta.env.VITE_SHIP_URL || '').endsWith('.test.tlon.systems') || + window.location.hostname.endsWith('.test.tlon.systems'); +export const isHosted = + isStagingHosted || + (import.meta.env.VITE_SHIP_URL || '').endsWith('.tlon.network') || + window.location.hostname.endsWith('.tlon.network'); + +export const hostingUploadURL = isStagingHosted + ? 'https://memex.test.tlon.systems' + : isHosted + ? 'https://memex.tlon.network' + : ''; + +export const dmListPath = '/messages'; + +export function createDevLogger(tag: string, enabled: boolean) { + return new Proxy(console, { + get(target: Console, prop, receiver) { + return (...args: unknown[]) => { + if (enabled && import.meta.env.DEV) { + const val = Reflect.get(target, prop, receiver); + val(`[${tag}]`, ...args); + } + }; + }, + }); +} + +export function log(...args: any[]) { + if (import.meta.env.DEV) { + const { stack } = new Error(); + const line = stack?.split('\n')[2].trim(); + console.log(`${line}:`, ...args); + } +} + +/** + * Logs a message when any property of an object changes. Uses shallow equality + * check to determine whether a change has occurred. + */ +export function useObjectChangeLogging( + o: Record, + logger: Console = window.console +) { + const lastValues = useRef(o); + Object.entries(o).forEach(([k, v]) => { + if (v !== lastValues.current[k]) { + logger.log('[change]', k, 'old:', lastValues.current[k], 'new:', v); + lastValues.current[k] = v; + } + }); +} + +export function logTime(...args: any[]) { + return log(...[performance.now(), ...args]); +} + +type App = 'chat' | 'heap' | 'diary'; + +export function checkNest(nest: string) { + if (nest.split('/').length !== 3) { + if (import.meta.env.DEV) { + throw new Error('Invalid nest'); + } else { + console.error('Invalid nest:', nest); + } + } +} + +export function nestToFlag(nest: string): [App, string] { + checkNest(nest); + const [app, ...rest] = nest.split('/'); + + return [app as App, rest.join('/')]; +} + +export function renderRank(rank: Rank, plural = false) { + if (rank === 'czar') { + return plural ? 'Galaxies' : 'Galaxy'; + } + if (rank === 'king') { + return plural ? 'Stars' : 'Star'; + } + if (rank === 'duke') { + return plural ? 'Planets' : 'Planet'; + } + if (rank === 'earl') { + return plural ? 'Moons' : 'Moon'; + } + return plural ? 'Comets' : 'Comet'; +} + +/** + * Processes a string to make it `@tas` compatible + */ +export function strToSym(str: string): string { + const ascii = anyAscii(str); + return ascii.toLowerCase().replaceAll(/[^a-zA-Z0-9-]/g, '-'); +} + +// encode the string into @ta-safe format, using logic from +wood. +// for example, 'some Chars!' becomes '~.some.~43.hars~21.' +// this is equivalent to (scot %t string), and is url-safe encoding for +// arbitrary strings. +// +// TODO should probably go into aura-js +export function stringToTa(string: string) { + let out = ''; + for (let i = 0; i < string.length; i += 1) { + const char = string[i]; + let add = ''; + switch (char) { + case ' ': + add = '.'; + break; + case '.': + add = '~.'; + break; + case '~': + add = '~~'; + break; + default: { + const codePoint = string.codePointAt(i); + if (!codePoint) break; + // js strings are encoded in UTF-16, so 16 bits per character. + // codePointAt() reads a _codepoint_ at a character index, and may + // consume up to two js string characters to do so, in the case of + // 16 bit surrogate pseudo-characters. here we detect that case, so + // we can advance the cursor to skip past the additional character. + if (codePoint > 0xffff) i += 1; + if ( + (codePoint >= 97 && codePoint <= 122) || // a-z + (codePoint >= 48 && codePoint <= 57) || // 0-9 + char === '-' + ) { + add = char; + } else { + add = `~${codePoint.toString(16)}.`; + } + } + } + out += add; + } + return `~~${out}`; +} + +export function makePrettyTime(date: Date) { + return format(date, 'HH:mm'); +} + +export function makePrettyDay(date: Date) { + const diff = differenceInDays(endOfToday(), date); + switch (diff) { + case 0: + return 'Today'; + case 1: + return 'Yesterday'; + default: + return `${format(date, 'LLLL')} ${format(date, 'do')}`; + } +} + +export function makePrettyDate(date: Date) { + return `${format(date, 'PPP')}`; +} + +export function makePrettyShortDate(date: Date) { + return format(date, 'MMM dd, yyyy'); +} + +export interface DayTimeDisplay { + original: Date; + diff: number; + day: string; + time: string; + asString: string; +} + +export function makePrettyDayAndTime(date: Date): DayTimeDisplay { + const diff = differenceInDays(endOfToday(), date); + const time = makePrettyTime(date); + let day = ''; + switch (true) { + case diff === 0: + day = 'Today'; + break; + case diff === 1: + day = 'Yesterday'; + break; + case diff > 1 && diff < 8: + day = format(date, 'cccc'); + break; + default: + day = `${format(date, 'LLLL')} ${format(date, 'do')}`; + } + + return { + original: date, + diff, + time, + day, + asString: `${day} • ${time}`, + }; +} + +export interface DateDayTimeDisplay extends DayTimeDisplay { + fullDate: string; +} + +export function makePrettyDayAndDateAndTime(date: Date): DateDayTimeDisplay { + const fullDate = `${format(date, 'LLLL')} ${format(date, 'do')}, ${format( + date, + 'yyyy' + )}`; + const dayTime = makePrettyDayAndTime(date); + + if (dayTime.diff >= 8) { + return { + ...dayTime, + fullDate, + asString: `${fullDate} • ${dayTime.time}`, + }; + } + + return { + ...dayTime, + fullDate, + asString: `${dayTime.asString} • ${fullDate}`, + }; +} + +export function whomIsDm(whom: string): boolean { + return whom.startsWith('~') && !whom.match('/') && !whom.startsWith('~~'); +} + +export function whomIsBroadcast(whom: string): boolean { + return whom.startsWith('~~'); +} + +// ship + term, term being a @tas: lower-case letters, numbers, and hyphens +export function whomIsFlag(whom: string): boolean { + return ( + /^~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) && + ob.isValidPatp(whom.split('/')[0]) + ); +} + +export function whomIsNest(whom: string): boolean { + return ( + /^[a-z]+\/~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) && + ob.isValidPatp(whom.split('/')[1]) + ); +} + +export function whomIsMultiDm(whom: string): boolean { + return whom.startsWith(`0v`); +} + +export function normalizeUrbitColor(color: string): string { + if (color.startsWith('#')) { + return color; + } + + const colorString = color.slice(2).replace('.', '').toUpperCase(); + const lengthAdjustedColor = _.padStart(colorString, 6, '0'); + return `#${lengthAdjustedColor}`; +} + +export function isColor(color: string): boolean { + try { + parseToRgba(color); + return true; + } catch (error) { + return false; + } +} + +export function pluralize(word: string, count: number): string { + if (count === 1) { + return word; + } + + return `${word}s`; +} + +export function createStorageKey(name: string): string { + return `~${window.ship}/landscape/${name}`; +} + +// for purging storage with version updates +export function clearStorageMigration() { + return {} as T; +} + +export const storageVersion = parseInt( + import.meta.env.VITE_STORAGE_VERSION, + 10 +); + +export function preSig(ship: string): string { + if (!ship) { + return ''; + } + + if (ship.trim().startsWith('~')) { + return ship.trim(); + } + + return '~'.concat(ship.trim()); +} + +export function newUv(seed = Date.now()) { + return formatUv(unixToDa(seed)); +} + +export function getSectTitle(cabals: Cabals, sect: string) { + return cabals[sect]?.meta.title || sect; +} + +export function getPatdaParts(patda: string) { + const parts = patda.split('/'); + + return { + ship: parts[0], + time: parts[1], + timeDec: udToDec(parts[1]), + }; +} + +export function getFlagParts(flag: string) { + const parts = flag.split('/'); + + return { + ship: parts[0], + name: parts[1], + }; +} + +export function getPrivacyFromCordon(cordon: Cordon): PrivacyType { + if ('shut' in cordon) { + return 'private'; + } + + return 'public'; +} + +export function getPrivacyFromPreview(preview: GroupPreview) { + if (preview.secret) { + return 'secret'; + } + + return getPrivacyFromCordon(preview.cordon); +} + +export function getPrivacyFromGroup(group: Group): PrivacyType { + if (group.secret) { + return 'secret'; + } + + return getPrivacyFromCordon(group.cordon); +} + +export function getPrivacyFromGang(gang: Gang): PrivacyType { + if (!gang.preview || gang.preview.secret) { + return 'secret'; + } + + return getPrivacyFromCordon(gang.preview.cordon); +} + +export interface WritePermissions { + perms: { + writers: string[]; + }; +} + +export function getPrivacyFromChannel( + groupChannel?: GroupChannel, + channel?: WritePermissions +): ChannelPrivacyType { + if (!groupChannel || !channel) { + return 'public'; + } + + if (groupChannel.readers.length > 0 || channel.perms.writers.length > 0) { + return 'custom'; + } + + return 'public'; +} + +export function pluralRank( + rank: 'galaxy' | 'star' | 'planet' | 'moon' | 'comet' +) { + switch (rank) { + case 'galaxy': + return 'galaxies'; + default: + return `${rank}s`; + } +} + +export function rankToClan( + rank: 'czar' | 'king' | 'duke' | 'earl' | 'pawn' | string +) { + switch (rank) { + case 'czar': + return 'galaxy'; + case 'king': + return 'star'; + case 'duke': + return 'planet'; + case 'earl': + return 'moon'; + default: + return 'comet'; + } +} + +export function matchesBans( + cordon: Cordon, + ship: string +): ReturnType | 'ship' | null { + const siggedShip = preSig(ship); + if (!('open' in cordon)) { + return null; + } + + if (cordon.open.ships.includes(siggedShip)) { + return 'ship'; + } + + const clan = ob.clan(siggedShip); + if (cordon.open.ranks.map(rankToClan).includes(clan)) { + return clan; + } + + return null; +} + +export function toTitleCase(s: string): string { + if (!s) { + return ''; + } + return s + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +export function randomElement(a: T[]) { + return a[Math.floor(Math.random() * a.length)]; +} + +export function randomIntInRange(min: number, max: number) { + return Math.round(Math.random() * (max - min) + min); +} + +export function hasKeys(obj: Record) { + return Object.keys(obj).length > 0; +} + +export const IMAGE_REGEX = + /(\.jpg|\.img|\.png|\.gif|\.tiff|\.jpeg|\.webp|\.svg)(?:\?.*)?$/i; +export const AUDIO_REGEX = /(\.mp3|\.wav|\.ogg|\.m4a)(?:\?.*)?$/i; +export const VIDEO_REGEX = /(\.mov|\.mp4|\.ogv|\.webm)(?:\?.*)?$/i; +export const URL_REGEX = /(https?:\/\/[^\s]+)/i; +export const PATP_REGEX = /(~[a-z0-9-]+)/i; +export const IMAGE_URL_REGEX = + /^(http(s?):)([/.\w\s-:]|%2*)*\.(?:jpg|img|png|gif|tiff|jpeg|webp|svg)(?:\?.*)?$/i; +export const REF_REGEX = /\/1\/(chan|group|desk)\/[^\s]+/g; +export const REF_URL_REGEX = /^\/1\/(chan|group|desk)\/[^\s]+/; +// sig and hep explicitly left out +export const PUNCTUATION_REGEX = /[.,/#!$%^&*;:{}=_`()]/g; + +export function isImageUrl(url: string) { + return IMAGE_URL_REGEX.test(url); +} + +export function isMediaUrl(url: string) { + return ( + isURL(url) && + (IMAGE_REGEX.test(url) || VIDEO_REGEX.test(url) || AUDIO_REGEX.test(url)) + ); +} + +export function isRef(text: string) { + return text.match(REF_URL_REGEX); +} + +export function isValidUrl(str?: string): boolean { + return str ? !!URL_REGEX.test(str) : false; +} + +const isFacebookGraphDependent = (url: string) => { + const caseDesensitizedURL = url.toLowerCase(); + return ( + caseDesensitizedURL.includes('facebook.com') || + caseDesensitizedURL.includes('instagram.com') + ); +}; + +export const validOembedCheck = (embed: any, url: string) => { + if (!isFacebookGraphDependent(url)) { + if (embed?.html) { + return true; + } + } + return false; +}; + +export async function jsonFetch( + info: RequestInfo, + init?: RequestInit +): Promise { + const res = await fetch(info, init); + if (!res.ok) { + throw new Error('Bad Fetch Response'); + } + const data = await res.json(); + return data as T; +} + +export function isGroupHost(flag: string) { + const { ship } = getFlagParts(flag); + return ship === window.our; +} + +/** + * Since there is no metadata persisted in a curio to determine what kind of + * curio it is (Link or Text), this function determines by checking the + * content's structure. + * + * @param content CurioContent + * @returns boolean + */ +export function isLinkCurio({ inline }: ChatStory) { + return ( + inline.length === 1 && typeof inline[0] === 'object' && 'link' in inline[0] + ); +} + +export function linkFromCurioContent(content: ChatStory) { + if (isLinkCurio(content)) { + return content.inline[0] as string; + } + + return ''; +} + +export function getFirstInline(content: Story) { + const inlines = content.filter((v) => 'inline' in v) as VerseInline[]; + if (inlines.length === 0) { + return null; + } + + return inlines[0].inline; +} + +export function citeToPath(cite: Cite) { + if ('desk' in cite) { + return `/1/desk/${cite.desk.flag}${cite.desk.where}`; + } + if ('chan' in cite) { + return `/1/chan/${cite.chan.nest}${cite.chan.where}`; + } + if ('group' in cite) { + return `/1/group/${cite.group}`; + } + + return `/1/bait/${cite.bait.group}/${cite.bait.graph}/${cite.bait.where}`; +} + +export function pathToCite(path: string): Cite | undefined { + const segments = path.split('/'); + if (segments.length < 3) { + return undefined; + } + const [, ver, kind, ...rest] = segments; + if (ver !== '1') { + return undefined; + } + if (kind === 'chan') { + if (rest.length < 3) { + return undefined; + } + const nest = rest.slice(0, 3).join('/'); + return { + chan: { + nest, + where: `/${rest.slice(3).join('/')}` || '/', + }, + }; + } + if (kind === 'desk') { + if (rest.length < 2) { + return undefined; + } + const flag = rest.slice(0, 2).join('/'); + return { + desk: { + flag, + where: `/${rest.slice(2).join('/')}` || '/', + }, + }; + } + if (kind === 'group') { + if (rest.length !== 2) { + return undefined; + } + return { + group: rest.join('/'), + }; + } + return undefined; +} + +export function useCopy(copied: string) { + const [didCopy, setDidCopy] = useState(false); + const [, copy] = useCopyToClipboard(); + + const copyFallback = async (text: string) => { + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + return true; + } catch (error) { + console.warn('Fallback copy failed', error); + return false; + } + }; + + const doCopy = useCallback(async () => { + let success = false; + if (!navigator.clipboard) { + success = await copyFallback(copied); + } else { + success = await copy(copied); + } + + setDidCopy(success); + + let timeout: NodeJS.Timeout; + if (success) { + timeout = setTimeout(() => { + setDidCopy(false); + }, 2000); + } + + return () => { + setDidCopy(false); + clearTimeout(timeout); + }; + }, [copied, copy]); + + return { doCopy, didCopy }; +} + +export function getNestShip(nest: string) { + const [, flag] = nestToFlag(nest); + const { ship } = getFlagParts(flag); + return ship; +} + +export async function asyncWithDefault( + cb: () => Promise, + def: T +): Promise { + try { + return await cb(); + } catch (error) { + return def; + } +} + +export async function asyncWithFallback( + cb: () => Promise, + def: (error: any) => Promise +): Promise { + try { + return await cb(); + } catch (error) { + return def(error); + } +} + +export function getDarkColor(color: string): string { + const hslaColor = parseToHsla(color); + return hsla(hslaColor[0], hslaColor[1], 1 - hslaColor[2], 1); +} +export function getAppHref(href: DocketHref) { + return 'site' in href ? href.site : `/apps/${href.glob.base}/`; +} + +export function disableDefault(e: T): void { + e.preventDefault(); +} + +export function handleDropdownLink( + setOpen?: (open: boolean) => void +): (e: Event) => void { + return (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + setTimeout(() => setOpen?.(false), 15); + }; +} + +export function getAppName( + app: (Docket & { desk: string }) | Treaty | undefined +): string { + if (!app) { + return ''; + } + + return app.title || app.desk; +} + +export function isSingleEmoji(input: string): boolean { + const regex = emojiRegex(); + const matches = input.match(regex); + + return ( + (matches && + matches.length === 1 && + matches.length === _.split(input, '').length) ?? + false + ); +} + +export function initializeMap(items: Record) { + let map = new BigIntOrderedMap(); + Object.entries(items).forEach(([k, v]) => { + map = map.set(bigInt(k), v as T); + }); + + return map; +} + +export function restoreMap(obj: any): BigIntOrderedMap { + const empty = new BigIntOrderedMap(); + if (!obj) { + return empty; + } + + if ('has' in obj) { + return obj; + } + + if ('root' in obj) { + return initializeMap(obj.root); + } + + return empty; +} + +export function sliceMap( + theMap: BigIntOrderedMap, + start: BigInteger, + end: BigInteger +): BigIntOrderedMap { + let empty = new BigIntOrderedMap(); + + [...theMap].forEach(([k, v]) => { + if (k.geq(start) && k.leq(end)) { + empty = empty.set(k, v); + } + }); + + return empty; +} + +const apps = ['chat', 'groups', 'channels', 'reel', 'grouper']; +const groups = [ + 'create', + 'zone', + 'mov', + 'mov-nest', + 'secret', + 'cordon', + 'open', + 'shut', + 'add-ships', + 'del-ships', + 'add-ranks', + 'del-ranks', + 'join', + 'cabal', + 'fleet', +]; +const chat = [ + 'chat-dm-action', + 'chat-club-action-0', + 'chat-dm-archive', + 'chat-dm-unarchive', + 'chat-dm-rsvp', + 'chat-club-create', + 'chat-block-ship', + 'chat-unblock-ship', + 'hive', + 'writ', +]; +const channels = [ + 'channel-action', + 'leave', + 'add-writers', + 'del-writers', + 'post', +]; +const lure = ['grouper-enable', 'grouper-disable']; +const misc = [ + 'saw-seam', + 'saw-rope', + 'anon', + 'settings-event', + 'put-bucket', + 'del-bucket', + 'put-entry', + 'del-entry', +]; +const wrappers = ['update', 'diff', 'delta', 'action', 'channel']; +const general = [ + 'add-sects', + 'del-sects', + 'view', + 'add', + 'del', + 'edit', + 'add-react', + 'del-react', + 'meta', + 'init', + 'reply', +]; + +export function actionDrill( + obj: Record, + level = 0, + prefix = '' +): string[] { + const keys: string[] = []; + const allowed = general.concat( + wrappers, + apps, + groups, + misc, + chat, + channels, + lure + ); + + Object.entries(obj).forEach(([key, val]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (!allowed.includes(key)) { + return; + } + + const skip = wrappers.includes(key); + const deeper = + val && + typeof val === 'object' && + Object.keys(val).some((k) => allowed.includes(k)); + + if (deeper && level < 4) { + // continue deeper and skip the key if just a wrapper, otherwise add on to path + keys.push( + ...actionDrill( + val as Record, + skip ? level : level + 1, + skip ? prefix : path + ) + ); + } else { + keys.push(path); + } + }); + + return keys.filter((k) => k !== ''); +} + +export function parseKind(json: Record): string { + const nest = + // eslint-disable-next-line + // @ts-ignore + json && json.channel && json.channel.nest ? json.channel.nest : ''; + + if (nest.includes('heap/~')) { + return 'heap'; + } + + if (nest.includes('diary/~')) { + return 'diary'; + } + + if (nest.includes('chat/~')) { + return 'chat'; + } + + return ''; +} + +export function truncateProse(content: Story, maxCharacters: number): Story { + const truncate = ( + [head, ...tail]: Inline[], + remainingChars: number, + acc: Inline[] + ): { truncatedItems: Inline[]; remainingChars: number } => { + if (!head || remainingChars <= 0) { + return { truncatedItems: acc, remainingChars }; + } + + let willBeEnd = false; + + if (typeof head === 'string') { + const truncatedString = head.slice(0, remainingChars); + willBeEnd = remainingChars - truncatedString.length <= 0; + return truncate(tail, remainingChars - truncatedString.length, [ + ...acc, + truncatedString.concat(willBeEnd ? '...' : ''), + ]); + } + + if ('bold' in head && typeof head.bold[0] === 'string') { + const truncatedString = (head.bold[0] as string).slice(0, remainingChars); + willBeEnd = remainingChars - truncatedString.length <= 0; + const truncatedBold: Bold = { + bold: [truncatedString.concat(willBeEnd ? '...' : '')], + }; + return truncate(tail, remainingChars - truncatedString.length, [ + ...acc, + truncatedBold, + ]); + } + + if ('italics' in head && typeof head.italics[0] === 'string') { + const truncatedString = (head.italics[0] as string).slice( + 0, + remainingChars + ); + willBeEnd = remainingChars - truncatedString.length <= 0; + const truncatedItalics: Italics = { + italics: [truncatedString.concat(willBeEnd ? '...' : '')], + }; + return truncate(tail, remainingChars - truncatedString.length, [ + ...acc, + truncatedItalics, + ]); + } + + if ('strike' in head && typeof head.strike[0] === 'string') { + const truncatedString = (head.strike[0] as string).slice( + 0, + remainingChars + ); + willBeEnd = remainingChars - truncatedString.length <= 0; + const truncatedStrike: Strikethrough = { + strike: [truncatedString.concat(willBeEnd ? '...' : '')], + }; + return truncate(tail, remainingChars - truncatedString.length, [ + ...acc, + truncatedStrike, + ]); + } + + return truncate(tail, remainingChars, [...acc, head]); + }; + + let remainingChars = maxCharacters; + let remainingImages = 1; + + const truncatedContent: Story = content + .map((verse: Verse): Verse => { + if ('inline' in verse) { + const lengthBefore = remainingChars; + const { truncatedItems, remainingChars: updatedRemainingChars } = + truncate(verse.inline, remainingChars, []); + const truncatedVerse: VerseInline = { + inline: truncatedItems, + }; + + remainingChars -= lengthBefore - updatedRemainingChars; + return truncatedVerse; + } + + if ('block' in verse) { + if (remainingChars <= 0) { + return { + inline: [''], + }; + } + + if ('cite' in verse.block) { + return { + inline: [''], + }; + } + + if ('image' in verse.block) { + if (remainingImages <= 0) { + return { + inline: [''], + }; + } + + remainingImages -= 1; + return verse; + } + + if ('header' in verse.block) { + // apparently users can add headers if they paste in content from elsewhere + const lengthBefore = remainingChars; + const { truncatedItems, remainingChars: updatedRemainingChars } = + truncate(verse.block.header.content, remainingChars, []); + const truncatedVerse: VerseBlock = { + block: { + header: { + ...verse.block.header, + content: truncatedItems, + }, + }, + }; + remainingChars = lengthBefore - updatedRemainingChars; + return truncatedVerse; + } + + if ( + 'listing' in verse.block && + 'list' in verse.block.listing && + 'items' in verse.block.listing.list + ) { + const lengthBefore = remainingChars; + const { + truncatedListItems, + remainingChars: remainingCharsAfterList, + } = verse.block.listing.list.items.reduce( + ( + accumulator: { + truncatedListItems: Listing[]; + remainingChars: number; + }, + listing: Listing + ) => { + if ('item' in listing) { + const lengthBeforeList = accumulator.remainingChars; + + if (lengthBeforeList <= 0) { + return accumulator; + } + + const { + truncatedItems, + remainingChars: updatedRemainingChars, + } = truncate(listing.item, lengthBeforeList, []); + const truncatedListing = { + item: truncatedItems, + }; + const remainingCharsInReducer = + lengthBeforeList - updatedRemainingChars; + return { + truncatedListItems: [ + ...accumulator.truncatedListItems, + truncatedListing, + ], + remainingChars: remainingCharsInReducer, + }; + } + return accumulator; + }, + { truncatedListItems: [], remainingChars } + ); + + remainingChars = remainingCharsAfterList; + const truncatedVerse: VerseBlock = { + block: { + listing: { + list: { + ...verse.block.listing.list, + items: truncatedListItems, + }, + }, + }, + }; + remainingChars -= lengthBefore - remainingChars; + return truncatedVerse; + } + + return verse; + } + + return verse; + }) + .filter((verse: Verse): boolean => { + if ('inline' in verse) { + return verse.inline.length > 0; + } + return true; + }); + + return truncatedContent; +} + +export const greenConnection = { + name: 'green', + dot: 'text-green-400', + bar: 'border-green-200 bg-green-50 text-green-500', +}; + +export const yellowConnection = { + name: 'yellow', + dot: 'text-yellow-400', + bar: 'border-yellow-400 bg-yellow-50 text-yellow-500', +}; + +export const redConnection = { + name: 'red', + dot: 'text-red-400', + bar: 'border-red-400 bg-red-50 text-red-500', +}; + +export const grayConnection = { + name: 'gray', + dot: 'text-gray-400', + bar: 'border-gray-400 bg-gray-50 text-gray-500', +}; + +export function getCompatibilityText(saga: Saga | null) { + if (saga && 'behind' in saga) { + return 'Host requires an update to communicate'; + } + + if (saga && 'ahead' in saga) { + return 'Your Groups app requires an update to communicate'; + } + + return "You're synced with host"; +} + +export function sagaCompatible(saga: Saga | null) { + // either host or synced with host + return saga === null || 'synced' in saga; +} + +export function useIsHttps() { + return window.location.protocol === 'https:'; +} + +export function useIsInThread() { + const { idTime } = useParams<{ + idTime: string; + }>(); + + return !!idTime; +} + +export function useIsDmOrMultiDm(whom: string) { + return useMemo(() => whomIsDm(whom) || whomIsMultiDm(whom), [whom]); +} + +export function useThreadParentId(whom: string) { + const isDMorMultiDM = useIsDmOrMultiDm(whom); + + const { idShip, idTime } = useParams<{ + idShip: string; + idTime: string; + }>(); + + if (isDMorMultiDM) { + return `${idShip}/${idTime}`; + } + + return idTime; +} + +export function cacheIdToString(id: CacheId) { + return `${id.author}/${id.sent}`; +} + +export function cacheIdFromString(str: string): CacheId { + const [author, sentStr] = str.split('/'); + return { + author, + sent: parseInt(udToDec(sentStr), 10), + }; +} + +export function getMessageKey(post: Post): MessageKey { + return { + id: `${post.essay.author}/${formatUd(unixToDa(post.essay.sent))}`, + time: formatUd(bigInt(post.seal.id)), + }; +} diff --git a/apps/tlon-web-new/src/main.tsx b/apps/tlon-web-new/src/main.tsx new file mode 100644 index 0000000000..8044abf286 --- /dev/null +++ b/apps/tlon-web-new/src/main.tsx @@ -0,0 +1,62 @@ +// if (import.meta.env.VITE_ENABLE_WDYR) { +// import.meta.glob('./wdyr.ts', { eager: true }); +// } + +/* eslint-disable */ +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { EditorView } from '@tiptap/pm/view'; +import { PostHogProvider } from 'posthog-js/react'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import _api from './api'; +import App from './app'; +import indexedDBPersistor from './indexedDBPersistor'; +import { setupDb } from './lib/webDb'; +import { analyticsClient, captureError } from './logic/analytics'; +import queryClient from './queryClient'; +import './styles/index.css'; + +setupDb().then(() => { + const oldUpdateState = EditorView.prototype.updateState; + + EditorView.prototype.updateState = function updateState(state) { + if (!(this as any).docView) { + //return; // This prevents the matchesNode error on hot reloads + } + // (this as any).updateStateInner(state, this.state.plugins != state.plugins); //eslint-disable-line + oldUpdateState.call(this, state); + }; + + const IS_MOCK = + import.meta.env.MODE === 'mock' || import.meta.env.MODE === 'staging'; + + if (IS_MOCK) { + window.ship = 'finned-palmer'; + window.our = '~finned-palmer'; + } + + window.our = `~${window.ship}`; + + window.addEventListener('error', (e) => { + captureError('window', e.error); + }); + + const container = document.getElementById('app') as HTMLElement; + const root = createRoot(container); + root.render( + + + + + + + + ); +}); diff --git a/apps/tlon-web-new/src/manifest-alpha.ts b/apps/tlon-web-new/src/manifest-alpha.ts new file mode 100644 index 0000000000..54a5598a58 --- /dev/null +++ b/apps/tlon-web-new/src/manifest-alpha.ts @@ -0,0 +1,40 @@ +import { ManifestOptions } from 'vite-plugin-pwa'; + +const manifest: Partial = { + name: 'Tlon', + description: + 'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a peer-to-peer collaboration tool built on Urbit that provides a few simple basics that communities can shape into something unique to their needs.', + short_name: 'Tlon', + start_url: '/apps/tm-alpha', + scope: '/apps/tm-alpha', + id: '/apps/tm-alpha/', + icons: [ + { + src: './icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: './icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: './icon-512-maskable.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + { + src: './icon-192-maskable.png', + sizes: '192x192', + type: 'image/png', + purpose: 'maskable', + }, + ], + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', +}; + +export default manifest; diff --git a/apps/tlon-web-new/src/manifest.ts b/apps/tlon-web-new/src/manifest.ts new file mode 100644 index 0000000000..481e347b96 --- /dev/null +++ b/apps/tlon-web-new/src/manifest.ts @@ -0,0 +1,40 @@ +import { ManifestOptions } from 'vite-plugin-pwa'; + +const manifest: Partial = { + name: 'Tlon', + description: + 'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a peer-to-peer collaboration tool built on Urbit that provides a few simple basics that communities can shape into something unique to their needs.', + short_name: 'Tlon', + start_url: '/apps/groups', + scope: '/apps/groups', + id: '/apps/groups/', + icons: [ + { + src: './icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: './icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: './icon-512-maskable.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + { + src: './icon-192-maskable.png', + sizes: '192x192', + type: 'image/png', + purpose: 'maskable', + }, + ], + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', +}; + +export default manifest; diff --git a/apps/tlon-web-new/src/mocks/chat.ts b/apps/tlon-web-new/src/mocks/chat.ts new file mode 100644 index 0000000000..1b50f5b779 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/chat.ts @@ -0,0 +1,181 @@ +import { faker } from '@faker-js/faker'; +import { Activity } from '@tloncorp/shared/dist/urbit/activity'; +import { + Post, + Posts, + Story, + storyFromChatStory, +} from '@tloncorp/shared/dist/urbit/channel'; +import { decToUd, unixToDa } from '@urbit/api'; +import { subDays, subMinutes } from 'date-fns'; +import _ from 'lodash'; + +import { AUTHORS } from '@/constants'; +import { randomElement } from '@/logic/utils'; + +const getUnix = (count: number, setTime?: Date) => + count > 1 + ? subMinutes(setTime ? setTime : new Date(), count * 5).getTime() + : setTime + ? setTime.getTime() + : new Date().getTime(); + +export const makeFakeChatWrit = ( + count: number, + author: string, + story: Story, + reacts?: Record, + setTime?: Date +): Post => { + const unix = getUnix(count, setTime); + const time = unixToDa(unix); + const da = decToUd(time.toString()); + return { + seal: { + id: `${author}/${da}`, + reacts: reacts ?? {}, + replies: null, + meta: { + replyCount: 0, + lastRepliers: [], + lastReply: null, + }, + }, + essay: { + 'kind-data': { + chat: null, + }, + author, + sent: unix, + content: story, + }, + }; +}; + +export const unixToDaStr = (unix: number) => decToUd(unixToDa(unix).toString()); + +export const makeFakeChatNotice = ( + count: number, + author: string, + setTime?: Date +): Post => { + const unix = getUnix(count, setTime); + const time = unixToDa(unix); + const da = decToUd(time.toString()); + return { + seal: { + id: `${author}/${da}`, + reacts: {}, + replies: null, + meta: { + replyCount: 0, + lastRepliers: [], + lastReply: null, + }, + }, + essay: { + 'kind-data': { + chat: { + notice: null, + }, + }, + author, + sent: unix, + content: [], + }, + }; +}; + +export const randInt = (max: number, min = 1) => + faker.datatype.number({ max, min }); + +const generateMessage = (time: Date) => { + const body = faker.lorem.sentences(randInt(5)); + const author = randomElement(AUTHORS); + + const chatStory = { + block: [], + inline: [body], + }; + + const story = storyFromChatStory(chatStory); + + return makeFakeChatWrit(0, author, story, undefined, time); +}; + +export const messageSequence = ({ + start = new Date(), + count, +}: { + start?: Date; + count: number; +}): Post[] => { + const times = []; + const messages: Post[] = []; + // eslint-disable-next-line no-plusplus + for (let i = 0; i < count; i++) { + times.push(subMinutes(start, i + randInt(30))); + } + times.sort(); + times.forEach((t) => { + messages.push(generateMessage(t)); + }); + + return messages; +}; + +export const makeFakeChatWrits = (offset: number) => { + const fakeChatWrits: Posts = _.keyBy( + messageSequence({ start: subDays(new Date(), offset), count: 100 }), + (val) => decToUd(unixToDa(val.essay.sent).toString()) + ); + + return fakeChatWrits; +}; + +export const chatKeys = ['~zod/test']; + +const emptySummary = { + recency: 0, + count: 0, + notify: false, + unread: null, + 'notify-count': 0, +}; + +export const dmList: Activity = { + '~fabled-faster': emptySummary, + '~nocsyx-lassul': { + recency: 1652302200000, + count: 3, + notify: false, + unread: null, + 'notify-count': 0, + }, + '~fallyn-balfus': emptySummary, + '~finned-palmer': { + recency: 1652302200000, + count: 2, + notify: false, + unread: null, + 'notify-count': 0, + }, + '~datder-sonnet': { + recency: 1652302200000, + count: 1, + notify: false, + unread: null, + 'notify-count': 0, + }, + '~hastuc-dibtux': emptySummary, + '~rilfun-lidlen': emptySummary, + '~mister-dister-dozzod-dozzod': emptySummary, +}; + +export const chatPerm = { + writers: [], +}; + +export const pendingDMs = ['~fabled-faster']; + +export const pinnedDMs = ['~nocsyx-lassul']; diff --git a/apps/tlon-web-new/src/mocks/contacts.ts b/apps/tlon-web-new/src/mocks/contacts.ts new file mode 100644 index 0000000000..c84c1163bc --- /dev/null +++ b/apps/tlon-web-new/src/mocks/contacts.ts @@ -0,0 +1,138 @@ +import { Rolodex } from '@urbit/api'; + +const mockContacts: Rolodex = { + '~finned-palmer': { + status: '', + 'last-updated': 1628685243041, + avatar: null, + cover: null, + bio: '', + nickname: '', + color: '0x2e.3cff', + groups: [], + }, + '~nocsyx-lassul': { + status: 'technomancing an electron wrapper for urbit', + 'last-updated': 1652879509836, + avatar: null, + cover: + 'https://i.pinimg.com/originals/20/62/59/2062590a440f717a2ae1065ad8e8a4c7.gif', + bio: 'Technomancer. Gaining clarity on reality daily, building calm, maintainable, and resilient systems.', + nickname: '~nocsyx-lassul ⚗️', + color: '0x39.00d6', + groups: [ + '~nocsyx-lassul/holy-grail-ui', + '~wolref-podlex/foundation', + '~fabled-faster/structure', + ], + }, + '~tocref-ripmyr': { + status: 'technomancing an electron wrapper for urbit', + 'last-updated': 1652879509836, + avatar: + 'https://bladee.nyc3.digitaloceanspaces.com/tocref-ripmyr/2022.2.19..01.24.21-White-Faced-Saki-Monkey-3-490x327x0x0x490x327x1611659214.jpg', + cover: + 'https://cdn.pocket-lint.com/r/s/970x/assets/images/69334-phones-review-lg-prada-mobile-phone-image1-uqssc0mrfz-jpg.webp', + bio: 'The LG KE850, also known as the LG Prada, is a touchscreen mobile phone made by LG Electronics. It was first announced on December 12, 2006 and was created in collaboration with Italian luxury designer Prada. It was made official in a press release on January 18, 2007. Sales started in May 2007, retailing for about $777 (600 euros). \n\n It is the first mobile phone with a capacitive touchscreen. The KE850 sold 1 million units in the first 18 months. A second version of the phone, the LG Prada II (KF900) was released December 2008.[citation needed]', + nickname: 'Dan', + color: '0x0', + groups: [ + '~zod/tlon0', + '~zod/tlon1', + '~zod/tlon2', + '~zod/tlon3', + '~zod/tlon4', + '~zod/tlon5', + '~zod/tlon6', + '~zod/tlon7', + '~zod/tlon8', + '~zod/tlon9', + '~zod/tlon10', + '~zod/tlon11', + '~zod/tlon12', + '~zod/tlon13', + '~zod/tlon14', + '~zod/tlon15', + '~zod/tlon16', + '~zod/tlon17', + '~zod/tlon18', + '~zod/tlon19', + ], + }, + '~hastuc-dibtux': { + status: 'Network Spirituality', + 'last-updated': 1649439191889, + avatar: + 'https://lh3.googleusercontent.com/M1kx277EWn0x1PR_kMb3hSJLj086mhasqkm3WkdUFLV4lhHJ6mZWFMaPBERn3A2iFubegTHe8dDDcx20iuJSK0o0bmu_UiwKXhNj=s0', + cover: + 'https://urbit.ewr1.vultrobjects.com/hastuc-dibtux/2021.2.12..03.56.32-A2E46340-7F0E-478D-97F5-ED85E0123459.jpeg', + bio: "Enfant terrible @ tlon.io\n\ntime warfare\n\nguerilla hooning\n\nif you're having trouble on Urbit, DM me!\n", + nickname: '~𝚑𝚊𝚜𝚝𝚞𝚌-𝚍𝚒𝚋𝚝𝚞𝚡 ⟳', + color: '0xee.5431', + groups: ['~hastuc-dibtux/pharma', '~ladpeg-pintem/freenode'], + }, + '~datder-sonnet': { + status: 'in ~zod we trust', + 'last-updated': 1647446196001, + avatar: null, + cover: + 'https://pbs.twimg.com/profile_banners/922197314579697666/1508704898/1500x500', + bio: 'High-Functioning Urbit Maximalist\n\nFrontend @ Tlon\n\nhttps://tholf.org', + nickname: '', + color: '0x0', + groups: ['~dister-datder-sonnet/hodl', '~batbex/gem-rubyists'], + }, + '~fallyn-balfus': { + status: '', + 'last-updated': 1635790731403, + avatar: '', + cover: + 'https://fallyn-balfus.sfo2.digitaloceanspaces.com/fallyn-balfus/2021.6.07..21.46.55-Screen%20Shot%202021-06-07%20at%202%2C46%2C32%20PM.png', + bio: 'pls no', + nickname: 'jyng', + color: '0x0', + groups: [ + '~fabled-faster/wind', + '~wolref-podlex/tea', + '~fabled-faster/interface-testing-facility', + ], + }, + '~fabled-faster': { + status: 'Build-a-Bear', + 'last-updated': 1652722355452, + avatar: '', + cover: '', + bio: 'thaumaturge at tlon\n\ned@tlon.io\n\nbc1qa256ysm78ss0k66ul8fd0g68m2zdmg8m3rncy4', + nickname: 'éd', + color: '0xc4.c5c6', + groups: [ + '~fabled-faster/wind', + '~fabled-faster/storage', + '~rondev/group-discovery', + '~fabled-faster/structure', + ], + }, + '~rilfun-lidlen': { + status: '', + 'last-updated': 1652915627705, + avatar: '', + cover: + 'http://inapcache.boston.com/universal/site_graphics/blogs/bigpicture/msh30_05_18/m19_mboe0016.jpg', + bio: '', + nickname: 'james', + color: '0xff.ffff', + groups: [], + }, + '~riprud-tidmel': { + status: '', + 'last-updated': 1652722355452, + avatar: '', + cover: '', + bio: '', + nickname: 'marisa', + color: '0x00.0000', + groups: [], + }, +}; + +export default mockContacts; diff --git a/apps/tlon-web-new/src/mocks/groups.ts b/apps/tlon-web-new/src/mocks/groups.ts new file mode 100644 index 0000000000..43ab5ad138 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/groups.ts @@ -0,0 +1,388 @@ +import { faker } from '@faker-js/faker'; +import { + Cordon, + Gang, + Gangs, + Group, + GroupIndex, + GroupPreview, + PrivacyType, + Vessel, +} from '@tloncorp/shared/dist/urbit/groups'; + +import { AUTHORS } from '@/constants'; +import { randomElement } from '@/logic/utils'; + +const emptyVessel = (): Vessel => ({ + sects: [], + joined: Date.now(), +}); + +const adminVessel = (): Vessel => ({ + sects: ['admin'], + joined: Date.now(), +}); + +const randomColor = () => Math.floor(Math.random() * 16777215).toString(16); + +export function makeCordon(privacy = 'public') { + let cordon: Cordon; + switch (privacy) { + case 'public': + cordon = { + open: { + ships: [], + ranks: [], + }, + }; + break; + case 'private': + cordon = { + shut: { + ask: [], + pending: [], + }, + }; + break; + default: + cordon = { + afar: { + app: '', + path: '', + desc: '', + }, + }; + break; + } + return cordon; +} + +export function makeGroupPreview(privacy = 'public'): GroupPreview { + return { + flag: '~zod/test', + cordon: makeCordon(privacy), + time: Date.now(), + meta: { + title: faker.company.name(), + description: faker.company.catchPhrase(), + image: `#${randomColor()}`, + cover: `#${randomColor()}`, + }, + secret: false, + }; +} + +export function createMockGang({ + flag, + hasClaim = false, + hasInvite = false, + hasPreview = false, + privacy = 'public', +}: { + flag: string; + hasClaim?: boolean; + hasInvite?: boolean; + hasPreview?: boolean; + privacy?: PrivacyType; +}): Gang { + return { + claim: hasClaim + ? { + progress: 'done', + 'join-all': false, + } + : null, + invite: hasInvite + ? { + flag, + ship: randomElement(AUTHORS), + } + : null, + preview: hasPreview ? makeGroupPreview(privacy) : null, + }; +} + +export function createMockIndex(ship: string): GroupIndex { + return { + [`~${ship}/some-public-group`]: makeGroupPreview(), + [`~${ship}/some-private-group`]: makeGroupPreview('private'), + [`~${ship}/some-secret-group`]: makeGroupPreview('secret'), + }; +} + +export function createMockGroup(title: string): Group { + return { + fleet: { + '~hastuc-dibtux': emptyVessel(), + '~finned-palmer': adminVessel(), + '~zod': emptyVessel(), + }, + cabals: { + admin: { + meta: { + title: 'Admin', + description: '', + image: '', + cover: '', + }, + }, + member: { + meta: { + title: 'Member', + description: '', + image: '', + cover: '', + }, + }, + }, + channels: {}, + cordon: { + open: { + ranks: ['czar'], + ships: ['~bus'], + }, + }, + meta: { + title, + description: + 'We build infrastructre that is technically excellent, architecturally sound, and aesthetically beautiful', + image: + 'https://nyc3.digitaloceanspaces.com/hmillerdev/nocsyx-lassul/2022.6.14..18.37.11-Icon Box.png', + cover: '', + }, + zones: { + default: { + meta: { + title: 'Sectionless', + cover: '', + image: '', + description: '', + }, + idx: [], + }, + }, + bloc: [], + 'zone-ord': ['default'], + secret: false, + saga: { synced: null }, + 'flagged-content': {}, + }; +} +const mockGroupOne: Group = { + fleet: { + '~finned-palmer': emptyVessel(), + '~zod': adminVessel(), + '~tocref-ripmyr': emptyVessel(), + '~hastuc-dibtux': emptyVessel(), + '~fallyn-balfus': emptyVessel(), + '~fabled-faster': emptyVessel(), + '~rilfun-lidlen': emptyVessel(), + '~nocsyx-lassul': emptyVessel(), + }, + cabals: { + admin: { + meta: { + title: 'Admin', + description: '', + image: '', + cover: '', + }, + }, + member: { + meta: { + title: 'Member', + description: '', + image: '', + cover: '', + }, + }, + }, + channels: { + 'chat/~dev/test': { + meta: { + title: 'Watercooler', + description: 'watering hole', + image: '', + cover: '', + }, + added: 1657774188151, + join: false, + readers: [], + zone: 'default', + }, + }, + cordon: { + open: { + ranks: ['czar'], + ships: ['~bus'], + }, + }, + meta: { + title: 'tlon', + description: 'the tlon corporation', + image: '', + cover: '', + }, + zones: { + default: { + meta: { + title: 'Sectionless', + cover: '', + image: '', + description: '', + }, + idx: ['/chat/~dev/test'], + }, + }, + bloc: [], + 'zone-ord': ['default'], + secret: false, + saga: { synced: null }, + 'flagged-content': {}, +}; + +const mockGroupTwo: Group = { + fleet: { + '~finned-palmer': adminVessel(), + }, + cabals: { + admin: { + meta: { + title: 'Admin', + description: '', + image: '', + cover: '', + }, + }, + member: { + meta: { + title: 'Member', + description: '', + image: '', + cover: '', + }, + }, + }, + channels: { + 'chat/~zod/test': { + meta: { + title: 'Milady', + description: 'Milady maker chatroom', + image: '', + cover: '', + }, + added: 1657774188151, + join: true, + readers: [], + zone: 'default', + }, + 'heap/~zod/testHeap': { + meta: { + title: 'Martini Gallery', + description: 'Martini Maker Gallery', + image: '', + cover: '', + }, + added: 1657774188151, + join: true, + readers: [], + zone: 'default', + }, + }, + cordon: { + open: { + ranks: ['czar'], + ships: ['~bus'], + }, + }, + meta: { + title: 'remco', + description: 'The urbit group for remilia, a digital art collective', + image: '', + cover: '', + }, + zones: { + default: { + meta: { + title: 'Sectionless', + cover: '', + image: '', + description: '', + }, + idx: ['heap/~zod/testHeap', 'chat/~zod/test'], + }, + }, + bloc: [], + 'zone-ord': ['default'], + secret: false, + saga: { synced: null }, + 'flagged-content': {}, +}; + +const mockGroups: { [flag: string]: Group } = { + '~zod/remco': mockGroupTwo, + '~dev/tlon': mockGroupOne, +}; + +export function createChannel(title: string) { + return { + meta: { + title, + description: 'Do some chatting', + image: '', + cover: '', + }, + added: 1657774188151, + join: false, + readers: [], + zone: 'default', + }; +} + +for (let i = 0; i < 20; i += 1) { + const group = createMockGroup(faker.company.name()); + + for (let j = 0; j < 20; j += 1) { + group.channels[`/chat/~zod/tlon${i}${j}`] = createChannel(j.toString()); + group.zones.default.idx.push(`/chat/~zod/tlon${i}${j}`); + } + + mockGroups[`~zod/tlon${i}`] = group; +} + +export const mockGangs: Gangs = { + '~zod/structure': { + invite: { + flag: '~zod/structure', + ship: '~fabled-faster', + }, + claim: { + progress: 'adding', + 'join-all': true, + }, + preview: { + flag: '~zod/structure', + time: Date.now(), + cordon: { + afar: { + app: '~zod/eth-verify', + path: '/x/can-join/', + desc: 'This group requires a', + }, + }, + meta: { + title: 'Structure', + description: + 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.', + image: + 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png', + cover: '', + }, + secret: false, + }, + }, +}; + +export const pinnedGroups = ['~zod/remco', '~dev/tlon']; + +export default mockGroups; diff --git a/apps/tlon-web-new/src/mocks/handlers.ts b/apps/tlon-web-new/src/mocks/handlers.ts new file mode 100644 index 0000000000..eef5db1aa2 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/handlers.ts @@ -0,0 +1,610 @@ +import UrbitMock, { + Handler, + Message, + Poke, + PokeHandler, + ScryHandler, + SubscriptionHandler, + SubscriptionRequestInterface, + createResponse, +} from '@tloncorp/mock-http-api'; +import { + Club, + ClubAction, + ClubCreate, + DMUnreads, + DMWhom, + DmRsvp, + WritDiff, +} from '@tloncorp/shared/dist/urbit/dms'; +import { GroupAction } from '@tloncorp/shared/dist/urbit/groups'; +import { decToUd, udToDec, unixToDa } from '@urbit/api'; +import bigInt from 'big-integer'; +import _ from 'lodash'; + +import { + chatKeys, + dmList, + makeFakeChatWrits, + pendingDMs, + pinnedDMs, +} from '@/mocks/chat'; +import mockContacts from '@/mocks/contacts'; +import mockGroups, { + createMockIndex, + mockGangs, + pinnedGroups, +} from '@/mocks/groups'; +import heapHandlers from '@/mocks/heaps'; + +const getNowUd = () => decToUd(unixToDa(Date.now() * 1000).toString()); + +const archive: string[] = []; +const pins: string[] = [...pinnedDMs, ...pinnedGroups]; +const sortByUd = (aString: string, bString: string) => { + const a = bigInt(udToDec(aString)); + const b = bigInt(udToDec(bString)); + + return a.compare(b); +}; + +const emptyChatWritsSet = {}; + +const chatWritsSet1 = makeFakeChatWrits(0); +const chatWritsSet1Keys = Object.keys(chatWritsSet1).sort(sortByUd); +const startIndexSet1 = chatWritsSet1Keys[0]; +const set1StartDa = startIndexSet1; +const set1EndDa = chatWritsSet1Keys[chatWritsSet1Keys.length - 1]; + +const chatWritsSet2 = makeFakeChatWrits(1); +const chatWritsSet2Keys = Object.keys(chatWritsSet2).sort(sortByUd); +const startIndexSet2 = chatWritsSet2Keys[0]; +const set2StartDa = startIndexSet2; +const set2EndDa = chatWritsSet2Keys[chatWritsSet2Keys.length - 1]; + +const chatWritsSet3 = makeFakeChatWrits(2); +const chatWritsSet3Keys = Object.keys(chatWritsSet3).sort(sortByUd); +const startIndexSet3 = chatWritsSet3Keys[0]; +const set3StartDa = startIndexSet3; +const set3EndDa = chatWritsSet3Keys[chatWritsSet3Keys.length - 1]; + +const chatWritsSet4 = makeFakeChatWrits(3); +const chatWritsSet4Keys = Object.keys(chatWritsSet4).sort(sortByUd); +const startIndexSet4 = chatWritsSet4Keys[0]; +const set4StartDa = startIndexSet4; +const set4EndDa = chatWritsSet4Keys[chatWritsSet4Keys.length - 1]; + +const fakeDefaultSub = { + action: 'subscribe', + app: 'chat', + path: '/', +} as SubscriptionRequestInterface; + +const groupSub = { + action: 'subscribe', + app: 'groups', + path: '/groups/ui', +} as SubscriptionHandler; + +const specificGroupSub = { + action: 'subscribe', + app: 'groups', + path: '/groups/:ship/:name/ui', +} as SubscriptionHandler; + +const unreadsSub = { + action: 'subscribe', + app: 'chat', + path: `/unreads`, +} as SubscriptionHandler; + +const settingsSub = { + action: 'subscribe', + app: 'settings-store', + path: '/desk/groups', +} as SubscriptionHandler; + +const settingsPoke: PokeHandler = { + action: 'poke', + app: 'settings-store', + mark: 'settings-action', + returnSubscription: settingsSub, + dataResponder: (req: Message & Poke) => createResponse(req), +}; + +const contactSub = { + action: 'subscribe', + app: 'contact-store', + path: '/all', + initialResponder: (req) => + createResponse(req, 'diff', { + 'contact-update-0': { + initial: { + 'is-public': false, + rolodex: mockContacts, + }, + }, + }), +} as SubscriptionHandler; + +const contactNacksSub = { + action: 'subscribe', + app: 'contact-pull-hook', + path: '/nacks', +} as SubscriptionHandler; + +const groupIndexSub = { + action: 'subscribe', + app: 'groups', + path: '/gangs/index/:ship', + initialResponder: (req) => + createResponse(req, 'diff', { + ...createMockIndex(req.ship), + }), +} as SubscriptionHandler; + +const groups: Handler[] = [ + groupSub, + specificGroupSub, + { + action: 'poke', + app: 'groups', + mark: 'group-action-2', + returnSubscription: specificGroupSub, + dataResponder: (req: Message & Poke) => + createResponse(req, 'diff', { + ...req.json.update, + time: getNowUd(), + }), + }, + { + action: 'scry', + app: 'groups', + path: '/groups', + func: () => mockGroups, + } as ScryHandler, + { + action: 'scry', + app: 'groups', + path: '/gangs', + func: () => mockGangs, + } as ScryHandler, + groupIndexSub, +]; + +const chatSub = { + action: 'subscribe', + app: 'chat', + path: `/chat/:ship/:name/ui/writs`, +} as SubscriptionHandler; + +const chat: Handler[] = [ + { + action: 'scry', + app: 'chat', + path: `/chat/:ship/:name/writs/newest/100`, + func: () => chatWritsSet1, + } as ScryHandler, + { + action: 'scry', + app: 'chat', + path: `/chat/:ship/:name/perm`, + func: () => ({ + writers: [], + }), + }, + unreadsSub, + { + action: 'scry' as const, + app: 'chat', + path: '/unreads', + func: () => { + const unarchived = _.fromPairs( + Object.entries(dmList).filter(([k]) => !archive.includes(k)) + ); + + const unreads: DMUnreads = {}; + Object.values(mockGroups).forEach((group) => + Object.entries(group.channels).forEach(([k]) => { + unreads[k] = { + recency: 1652302200000, + count: 1, + unread: null, + threads: {}, + }; + }) + ); + + return { + ...unarchived, + ...unreads, + '0v4.00000.qcas9.qndoa.7loa7.loa7l': { + recency: 1652302200000, + count: 1, + unread: null, + threads: {}, + }, + '~zod/test': { + recency: 1652302200000, + count: 1, + unread: null, + threads: {}, + }, + }; + }, + }, + { + action: 'scry', + app: 'chat', + path: `/draft/:ship/:name`, + func: (p, api, params) => { + if (!params) { + return ''; + } + + const key = params.name + ? `draft-${params.ship}/${params.name}` + : `draft-${params.ship}`; + + return JSON.parse(localStorage.getItem(key) || ''); + }, + }, + { + action: 'scry', + app: 'chat', + path: '/chat', + func: () => chatKeys, + } as ScryHandler, + { + action: 'poke', + app: 'chat', + mark: 'chat-remark-action', + returnSubscription: unreadsSub, + dataResponder: ( + req: Message & Poke<{ whom: DMWhom; diff: { read: null } }> + ) => + createResponse(req, 'diff', { + whom: req.json.whom, + unread: { + recency: 0, + count: 0, + unread: null, + threads: {}, + }, + }), + }, +]; + +const newerChats: ScryHandler[] = [ + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/newer/${set1EndDa}/100`, + app: 'chat', + func: () => emptyChatWritsSet, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/newer/${set2EndDa}/100`, + app: 'chat', + func: () => chatWritsSet1, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/newer/${set3EndDa}/100`, + app: 'chat', + func: () => chatWritsSet2, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/newer/${set4EndDa}/100`, + app: 'chat', + func: () => chatWritsSet3, + }, +]; + +const olderChats: ScryHandler[] = [ + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/older/${set1StartDa}/100`, + app: 'chat', + func: () => chatWritsSet2, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/older/${set2StartDa}/100`, + app: 'chat', + func: () => chatWritsSet3, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/older/${set3StartDa}/100`, + app: 'chat', + func: () => chatWritsSet4, + }, + { + action: 'scry' as const, + path: `/chat/:ship/:name/writs/older/${set4StartDa}/100`, + app: 'chat', + func: () => emptyChatWritsSet, + }, +]; + +const dmSub = { + action: 'subscribe', + app: 'chat', + path: `/dm/:ship/ui`, +} as SubscriptionHandler; + +const dms: Handler[] = [ + dmSub, + { + action: 'scry' as const, + path: `/dm/:ship/writs/newest/100`, + app: 'chat', + func: () => chatWritsSet1, + }, + // newer + { + action: 'scry' as const, + path: `/dm/:ship/writs/newer/${set1EndDa}/100`, + app: 'chat', + func: () => emptyChatWritsSet, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/newer/${set2EndDa}/100`, + app: 'chat', + func: () => chatWritsSet1, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/newer/${set3EndDa}/100`, + app: 'chat', + func: () => chatWritsSet2, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/newer/${set4EndDa}/100`, + app: 'chat', + func: () => chatWritsSet3, + }, + // older + { + action: 'scry' as const, + path: `/dm/:ship/writs/older/${set1StartDa}/100`, + app: 'chat', + func: () => chatWritsSet2, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/older/${set2StartDa}/100`, + app: 'chat', + func: () => chatWritsSet3, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/older/${set3StartDa}/100`, + app: 'chat', + func: () => chatWritsSet4, + }, + { + action: 'scry' as const, + path: `/dm/:ship/writs/older/${set4StartDa}/100`, + app: 'chat', + func: () => emptyChatWritsSet, + }, + { + action: 'scry', + app: 'chat', + path: '/dm', + func: () => Object.keys(dmList).filter((k) => !archive.includes(k)), + }, + { + action: 'scry', + app: 'chat', + path: '/dm/invited', + func: () => pendingDMs, + }, + { + action: 'subscribe', + app: 'chat', + path: '/dm/invited', + }, + { + action: 'scry', + app: 'chat', + path: '/dm/archive', + func: () => archive, + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-dm-action', + returnSubscription: dmSub, + initialResponder: ( + req: Message & Poke<{ ship: string; diff: WritDiff }>, + api: UrbitMock + ) => { + if (!Object.keys(dmList).includes(req.json.ship)) { + const unread = { + recency: 1652302200000, + count: 1, + notify: false, + unread: null, + 'notify-count': 0, + }; + dmList[req.json.ship] = unread; + + api.publishUpdate( + unreadsSub, + { + whom: req.json.ship, + unread, + }, + req.mark + ); + } + + return createResponse(req); + }, + dataResponder: (req: Message & Poke<{ ship: string; diff: WritDiff }>) => + createResponse(req, 'diff', req.json.diff), + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-dm-rsvp', + returnSubscription: { + action: 'subscribe', + app: 'chat', + path: '/', + } as SubscriptionRequestInterface, + dataResponder: (req: Message & Poke) => { + if (req.json.ok) { + archive.splice(archive.indexOf(req.json.ship), 1); + } else { + archive.push(req.json.ship); + } + + return createResponse(req, 'diff'); + }, + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-dm-archive', + returnSubscription: { + action: 'subscribe', + app: 'chat', + path: '/', + } as SubscriptionRequestInterface, + dataResponder: (req: Message & Poke) => { + archive.push(req.json); + + return createResponse(req, 'diff'); + }, + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-dm-unarchive', + returnSubscription: fakeDefaultSub, + dataResponder: (req: Message & Poke) => { + const index = archive.indexOf(req.json); + archive.splice(index, 1); + + return createResponse(req, 'diff'); + }, + }, +]; + +const clubs: { [id: string]: Club } = { + '0v4.00000.qcas9.qndoa.7loa7.loa7l': { + team: ['~nocsyx-lassul', '~datder-sonnet'], + hive: ['~rilfun-lidlen', '~finned-palmer'], + meta: { + title: 'Pain Gang', + description: '', + image: '', + cover: '', + }, + }, +}; + +const clubSub = { + action: 'subscribe', + app: 'chat', + path: '/club/:id/ui', +} as SubscriptionHandler; + +const clubWritsSub = { + action: 'subscribe', + app: 'chat', + path: '/club/:id/ui/writs', +} as SubscriptionHandler; + +const clubHandlers: Handler[] = [ + clubSub, + clubWritsSub, + { + action: 'subscribe', + app: 'chat', + path: '/club/new', + }, + { + action: 'scry', + app: 'chat', + path: '/club/:id/writs/newest/:count', + func: () => ({}), + }, + { + action: 'scry', + app: 'chat', + path: '/club/:id/crew', + func: (req, api, params) => { + if (!params || !(params.id in clubs)) { + return null; + } + + return clubs[params.id]; + }, + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-club-action', + returnSubscription: (req: Message & Poke) => + 'writ' in req.json.diff.delta ? clubWritsSub : clubSub, + dataResponder: (req: Message & Poke) => + createResponse( + req, + 'diff', + 'writ' in req.json.diff.delta + ? req.json.diff.delta.writ + : req.json.diff.delta + ), + }, + { + action: 'poke', + app: 'chat', + mark: 'chat-club-create', + returnSubscription: fakeDefaultSub, + dataResponder: (req: Message & Poke) => { + clubs[req.json.id] = { + team: [window.our], + hive: req.json.hive, + meta: { + title: '', + description: '', + image: '', + cover: '', + }, + }; + + return createResponse(req, 'diff'); + }, + }, +]; + +const mockHandlers: Handler[] = ( + [ + settingsSub, + settingsPoke, + contactSub, + contactNacksSub, + { + action: 'scry', + app: 'settings-store', + path: '/desk/groups', + func: () => ({ + desk: { + display: { + theme: 'auto', + }, + }, + }), + }, + ] as Handler[] +).concat(groups, chat, dms, newerChats, olderChats, clubHandlers, heapHandlers); + +export default mockHandlers; diff --git a/apps/tlon-web-new/src/mocks/heaps.ts b/apps/tlon-web-new/src/mocks/heaps.ts new file mode 100644 index 0000000000..75ca19ea08 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/heaps.ts @@ -0,0 +1,221 @@ +import { + Handler, + ScryHandler, + SubscriptionHandler, +} from '@tloncorp/mock-http-api'; +import { Channels, Perm, Posts } from '@tloncorp/shared/dist/urbit/channel'; +import { subMinutes } from 'date-fns'; + +const unixTime = subMinutes(new Date(), 1).getTime(); + +const mockPerms: Perm = { + writers: ['~zod', '~finned-palmer'], + group: '~zod/test', +}; + +const mockStash: Channels = { + 'heap/~zod/testHeap': { + perms: mockPerms, + view: 'grid', + order: [], + sort: 'time', + pending: { + posts: {}, + replies: {}, + }, + }, +}; + +const mockCurios: Posts = { + '170141184505776467152677676749638598656': { + seal: { + id: '170141184505776467152677676749638598656', + replies: [], + meta: { + lastReply: null, + replyCount: 0, + lastRepliers: [], + }, + reacts: {}, + }, + essay: { + 'kind-data': { + heap: 'House rendering', + }, + content: [ + { + inline: [ + 'https://finned-palmer.s3.filebase.com/finned-palmer/2022.3.31..15.13.50-rendering1.png', + ], + }, + ], + author: '~finned-palmer', + sent: unixTime, + }, + }, + '170141184505776467152677676749638598657': { + seal: { + id: '170141184505776467152677676749638598657', + replies: [], + meta: { + lastReply: null, + replyCount: 0, + lastRepliers: [], + }, + reacts: {}, + }, + essay: { + 'kind-data': { + heap: 'Description of a Martini', + }, + content: [ + { + inline: [ + 'The martini is a cocktail made with gin and vermouth, and garnished with an olive or a lemon twist.', + ], + }, + ], + author: '~finned-palmer', + sent: unixTime, + }, + }, + '170141184505776467152677676749638598658': { + seal: { + id: '170141184505776467152677676749638598658', + replies: [], + meta: { + lastReply: null, + replyCount: 0, + lastRepliers: [], + }, + reacts: {}, + }, + essay: { + 'kind-data': { + heap: 'House rendering', + }, + content: [ + { + inline: [ + 'https://finned-palmer.s3.filebase.com/finned-palmer/2022.3.31..15.13.50-rendering1.png', + ], + }, + ], + author: '~finned-palmer', + sent: unixTime, + }, + }, + '170141184505776467152677676749638598659': { + seal: { + id: '170141184505776467152677676749638598659', + replies: [], + meta: { + lastReply: null, + replyCount: 0, + lastRepliers: [], + }, + reacts: {}, + }, + essay: { + 'kind-data': { + heap: '', + }, + content: [ + { + inline: [ + 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/15-09-26-RalfR-WLC-0084.jpg/1920px-15-09-26-RalfR-WLC-0084.jpg', + ], + }, + ], + author: '~finned-palmer', + sent: unixTime, + }, + }, + '170141184505776467152677676749638598660': { + seal: { + id: '170141184505776467152677676749638598660', + replies: [], + meta: { + lastReply: null, + replyCount: 0, + lastRepliers: [], + }, + reacts: {}, + }, + essay: { + 'kind-data': { + heap: 'One Thing About Me', + }, + content: [ + { + inline: [ + 'https://twitter.com/noagencynewyork/status/1540353656326946817?s=20&t=OSmaPCFVGbJmjvs1VtJtkg', + ], + }, + ], + author: '~finned-palmer', + sent: unixTime, + }, + }, +}; + +export const heapUnreadsSub: SubscriptionHandler = { + action: 'subscribe', + app: 'heap', + path: '/unreads', +}; + +export const heapStashScry: ScryHandler = { + action: 'scry', + app: 'heap', + path: '/stash', + func: () => mockStash, +}; + +export const heapUnreadsScry: ScryHandler = { + action: 'scry', + app: 'heap', + path: '/unreads', + func: () => ({ + '~zod/testHeap': { + last: unixTime, + count: 1, + 'unread-id': null, + }, + }), +}; + +export const heapPermsScry: ScryHandler = { + action: 'scry', + app: 'heap', + path: '/heap/~zod/testHeap/perm', + func: () => ({ + perms: { + writers: ['~zod', '~finned-palmer'], + }, + }), +}; + +export const heapCuriosScry: ScryHandler = { + action: 'scry', + app: 'heap', + path: '/heap/~zod/testHeap/curios/newest/100', + func: () => mockCurios, +}; + +export const heapCuriosSubscribe: SubscriptionHandler = { + action: 'subscribe', + app: 'heap', + path: '/heap/~zod/testHeap/ui/curios', +}; + +const heapHandlers: Handler[] = [ + heapUnreadsSub, + heapStashScry, + heapUnreadsScry, + heapPermsScry, + heapCuriosScry, + heapCuriosSubscribe, +]; + +export default heapHandlers; diff --git a/apps/tlon-web-new/src/mocks/react-native-firebase-app.js b/apps/tlon-web-new/src/mocks/react-native-firebase-app.js new file mode 100644 index 0000000000..512e4c67a8 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/react-native-firebase-app.js @@ -0,0 +1,10 @@ +// src/mocks/react-native-firebase-app.js +const mockFirebaseApp = { + initializeApp: () => {}, + app: () => ({ + // Mock app methods + }), + // Add any other methods or properties used in your app +}; + +export default mockFirebaseApp; diff --git a/apps/tlon-web-new/src/mocks/react-native-firebase-crashlytics.js b/apps/tlon-web-new/src/mocks/react-native-firebase-crashlytics.js new file mode 100644 index 0000000000..b9cf474452 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/react-native-firebase-crashlytics.js @@ -0,0 +1,11 @@ +// src/mocks/react-native-firebase-crashlytics.js +const mockCrashlytics = { + crash: () => {}, + log: () => {}, + setAttribute: () => {}, + setAttributes: () => {}, + setUserId: () => {}, + // Add any other methods used in your app +}; + +export default mockCrashlytics; diff --git a/apps/tlon-web-new/src/mocks/react-native-gesture-handler.js b/apps/tlon-web-new/src/mocks/react-native-gesture-handler.js new file mode 100644 index 0000000000..5d146535f8 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/react-native-gesture-handler.js @@ -0,0 +1,3 @@ +const Swipeable = {}; + +export default Swipeable; diff --git a/apps/tlon-web-new/src/mocks/tentap-editor.js b/apps/tlon-web-new/src/mocks/tentap-editor.js new file mode 100644 index 0000000000..6c24c7b21f --- /dev/null +++ b/apps/tlon-web-new/src/mocks/tentap-editor.js @@ -0,0 +1,5 @@ +export const PlaceholderBridge = {}; +export const RichText = {}; +export const TenTapStartKit = {}; +export const useBridgeState = {}; +export const useEditorBridge = {}; diff --git a/apps/tlon-web-new/src/mocks/tloncorp-editor-bridges.js b/apps/tlon-web-new/src/mocks/tloncorp-editor-bridges.js new file mode 100644 index 0000000000..da91d2de48 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/tloncorp-editor-bridges.js @@ -0,0 +1,3 @@ +export const CodeBlockBridge = {}; +export const MentionsBridge = {}; +export const ShortcutsBridge = {}; diff --git a/apps/tlon-web-new/src/mocks/tloncorp-editor-html.js b/apps/tlon-web-new/src/mocks/tloncorp-editor-html.js new file mode 100644 index 0000000000..9bd4976c77 --- /dev/null +++ b/apps/tlon-web-new/src/mocks/tloncorp-editor-html.js @@ -0,0 +1 @@ +export const editorHtml = ''; diff --git a/apps/tlon-web-new/src/queryClient.ts b/apps/tlon-web-new/src/queryClient.ts new file mode 100644 index 0000000000..20e92c88d8 --- /dev/null +++ b/apps/tlon-web-new/src/queryClient.ts @@ -0,0 +1,17 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + cacheTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + mutations: { + // because of urbit's single threaded nature, we don't want to retry mutations since it might just be busy + retry: false, + }, + }, +}); + +export default queryClient; diff --git a/apps/tlon-web-new/src/state/base.ts b/apps/tlon-web-new/src/state/base.ts new file mode 100644 index 0000000000..8f4a216a54 --- /dev/null +++ b/apps/tlon-web-new/src/state/base.ts @@ -0,0 +1,227 @@ +/* eslint-disable no-param-reassign */ +import UrbitMock from '@tloncorp/mock-http-api'; +import { Poke } from '@urbit/api'; +import Urbit, { + FatalError, + SubscriptionRequestInterface, +} from '@urbit/http-api'; +import { + Patch, + applyPatches, + enablePatches, + produceWithPatches, + setAutoFreeze, +} from 'immer'; +import _ from 'lodash'; +import { compose } from 'lodash/fp'; +import create, { GetState, SetState, UseStore } from 'zustand'; +import { PersistOptions, persist } from 'zustand/middleware'; + +import api from '../api'; +import { + clearStorageMigration, + createStorageKey, + storageVersion, +} from '../logic/utils'; + +setAutoFreeze(false); +enablePatches(); + +export const stateSetter = >( + fn: (state: Readonly>) => void, + set: (newState: T & BaseState) => void, + get: () => T & BaseState +): void => { + const old = get(); + const [state] = produceWithPatches(old, fn) as readonly [ + T & BaseState, + any, + Patch[], + ]; + // console.log(patches); + set(state); +}; + +export const optStateSetter = >( + fn: (state: T & BaseState) => void, + set: (newState: T & BaseState) => void, + get: () => T & BaseState +): string => { + const old = get(); + const id = _.uniqueId(); + const [state, , patches] = produceWithPatches(old, fn) as readonly [ + T & BaseState, + any, + Patch[], + ]; + set({ ...state, patches: { ...state.patches, [id]: patches } }); + return id; +}; + +export const reduceState = , U>( + state: UseStore>, + data: U, + reducers: ((payload: U, current: S & BaseState) => S & BaseState)[] +): void => { + const reducer = compose(reducers.map((r) => (sta) => r(data, sta))); + state.getState().set((s) => { + reducer(s); + }); +}; + +export const reduceStateN = , U>( + state: S & BaseState, + data: U, + reducers: ((payload: U, current: S & BaseState) => S & BaseState)[] +): void => { + const reducer = compose(reducers.map((r) => (sta) => r(data, sta))); + state.set(reducer); +}; + +export const optReduceState = , U>( + state: UseStore>, + data: U, + reducers: ((payload: U, current: S & BaseState) => BaseState & S)[] +): string => { + const reducer = compose(reducers.map((r) => (sta) => r(data, sta))); + return state.getState().optSet((s) => { + reducer(s); + }); +}; + +export let stateStorageKeys: string[] = []; + +export const stateStorageKey = (stateName: string): string => { + const key = createStorageKey(`${stateName}State`); + stateStorageKeys = [...new Set([...stateStorageKeys, key])]; + return key; +}; + +(window as any).clearStates = () => { + stateStorageKeys.forEach((key) => { + localStorage.removeItem(key); + }); +}; + +export interface BaseState> { + rollback: (id: string) => void; + patches: { + [id: string]: Patch[]; + }; + set: (fn: (state: StateType & BaseState) => void) => void; + addPatch: (id: string, ...patch: Patch[]) => void; + removePatch: (id: string) => void; + optSet: (fn: (state: StateType & BaseState) => void) => string; + initialize: (airlock: Urbit | UrbitMock) => Promise; +} + +export function createSubscription( + app: string, + path: string, + e: (data: any) => void +): SubscriptionRequestInterface { + const request = { + app, + path, + event: e, + err: () => null, + quit: () => null, + }; + // TODO: err, quit handling (resubscribe?) + return request; +} + +export const createState = >( + name: string, + properties: + | T + | ((set: SetState>, get: GetState>) => T), + options: Partial>>, + subscriptions: (( + set: SetState>, + get: GetState> + ) => SubscriptionRequestInterface)[] = [] +): UseStore> => { + const persistOptions = { + name: stateStorageKey(name), + version: storageVersion, + migrate: clearStorageMigration, + ...options, + }; + + return create>( + persist>( + (set, get) => ({ + initialize: async (airlock: Urbit) => { + await Promise.all( + subscriptions.map((sub) => airlock.subscribe(sub(set, get))) + ); + }, + set: (fn) => stateSetter(fn, set, get), + optSet: (fn) => optStateSetter(fn, set, get), + patches: {}, + addPatch: (id: string, patch: Patch[]) => { + set((s) => ({ ...s, patches: { ...s.patches, [id]: patch } })); + }, + removePatch: (id: string) => { + set((s) => ({ ...s, patches: _.omit(s.patches, id) })); + }, + rollback: (id: string) => { + set((state) => { + const applying = state.patches[id]; + return { + ...applyPatches(state, applying), + patches: _.omit(state.patches, id), + }; + }); + }, + ...(typeof properties === 'function' + ? (properties as any)(set, get) + : properties), + }), + persistOptions + ) + ); +}; + +export async function doOptimistically>( + state: UseStore>, + action: A, + call: (a: A) => Promise, + reduce: ((a: A, fn: S & BaseState) => S & BaseState)[] +) { + let num: string | undefined; + try { + num = optReduceState(state, action, reduce); + await call(action); + state.getState().removePatch(num); + } catch (e) { + console.error(e); + if (num) { + state.getState().rollback(num); + } + } +} + +export async function pokeOptimisticallyN>( + state: UseStore>, + poke: Poke, + reduce: ((a: A, fn: S & BaseState) => S & BaseState)[], + withRollback = true +) { + let num: string | undefined; + try { + num = optReduceState(state, poke.json, reduce); + await api.poke(poke); + state.getState().removePatch(num); + } catch (e) { + if (!withRollback) { + throw e; + } + + console.error(e); + if (num) { + state.getState().rollback(num); + } + } +} diff --git a/apps/tlon-web-new/src/state/eyre.ts b/apps/tlon-web-new/src/state/eyre.ts new file mode 100644 index 0000000000..98117fab92 --- /dev/null +++ b/apps/tlon-web-new/src/state/eyre.ts @@ -0,0 +1,160 @@ +import Urbit from '@urbit/http-api'; +import produce from 'immer'; +import create from 'zustand'; + +export type ChannelStatus = + | 'initial' + | 'opening' + | 'active' + | 'reconnecting' + | 'reconnected' + | 'errored'; + +export interface Fact { + id: number; + time: number; + data: any; +} + +export interface AnError { + time: number; + msg: string; +} + +export interface IdStatus { + current: number; + lastHeard: number; + lastAcknowledged: number; +} + +export interface Subscription { + id: number; + app?: string; + path?: string; +} + +interface UrbitLike { + on: Urbit['on']; + reset: Urbit['reset']; +} + +export interface StartParams { + api: UrbitLike; + onReset?: () => void; +} + +export interface EyreState { + listening: UrbitLike | null; + open: boolean; + toggle: (open: boolean) => void; + channel: string; + status: ChannelStatus; + facts: Fact[]; + errors: AnError[]; + idStatus: IdStatus; + subscriptions: Record; + onReset: () => void; + update: (cb: (draft: EyreState) => void) => void; + start: ({ api, onReset }: StartParams) => void; +} + +export const useEyreState = create((set, get) => ({ + listening: null, + open: false, + channel: '', + status: 'initial', + idStatus: { + current: 0, + lastHeard: -1, + lastAcknowledged: -1, + }, + facts: [], + errors: [], + subscriptions: {}, + onReset: () => null, + update(cb) { + set(produce(cb)); + }, + toggle: (open) => { + get().update((draft) => { + draft.open = open; + }); + }, + start: ({ api, onReset }) => { + const { update, listening } = get(); + if (api === listening) { + // same API object, no need to add listeners + return; + } + update((draft) => { + draft.onReset = () => { + if (onReset) { + onReset(); + } + api.reset(); + }; + + draft.listening = api; + }); + + api.on('id-update', (status) => { + update((draft) => { + draft.idStatus = { + ...draft.idStatus, + ...status, + }; + }); + }); + api.on('status-update', ({ status }) => { + update((draft) => { + draft.status = status; + }); + }); + api.on('subscription', ({ status, ...sub }) => { + update((draft) => { + if (status === 'open') { + draft.subscriptions[sub.id.toString()] = sub; + } else { + delete draft.subscriptions[sub.id.toString()]; + } + }); + }); + api.on('fact', (fact) => { + update((draft) => { + draft.facts.unshift(fact); + }); + }); + api.on('error', (err) => { + update((draft) => { + draft.errors.unshift(err); + }); + }); + api.on('reset', ({ uid }) => { + update((draft) => { + draft.channel = uid; + draft.status = 'initial'; + draft.idStatus = { + current: 0, + lastHeard: -1, + lastAcknowledged: -1, + }; + draft.facts = []; + draft.errors = []; + }); + }); + api.on('init', ({ uid, subscriptions }) => { + update((draft) => { + draft.channel = uid; + draft.subscriptions = + subscriptions.reduce( + (subs, s) => { + // eslint-disable-next-line no-param-reassign + subs[s.id.toString()] = s; + return subs; + }, + {} as Record + ) || {}; + }); + }); + }, +})); diff --git a/apps/tlon-web-new/src/state/kiln.ts b/apps/tlon-web-new/src/state/kiln.ts new file mode 100644 index 0000000000..4b905387a5 --- /dev/null +++ b/apps/tlon-web-new/src/state/kiln.ts @@ -0,0 +1,55 @@ +import { Pike, Pikes, getPikes, scryLag } from '@urbit/api'; +import produce from 'immer'; +import { useCallback } from 'react'; +import create from 'zustand'; + +import api from '@/api'; + +interface KilnState { + pikes: Pikes; + loaded: boolean; + lag: boolean; + fetchLag: () => Promise; + fetchPikes: () => Promise; + set: (s: KilnState) => void; + initializeKiln: () => Promise; +} +const useKilnState = create((set, get) => ({ + pikes: {}, + lag: false, + loaded: false, + fetchPikes: async () => { + const pikes = await api.scry(getPikes); + set({ pikes, loaded: true }); + }, + fetchLag: async () => { + const lag = await api.scry(scryLag); + set({ lag }); + }, + set: produce(set), + initializeKiln: async () => { + await get().fetchLag(); + await get().fetchPikes(); + }, +})); + +const selPikes = (s: KilnState) => s.pikes; +export function usePikes(): Pikes { + return useKilnState(selPikes); +} + +export function usePike(desk: string): Pike | undefined { + return useKilnState(useCallback((s) => s.pikes[desk], [desk])); +} + +const selLag = (s: KilnState) => s.lag; +export function useLag() { + return useKilnState(selLag); +} + +const selLoaded = (s: KilnState) => s.loaded; +export function useKilnLoaded() { + return useKilnState(selLoaded); +} + +export default useKilnState; diff --git a/apps/tlon-web-new/src/state/local.ts b/apps/tlon-web-new/src/state/local.ts new file mode 100644 index 0000000000..933c5201fe --- /dev/null +++ b/apps/tlon-web-new/src/state/local.ts @@ -0,0 +1,116 @@ +import { format } from 'date-fns'; +import produce from 'immer'; +import create from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { + clearStorageMigration, + createStorageKey, + storageVersion, +} from '../logic/utils'; + +export type SubscriptionStatus = 'connected' | 'disconnected' | 'reconnecting'; + +interface LocalState { + browserId: string; + currentTheme: 'light' | 'dark'; + manuallyShowTalkSunset: boolean; + subscription: SubscriptionStatus; + groupsLocation: string; + messagesLocation: string; + showDevTools: boolean; + errorCount: number; + airLockErrorCount: number; + lastReconnect: number; + inFocus: boolean; + onReconnect: (() => void) | null; + logs: string[]; + log: (msg: string) => void; + set: (f: (s: LocalState) => void) => void; +} + +export const useLocalState = create( + persist( + (set, get) => ({ + set: (f) => set(produce(get(), f)), + currentTheme: 'light', + browserId: '', + subscription: 'connected', + groupsLocation: '/', + messagesLocation: '/messages', + manuallyShowTalkSunset: false, + showDevTools: import.meta.env.DEV, + errorCount: 0, + airLockErrorCount: 0, + lastReconnect: Date.now(), + onReconnect: null, + logs: [], + inFocus: true, + log: (msg: string) => { + set( + produce((s) => { + s.logs.unshift(`${format(new Date(), 'HH:mm:ss')} ${msg}`); + }) + ); + }, + }), + { + name: createStorageKey('local'), + version: storageVersion, + migrate: clearStorageMigration, + partialize: ({ currentTheme, browserId, showDevTools }) => ({ + currentTheme, + browserId, + showDevTools, + }), + } + ) +); + +const selShowDevTools = (s: LocalState) => s.showDevTools; +export function useShowDevTools() { + return useLocalState(selShowDevTools); +} + +const selBrowserId = (s: LocalState) => s.browserId; +export function useBrowserId() { + return useLocalState(selBrowserId); +} + +const selCurrentTheme = (s: LocalState) => s.currentTheme; +export function useCurrentTheme() { + return useLocalState(selCurrentTheme); +} + +const selManuallyShowTalkSunset = (s: LocalState) => s.manuallyShowTalkSunset; +export function useManuallyShowTalkSunset() { + return useLocalState(selManuallyShowTalkSunset); +} + +export const setLocalState = (f: (s: LocalState) => void) => + useLocalState.getState().set(f); + +export const toggleDevTools = () => + setLocalState((s) => ({ + ...s, + showDevTools: !s.showDevTools, + })); + +const selSubscriptionStatus = (s: LocalState) => ({ + subscription: s.subscription, + errorCount: s.errorCount, + airLockErrorCount: s.airLockErrorCount, +}); +export function useSubscriptionStatus() { + return useLocalState(selSubscriptionStatus); +} + +const selLast = (s: LocalState) => s.lastReconnect; +export function useLastReconnect() { + return useLocalState(selLast); +} + +const selInFocus = (s: LocalState) => s.inFocus; +export function useInFocus() { + return useLocalState(selInFocus); +} diff --git a/apps/tlon-web-new/src/state/scheduler.ts b/apps/tlon-web-new/src/state/scheduler.ts new file mode 100644 index 0000000000..69fba47155 --- /dev/null +++ b/apps/tlon-web-new/src/state/scheduler.ts @@ -0,0 +1,93 @@ +import produce from 'immer'; +import { useCallback, useEffect } from 'react'; +import create from 'zustand'; + +interface Waiter { + id: string; + phase: number; + callback: () => void; +} + +interface SchedulerStore { + phase: number; + waiting: Record; + wait: (callback: () => T, phase: number) => Promise; + start: (phase: number) => void; + next: () => void; + reset: () => void; +} + +const MAX_PHASE = 5; + +const useSchedulerStore = create((set, get) => ({ + phase: 0, + waiting: {}, + reset: () => { + set({ phase: 0, waiting: {} }); + }, + next: () => { + const { waiting, phase } = get(); + + if (phase === MAX_PHASE) { + return; + } + + set( + produce((draft) => { + draft.phase += 1; + }) + ); + }, + start: (phase) => { + const waiters = get().waiting[phase]; + waiters?.forEach((w) => { + w.callback(); + }); + + set( + produce((draft: SchedulerStore) => { + delete draft.waiting[phase]; + }) + ); + + setTimeout(() => get().next(), 16); + }, + wait: (cb, phase) => + new Promise((resolve) => { + const id = Date.now().toString(); + const { phase: p } = get(); + + if (phase <= p) { + resolve(cb()); + return; + } + + set( + produce((draft) => { + if (!draft.waiting[phase]) { + draft.waiting[phase] = []; + } + + draft.waiting[phase].push({ + id, + phase, + callback: () => { + resolve(cb()); + }, + }); + }) + ); + }), +})); + +export default useSchedulerStore; + +export function useScheduler() { + const { phase, start } = useSchedulerStore( + useCallback((s: SchedulerStore) => ({ phase: s.phase, start: s.start }), []) + ); + + useEffect(() => { + start(phase); + }, [phase, start]); +} diff --git a/apps/tlon-web-new/src/state/settings.ts b/apps/tlon-web-new/src/state/settings.ts new file mode 100644 index 0000000000..daf1133961 --- /dev/null +++ b/apps/tlon-web-new/src/state/settings.ts @@ -0,0 +1,694 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { DisplayMode, SortMode } from '@tloncorp/shared/dist/urbit/channel'; +import { DelBucket, DelEntry, PutBucket, Value } from '@urbit/api'; +import cookies from 'browser-cookies'; +import produce from 'immer'; +import _ from 'lodash'; +import { useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import api from '@/api'; +import { + ALPHABETICAL_SORT, + DEFAULT_SORT, + RECENT_SORT, + SortMode as SidebarSortMode, + lsDesk, +} from '@/constants'; +import useReactQuerySubscription from '@/logic/useReactQuerySubscription'; +import { isHosted } from '@/logic/utils'; + +interface ChannelSetting { + flag: string; +} + +export interface HeapSetting extends ChannelSetting { + sortMode: SortMode; + displayMode: DisplayMode; +} + +export interface DiarySetting extends ChannelSetting { + sortMode: 'arranged' | 'time-dsc' | 'quip-dsc' | 'time-asc' | 'quip-asc'; + commentSortMode: 'asc' | 'dsc'; + displayMode: DisplayMode; +} + +interface GroupSideBarSort { + [flag: string]: + | typeof ALPHABETICAL_SORT + | typeof RECENT_SORT + | typeof DEFAULT_SORT; +} + +interface PutEntry { + // this is defined here because the PutEntry type in @urbit/api is missing the desk field + 'put-entry': { + 'bucket-key': string; + 'entry-key': string; + value: Value; + desk: string; + }; +} + +interface SettingsEvent { + 'settings-event': PutEntry | PutBucket | DelEntry | DelBucket; +} + +export type SidebarFilter = + | 'Direct Messages' + | 'All Messages' + | 'Group Channels' + | 'Broadcasts'; + +export const filters: Record = { + dms: 'Direct Messages', + all: 'All Messages', + groups: 'Group Channels', + broadcasts: 'Broadcasts', +}; + +export type Theme = 'light' | 'dark' | 'auto'; + +export interface SettingsState { + display: { + theme: Theme; + }; + calmEngine: { + disableAppTileUnreads: boolean; + disableAvatars: boolean; + disableRemoteContent: boolean; + disableSpellcheck: boolean; + disableNicknames: boolean; + showUnreadCounts: boolean; + }; + tiles: { + order: string[]; + }; + heaps: { + heapSettings: Stringified; + }; + diary: { + settings: Stringified; + markdown: boolean; + }; + talk: { + messagesFilter: SidebarFilter; + showVitaMessage: boolean; + seenSunsetMessage: boolean; + }; + groups: { + orderedGroupPins: string[]; + sideBarSort: SidebarSortMode; + groupSideBarSort: Stringified; + hasBeenUsed: boolean; + showActivityMessage?: boolean; + logActivity?: boolean; + analyticsId?: string; + seenWelcomeCard?: boolean; + newGroupFlags: string[]; + groupsNavState?: string; + messagesNavState?: string; + }; + loaded: boolean; + putEntry: (bucket: string, key: string, value: Value) => Promise; + fetchAll: () => Promise; + [ref: string]: unknown; +} + +export const useLandscapeSettings = () => { + const { data, isLoading } = useReactQuerySubscription({ + scry: `/desk/${lsDesk}`, + scryApp: 'settings', + app: 'settings', + path: `/desk/${lsDesk}`, + queryKey: ['settings', lsDesk], + }); + + return useMemo(() => { + if (!data) { + return { data: {} as SettingsState, isLoading }; + } + + const { desk } = data as { desk: SettingsState }; + + return { data: desk, isLoading }; + }, [isLoading, data]); +}; + +export const useSettings = () => { + const { data, isLoading } = useReactQuerySubscription({ + scry: `/desk/${window.desk}`, + scryApp: 'settings', + app: 'settings', + path: `/desk/${window.desk}`, + queryKey: ['settings', window.desk], + }); + + return useMemo(() => { + if (!data) { + return { data: {} as SettingsState, isLoading }; + } + + const { desk } = data as { desk: SettingsState }; + + return { data: desk, isLoading }; + }, [isLoading, data]); +}; + +export const useMergedSettings = () => { + const { data: settings, isLoading: isSettingsLoading } = useSettings(); + const { data: lsSettings, isLoading: isLandscapeSettingsLoading } = + useLandscapeSettings(); + + return useMemo(() => { + if (isSettingsLoading || isLandscapeSettingsLoading) { + return { data: {} as SettingsState, isLoading: true }; + } + + return { + data: { + ..._.mergeWith( + lsSettings as Record, + settings as Record, + (obj, src) => (_.isArray(src) ? src : undefined) + ), + } as SettingsState, + isLoading: isSettingsLoading || isLandscapeSettingsLoading, + }; + }, [isSettingsLoading, isLandscapeSettingsLoading, settings, lsSettings]); +}; + +export const useSettingsLoaded = () => { + const { isLoading } = useMergedSettings(); + + return !isLoading; +}; + +export function useTheme() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.display === undefined) { + return 'auto'; + } + + const { display } = data; + + return display.theme || 'auto'; + }, [isLoading, data]); +} + +const emptyCalm: SettingsState['calmEngine'] = { + disableAppTileUnreads: false, + disableAvatars: false, + disableRemoteContent: false, + disableSpellcheck: false, + disableNicknames: false, + showUnreadCounts: false, +}; + +const loadingCalm: SettingsState['calmEngine'] = { + disableAppTileUnreads: true, + disableAvatars: true, + disableRemoteContent: true, + disableSpellcheck: true, + disableNicknames: true, + showUnreadCounts: false, +}; + +export function useCalm() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading) { + return loadingCalm; + } + + if (!data || !data.calmEngine) { + return emptyCalm; + } + + const { calmEngine } = data; + + return calmEngine as SettingsState['calmEngine']; + }, [isLoading, data]); +} + +export function useCalmSetting(key: keyof SettingsState['calmEngine']) { + const data = useCalm(); + + return data[key]; +} + +export function usePutEntryMutation({ + bucket, + key, +}: { + bucket: string; + key: string; +}) { + const queryClient = useQueryClient(); + const mutationFn = async (variables: { val: Value }) => { + const { val } = variables; + await api.trackedPoke( + { + app: 'settings', + mark: 'settings-event', + json: { + 'put-entry': { + desk: window.desk, + 'bucket-key': bucket, + 'entry-key': key, + value: val, + }, + }, + }, + { + app: 'settings', + path: `/desk/${window.desk}`, + }, + (event) => { + // default validator was not working + const { 'settings-event': data } = event; + + if (data && 'put-entry' in data) { + const { 'put-entry': entry } = data; + if (entry) { + const { 'bucket-key': bk, 'entry-key': ek, value: v } = entry; + + if (bk === bucket && ek === key) { + return v === val; + } + + return false; + } + return false; + } + return false; + } + ); + }; + + return useMutation(['put-entry', bucket, key], mutationFn, { + onMutate: ({ val }) => { + const previousSettings = queryClient.getQueryData<{ + desk: SettingsState; + }>(['settings', window.desk]); + queryClient.setQueryData<{ desk: SettingsState }>( + ['settings', window.desk], + // eslint-disable-next-line consistent-return + produce((draft) => { + if (!draft) { + return { desk: { [bucket]: { [key]: val } } }; + } + + if (!(draft.desk as any)[bucket]) { + (draft.desk as any)[bucket] = { [key]: val }; + } else { + (draft.desk as any)[bucket][key] = val; + } + }) + ); + + return { previousSettings }; + }, + onError: (err, variables, rollback) => { + queryClient.setQueryData<{ desk: SettingsState }>( + ['settings', window.desk], + rollback?.previousSettings + ); + }, + onSettled: () => { + queryClient.invalidateQueries(['settings', window.desk]); + }, + }); +} + +export function useCalmSettingMutation(key: keyof SettingsState['calmEngine']) { + const { mutate, status } = usePutEntryMutation({ + bucket: 'calmEngine', + key, + }); + + return { + mutate: (val: boolean) => mutate({ val }), + status, + }; +} + +export function useMarkdownInDiaries() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.diary === undefined) { + return false; + } + + const { diary } = data; + + return diary.markdown || false; + }, [isLoading, data]); +} + +export function useLogActivity() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + return isHosted; + } + + return data.groups?.logActivity ?? isHosted; + }, [isLoading, data]); +} + +export function useLogActivityMutation() { + const { mutate, status } = usePutEntryMutation({ + bucket: 'groups', + key: 'logActivity', + }); + + // also wrap vita toggling + return { + mutate: (val: boolean) => { + api.poke({ + app: 'groups-ui', + mark: 'ui-vita-toggle', + json: val, + }); + return mutate({ val }); + }, + status, + }; +} + +export function parseSettings(settings: Stringified): T[] { + return settings !== '' ? JSON.parse(settings) : []; +} + +export function getChannelSetting( + settings: T[], + flag: string +): T | undefined { + return settings.find((el) => el.flag === flag); +} + +export function setChannelSetting( + settings: T[], + newSetting: Partial, + flag: string +): T[] { + const oldSettings = settings.slice(0); + const oldSettingIndex = oldSettings.findIndex((s) => s.flag === flag); + const setting = { + ...oldSettings[oldSettingIndex], + flag, + ...newSetting, + }; + + if (oldSettingIndex >= 0) { + oldSettings.splice(oldSettingIndex, 1); + } + + return [...oldSettings, setting]; +} + +export function useHeapSettings(): HeapSetting[] { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.heaps === undefined) { + return []; + } + + const { heaps } = data; + + return parseSettings(heaps.heapSettings) as HeapSetting[]; + }, [isLoading, data]); +} + +export function useHeapSortMode(flag: string): SortMode { + const settings = useHeapSettings(); + const heapSetting = getChannelSetting(settings, flag); + return heapSetting?.sortMode ?? 'time'; +} + +export function useHeapDisplayMode(flag: string): DisplayMode { + const settings = useHeapSettings(); + const heapSetting = getChannelSetting(settings, flag); + return heapSetting?.displayMode ?? 'grid'; +} + +export function useDiarySettings(): DiarySetting[] { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if ( + isLoading || + data === undefined || + data.diary === undefined || + data.diary.settings === undefined + ) { + return []; + } + + const { diary } = data; + + return parseSettings(diary.settings) as DiarySetting[]; + }, [isLoading, data]); +} + +export function useUserDiarySortMode( + flag: string +): 'time-dsc' | 'quip-dsc' | 'time-asc' | 'quip-asc' | 'arranged' | undefined { + const settings = useDiarySettings(); + const diarySetting = getChannelSetting(settings, flag); + return diarySetting?.sortMode; +} + +export function useUserDiaryDisplayMode(flag: string): DisplayMode | undefined { + const settings = useDiarySettings(); + const diarySetting = getChannelSetting(settings, flag); + return diarySetting?.displayMode; +} + +export function useDiaryCommentSortMode(flag: string): 'asc' | 'dsc' { + const settings = useDiarySettings(); + const setting = getChannelSetting(settings, flag); + return setting?.commentSortMode ?? 'asc'; +} + +const emptyGroupSideBarSort = { '~': 'A → Z' }; +export function useGroupSideBarSort(): Record { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + return emptyGroupSideBarSort; + } + + const { groups } = data; + + return JSON.parse(groups.groupSideBarSort ?? '{"~": "A → Z"}'); + }, [isLoading, data]); +} + +export function useSeenWelcomeCard() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + console.log('returning default'); + return true; + } + + return data.groups.seenWelcomeCard ?? false; + }, [isLoading, data]); +} + +export function useNavState() { + const { data, isLoading } = useMergedSettings(); + + if (isLoading || data === undefined || data.groups === undefined) { + return { groups: '', messages: '' }; + } + + return { + groups: data.groups.groupsNavState ?? '', + messages: data.groups.messagesNavState ?? '', + }; +} + +export function useSeenTalkSunset() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.talk === undefined) { + return false; + } + + return data.talk.seenSunsetMessage ?? false; + }, [isLoading, data]); +} + +export function useNewGroupFlags() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + return []; + } + + return data.groups.newGroupFlags ?? []; + }, [isLoading, data]); +} + +export function useSideBarSortMode(): SidebarSortMode { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + return RECENT_SORT; + } + + const { groups } = data; + + return groups.sideBarSort ?? RECENT_SORT; + }, [isLoading, data]); +} + +export function useShowActivityMessage() { + const { data, isLoading } = useMergedSettings(); + const cookie = cookies.get('hasUsedGroups'); + + return useMemo(() => { + if ( + isLoading || + data === undefined || + window.desk !== 'groups' || + import.meta.env.DEV + ) { + return false; + } + + if ((!cookie || cookie === '1') && data.groups?.showActivityMessage) { + return false; + } + + if ( + cookie && + cookie !== '1' && + data.groups?.showActivityMessage === undefined + ) { + return true; + } + + return data.groups?.showActivityMessage || false; + }, [isLoading, data, cookie]); +} + +export function useShowVitaMessage() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || window.desk !== 'talk') { + return false; + } + + const setting = data[window.desk]?.showVitaMessage; + return setting; + }, [isLoading, data]); +} + +export function useMessagesFilter() { + const { data, isLoading } = useMergedSettings(); + + return useMemo(() => { + if (isLoading || data === undefined || data.talk === undefined) { + return filters.dms; + } + + const { talk } = data; + + return talk.messagesFilter ?? filters.dms; + }, [isLoading, data]); +} + +export function useTiles() { + const { data, isLoading } = useMergedSettings(); + + return useMemo( + () => ({ + order: data?.tiles?.order ?? [], + loaded: !isLoading, + }), + [data, isLoading] + ); +} + +export function useThemeMutation() { + const { mutate, status } = usePutEntryMutation({ + bucket: 'display', + key: 'theme', + }); + + return { + mutate: (theme: Theme) => mutate({ val: theme }), + status, + }; +} + +export function createAnalyticsId() { + return uuidv4(); +} + +export function useAnalyticsIdMutation() { + const { mutate, status } = usePutEntryMutation({ + bucket: 'groups', + key: 'analyticsId', + }); + + return { + mutate: (analyticsId: string) => mutate({ val: analyticsId }), + status, + }; +} + +export function useResetAnalyticsIdMutation() { + const { mutate, status } = useAnalyticsIdMutation(); + + const newAnalyticsId = createAnalyticsId(); + + return { + mutate: () => mutate(newAnalyticsId), + status, + }; +} + +export const useAnalyticsId = () => { + const { data, isLoading } = useMergedSettings(); + const { mutate, status } = useAnalyticsIdMutation(); + + return useMemo(() => { + if (isLoading || data === undefined || data.groups === undefined) { + return ''; + } + + if ( + status !== 'loading' && + (data.groups.analyticsId === undefined || data.groups.analyticsId === '') + ) { + const newAnalyticsId = createAnalyticsId(); + + mutate(newAnalyticsId); + + if (status !== 'success') { + return ''; + } + + return newAnalyticsId; + } + + return data.groups.analyticsId; + }, [isLoading, data, mutate, status]); +}; diff --git a/apps/tlon-web-new/src/storage-wipe.ts b/apps/tlon-web-new/src/storage-wipe.ts new file mode 100644 index 0000000000..a5a9919c7f --- /dev/null +++ b/apps/tlon-web-new/src/storage-wipe.ts @@ -0,0 +1,12 @@ +import { createStorageKey } from './logic/utils'; + +const key = createStorageKey(`storage-wipe-${import.meta.env.VITE_LAST_WIPE}`); +const wiped = localStorage.getItem(key); + +// Loaded before everything, this clears local storage just once. +// Change VITE_LAST_WIPE in .env to date of wipe + +if (!wiped) { + localStorage.clear(); + localStorage.setItem(key, 'true'); +} diff --git a/apps/tlon-web-new/src/styles/base.css b/apps/tlon-web-new/src/styles/base.css new file mode 100644 index 0000000000..e0a88ed60b --- /dev/null +++ b/apps/tlon-web-new/src/styles/base.css @@ -0,0 +1,82 @@ +* { + /* Scrollbar resizing for Firefox */ + scrollbar-width: thin; + scrollbar-color: rgb(var(--colors-gray-200)) transparent; + /* Hide gray box when tapping a link on iOS */ + -webkit-tap-highlight-color: transparent; + /* Disable double-tap to zoom, removes click delay */ + touch-action: manipulation; + /* Better font rendering */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: subpixel-antialiased; +} + +/* Scrollbar resizing for Chrome, Edge, and Safari */ +*::-webkit-scrollbar { + width: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; + border: solid 2px transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgb(var(--colors-gray-200)); + border: solid 2px transparent; + border-radius: 20px; + background-clip: content-box; +} + +html, +body, +#app { + /* adding for mobile bottom bars */ + @apply h-full min-h-screen w-full; + min-height: -webkit-fill-available; + font-variant-ligatures: contextual; + /* Prevents undesirable scroll capture / locking on iOS */ + overflow: clip; +} + +@media all and (display-mode: standalone) { + body { + padding-bottom: 2rem; + } +} + +blockquote { + @apply block border-l-2 border-gray-100 pl-3 text-gray-600; +} + +a:not([class]) { + @apply underline; +} + +::selection { + @apply bg-gray-200; +} + +.alt-highlight::selection { + @apply bg-white; +} + +/* Ensures text selection is visible on dark mode desktop safari */ +_::-webkit-full-page-media, +_:future, +:root .safari_only, +body.dark ::selection { + @apply bg-gray-200; +} + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: initial; +} + +iframe { + @apply max-w-full; +} diff --git a/apps/tlon-web-new/src/styles/components.css b/apps/tlon-web-new/src/styles/components.css new file mode 100644 index 0000000000..d95530ed4a --- /dev/null +++ b/apps/tlon-web-new/src/styles/components.css @@ -0,0 +1,209 @@ +.button { + @apply inline-flex items-center justify-center rounded-lg bg-gray-800 px-4 py-2 text-lg font-semibold leading-4 text-white ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2 disabled:bg-gray-200 disabled:text-gray-400 sm:text-base; +} + +.secondary-button { + @apply inline-flex items-center justify-center rounded-lg bg-gray-50 px-4 py-2 text-lg font-semibold leading-4 text-gray-800 mix-blend-multiply ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2 disabled:bg-gray-50 disabled:text-gray-400 dark:mix-blend-screen sm:text-base; +} + +.small-button { + @apply inline-flex items-center justify-center rounded-md bg-gray-800 px-2 py-1 text-sm font-semibold text-white ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2 disabled:bg-gray-200 disabled:text-gray-400 sm:h-6 sm:leading-4; +} + +.small-secondary-button { + @apply inline-flex h-6 items-center justify-center whitespace-nowrap rounded-md bg-gray-50 px-2 py-1 text-sm font-semibold leading-4 text-gray-800 mix-blend-multiply ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2 disabled:bg-gray-200 disabled:text-gray-400 dark:mix-blend-screen; +} + +.red-text-button { + @apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-lg font-semibold leading-4 text-red ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2 disabled:bg-gray-200 disabled:text-gray-400 sm:text-base; +} + +.icon-button { + @apply inline-flex h-6 w-6 items-center justify-center rounded-lg bg-gray-100 p-0 text-sm font-bold leading-4 text-gray-600 ring-gray-200 ring-offset-2 transition-colors hover:bg-gray-50 focus:outline-none focus-visible:ring-2 disabled:text-gray-300 sm:text-base; +} + +.icon-toggle { + @apply inline-flex h-6 w-6 items-center justify-center rounded-lg bg-white p-0 text-sm font-bold leading-4 text-gray-400 ring-gray-200 ring-offset-2 transition-colors hover:bg-gray-50 focus:outline-none focus-visible:ring-2 disabled:text-gray-200 sm:text-base; +} + +.icon-toggle-active { + @apply bg-gray-100 text-gray-600; +} + +.input { + @apply flex rounded-lg border-2 border-transparent bg-gray-50 px-2 py-1 text-lg leading-5 caret-blue-400 transition-colors focus-within:border-gray-100 + focus-within:bg-white focus:outline-none focus-visible:border-gray-100 focus-visible:bg-white sm:text-base sm:leading-5; +} + +.input-inner { + @apply border-transparent bg-transparent px-2 py-1 text-lg leading-5 focus:outline-none sm:text-base sm:leading-5; +} + +.input-transparent { + @apply bg-transparent text-lg leading-5 caret-blue-400 transition-colors focus-within:border-gray-100 focus-within:bg-white focus:outline-none focus-visible:border-gray-100 sm:text-base sm:leading-5; +} + +.ProseMirror { + @apply bg-transparent caret-blue-400 transition-colors focus-within:border-gray-100 focus:outline-none focus-visible:border-gray-100; + /* Father forgive me for my sins. + we use anywhere here because any other value makes the input overflow. */ + overflow-wrap: anywhere !important; +} + +/* Placeholder (on every new line) */ +.ProseMirror:not(.ProseMirror-focused) p.is-empty::before { + content: attr(data-placeholder); + @apply pointer-events-none float-left h-0 text-gray-400; +} + +.ProseMirror li :where(p):not(:where([class~='not-prose'] *)) { + @apply my-0; +} + +.ProseMirror a { + @apply underline; +} + +/* hack to prevent auto-zoom on em-emoji-picker */ +@media screen and (max-width: 767px) { + em-emoji-picker { + --font-size: 17px; + height: 45vh; + } +} + +.dialog-container { + @apply fixed left-1/2 top-1/2 z-40 max-h-[100vh] max-w-[100vw] -translate-x-1/2 -translate-y-1/2 transform overflow-auto p-4; +} + +.secondary-dialog-container { + @apply fixed left-1/2 top-3/4 z-40 -translate-x-1/2 -translate-y-1/2 transform p-4; +} + +.dialog { + @apply relative rounded-xl bg-white p-6; +} + +.sheet-container { + @apply fixed bottom-0 left-0 z-50 w-full; +} + +.sheet { + @apply relative max-h-[75vh] w-full rounded-t-xl bg-white p-6 pb-12; +} + +.dialog-inner-container { + @apply h-full space-y-8 overflow-y-auto p-4 md:p-8; +} + +.dropdown { + @apply z-10 flex min-w-40 flex-col space-y-0.5 rounded-lg bg-white p-2 text-gray-800 shadow-xl dark:border dark:border-gray-50; +} + +.dropdown-item { + @apply cursor-pointer rounded p-2 text-left text-[17px] font-medium no-underline ring-gray-200 hover:bg-gray-50 focus:outline-none sm:text-sm; +} + +.dropdown-item > a { + @apply no-underline; +} + +.dropdown-item-disabled { + @apply dropdown-item cursor-not-allowed text-gray-400 hover:bg-transparent; +} + +.dropdown-item-blue { + @apply dropdown-item text-blue hover:bg-blue-50 dark:hover:bg-blue-900; +} + +.dropdown-item-red { + @apply dropdown-item text-red hover:bg-red-50 dark:hover:bg-red-900; +} + +.dropdown-item-icon { + @apply dropdown-item flex w-full items-center space-x-2 p-2; +} + +.card { + @apply w-full rounded-lg bg-white p-4 sm:rounded-xl sm:p-6; +} + +.switch { + @apply relative ml-2 h-4 w-6 space-x-2 rounded-full bg-gray-200 px-0.5 transition-colors duration-100; +} + +.switch[data-state='checked'] { + @apply bg-black; +} + +.switch-thumb { + @apply block h-3 w-3 rounded-full bg-white transition-transform; +} + +.switch-thumb[data-state='checked'] { + @apply translate-x-2; +} + +.heap-block { + @apply absolute flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-lg bg-white bg-cover bg-center bg-no-repeat object-cover object-center p-2; +} + +.heap-inline-block { + @apply relative flex h-[300px] w-full flex-col justify-between overflow-hidden rounded-lg border-2 border-gray-50 bg-white bg-cover bg-center bg-no-repeat; +} + +.note-inline-block { + @apply relative flex h-full w-full flex-col justify-between overflow-hidden rounded-lg border-2 border-gray-50 bg-white bg-cover bg-center bg-no-repeat; +} + +.writ-inline-block { + @apply relative flex h-full w-full flex-col justify-center overflow-hidden rounded-lg border-2 border-gray-50 bg-white bg-cover bg-center bg-no-repeat; +} + +.embed-inline-block { + @apply relative flex h-full max-w-fit flex-col justify-center overflow-hidden rounded-lg border-2 border-gray-50 bg-white bg-cover bg-center bg-no-repeat px-4 py-2; +} + +.heap-grid { + @apply grid gap-4; + /* tailwind crashes when i try to fill grid-cols in with the custom property brackets */ + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.heap-grid-mobile { + @apply grid gap-4; + grid-template-columns: repeat(auto-fill, minmax(165px, 1fr)); +} + +.heap-list { + @apply flex flex-col gap-4 sm:grid-cols-[repeat(auto-fit,minmax(auto,250px))]; +} + +.new-curio-input p.is-editor-empty:first-child::before { + @apply !text-gray-300; + font-weight: 600 !important; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.new-curio-input::-webkit-scrollbar-corner { + background: rgba(0, 0, 0, 0); +} + +.small-menu-button { + @apply w-full rounded bg-transparent p-2 text-left font-semibold ring-gray-200 hover:bg-gray-50 focus:outline-none focus-visible:ring-2; +} + +.padding-bottom-transition { + transition-property: padding-bottom; + transition-timing-function: ease-out; + transition-duration: 150ms; +} + +.navbar-transition { + transition-property: transform, opacity; + transition-timing-function: ease-in-out; + transition-duration: 250ms; +} diff --git a/apps/tlon-web-new/src/styles/cosmos.css b/apps/tlon-web-new/src/styles/cosmos.css new file mode 100644 index 0000000000..efcb1b4d45 --- /dev/null +++ b/apps/tlon-web-new/src/styles/cosmos.css @@ -0,0 +1,3 @@ +body { + background-color: transparent !important; +} diff --git a/apps/tlon-web-new/src/styles/index.css b/apps/tlon-web-new/src/styles/index.css new file mode 100644 index 0000000000..297c00dad6 --- /dev/null +++ b/apps/tlon-web-new/src/styles/index.css @@ -0,0 +1,17 @@ +/** +* See: https://tailwindcss.com/docs/using-with-preprocessors#using-post-css-as-your-preprocessor= +*/ +@import 'tailwindcss/base'; +@import './base'; + +@import 'tailwindcss/components'; +@import './components'; +@import '../components/Layout/Layout.css'; + +@import 'tailwindcss/utilities'; + +@import './utilities.css'; + +@import 'prismjs/themes/prism-tomorrow.min.css'; + +@import 'video-react/dist/video-react.css'; diff --git a/apps/tlon-web-new/src/styles/utilities.css b/apps/tlon-web-new/src/styles/utilities.css new file mode 100644 index 0000000000..d42ad6b33f --- /dev/null +++ b/apps/tlon-web-new/src/styles/utilities.css @@ -0,0 +1,39 @@ +.default-focus { + @apply ring-gray-200 ring-offset-2 ring-offset-white focus:outline-none focus-visible:ring-2; +} + +/* Useful for showing selections programmatically */ +.default-focus-on { + @apply outline-none ring-2; +} + +.bottom-shadow { + box-shadow: 0px 8px 5px -5px rgba(0, 0, 0, 0.1); +} + +a[aria-disabled='true'] { + opacity: 0.5; + pointer-events: none; + cursor: not-allowed; +} + +.hide-scroll { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } +} + +.wrap-anywhere { + overflow-wrap: anywhere; +} + +.height-transition { + @apply transition-all duration-300 ease-in-out; +} + +/* This prevents clamped text from being repainted during scroll */ +.line-clamp-1 { + overflow: clip; +} diff --git a/apps/tlon-web-new/src/sw.ts b/apps/tlon-web-new/src/sw.ts new file mode 100644 index 0000000000..936d95fd9a --- /dev/null +++ b/apps/tlon-web-new/src/sw.ts @@ -0,0 +1,17 @@ +/* eslint-env serviceworker, browser */ + +/* global workbox */ + +/* eslint no-underscore-dangle: off */ +import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'; + +declare let self: ServiceWorkerGlobalScope; + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +cleanupOutdatedCaches(); +precacheAndRoute(self.__WB_MANIFEST); diff --git a/apps/tlon-web-new/src/tamagui.d.ts b/apps/tlon-web-new/src/tamagui.d.ts new file mode 100644 index 0000000000..15684f9764 --- /dev/null +++ b/apps/tlon-web-new/src/tamagui.d.ts @@ -0,0 +1,13 @@ +import { config } from '../tamagui.config'; + +export type Conf = typeof config; + +// Sets up typing for tamagui so that theme variables autocomplete +declare module 'tamagui' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface TamaguiCustomConfig extends Conf {} + + interface TypeOverride { + groupNames(): 'button'; + } +} diff --git a/apps/tlon-web-new/src/vite-env.d.ts b/apps/tlon-web-new/src/vite-env.d.ts new file mode 100644 index 0000000000..14fa749230 --- /dev/null +++ b/apps/tlon-web-new/src/vite-env.d.ts @@ -0,0 +1 @@ +// / diff --git a/apps/tlon-web-new/src/wdyr.ts b/apps/tlon-web-new/src/wdyr.ts new file mode 100644 index 0000000000..c7239743dc --- /dev/null +++ b/apps/tlon-web-new/src/wdyr.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import wdyr from '@welldone-software/why-did-you-render'; +import * as React from 'react'; + +wdyr(React, { + include: [ + /^RoutedApp/, + /^App$/, + /^GroupsRoutes/, + /^Sidebar/, + /^GroupSidebar/, + ], + exclude: [/^Link/, /^Route/, /^BrowserRouter/], + trackHooks: true, + trackAllPureComponents: true, +}); diff --git a/apps/tlon-web-new/src/window.ts b/apps/tlon-web-new/src/window.ts new file mode 100644 index 0000000000..05952c2228 --- /dev/null +++ b/apps/tlon-web-new/src/window.ts @@ -0,0 +1,30 @@ +import type { NativeWebViewOptions } from '@tloncorp/shared'; +import { Rope } from '@tloncorp/shared/dist/urbit/hark'; + +declare global { + interface Window { + ship: string; + desk: string; + our: string; + scroller?: string; + bootstrapApi: boolean; + toggleDevTools: () => void; + unread: any; + markRead: Rope; + recents: any; + ReactNativeWebView?: { + postMessage: (message: string) => void; + }; + nativeOptions?: NativeWebViewOptions; + // old values for backwards compatibility with Tlon Mobile v3 + colorscheme: any; + safeAreaInsets?: { + top: number; + bottom: number; + left: number; + right: number; + }; + } +} + +export {}; diff --git a/apps/tlon-web-new/tailwind.config.js b/apps/tlon-web-new/tailwind.config.js new file mode 100644 index 0000000000..94860b0f58 --- /dev/null +++ b/apps/tlon-web-new/tailwind.config.js @@ -0,0 +1,333 @@ +/* eslint-disable global-require */ +const colors = require('tailwindcss/colors'); +const defaultTheme = require('tailwindcss/defaultTheme'); +const plugin = require('tailwindcss/plugin'); + +const lightColors = { + white: '#FFFFFF', + black: '#000000', + gray: { + 50: '#F5F5F5', + 100: '#E5E5E5', + 200: '#CCCCCC', + 300: '#B3B3B3', + 400: '#999999', + 500: '#808080', + 600: '#666666', + 700: '#4C4C4C', + 800: '#333333', + 900: '#1A1A1A', + }, + red: { + DEFAULT: '#FF6240', + soft: '#FFEFEC', + }, + orange: { + DEFAULT: '#FF9040', + soft: '#FFF4EC', + }, + yellow: { + DEFAULT: '#FADE7A', + soft: '#FAF5D9', + }, + green: { + DEFAULT: '#2AD546', + soft: '#EAFBEC', + }, + blue: { + DEFAULT: '#008EFF', + soft: '#E5F4FF', + softer: 'rgba(0, 142, 255, 0.1)', + }, + indigo: { + DEFAULT: '#615FD3', + soft: '#EFEFFB', + }, +}; + +const darkColors = { + white: '#000000', + black: '#FFFFFF', + gray: { + 50: '#1A1A1A', + 100: '#333333', + 200: '#4C4C4C', + 300: '#666666', + 400: '#808080', + 500: '#999999', + 600: '#B3B3B3', + 700: '#CCCCCC', + 800: '#E5E5E5', + 900: '#F5F5F5', + }, + red: { + DEFAULT: '#FF6240', + soft: colors.red['900'], + }, + orange: { + DEFAULT: '#FF9040', + soft: colors.orange['900'], + }, + yellow: { + DEFAULT: '#FADE7A', + soft: colors.yellow['900'], + }, + green: { + DEFAULT: '#2AD546', + soft: colors.green['900'], + }, + blue: { + DEFAULT: '#008EFF', + soft: colors.blue['900'], + softer: 'rgba(0, 142, 255, 0.2)', + }, + indigo: { + DEFAULT: '#615FD3', + soft: colors.indigo['900'], + }, +}; + +const base = { + theme: { + colors: lightColors, + }, +}; + +const dark = { + theme: { + colors: darkColors, + }, +}; + +module.exports = { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + darkMode: 'class', // or 'media' or 'class' + // This disables CSS hovers on mobile, avoiding double-tap scenarios + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + fontFamily: { + sans: [ + 'system-ui', + '-apple-system', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Roboto', + 'Oxygen', + 'Ubuntu', + 'Cantarell', + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + ], + mono: [ + 'ui-monospace', + 'Menlo', + 'Monaco', + 'Cascadia Mono', + 'Segoe UI Mono', + 'Roboto Mono', + 'Oxygen Mono', + 'Ubuntu Monospace', + 'Source Code Pro', + 'Fira Mono', + 'Droid Sans Mono', + 'Courier New', + 'monospace', + ], + }, + fontSize: { + xs: ['.625rem', '1rem'], + sm: ['.75rem', '1rem'], + base: ['.875rem', '1rem'], + lg: ['1rem', '1.5rem'], + xl: ['1.25rem', '2rem'], + '2xl': ['1.5rem', '2rem'], + '3xl': ['2rem', '3rem'], + }, + extend: { + colors: { + transparent: 'transparent', + current: 'currentColor', + }, + minWidth: (theme) => theme('spacing'), + lineHeight: { + tight: 1.2, + snug: 1.33334, + relaxed: 1.66667, + }, + boxShadow: { + xl: '0px 4px 16px rgba(0, 0, 0, 0.20)', + }, + lineClamp: { + 7: '7', + 8: '8', + 9: '9', + }, + zIndex: { + 45: '45', + }, + typography: { + DEFAULT: { + css: { + a: { + color: lightColors.blue.DEFAULT, + textDecoration: 'underline', + textUnderlineOffset: '0.125em', + fontWeight: 'inherit', + }, + code: { + display: 'inline-block', + padding: '0 0.25rem', + }, + 'code::before': { + content: '""', + }, + 'code::after': { + content: '""', + }, + }, + }, + sm: { + css: { + h1: { + marginBottom: '0.5rem', + fontWeight: '600', + fontSize: '1rem', + paddingBottom: '0.3em', + borderBottom: '1px solid var(--tw-prose-hr)', + }, + h2: { + fontWeight: '600', + fontSize: '1rem', + marginTop: '0', + marginBottom: '0.5rem', + paddingBottom: '0.3em', + borderBottom: '1px solid var(--tw-prose-hr)', + }, + h3: { + fontWeight: '600', + fontSize: '1rem', + marginTop: '0', + marginBottom: '0.5rem', + }, + h4: { + fontWeight: '600', + marginTop: '0', + marginBottom: '0.5rem', + }, + pre: { + marginBottom: '1rem', + fontSize: '1rem', + }, + hr: { + marginTop: '2rem', + marginBottom: '2rem', + }, + 'hr + *': { + marginTop: '0', + }, + 'h1 + *, h2 + *, h3 + *, h4 + *, hr + *': { + marginTop: '0', + }, + '.node-diary-image + *,.node-diary-cite + *, .node-codeBlock + *': { + marginTop: '1.33333rem', + }, + }, + }, + lg: { + css: { + h1: { + marginBottom: '1rem', + fontWeight: '600', + fontSize: '2rem', + paddingBottom: '0.3em', + borderBottom: '1px solid var(--tw-prose-hr)', + }, + h2: { + fontWeight: '600', + fontSize: '1.5rem', + marginTop: '0', + marginBottom: '1rem', + paddingBottom: '0.3em', + borderBottom: '1px solid var(--tw-prose-hr)', + }, + h3: { + fontWeight: '600', + fontSize: '1.25rem', + marginTop: '0', + marginBottom: '1rem', + }, + h4: { + fontWeight: '600', + marginTop: '0', + marginBottom: '1rem', + }, + pre: { + marginBottom: '1rem', + fontSize: '1rem', + }, + hr: { + marginTop: '2rem', + marginBottom: '2rem', + }, + 'hr + *': { + marginTop: '0', + }, + 'h1 + *, h2 + *, h3 + *, h4 + *, hr + *': { + marginTop: '0', + }, + '.node-diary-image + *,.node-diary-cite + *, .node-codeBlock + *': { + marginTop: '1.33333rem', + }, + }, + }, + }, + }, + }, + screens: { + ...defaultTheme.screens, + xl: '1440px', + '2xl': '2200px', + }, + variants: { + extend: { + opacity: ['hover-none'], + display: ['group-hover'], + }, + }, + plugins: [ + require('tailwindcss-scoped-groups')({ + groups: ['one', 'two'], + }), + require('@tailwindcss/aspect-ratio'), + require('tailwindcss-opentype'), + require('tailwindcss-theme-swapper')({ + themes: [ + { name: 'base', selectors: [':root'], theme: base.theme }, + { name: 'dark', selectors: ['.dark'], theme: dark.theme }, + ], + }), + require('@tailwindcss/typography'), + require('@tailwindcss/container-queries'), + plugin(({ addUtilities }) => { + addUtilities({ + '.hide-scrollbar': { + /* IE and Edge */ + '-ms-overflow-style': 'none', + + /* Firefox */ + 'scrollbar-width': 'none', + + /* Safari and Chrome */ + '&::-webkit-scrollbar': { + display: 'none', + }, + }, + }); + }), + ], +}; diff --git a/apps/tlon-web/tamagui.config.ts b/apps/tlon-web-new/tamagui.config.ts similarity index 100% rename from apps/tlon-web/tamagui.config.ts rename to apps/tlon-web-new/tamagui.config.ts diff --git a/apps/tlon-web-new/tsconfig.json b/apps/tlon-web-new/tsconfig.json new file mode 100644 index 0000000000..08771a1a4a --- /dev/null +++ b/apps/tlon-web-new/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"], + "experimentalDecorators": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": [ + "vite/client", + "vitest/globals", + "@testing-library/jest-dom", + "vite-plugin-pwa/react" + ], + "composite": true, + "paths": { + "@/*": ["./apps/tlon-web-new/src/*"], + "sqlocal/drizzle": ["node_modules/sqlocal/dist/drizzle"] + } + }, + "include": [ + "./src", + "./test", + "./e2e", + "./*.config.ts", + "./*.setup.ts", + "./rube", + "cosmos/setup.ts", + "vite.config.mts", + "reactNativeWebPlugin.ts" + ] +} diff --git a/apps/tlon-web-new/vite.config.mts b/apps/tlon-web-new/vite.config.mts new file mode 100644 index 0000000000..f6f5c343bd --- /dev/null +++ b/apps/tlon-web-new/vite.config.mts @@ -0,0 +1,272 @@ +/// +import { tamaguiPlugin } from '@tamagui/vite-plugin'; +import { urbitPlugin } from '@urbit/vite-plugin-urbit'; +import basicSsl from '@vitejs/plugin-basic-ssl'; +import react from '@vitejs/plugin-react'; +import analyze from 'rollup-plugin-analyzer'; +import { visualizer } from 'rollup-plugin-visualizer'; +import { fileURLToPath } from 'url'; +import { + BuildOptions, + Plugin, + PluginOption, + defineConfig, + loadEnv, +} from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; +import svgr from 'vite-plugin-svgr'; + +import packageJson from './package.json'; +import reactNativeWeb from './reactNativeWebPlugin'; +import manifest from './src/manifest'; +import manifestAlpha from './src/manifest-alpha'; + +// https://vitejs.dev/config/ +export default ({ mode }: { mode: string }) => { + process.env.VITE_STORAGE_VERSION = + mode === 'dev' ? Date.now().toString() : packageJson.version; + + Object.assign(process.env, loadEnv(mode, process.cwd())); + const SHIP_URL = + process.env.SHIP_URL || + process.env.VITE_SHIP_URL || + 'http://localhost:8080'; + console.log(SHIP_URL); + const SHIP_URL2 = + process.env.SHIP_URL2 || + process.env.VITE_SHIP_URL2 || + 'http://localhost:8080'; + console.log(SHIP_URL2); + + // eslint-disable-next-line + const base = (mode: string) => { + if (mode === 'mock' || mode === 'staging') { + return ''; + } + + if (mode === 'alpha') { + return '/apps/tm-alpha/'; + } + }; + + // eslint-disable-next-line + const plugins = (mode: string): PluginOption[] => { + if (mode === 'mock' || mode === 'staging') { + return [ + basicSsl() as Plugin, + react({ + jsxImportSource: '@welldone-software/why-did-you-render', + }) as PluginOption[], + ]; + } + + return [ + process.env.SSL === 'true' ? (basicSsl() as PluginOption) : null, + urbitPlugin({ + base: mode === 'alpha' ? 'tm-alpha' : 'groups', + target: mode === 'dev2' ? SHIP_URL2 : SHIP_URL, + changeOrigin: true, + secure: false, + }) as PluginOption[], + react({ + babel: { + // adding these per instructions here: + // https://docs.swmansion.com/react-native-reanimated/docs/guides/web-support/ + plugins: [ + '@babel/plugin-proposal-export-namespace-from', + 'react-native-reanimated/plugin', + ], + }, + jsxImportSource: '@welldone-software/why-did-you-render', + }) as PluginOption[], + svgr({ + include: '**/*.svg', + }) as Plugin, + reactNativeWeb(), + tamaguiPlugin({ + config: './tamagui.config.ts', + platform: 'web', + }) as Plugin, + VitePWA({ + base: mode === 'alpha' ? '/apps/tm-alpha/' : '/apps/groups/', + manifest: mode === 'alpha' ? manifestAlpha : manifest, + injectRegister: 'inline', + registerType: 'prompt', + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + useCredentials: true, + devOptions: { + enabled: mode === 'sw', + type: 'module', + }, + injectManifest: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + maximumFileSizeToCacheInBytes: 100000000, + plugins: [reactNativeWeb()], + }, + }), + ]; + }; + + const rollupOptions = { + external: + mode === 'mock' || mode === 'staging' + ? ['virtual:pwa-register/react'] + : [ + '@urbit/sigil-js/dist/core', + 'react-native-device-info', + '@react-navigation/bottom-tabs', + '@react-navigation/native-stack', + ], + output: { + hashCharacters: 'base36' as any, + manualChunks: { + lodash: ['lodash'], + 'lodash/fp': ['lodash/fp'], + 'urbit/api': ['@urbit/api'], + 'urbit/http-api': ['@urbit/http-api'], + 'urbit/sigil-js': ['@urbit/sigil-js'], + 'any-ascii': ['any-ascii'], + 'react-beautiful-dnd': ['react-beautiful-dnd'], + 'emoji-mart': ['emoji-mart'], + 'tiptap/core': ['@tiptap/core'], + 'tiptap/extension-placeholder': ['@tiptap/extension-placeholder'], + 'tiptap/extension-link': ['@tiptap/extension-link'], + 'react-virtuoso': ['react-virtuoso'], + 'react-select': ['react-select'], + 'react-hook-form': ['react-hook-form'], + 'framer-motion': ['framer-motion'], + 'date-fns': ['date-fns'], + 'tippy.js': ['tippy.js'], + 'aws-sdk/client-s3': ['@aws-sdk/client-s3'], + 'aws-sdk/s3-request-presigner': ['@aws-sdk/s3-request-presigner'], + refractor: ['refractor'], + 'urbit-ob': ['urbit-ob'], + 'hast-to-hyperscript': ['hast-to-hyperscript'], + 'radix-ui/react-dialog': ['@radix-ui/react-dialog'], + 'radix-ui/react-dropdown-menu': ['@radix-ui/react-dropdown-menu'], + 'radix-ui/react-popover': ['@radix-ui/react-popover'], + 'radix-ui/react-toast': ['@radix-ui/react-toast'], + 'radix-ui/react-tooltip': ['@radix-ui/react-tooltip'], + 'react-native-reanimated': ['react-native-reanimated'], + }, + }, + }; + + const port = + process.env.E2E_PORT_3001 === 'true' + ? 3001 + : process.env.VITE_PORT + ? parseInt(process.env.VITE_PORT) + : 3000; + + return defineConfig({ + base: base(mode), + server: { + host: 'localhost', + port, + //NOTE the proxy used by vite is written poorly, and ends up removing + // empty path segments from urls: http-party/node-http-proxy#1420. + // as a workaround for this, we rewrite the path going into the + // proxy to "hide" the empty path segments, and then rewrite the + // path coming "out" of the proxy to obtain the original path. + proxy: { + '^.*//.*': { + target: SHIP_URL, + rewrite: (path) => path.replaceAll('//', '/@@@/'), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.path = proxyReq.path.replaceAll('/@@@/', '//'); + }); + }, + }, + }, + }, + build: + mode !== 'profile' + ? { + sourcemap: false, + rollupOptions, + } + : ({ + rollupOptions: { + ...rollupOptions, + plugins: [ + analyze({ + limit: 20, + }), + visualizer(), + ], + }, + } as BuildOptions), + worker: { + rollupOptions: { + output: { + hashCharacters: 'base36' as any, + }, + }, + }, + plugins: plugins(mode), + resolve: { + dedupe: ['@tanstack/react-query'], + alias: [ + { + find: '@', + replacement: fileURLToPath(new URL('./src', import.meta.url)), + }, + { + find: '@react-native-firebase/crashlytics', + replacement: fileURLToPath( + new URL( + './src/mocks/react-native-firebase-crashlytics.js', + import.meta.url + ) + ), + }, + { + find: '@tloncorp/editor/dist/editorHtml', + replacement: fileURLToPath( + new URL('./src/mocks/tloncorp-editor-html.js', import.meta.url) + ), + }, + { + find: '@tloncorp/editor/src/bridges', + replacement: fileURLToPath( + new URL('./src/mocks/tloncorp-editor-bridges.js', import.meta.url) + ), + }, + { + find: '@10play/tentap-editor', + replacement: fileURLToPath( + new URL('./src/mocks/tentap-editor.js', import.meta.url) + ), + }, + { + find: 'react-native-gesture-handler/ReanimatedSwipeable', + replacement: fileURLToPath( + new URL( + './src/mocks/react-native-gesture-handler.js', + import.meta.url + ) + ), + }, + ], + }, + optimizeDeps: { + exclude: ['sqlocal'], + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './test/setup.ts', + deps: {}, + include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + server: { + deps: { + inline: ['react-tweet'], + }, + }, + }, + }); +}; diff --git a/apps/tlon-web/package.json b/apps/tlon-web/package.json index 71fb870f14..8ddd7ce2b5 100644 --- a/apps/tlon-web/package.json +++ b/apps/tlon-web/package.json @@ -58,32 +58,32 @@ "@tanstack/react-query-devtools": "^4.28.0", "@tanstack/react-query-persist-client": "^4.28.0", "@tanstack/react-virtual": "^3.0.0-beta.60", - "@tiptap/core": "^2.0.3", - "@tiptap/extension-blockquote": "^2.0.3", - "@tiptap/extension-bold": "^2.0.3", - "@tiptap/extension-bullet-list": "^2.0.3", - "@tiptap/extension-code": "^2.0.3", - "@tiptap/extension-code-block": "^2.0.3", - "@tiptap/extension-document": "^2.0.3", - "@tiptap/extension-floating-menu": "^2.0.3", - "@tiptap/extension-hard-break": "^2.0.3", - "@tiptap/extension-heading": "^2.0.3", - "@tiptap/extension-history": "^2.0.3", - "@tiptap/extension-horizontal-rule": "^2.0.3", - "@tiptap/extension-italic": "^2.0.3", - "@tiptap/extension-link": "^2.0.3", - "@tiptap/extension-list-item": "^2.0.3", - "@tiptap/extension-mention": "^2.0.3", - "@tiptap/extension-ordered-list": "^2.0.3", - "@tiptap/extension-paragraph": "^2.0.3", - "@tiptap/extension-placeholder": "^2.0.3", - "@tiptap/extension-strike": "^2.0.3", - "@tiptap/extension-task-item": "^2.1.7", - "@tiptap/extension-task-list": "^2.1.7", - "@tiptap/extension-text": "^2.0.3", - "@tiptap/pm": "^2.0.3", - "@tiptap/react": "^2.0.3", - "@tiptap/suggestion": "^2.0.3", + "@tiptap/core": "^2.6.6", + "@tiptap/extension-blockquote": "^2.6.6", + "@tiptap/extension-bold": "^2.6.6", + "@tiptap/extension-bullet-list": "^2.6.6", + "@tiptap/extension-code": "^2.6.6", + "@tiptap/extension-code-block": "^2.6.6", + "@tiptap/extension-document": "^2.6.6", + "@tiptap/extension-floating-menu": "^2.6.6", + "@tiptap/extension-hard-break": "^2.6.6", + "@tiptap/extension-heading": "^2.6.6", + "@tiptap/extension-history": "^2.6.6", + "@tiptap/extension-horizontal-rule": "^2.6.6", + "@tiptap/extension-italic": "^2.6.6", + "@tiptap/extension-link": "^2.6.6", + "@tiptap/extension-list-item": "^2.6.6", + "@tiptap/extension-mention": "^2.6.6", + "@tiptap/extension-ordered-list": "^2.6.6", + "@tiptap/extension-paragraph": "^2.6.6", + "@tiptap/extension-placeholder": "^2.6.6", + "@tiptap/extension-strike": "^2.6.6", + "@tiptap/extension-task-item": "^2.6.6", + "@tiptap/extension-task-list": "^2.6.6", + "@tiptap/extension-text": "^2.6.6", + "@tiptap/pm": "^2.6.6", + "@tiptap/react": "^2.6.6", + "@tiptap/suggestion": "^2.6.6", "@tloncorp/mock-http-api": "^1.2.0", "@tloncorp/shared": "workspace:*", "@tloncorp/ui": "workspace:*", diff --git a/apps/tlon-web/src/diary/PrismCodeBlock.tsx b/apps/tlon-web/src/diary/PrismCodeBlock.tsx index f52326f5ee..c55da2cefd 100644 --- a/apps/tlon-web/src/diary/PrismCodeBlock.tsx +++ b/apps/tlon-web/src/diary/PrismCodeBlock.tsx @@ -190,6 +190,7 @@ const PrismCodeBlock = CodeBlock.extend({ ); if ( + // @ts-expect-error - not a real type issue transaction.docChanged && // Apply decorations if: // selection includes named node, @@ -199,28 +200,28 @@ const PrismCodeBlock = CodeBlock.extend({ // OR transaction has changes that completely encapsulte a node // (for example, a transaction that affects the entire document). // Such transactions can happen during collab syncing via y-prosemirror, for example. + // @ts-expect-error - not a real type issue transaction.steps.some( + // @ts-expect-error - not a real type issue (step) => - // @ts-expect-error prosemirror#step step.from !== undefined && - // @ts-expect-error prosemirror#step step.to !== undefined && oldNodes.some( (node) => - // @ts-expect-error prosemirror#step node.pos >= step.from && - // @ts-expect-error prosemirror#step node.pos + node.node.nodeSize <= step.to ) )) ) { return getDecorations({ + // @ts-expect-error - not a real type issue doc: transaction.doc, name, defaultLanguage: options.defaultLanguage, }); } + // @ts-expect-error - not a real type issue return decorationSet.map(transaction.mapping, transaction.doc); }, }, diff --git a/apps/tlon-web/src/logic/branch.ts b/apps/tlon-web/src/logic/branch.ts index 786dad12a3..07c01406f7 100644 --- a/apps/tlon-web/src/logic/branch.ts +++ b/apps/tlon-web/src/logic/branch.ts @@ -60,7 +60,9 @@ export const createDeepLink = async ( } } - const alias = path.replace('~', '').replace('/', '-'); + const parsedURL = new URL(fallbackUrl); + const token = parsedURL.pathname.split('/').pop(); + const alias = token || path.replace('~', '').replace('/', '-'); const data: DeepLinkData = { $desktop_url: fallbackUrl, $canonical_url: fallbackUrl, diff --git a/apps/tlon-web/src/state/lure/lure.ts b/apps/tlon-web/src/state/lure/lure.ts index ee67a8bda7..054c844e31 100644 --- a/apps/tlon-web/src/state/lure/lure.ts +++ b/apps/tlon-web/src/state/lure/lure.ts @@ -15,6 +15,7 @@ import { createStorageKey, getFlagParts, storageVersion, + stringToTa, } from '@/logic/utils'; import { useLocalState } from '../local'; @@ -69,12 +70,11 @@ export const useLureState = create( bait: null, lures: {}, describe: async (flag, metadata) => { - const { name } = getFlagParts(flag); await api.poke({ app: 'reel', mark: 'reel-describe', json: { - token: name, + token: flag, metadata, }, }); @@ -90,7 +90,7 @@ export const useLureState = create( app: 'reel', mark: 'reel-undescribe', json: { - token: getFlagParts(flag).name, + token: flag, }, }); } else { @@ -127,7 +127,6 @@ export const useLureState = create( ); }, fetchLure: async (flag) => { - const { name } = getFlagParts(flag); const prevLure = get().lures[flag]; const [enabled, url, metadata, outstandingPoke] = await Promise.all([ // enabled @@ -149,11 +148,10 @@ export const useLureState = create( asyncWithDefault(() => { lureLogger.log(performance.now(), 'fetching url', flag); return api - .subscribeOnce( - 'reel', - `/token-link/${flag}`, - LURE_REQUEST_TIMEOUT - ) + .scry({ + app: 'reel', + path: `/v1/id-url/${flag}`, + }) .then((u) => { lureLogger.log(performance.now(), 'url fetched', flag); return u; @@ -164,7 +162,7 @@ export const useLureState = create( () => api.scry({ app: 'reel', - path: `/metadata/${name}`, + path: `/v1/metadata/${flag}`, }), prevLure?.metadata ), @@ -248,14 +246,19 @@ export function useLure(flag: string, disableLoading = false) { }; } -export function useLureLinkChecked(flag: string, enabled: boolean) { +export function useLureLinkChecked(url: string, enabled: boolean) { const prevData = useRef(false); + const pathEncodedUrl = stringToTa(url); const { data, ...query } = useQuery( - ['lure-check', flag], + ['lure-check', url], () => asyncWithDefault( () => - api.subscribeOnce('grouper', `/check-link/${flag}`, 4500), + api.subscribeOnce( + 'grouper', + `/v1/check-link/${pathEncodedUrl}`, + 4500 + ), prevData.current ?? false ), { @@ -274,9 +277,9 @@ export function useLureLinkChecked(flag: string, enabled: boolean) { } export function useLureLinkStatus(flag: string) { - const { supported, fetched, enabled, enableAcked, url, deepLinkUrl, toggle } = + const { supported, fetched, enabled, url, deepLinkUrl, toggle } = useLure(flag); - const { good, checked } = useLureLinkChecked(flag, !!enabled); + const { good, checked } = useLureLinkChecked(url, !!enabled); const status = useMemo(() => { if (!supported) { diff --git a/desk/app/activity.hoon b/desk/app/activity.hoon index 0e29281c07..5ea131a496 100644 --- a/desk/app/activity.hoon +++ b/desk/app/activity.hoon @@ -256,6 +256,8 @@ %refresh-activity refresh-all-summaries %clean-keys correct-dm-keys %fix-init-unreads fix-init-unreads + %show-orphans (drop-orphans &) + %drop-orphans (drop-orphans |) :: %sync-reads =^ indices activity @@ -499,6 +501,24 @@ |= [=source:a out=activity:a] (~(put by out) source (~(got by activity) source)) ``activity-summary-4+!>(threads) + :: + [%x %v4 %activity %unreads ~] + =/ unreads + %+ skim + ~(tap by activity) + |= [=source:a as=activity-summary:a] + ?. |(?=(%thread -.source) ?=(%dm-thread -.source)) + (gth count.as 0) + (gth notify-count.as 0) + ``activity-summary-pairs-4+!>(unreads) + :: + [%x %v4 %activity %notified ~] + =/ notified + %+ skim + ~(tap by activity) + |= [=source:a as=activity-summary:a] + notify.as + ``activity-summary-pairs-4+!>(notified) :: [%x any %volume-settings ~] ``activity-settings+!>(volume-settings) @@ -895,6 +915,30 @@ ++ summarize-unreads ~(summarize-unreads urd indices activity volume-settings log) :: +++ drop-orphans + |= dry-run=? + =/ indexes ~(tap by indices) + =/ orphan-count=@ud 0 + |- + ?~ indexes + ~? =(orphan-count 0) "no orphans found" + ?: dry-run cor + ?: =(orphan-count 0) cor + refresh-all-summaries + =/ [=source:a =index:a] i.indexes + =/ parent (get-parent:src indices source) + =/ missing-parent &(=(parent ~) ?!(?=(%base -.source))) + =/ new-count ?:(missing-parent +(orphan-count) orphan-count) + ?: dry-run + ~? missing-parent "orphaned source: {}" + $(indexes t.indexes, orphan-count new-count) + ?. missing-parent $(indexes t.indexes) + =. indices (~(del by indices) source) + =. activity (~(del by activity) source) + =. volume-settings (~(del by volume-settings) source) + $(indexes t.indexes, orphan-count new-count) + +:: :: when we migrated from chat and channels, we always added an init event :: so that we can mark what's been joined and have something affect the :: recency so that it ends up in the correct place on the sidebar. diff --git a/desk/app/chat.hoon b/desk/app/chat.hoon index a1e6032f29..c04ac3a5fe 100644 --- a/desk/app/chat.hoon +++ b/desk/app/chat.hoon @@ -32,18 +32,20 @@ == ++ club-eq 2 :: reverb control: max number of forwards for clubs +$ current-state - $: %6 + $: %7 dms=(map ship dm:c) clubs=(map id:club:c club:c) pins=(list whom:c) - bad=(set ship) ::TODO vestigial, remove me - inv=(set ship) ::TODO vestigial, remove me + sends=(map whom:c (qeu sent-id)) blocked=(set ship) blocked-by=(set ship) hidden-messages=(set id:c) old-chats=(map flag:old chat:old) :: for migration old-pins=(list whom:old) == + +$ sent-id + $@ time :: top-level msg + [top=id:c new=time] :: or reply -- =| current-state =* state - @@ -122,10 +124,11 @@ %3 $(old (state-3-to-4 old)) %4 $(old (state-4-to-5 old)) %5 $(old (state-5-to-6 old)) - %6 (emil(state old) (drop load:epos)) + %6 $(old (state-6-to-7 old)) + %7 (emil(state old) (drop load:epos)) == :: - +$ versioned-state $%(current-state state-5 state-4 state-3 state-2) + +$ versioned-state $%(current-state state-6 state-5 state-4 state-3 state-2) +$ state-2 $: %2 chats=(map flag:two chat:two) @@ -189,9 +192,26 @@ +$ club-5 [heard:club:c remark=remark-5 =pact:c crew:club:c] +$ dm-5 [=pact:c remark=remark-5 net:dm:c pin=_|] +$ remark-5 [last-read=time watching=_| unread-threads=(set id:c)] - +$ state-6 current-state + +$ state-6 + $: %6 + dms=(map ship dm:c) + clubs=(map id:club:c club:c) + pins=(list whom:c) + bad=(set ship) + inv=(set ship) + blocked=(set ship) + blocked-by=(set ship) + hidden-messages=(set id:c) + old-chats=(map flag:old chat:old) :: for migration + old-pins=(list whom:old) + == + +$ state-7 current-state ++ two old ++ three c + ++ state-6-to-7 + |= state-6 + ^- state-7 + [%7 dms clubs pins ~ blocked blocked-by hidden-messages old-chats old-pins] ++ state-5-to-6 |= s=state-5 ^- state-6 @@ -1719,6 +1739,22 @@ ++ di-proxy |= =diff:dm:c =. di-core (di-ingest-diff diff) + :: track the id of the message we sent so that we can handle nacks + :: gracefully, such as retrying with an older protocol version. + :: note that we don't put this information in the wire. +proxy:di-pass + :: always uses the same wire. this is important, because ames gives + :: message ordering guarantees only within the same flow, so we want to + :: re-use the same flow (duct stack) for the same target ship whenever + :: we can. (arguably rsvp pokes should go over that same flow also, but + :: they only happen once, and their ordering wrt the messages isn't _that_ + :: important.) + :: + =. sends + %+ ~(put by sends) [%ship ship] + %- ~(put to (~(gut by sends) [%ship ship] ~)) + ?. ?=(%reply -.q.diff) + q.p.diff + [[p.p q.p] q.id.q]:diff =. cor (emit (proxy:di-pass diff)) di-core :: @@ -1892,6 +1928,18 @@ :: [%proxy *] ?> ?=(%poke-ack -.sign) + :: for pokes whose id we care about, pop it from the queue + :: + =^ sent=(unit sent-id) sends + ?. ?=([%diff ~] t.wire) [~ sends] + =/ queue=(qeu sent-id) + (~(gut by sends) [%ship ship] ~) + ?: =(~ queue) + ~& [dap.bowl %strange-empty-sends-queue [%ship ship]] + [~ sends] + =^ id queue ~(get to queue) + :- `id + (~(put by sends) [%ship ship] queue) ?~ p.sign di-core :: if we already tried hard, this is the end of the road. :: @@ -1914,10 +1962,21 @@ ?~ c cor %+ emit %pass [(weld di-area /proxy/archaic) %agent [ship %chat] %poke u.c] + |- ?+ t.wire ~ [%rsvp @ ~] =/ ok=? ;;(? (slav %f i.t.t.wire)) `[%dm-rsvp !>(`rsvp:dm:old`[our.bowl ok])] + :: + [%diff ~] + ?> ?=(^ sent) + =* s u.sent + ::NOTE we just pretend it's an old-style wire, to avoid duplicating + :: code. the re-serialization overhead isn't too big, and this + :: isn't the common path anyway. + ?@ s + $(t.wire /(scot %ud s)) + $(t.wire /(scot %p p.top.s)/(scot %ud q.top.s)/(scot %ud new.s)) :: [@ ?(~ [@ @ ~])] %- some @@ -2035,14 +2094,9 @@ ++ proxy-rsvp |=(ok=? (poke-them /proxy/rsvp/(scot %f ok) chat-dm-rsvp+!>([our.bowl ok]))) ++ proxy |= =diff:dm:c - =; =wire - (poke-them wire chat-dm-diff+!>(diff)) - :: we put some details about the message into the wire, so that we may - :: re-try a send for backwards compatibility in some cases - :: - ?. ?=(%reply -.q.diff) - /proxy/(scot %ud q.p.diff) - /proxy/(scot %p p.p.diff)/(scot %ud q.p.diff)/(scot %ud q.id.q.diff) + ::NOTE static wire important for ordering guarantees and preventing flow + :: proliferation, see also +di-proxy + (poke-them /proxy/diff chat-dm-diff+!>(diff)) -- -- -- diff --git a/desk/app/grouper.hoon b/desk/app/grouper.hoon index 5dac7655fe..bb20fd14d0 100644 --- a/desk/app/grouper.hoon +++ b/desk/app/grouper.hoon @@ -1,5 +1,5 @@ -/- reel, groups -/+ default-agent, verb, dbug +/- reel, groups, c=chat, ch=channels +/+ gj=groups-json, default-agent, verb, dbug :: |% ++ dev-mode | @@ -36,39 +36,50 @@ ^- (quip card _this) ?+ mark (on-poke:def mark vase) %leave :_ this ~[[%pass /bite-wire %agent [our.bowl %reel] %leave ~]] - %watch - :_ this + :: + %watch + :_ this ?: (~(has by wex.bowl) [/bite-wire our.bowl %reel]) ~ ~[(bite-subscribe bowl)] - :: + :: %grouper-enable =+ !<(name=cord vase) `this(enabled-groups (~(put in enabled-groups) name)) + :: %grouper-disable =+ !<(name=cord vase) `this(enabled-groups (~(del in enabled-groups) name)) + :: %grouper-ask-enabled =+ !<(name=cord vase) =/ enabled (~(has in enabled-groups) name) :_ this ~[[%pass [%ask name ~] %agent [src.bowl %grouper] %poke %grouper-answer-enabled !>([name enabled])]] + :: %grouper-answer-enabled =/ [name=cord enabled=?] !<([cord ?] vase) :_ this ~[[%give %fact ~[[%group-enabled (scot %p src.bowl) name ~]] %json !>(b+enabled)]] + :: %grouper-check-link - =+ !<(=path vase) - ?> ?=([%check-link @ @ ~] path) + =+ !<(=(pole knot) vase) + ?> ?=([%check-link rest=*] pole) =/ baseurl .^(cord %gx /(scot %p our.bowl)/reel/(scot %da now.bowl)/service/noun) - =/ target=ship (slav %p i.t.path) - =/ group=cord i.t.t.path + :: it really is necessary to double-encode this, make sure we strip + :: the leading slash before encoding + =/ end (en-urlt:html (en-urlt:html +:(spud rest.pole))) + =/ url + ?. =(baseurl 'https://tlon.network/lure/') + (crip "{(trip baseurl)}{end}") + (crip "https://tlon.network/v1/policies/lure/{end}") :_ this - :~ :* %pass path + :~ :* %pass pole %arvo %k %fard q.byk.bowl %lure-check-link %noun - !>(`[baseurl target group path]) + !>(`[url pole]) == == + :: %grouper-link-checked =+ !<([good=? =path] vase) :_ this @@ -88,9 +99,20 @@ :~ [%pass path %agent [target %grouper] %poke %grouper-ask-enabled !>(group)] [%pass /expire/(scot %p our.bowl)/[group] %arvo %b [%wait (add ~h1 now.bowl)]] == + :: [%check-link @ @ ~] :_ this ~[[%pass path %agent [our.bowl %grouper] %poke %grouper-check-link !>(path)]] + :: + [%v1 %check-link @ ~] + =/ url (slav %t i.t.t.path) + :_ this + :~ :* %pass path + %arvo %k %fard + q.byk.bowl %lure-check-link %noun + !>(`[url path]) + == + == == :: ++ on-agent @@ -104,30 +126,49 @@ ?- -.sign %poke-ack `this %watch-ack `this - %kick + %kick :_ this ~[(bite-subscribe bowl)] :: %fact =+ !<(=bite:reel q.cage.sign) - ~? dev-mode [bite (~(has in enabled-groups) token.bite)] - ?> (~(has in enabled-groups) token.bite) - ?> ?=([%bite-1 *] bite) - ~? dev-mode 'inviting' - =/ =invite:groups [[our.bowl token.bite] joiner.bite] + ?> ?=([%bite-2 *] bite) :_ this - =/ our (scot %p our.bowl) - =/ =path /[our]/groups/(scot %da now.bowl)/groups/[our]/[token.bite]/noun - =+ .^(=group:groups %gx path) + =; caz=(list card) + =/ wir=^wire /dm/(scot %p joiner.bite) + =/ =dock [our.bowl %chat] + =/ =id:c [our now]:bowl + =/ =memo:ch + [~[[%inline ~[[%ship joiner.bite] ' has joined the network']]] id] + =/ =action:dm:c + :- joiner.bite + [id %add memo [%notice ~] ~] + =/ =cage chat-dm-action+!>(`action:dm:c`action) + (snoc caz [%pass wir %agent dock %poke cage]) + ?~ group=(~(get by fields.metadata.bite) 'group') + ~&("no group field for token: {}" ~) + =/ =flag:groups (flag:dejs:gj s+u.group) + ~? dev-mode [bite (~(has in enabled-groups) q.flag)] + ?. (~(has in enabled-groups) q.flag) + ~&("group lure not enabled: {}" ~) + ~? dev-mode 'inviting' + =/ =invite:groups [flag joiner.bite] + =/ prefix /(scot %p our.bowl)/groups/(scot %da now.bowl) + ?. .^(? %gu (weld prefix /$)) + ~?(dev-mode "groups not running" ~) + =/ gnat=path /(scot %p p.flag)/[q.flag]/noun + ?. .^(? %gx :(weld prefix /exists gnat)) + ~?(dev-mode "group doesn't exist" ~) + =+ .^(=group:groups %gx :(weld prefix /groups gnat)) ~? dev-mode cordon.group ?+ -.cordon.group ~ %open - ~? dev-mode ['inviting to public' joiner.bite] + ~? dev-mode ['inviting to public' joiner.bite] ~[[%pass /invite %agent [our.bowl %groups] %poke %group-invite !>(invite)]] :: %shut ~? dev-mode ['inviting to private/secret' joiner.bite] - =/ =action:groups + =/ =action:groups :- [our.bowl token.bite] :- now.bowl :- %cordon diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 521a65721c..ed12ec966c 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,9 +2,9 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v2.f18oq.l19d4.7t1c6.onouc.uf57n.glob' 0v2.f18oq.l19d4.7t1c6.onouc.uf57n] + glob-http+['https://bootstrap.urbit.org/glob-0v4.kgre5.dcoki.is810.6mj41.s3k9q.glob' 0v4.kgre5.dcoki.is810.6mj41.s3k9q] base+'groups' - version+[6 2 0] + version+[6 3 0] website+'https://tlon.io' license+'MIT' == diff --git a/desk/lib/activity-json.hoon b/desk/lib/activity-json.hoon index 9002fefe3c..7a67ddfb71 100644 --- a/desk/lib/activity-json.hoon +++ b/desk/lib/activity-json.hoon @@ -282,6 +282,17 @@ ?. full (activity-summary sum) (activity-summary-full sum) :: + ++ activity-pairs + |= activity=(list [source:a activity-summary:a]) + :- %a + %+ turn + activity + |= [s=source:a as=activity-summary:a] + %- pairs + :~ source+(source s) + activity+(activity-summary as) + == + :: ++ full-info |= fi=full-info:a %- pairs diff --git a/desk/lib/activity.hoon b/desk/lib/activity.hoon index e6696950fb..f0caa8496c 100644 --- a/desk/lib/activity.hoon +++ b/desk/lib/activity.hoon @@ -19,6 +19,21 @@ == == :: + ++ get-parent + |= [=indices:a =source:a] + ^- (unit source:a) + ?: ?=(%base -.source) ~ + ?< ?=(%base -.source) + =/ parent + ?- -.source + %dm [%base ~] + %group [%base ~] + %channel [%group group.source] + %dm-thread [%dm whom.source] + %thread [%channel channel.source group.source] + == + ?. (~(has by indices) parent) ~ + `parent ++ get-children :: direct children only |= [=indices:a =source:a] ^- (list source:a) diff --git a/desk/mar/activity/summary-pairs-4.hoon b/desk/mar/activity/summary-pairs-4.hoon new file mode 100644 index 0000000000..e1b5cdea81 --- /dev/null +++ b/desk/mar/activity/summary-pairs-4.hoon @@ -0,0 +1,15 @@ +/- a=activity +/+ aj=activity-json +|_ activity=(list [=source:a =activity-summary:a]) +:: this version matches state 4 which is why it skips over versions 2-3 +++ grad %noun +++ grow + |% + ++ noun activity + ++ json (activity-pairs:enjs:aj activity) + -- +++ grab + |% + ++ noun (list [source:a activity-summary:a]) + -- +-- diff --git a/desk/mar/jpg.hoon b/desk/mar/jpg.hoon new file mode 100644 index 0000000000..cf220b169d --- /dev/null +++ b/desk/mar/jpg.hoon @@ -0,0 +1,13 @@ +:: This is needed because we have a jpg in the dist +|_ dat=@ +++ grow + |% + ++ mime [/image/jpeg (as-octs:mimes:html dat)] + -- +++ grab + |% + ++ mime |=([p=mite q=octs] q.q) + ++ noun @ + -- +++ grad %mime +-- diff --git a/desk/mar/wasm.hoon b/desk/mar/wasm.hoon new file mode 100644 index 0000000000..54940d85f1 --- /dev/null +++ b/desk/mar/wasm.hoon @@ -0,0 +1,13 @@ +:: NOTE: this file must be present in the %garden and %work desks on the globber ship +|_ dat=octs +++ grow + |% + ++ mime [/application/wasm dat] + -- +++ grab + |% + ++ mime |=([=mite =octs] octs) + ++ noun octs + -- +++ grad %mime +-- diff --git a/desk/sur/reel.hoon b/desk/sur/reel.hoon index b3fee23d93..1f46275c65 100644 --- a/desk/sur/reel.hoon +++ b/desk/sur/reel.hoon @@ -7,7 +7,11 @@ +$ bite $% [%bite-0 token=@ta ship=@p] [%bite-1 token=@ta joiner=@p inviter=@p] + [%bite-2 =token joiner=@p =metadata] == :: ++$ token cord ++$ nonce @ta +$ metadata [tag=term fields=(map cord cord)] ++$ confirmation [=nonce =token] -- diff --git a/desk/ted/lure-check-link.hoon b/desk/ted/lure-check-link.hoon index ea03be5c86..85a369e8ba 100644 --- a/desk/ted/lure-check-link.hoon +++ b/desk/ted/lure-check-link.hoon @@ -4,32 +4,17 @@ =, strand-fail=strand-fail:libstrand:spider ^- thread:spider =/ m (strand ,vase) -|^ ted -++ ted - |= arg=vase - ^- form:m - ;< our=@p bind:m get-our - =/ arguments !<((unit [cord ship cord path]) arg) - =/ [baseurl=cord target=ship group=cord =path] (need arguments) - =/ target-tape (trip (scot %p target)) - ?~ target-tape !! - ;< ~ bind:m (send-request %'GET' (url baseurl target group) ~ ~) - ;< rep=client-response:iris bind:m - take-client-response - ?> ?=(%finished -.rep) - =/ result =(200 status-code.response-header.rep) - ;< ~ bind:m (poke [our %grouper] grouper-link-checked+!>([result path])) - (pure:m !>(~)) -++ url - |= [baseurl=cord target=ship group=cord] - ^- cord - =/ target-tape (trip (scot %p target)) - ?~ target-tape - ~& "lure link check: bad target ship" - !! - ?. =(baseurl 'https://tlon.network/lure/') - (crip "{(trip baseurl)}{target-tape}/{(trip group)}") - :: it really is necessary to double-encode this - =/ end (en-urlt:html "%7E{t.target-tape}%2F{(trip group)}") - (crip "https://tlon.network/v1/policies/lure/{end}") --- +|= arg=vase +^- form:m +;< our=@p bind:m get-our +=+ !<(args=(unit [url=cord =path]) arg) +?~ args (pure:m !>(~)) +=/ [url=cord =path] u.args +;< ~ bind:m (send-request %'GET' url ~ ~) +;< rep=client-response:iris bind:m + take-client-response +?> ?=(%finished -.rep) +=/ result =(200 status-code.response-header.rep) +;< ~ bind:m (poke [our %grouper] grouper-link-checked+!>([result path])) +::TODO make %grouper handle thread result +(pure:m !>(~)) diff --git a/package.json b/package.json index 2913987fe6..15d5da947d 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,24 @@ "react-native-reanimated@3.8.1": "patches/react-native-reanimated@3.8.1.patch", "drizzle-orm@0.30.9": "patches/drizzle-orm@0.30.9.patch", "@10play/tentap-editor@0.4.55": "patches/@10play__tentap-editor@0.4.55.patch", - "any-ascii@0.3.2": "patches/any-ascii@0.3.2.patch" + "any-ascii@0.3.2": "patches/any-ascii@0.3.2.patch", + "react-native-gesture-handler@2.18.1": "patches/react-native-gesture-handler@2.18.1.patch", + "@likashefqet/react-native-image-zoom@3.0.0": "patches/@likashefqet__react-native-image-zoom@3.0.0.patch", + "react-native@0.73.4": "patches/react-native@0.73.4.patch" }, "allowNonAppliedPatches": true, "overrides": { "typescript": "5.4.5", - "@urbit/http-api": "3.1.0-dev-3" + "@10play/tentap-editor": "0.5.11", + "@tiptap/suggestion": "2.6.0", + "@tiptap/extension-mention": "2.6.0", + "@tiptap/extension-hard-break": "2.6.0", + "@urbit/http-api": "3.1.0-dev-3", + "@urbit/api": "2.2.0", + "prosemirror-model": "1.19.3", + "prosemirror-view": "1.33.4", + "prosemirror-state": "1.4.3", + "@tiptap/pm": "2.6.6" } } } diff --git a/packages/app/contexts/ship.tsx b/packages/app/contexts/ship.tsx index e428796baf..c88aecfff2 100644 --- a/packages/app/contexts/ship.tsx +++ b/packages/app/contexts/ship.tsx @@ -106,7 +106,7 @@ export const ShipProvider = ({ children }: { children: ReactNode }) => { setShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie }); saveShipInfo({ ...nextShipInfo, authCookie: fetchedAuthCookie }); // Save to native storage - UrbitModule.setUrbit(ship, normalizedShipUrl); + UrbitModule.setUrbit(ship, normalizedShipUrl, fetchedAuthCookie); } })(); } diff --git a/packages/app/features/channels/ChannelMembersScreen.tsx b/packages/app/features/channels/ChannelMembersScreen.tsx new file mode 100644 index 0000000000..6428e74496 --- /dev/null +++ b/packages/app/features/channels/ChannelMembersScreen.tsx @@ -0,0 +1,21 @@ +import * as store from '@tloncorp/shared/dist/store'; +import { ChannelMembersScreenView } from '@tloncorp/ui'; + +export function ChannelMembersScreen({ + channelId, + onGoBack, +}: { + channelId: string; + onGoBack: () => void; +}) { + const channelQuery = store.useChannelWithRelations({ + id: channelId, + }); + + return ( + + ); +} diff --git a/apps/tlon-mobile/src/screens/ChannelMetaScreen.tsx b/packages/app/features/channels/ChannelMetaScreen.tsx similarity index 63% rename from apps/tlon-mobile/src/screens/ChannelMetaScreen.tsx rename to packages/app/features/channels/ChannelMetaScreen.tsx index 0720b50a27..09b7c31dc5 100644 --- a/apps/tlon-mobile/src/screens/ChannelMetaScreen.tsx +++ b/packages/app/features/channels/ChannelMetaScreen.tsx @@ -1,28 +1,25 @@ -import { NativeStackScreenProps } from '@react-navigation/native-stack'; import * as db from '@tloncorp/shared/dist/db'; import * as store from '@tloncorp/shared/dist/store'; import { uploadAsset, useCanUpload } from '@tloncorp/shared/dist/store'; import { AttachmentProvider, MetaEditorScreenView } from '@tloncorp/ui'; import { useCallback } from 'react'; -import { RootStackParamList } from '../types'; - -type ChannelMetaScreenProps = NativeStackScreenProps< - RootStackParamList, - 'ChannelMeta' ->; - -export function ChannelMetaScreen(props: ChannelMetaScreenProps) { - const { channelId } = props.route.params; +export function ChannelMetaScreen({ + channelId, + onGoBack, +}: { + channelId: string; + onGoBack: () => void; +}) { const channelQuery = store.useChannel({ id: channelId }); const canUpload = useCanUpload(); const handleSubmit = useCallback( (meta: db.ClientMeta) => { store.updateDMMeta(channelId, meta); - props.navigation.goBack(); + onGoBack(); }, - [channelId, props.navigation] + [channelId, onGoBack] ); return ( @@ -30,7 +27,7 @@ export function ChannelMetaScreen(props: ChannelMetaScreenProps) { diff --git a/apps/tlon-mobile/src/screens/GroupSettings/EditChannelScreen.tsx b/packages/app/features/groups/EditChannelScreen.tsx similarity index 54% rename from apps/tlon-mobile/src/screens/GroupSettings/EditChannelScreen.tsx rename to packages/app/features/groups/EditChannelScreen.tsx index 7263c8781f..e746d48f7d 100644 --- a/apps/tlon-mobile/src/screens/GroupSettings/EditChannelScreen.tsx +++ b/packages/app/features/groups/EditChannelScreen.tsx @@ -1,19 +1,18 @@ -import { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useGroupContext } from '@tloncorp/app/hooks/useGroupContext'; import * as store from '@tloncorp/shared/dist/store'; import { EditChannelScreenView } from '@tloncorp/ui'; import { useCallback } from 'react'; -import { GroupSettingsStackParamList } from '../../types'; - -type ManageChannelsScreenProps = NativeStackScreenProps< - GroupSettingsStackParamList, - 'EditChannel' ->; - -export function EditChannelScreen(props: ManageChannelsScreenProps) { - const { groupId, channelId } = props.route.params; +import { useGroupContext } from '../../hooks/useGroupContext'; +export function EditChannelScreen({ + groupId, + channelId, + onGoBack, +}: { + groupId: string; + channelId: string; + onGoBack: () => void; +}) { const { updateChannel, deleteChannel } = useGroupContext({ groupId, }); @@ -25,28 +24,28 @@ export function EditChannelScreen(props: ManageChannelsScreenProps) { const prevChannel = data; if (prevChannel) { deleteChannel(prevChannel.id); - props.navigation.goBack(); + onGoBack(); } - }, [data, deleteChannel, props.navigation]); + }, [data, deleteChannel, onGoBack]); const handleSubmit = useCallback( - (name: string, description: string) => { + (title: string, description?: string) => { const prevChannel = data; if (prevChannel) { updateChannel({ ...prevChannel, - title: name, + title, description, }); - props.navigation.goBack(); + onGoBack(); } }, - [data, updateChannel, props.navigation] + [data, updateChannel, onGoBack] ); return ( ; - -export function GroupMembersScreen(props: GroupMembersScreenProps) { - const { groupId } = props.route.params; +import { useCurrentUserId } from '../../hooks/useCurrentUser'; +import { useGroupContext } from '../../hooks/useGroupContext'; +export function GroupMembersScreen({ + groupId, + onGoBack, +}: { + groupId: string; + onGoBack: () => void; +}) { const { groupMembers, groupRoles, @@ -32,7 +29,7 @@ export function GroupMembersScreen(props: GroupMembersScreenProps) { return ( ; - -export function GroupMetaScreen(props: GroupMetaScreenProps) { - const { groupId } = props.route.params; +export function GroupMetaScreen({ + groupId, + onGoBack, +}: { + groupId: string; + onGoBack: () => void; +}) { const { group, setGroupMetadata, deleteGroup } = useGroupContext({ groupId, }); const canUpload = useCanUpload(); const [showDeleteSheet, setShowDeleteSheet] = useState(false); + const { enabled, describe } = store.useLure({ + flag: groupId, + branchDomain: BRANCH_DOMAIN, + branchKey: BRANCH_KEY, + }); const handleSubmit = useCallback( (data: db.ClientMeta) => { setGroupMetadata(data); - props.navigation.goBack(); + onGoBack(); + if (enabled) { + describe({ + title: data.title ?? '', + description: data.description ?? '', + image: data.iconImage ?? '', + cover: data.coverImage ?? '', + }); + } }, - [setGroupMetadata, props.navigation] + [setGroupMetadata, onGoBack, enabled, describe] ); const handlePressDelete = useCallback(() => { @@ -42,7 +55,7 @@ export function GroupMetaScreen(props: GroupMetaScreenProps) {