├── .github └── workflows │ ├── docker.yaml │ ├── helm.yaml │ └── k8s.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── etc └── nginx.conf ├── helm ├── cerulean │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── hpa.yaml │ │ ├── ingress.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tests │ │ │ └── test-connection.yaml │ └── values.yaml ├── cr.yaml └── ct.yaml ├── package.json ├── public ├── Inter.ttf ├── chevron.svg ├── close.svg ├── delete.svg ├── favicon.ico ├── filter.svg ├── icon.svg ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt ├── send-active.svg ├── send.svg ├── thread-corner.svg └── thread-line.svg ├── src ├── App.css ├── App.js ├── Client.js ├── Client.test.js ├── ClientContext.js ├── InputPost.css ├── InputPost.js ├── Message.css ├── Message.js ├── Modal.js ├── Reputation.js ├── Reputation.test.js ├── ReputationList.js ├── ReputationPane.css ├── ReputationPane.js ├── StatusPage.css ├── StatusPage.js ├── TimelinePage.css ├── TimelinePage.js ├── UserPage.css ├── UserPage.js ├── index.js ├── routing.js └── setupTests.js └── yarn.lock /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/docker/build-push-action 2 | 3 | name: "Docker" 4 | 5 | on: 6 | push: 7 | branches: ["main"] 8 | release: # A GitHub release was published 9 | types: [published] 10 | workflow_dispatch: # A build was manually requested 11 | workflow_call: # Another pipeline called us 12 | 13 | env: 14 | GHCR_NAMESPACE: matrix-org 15 | PLATFORMS: linux/amd64 16 | 17 | jobs: 18 | cerulean: 19 | name: Cerulean image 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | packages: write 24 | security-events: write # To upload Trivy sarif files 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v1 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v2 32 | - name: Login to GitHub Containers 33 | uses: docker/login-action@v2 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Build main Cerulean image 40 | if: github.ref_name == 'main' 41 | id: docker_build_cerulean 42 | uses: docker/build-push-action@v3 43 | with: 44 | cache-from: type=gha 45 | cache-to: type=gha,mode=max 46 | context: . 47 | platforms: ${{ env.PLATFORMS }} 48 | push: true 49 | tags: | 50 | ghcr.io/${{ env.GHCR_NAMESPACE }}/cerulean:main 51 | 52 | - name: Build release Cerulean image 53 | if: github.event_name == 'release' # Only for GitHub releases 54 | id: docker_build_cerulean_release 55 | uses: docker/build-push-action@v3 56 | with: 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | context: . 60 | platforms: ${{ env.PLATFORMS }} 61 | push: true 62 | tags: | 63 | ghcr.io/${{ env.GHCR_NAMESPACE }}/cerulean:latest 64 | ghcr.io/${{ env.GHCR_NAMESPACE }}/cerulean:${{ env.RELEASE_VERSION }} 65 | 66 | - name: Run Trivy vulnerability scanner 67 | uses: aquasecurity/trivy-action@master 68 | if: github.ref_name == 'main' 69 | with: 70 | image-ref: ghcr.io/${{ env.GHCR_NAMESPACE }}/cerulean:main 71 | format: "sarif" 72 | output: "trivy-results.sarif" 73 | 74 | - name: Upload Trivy scan results to GitHub Security tab 75 | if: github.ref_name == 'main' 76 | uses: github/codeql-action/upload-sarif@v2 77 | with: 78 | sarif_file: "trivy-results.sarif" -------------------------------------------------------------------------------- /.github/workflows/helm.yaml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'helm/**' # only execute if we have helm chart changes 9 | 10 | jobs: 11 | release: 12 | # depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions 13 | # see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token 14 | permissions: 15 | contents: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Configure Git 24 | run: | 25 | git config user.name "$GITHUB_ACTOR" 26 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 27 | 28 | - name: Install Helm 29 | uses: azure/setup-helm@v3 30 | with: 31 | version: v3.10.0 32 | 33 | - name: Run chart-releaser 34 | uses: helm/chart-releaser-action@v1.4.1 35 | env: 36 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 37 | with: 38 | config: helm/cr.yaml 39 | charts_dir: helm/ 40 | -------------------------------------------------------------------------------- /.github/workflows/k8s.yaml: -------------------------------------------------------------------------------- 1 | name: k8s 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - 'helm/**' # only execute if we have helm chart changes 8 | pull_request: 9 | branches: ["main"] 10 | paths: 11 | - 'helm/**' 12 | 13 | jobs: 14 | lint: 15 | name: Lint Helm chart 16 | runs-on: ubuntu-latest 17 | outputs: 18 | changed: ${{ steps.list-changed.outputs.changed }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - uses: azure/setup-helm@v3 24 | with: 25 | version: v3.10.0 26 | - uses: actions/setup-python@v4 27 | with: 28 | python-version: 3.11 29 | check-latest: true 30 | - uses: helm/chart-testing-action@v2.3.1 31 | - name: Get changed status 32 | id: list-changed 33 | run: | 34 | changed=$(ct list-changed --config helm/ct.yaml --target-branch ${{ github.event.repository.default_branch }}) 35 | if [[ -n "$changed" ]]; then 36 | echo "::set-output name=changed::true" 37 | fi 38 | 39 | - name: Run lint 40 | run: ct lint --config helm/ct.yaml 41 | 42 | # only bother to run if lint step reports a change to the helm chart 43 | install: 44 | needs: 45 | - lint 46 | if: ${{ needs.lint.outputs.changed == 'true' }} 47 | name: Install Helm charts 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v3 52 | with: 53 | fetch-depth: 0 54 | ref: ${{ inputs.checkoutCommit }} 55 | - name: Install Kubernetes tools 56 | uses: yokawasa/action-setup-kube-tools@v0.8.2 57 | with: 58 | setup-tools: | 59 | helmv3 60 | helm: "3.10.3" 61 | - uses: actions/setup-python@v4 62 | with: 63 | python-version: "3.10" 64 | - name: Set up chart-testing 65 | uses: helm/chart-testing-action@v2.3.1 66 | - name: Create k3d cluster 67 | uses: nolar/setup-k3d-k3s@v1 68 | with: 69 | version: v1.21 70 | - name: Remove node taints 71 | run: | 72 | kubectl taint --all=true nodes node.cloudprovider.kubernetes.io/uninitialized- || true 73 | - name: Run chart-testing (install) 74 | run: ct install --config helm/ct.yaml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine AS builder 2 | WORKDIR /app 3 | COPY package.json ./ 4 | COPY yarn.lock ./ 5 | RUN --mount=type=cache,target=/root/.yarn \ 6 | YARN_CACHE_FOLDER=/root/.yarn \ 7 | yarn install --frozen-lockfile 8 | COPY . . 9 | RUN --mount=type=cache,target=/root/.yarn \ 10 | YARN_CACHE_FOLDER=/root/.yarn \ 11 | yarn build 12 | 13 | FROM nginx:1.22-alpine AS server 14 | COPY ./etc/nginx.conf /etc/nginx/conf.d/default.conf 15 | COPY --from=builder ./app/build /usr/share/nginx/html 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cerulean 2 | 3 | Cerulean is a highly experimental [Matrix](https://matrix.org) client intended to 4 | demonstrate the viability of freestyle public threaded conversations a la Twitter. 5 | 6 | As such, it is built as simply as possible, in order to demonstrate to someone 7 | unfamiliar with Matrix how the Client Server API can be used in this manner. 8 | It has no dependencies (other than create-react-app) and has no optimisations. 9 | It uses a naive View+Model architecture for legibility (although ideally it'd 10 | grow to be MVVM in future). 11 | 12 | For more info, see https://matrix.org/blog/2020/12/18/introducing-cerulean 13 | 14 | ## Design 15 | 16 | The way Cerulean works is: 17 | * Messages are sent into 2 rooms: the 'user timeline' room and a 'thread' room. 18 | * For instance, my user timeline room would be #@matthew:matrix.org 19 | * A thread room is created for each unique post. Replies to the thread are sent into this room. 20 | * Messages are viewed in the context of a given 'thread' room. 21 | * e.g. https://cerulean/#/@matthew:matrix.org/!cURbafjkfsMDVwdRDQ:matrix.org/$nqeHq7lJyFp4UZNlE3rN4xPVsez0vZnIcaM6SQB9waw 22 | is a given message that I've sent, and that is a permalink to the message with surrounding replies. 23 | * User timelines are viewed in the context of a given 'user timeline' room. 24 | * e.g https://cerulean/#/@matthew:matrix.org is my user timeline which has all my posts and all my replies. 25 | * Messages are threaded in 'thread' rooms using MSC2836. 26 | * Users **should** only `/join` other's timeline rooms to 'follow' them and get updates whenever they make a post/reply. 27 | * Users **should** only `/join` a thread room to reply to a post in that room, otherwise they should `/peek` to get a read-only view of the thread. 28 | * Users **should** start off as guests on their chosen homeserver, and then login if they want to post. 29 | 30 | Cerulean uses the following experimental [MSCs](https://matrix.org/docs/spec/proposals): 31 | * Threading from [MSC2836](https://github.com/matrix-org/matrix-doc/pull/2836) 32 | * `#@user:domain` user profile/timeline rooms from [MSC1769](https://github.com/matrix-org/matrix-doc/pull/1769) 33 | * peeking via `/sync` [MSC2753](https://github.com/matrix-org/matrix-doc/pull/2753) - optional 34 | * peeking over federation [MSC2444](https://github.com/matrix-org/matrix-doc/pull/2444) - optional 35 | 36 | ## Features 37 | 38 | * [x] User timelines 39 | * [x] User timelines with replies 40 | * [x] Individual messages with surrounding threaded conversation 41 | * [x] Ability to expand out threads to explore further 42 | * [x] Ability to display parent rather than child threads if the parent started on a different timeline 43 | * [x] Live updates as messages arrive (i.e. a `/sync` loop) 44 | * [ ] HTML messages 45 | * [ ] Likes 46 | * [ ] RTs 47 | 48 | Pending serverside work: 49 | * [ ] Search. We don't currently have a fast search across all public rooms, but it could of course be added. 50 | * [ ] Hashtags. These are effectively a subset of search. 51 | 52 | This test jig could also be used for experimenting with other threaded conversation formats, e.g: 53 | * Mailing lists 54 | * Newsgroups 55 | * HN/Reddit style forums 56 | 57 | ## To build 58 | 59 | ``` 60 | yarn install 61 | yarn start 62 | ``` 63 | 64 | ## License 65 | 66 | All files in this repository are licensed as follows: 67 | 68 | ``` 69 | Copyright 2020 The Matrix.org Foundation C.I.C. 70 | 71 | Licensed under the Apache License, Version 2.0 (the "License"); 72 | you may not use this file except in compliance with the License. 73 | You may obtain a copy of the License at 74 | 75 | http://www.apache.org/licenses/LICENSE-2.0 76 | 77 | Unless required by applicable law or agreed to in writing, software 78 | distributed under the License is distributed on an "AS IS" BASIS, 79 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 80 | See the License for the specific language governing permissions and 81 | limitations under the License. 82 | ``` 83 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | SHA=$(git rev-parse --short HEAD) 4 | npm run build 5 | tar -zcvf $SHA.tar.gz build/ 6 | -------------------------------------------------------------------------------- /etc/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80 default ipv6only=on; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | 8 | server_tokens off; 9 | server_name _; 10 | 11 | gzip on; 12 | gzip_disable "msie6"; 13 | 14 | gzip_vary on; 15 | gzip_proxied any; 16 | gzip_comp_level 6; 17 | gzip_buffers 16 8k; 18 | gzip_http_version 1.1; 19 | gzip_min_length 0; 20 | gzip_types text/plain application/javascript text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype; 21 | 22 | location / { 23 | try_files $uri /index.html; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /helm/cerulean/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/cerulean/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: cerulean 3 | description: A Helm chart for Cerulean 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "1.16.0" 25 | -------------------------------------------------------------------------------- /helm/cerulean/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "cerulean.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "cerulean.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "cerulean.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "cerulean.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /helm/cerulean/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "cerulean.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "cerulean.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "cerulean.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "cerulean.labels" -}} 37 | helm.sh/chart: {{ include "cerulean.chart" . }} 38 | {{ include "cerulean.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "cerulean.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "cerulean.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "cerulean.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "cerulean.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /helm/cerulean/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "cerulean.fullname" . }} 5 | labels: 6 | {{- include "cerulean.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "cerulean.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "cerulean.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "cerulean.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | {{- toYaml .Values.securityContext | nindent 12 }} 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | ports: 37 | - name: http 38 | containerPort: {{ .Values.service.port }} 39 | protocol: TCP 40 | livenessProbe: 41 | httpGet: 42 | path: / 43 | port: http 44 | readinessProbe: 45 | httpGet: 46 | path: / 47 | port: http 48 | resources: 49 | {{- toYaml .Values.resources | nindent 12 }} 50 | {{- with .Values.nodeSelector }} 51 | nodeSelector: 52 | {{- toYaml . | nindent 8 }} 53 | {{- end }} 54 | {{- with .Values.affinity }} 55 | affinity: 56 | {{- toYaml . | nindent 8 }} 57 | {{- end }} 58 | {{- with .Values.tolerations }} 59 | tolerations: 60 | {{- toYaml . | nindent 8 }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /helm/cerulean/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "cerulean.fullname" . }} 6 | labels: 7 | {{- include "cerulean.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "cerulean.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm/cerulean/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "cerulean.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "cerulean.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /helm/cerulean/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "cerulean.fullname" . }} 5 | labels: 6 | {{- include "cerulean.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "cerulean.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /helm/cerulean/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "cerulean.serviceAccountName" . }} 6 | labels: 7 | {{- include "cerulean.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/cerulean/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "cerulean.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "cerulean.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "cerulean.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /helm/cerulean/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for cerulean. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: ghcr.io/matrix-org/cerulean 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "main" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Annotations to add to the service account 21 | annotations: {} 22 | # The name of the service account to use. 23 | # If not set and create is true, a name is generated using the fullname template 24 | name: "" 25 | 26 | podAnnotations: {} 27 | 28 | podSecurityContext: {} 29 | # fsGroup: 2000 30 | 31 | securityContext: {} 32 | # capabilities: 33 | # drop: 34 | # - ALL 35 | # readOnlyRootFilesystem: true 36 | # runAsNonRoot: true 37 | # runAsUser: 1000 38 | 39 | service: 40 | type: ClusterIP 41 | port: 80 42 | 43 | ingress: 44 | enabled: false 45 | className: "" 46 | annotations: {} 47 | # kubernetes.io/ingress.class: nginx 48 | # kubernetes.io/tls-acme: "true" 49 | hosts: [] 50 | #- host: chart-example.local 51 | # paths: 52 | # - path: / 53 | # pathType: ImplementationSpecific 54 | tls: [] 55 | # - secretName: chart-example-tls 56 | # hosts: 57 | # - chart-example.local 58 | 59 | resources: {} 60 | # We usually recommend not to specify default resources and to leave this as a conscious 61 | # choice for the user. This also increases chances charts run on environments with little 62 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 63 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 64 | # limits: 65 | # cpu: 100m 66 | # memory: 128Mi 67 | # requests: 68 | # cpu: 100m 69 | # memory: 128Mi 70 | 71 | autoscaling: 72 | enabled: false 73 | minReplicas: 1 74 | maxReplicas: 100 75 | targetCPUUtilizationPercentage: 80 76 | # targetMemoryUtilizationPercentage: 80 77 | 78 | nodeSelector: {} 79 | 80 | tolerations: [] 81 | 82 | affinity: {} 83 | -------------------------------------------------------------------------------- /helm/cr.yaml: -------------------------------------------------------------------------------- 1 | release-name-template: "helm-{{ .Name }}-{{ .Version }}" -------------------------------------------------------------------------------- /helm/ct.yaml: -------------------------------------------------------------------------------- 1 | remote: origin 2 | target-branch: main 3 | chart-dirs: 4 | - helm 5 | validate-maintainers: false -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cerulean", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.13.1", 7 | "react-dom": "^16.13.1" 8 | }, 9 | "devDependencies": { 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.3.2", 12 | "@testing-library/user-event": "^7.1.2", 13 | "prettier": "^2.1.2", 14 | "react-scripts": "3.4.3" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "prettier": { 26 | "trailingComma": "es5", 27 | "tabWidth": 4, 28 | "semi": true, 29 | "singleQuote": false 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/Inter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/cerulean/6c9d7814f63bdc5bf62a5e68dc9fbbdf118cc338/public/Inter.ttf -------------------------------------------------------------------------------- /public/chevron.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/cerulean/6c9d7814f63bdc5bf62a5e68dc9fbbdf118cc338/public/favicon.ico -------------------------------------------------------------------------------- /public/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | Cerulean 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/cerulean/6c9d7814f63bdc5bf62a5e68dc9fbbdf118cc338/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/cerulean/6c9d7814f63bdc5bf62a5e68dc9fbbdf118cc338/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/send-active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/thread-corner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/thread-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Inter"; 3 | src: url("/Inter.ttf"); 4 | } 5 | 6 | body { 7 | margin: 0px; 8 | } 9 | 10 | .App { 11 | margin: 0 auto; 12 | font-family: Inter, Helvetica, arial, sans-serif; 13 | } 14 | 15 | .AppHeader { 16 | color: #ffffff; 17 | background: #2952be; 18 | width: 100%; 19 | height: 47px; 20 | left: 0px; 21 | top: 0px; 22 | } 23 | 24 | .AppMain { 25 | margin: 0px auto; 26 | max-width: 960px; 27 | } 28 | 29 | .topRightNav { 30 | margin-right: 16px; 31 | float: right; 32 | transform: translate(0%, 50%); 33 | min-width: 0; 34 | max-width: 40%; 35 | display: flex; 36 | } 37 | 38 | .spacer { 39 | margin-left: 8px; 40 | } 41 | 42 | .title { 43 | font-family: Inter; 44 | font-style: normal; 45 | font-weight: 600; 46 | font-size: 15px; 47 | line-height: 18px; 48 | text-align: center; 49 | letter-spacing: 0.08em; 50 | color: #ffffff; 51 | margin-left: 8px; 52 | } 53 | 54 | .titleAndLogo { 55 | display: flex; 56 | align-items: center; 57 | cursor: pointer; 58 | position: absolute; 59 | left: 50%; 60 | transform: translate(-50%, -50%); 61 | top: 23px; /* AppHeader.height / 2 */ 62 | } 63 | 64 | .lightButton { 65 | background: #ffffff; 66 | border-radius: 6px; 67 | border: none; 68 | font-family: Inter; 69 | color: #2952be; 70 | font-style: normal; 71 | font-weight: 600; 72 | font-size: 12px; 73 | line-height: 15px; 74 | padding: 4px 8px 4px 8px; 75 | } 76 | 77 | .darkButton { 78 | background: #2952be; 79 | border-radius: 6px; 80 | border: none; 81 | font-family: Inter; 82 | color: #ffffff; 83 | font-style: normal; 84 | font-weight: 600; 85 | font-size: 12px; 86 | line-height: 15px; 87 | padding: 4px 8px 4px 8px; 88 | } 89 | 90 | button:hover { 91 | cursor: pointer; 92 | } 93 | 94 | .loggedInUser { 95 | margin-right: 16px; 96 | cursor: pointer; 97 | vertical-align: middle; 98 | display: inline-block; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | white-space: nowrap; 102 | } 103 | 104 | .inputLogin { 105 | background: #ffffff; 106 | border: 0px; 107 | border-radius: 200px; 108 | padding-left: 16px; 109 | width: 100%; 110 | margin-top: 6px; 111 | margin-bottom: 6px; 112 | height: 36px; 113 | } 114 | .inputLogin:focus { 115 | outline: none; 116 | } 117 | 118 | .modalSignIn { 119 | font-family: Inter; 120 | font-style: normal; 121 | font-weight: 600; 122 | font-size: 15px; 123 | line-height: 18px; 124 | text-align: center; 125 | padding-top: 15px; 126 | padding-bottom: 15px; 127 | color: #2952be; 128 | margin-bottom: 16px; 129 | } 130 | 131 | .modalSignInButton { 132 | margin-top: 20px; 133 | float: right; 134 | cursor: pointer; 135 | } 136 | 137 | .modal { 138 | position: fixed; 139 | top: 50%; 140 | left: 50%; 141 | transform: translate(-50%, -50%); 142 | background: #f7f7f7; 143 | border-radius: 12px; 144 | z-index: 1010; 145 | width: 640px; 146 | max-width: 100%; 147 | height: auto; 148 | padding: 24px; 149 | } 150 | 151 | .modal-overlay { 152 | z-index: 1000; 153 | position: fixed; 154 | top: 0; 155 | left: 0; 156 | width: 100%; 157 | height: 100%; 158 | background: rgba(0, 0, 0, 0.6); 159 | } 160 | 161 | .display-block { 162 | display: block; 163 | } 164 | 165 | .display-none { 166 | display: none; 167 | } 168 | 169 | .closeButton { 170 | cursor: pointer; 171 | margin: 8px; 172 | float: right; 173 | } 174 | 175 | .filterButton { 176 | margin-right: 20px; 177 | vertical-align: middle; 178 | cursor: pointer; 179 | } 180 | 181 | .errblock { 182 | background-color: lightcoral; 183 | padding: 15px; 184 | } 185 | 186 | .loader, 187 | .loader:after { 188 | border-radius: 50%; 189 | width: 2em; 190 | height: 2em; 191 | } 192 | .loader { 193 | margin: 20px auto; 194 | font-size: 10px; 195 | position: relative; 196 | text-indent: -9999em; 197 | border-top: 0.5em solid rgba(41, 82, 190, 1); 198 | border-right: 0.5em solid rgba(41, 82, 190, 1); 199 | border-bottom: 0.5em solid rgba(41, 82, 190, 1); 200 | border-left: 0.5em solid #f7f7f7; 201 | -webkit-transform: translateZ(0); 202 | -ms-transform: translateZ(0); 203 | transform: translateZ(0); 204 | -webkit-animation: load8 1.1s infinite linear; 205 | animation: load8 1.1s infinite linear; 206 | } 207 | @-webkit-keyframes load8 { 208 | 0% { 209 | -webkit-transform: rotate(0deg); 210 | transform: rotate(0deg); 211 | } 212 | 100% { 213 | -webkit-transform: rotate(360deg); 214 | transform: rotate(360deg); 215 | } 216 | } 217 | @keyframes load8 { 218 | 0% { 219 | -webkit-transform: rotate(0deg); 220 | transform: rotate(0deg); 221 | } 222 | 100% { 223 | -webkit-transform: rotate(360deg); 224 | transform: rotate(360deg); 225 | } 226 | } 227 | 228 | @media screen and (max-width: 960px) { 229 | .titleAndLogo { 230 | margin-left: 8px; 231 | margin-right: 8px; 232 | left: 0%; 233 | transform: translate(0%, -50%); 234 | } 235 | .topRightNav { 236 | max-width: 60%; 237 | } 238 | } 239 | 240 | #recaptchaguest { 241 | margin: 0 auto; 242 | display: table; 243 | padding: 10px; 244 | } 245 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./App.css"; 3 | import UserPage from "./UserPage"; 4 | import StatusPage from "./StatusPage"; 5 | import TimelinePage from "./TimelinePage"; 6 | import Modal from "./Modal"; 7 | import ReputationPane from "./ReputationPane"; 8 | 9 | const constDendriteServer = "https://dendrite.matrix.org"; 10 | 11 | // Main entry point for Cerulean. 12 | // - Reads the address bar and loads the correct page. 13 | // - Loads and handles the top bar which is present on every page. 14 | class App extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | 18 | /* 19 | Possible Cerulean paths: 20 | / --> aggregated feed of all timelines followed 21 | /username --> user's timeline 22 | /username/with_replies --> timeline with replies 23 | /username/room_id/id --> permalink 24 | Examples: 25 | http://localhost:3000/@really:bigstuff.com/with_replies 26 | http://localhost:3000/@really:bigstuff.com 27 | http://localhost:3000/@really:bigstuff.com/!cURbafjkfsMDVwdRDQ:matrix.org/$foobar 28 | */ 29 | 30 | // sensible defaults 31 | this.state = { 32 | page: "timeline", 33 | viewingUserId: this.props.client.userId, 34 | withReplies: false, 35 | statusId: null, 36 | showLoginModal: false, 37 | showRegisterModal: false, 38 | showFilterPane: false, 39 | inputLoginUrl: constDendriteServer, 40 | inputLoginUsername: "", 41 | inputLoginPassword: "", 42 | error: null, 43 | }; 44 | 45 | // parse out state from path 46 | const path = window.location.pathname.split("/"); 47 | console.log("input path: " + window.location.pathname); 48 | if (path.length < 2) { 49 | console.log("viewing timeline"); 50 | return; 51 | } 52 | const userId = path[1]; 53 | if (!userId) { 54 | console.log("viewing timeline"); 55 | this.state.page = "timeline"; 56 | return; 57 | } else if (!userId.startsWith("@")) { 58 | console.log("unknown user ID in path: " + path); 59 | return; 60 | } 61 | this.state.page = "user"; 62 | this.state.viewingUserId = userId; 63 | this.state.withReplies = path[2] === "with_replies"; 64 | if ((path[2] || "").startsWith("!") && path[3]) { 65 | this.state.page = "status"; 66 | this.state.statusId = path[3]; 67 | this.state.roomId = path[2]; 68 | } 69 | } 70 | 71 | componentDidMount() { 72 | // auto-register as a guest if not logged in 73 | if (!this.props.client.accessToken) { 74 | this.registerAsGuest(); 75 | } 76 | } 77 | 78 | async registerAsGuest(recaptchaToken) { 79 | try { 80 | let serverUrl = this.state.inputLoginUrl + "/_matrix/client"; 81 | if (recaptchaToken) { 82 | await this.props.client.registerWithCaptcha( 83 | serverUrl, 84 | recaptchaToken 85 | ); 86 | } else { 87 | await this.props.client.registerAsGuest(serverUrl); 88 | } 89 | if (this.props.client.recaptcha) { 90 | console.log("recaptcha is required"); 91 | this.setState( 92 | { 93 | recaptchaGuest: this.props.client.recaptcha, 94 | }, 95 | () => { 96 | window.recaptchaCallback = (token) => { 97 | this.registerAsGuest(token); 98 | }; 99 | window.grecaptcha.render("recaptchaguest", { 100 | sitekey: this.props.client.recaptcha.response 101 | .public_key, 102 | }); 103 | } 104 | ); 105 | return; 106 | } 107 | window.location.reload(); 108 | } catch (err) { 109 | console.error("Failed to register as guest:", err); 110 | this.setState({ 111 | error: "Failed to register as guest: " + JSON.stringify(err), 112 | }); 113 | } 114 | } 115 | 116 | handleInputChange(event) { 117 | const target = event.target; 118 | const value = 119 | target.type === "checkbox" ? target.checked : target.value; 120 | const name = target.name; 121 | this.setState({ 122 | [name]: value, 123 | }); 124 | } 125 | 126 | onLoginClose() { 127 | this.setState({ showLoginModal: false, error: null }); 128 | } 129 | 130 | onRegisterClose() { 131 | this.setState({ showRegisterModal: false, error: null }); 132 | } 133 | 134 | onLoginClick(ev) { 135 | this.setState({ 136 | showLoginModal: true, 137 | showRegisterModal: false, 138 | inputLoginUrl: constDendriteServer, 139 | inputLoginUsername: "", 140 | inputLoginPassword: "", 141 | }); 142 | } 143 | 144 | onRegisterClick(ev) { 145 | this.setState({ 146 | showLoginModal: false, 147 | showRegisterModal: true, 148 | inputLoginUrl: constDendriteServer, 149 | inputLoginUsername: "", 150 | inputLoginPassword: "", 151 | }); 152 | } 153 | 154 | onFilterClick(ev) { 155 | this.setState({ 156 | showFilterPane: !this.state.showFilterPane, 157 | }); 158 | } 159 | 160 | onKeyDown(formType, event) { 161 | if (event.key !== "Enter") { 162 | return; 163 | } 164 | if (formType === "login") { 165 | this.onSubmitLogin(); 166 | } else if (formType === "register") { 167 | this.onSubmitRegister(); 168 | } else { 169 | console.warn("onKeyDown for unknown form type:", formType); 170 | } 171 | } 172 | 173 | async onSubmitLogin() { 174 | let serverUrl = this.state.inputLoginUrl + "/_matrix/client"; 175 | try { 176 | await this.props.client.login( 177 | serverUrl, 178 | this.state.inputLoginUsername, 179 | this.state.inputLoginPassword, 180 | true 181 | ); 182 | this.setState({ 183 | page: "user", 184 | viewingUserId: this.props.client.userId, 185 | showLoginModal: false, 186 | }); 187 | } catch (err) { 188 | console.error("Failed to login:", err); 189 | this.setState({ 190 | error: "Failed to login: " + JSON.stringify(err), 191 | }); 192 | } 193 | } 194 | 195 | async onSubmitRegister(ev, recaptchaToken) { 196 | try { 197 | let serverUrl = this.state.inputLoginUrl + "/_matrix/client"; 198 | if (recaptchaToken) { 199 | await this.props.client.registerWithCaptcha( 200 | serverUrl, 201 | recaptchaToken 202 | ); 203 | } else { 204 | await this.props.client.register( 205 | serverUrl, 206 | this.state.inputLoginUsername, 207 | this.state.inputLoginPassword 208 | ); 209 | } 210 | if (this.props.client.recaptcha) { 211 | console.log("recaptcha is required for registration"); 212 | this.setState( 213 | { 214 | recaptcha: this.props.client.recaptcha, 215 | }, 216 | () => { 217 | window.recaptchaCallback = (token) => { 218 | this.onSubmitRegister(null, token); 219 | }; 220 | window.grecaptcha.render("recaptchareg", { 221 | sitekey: this.props.client.recaptcha.response 222 | .public_key, 223 | }); 224 | } 225 | ); 226 | return; 227 | } 228 | this.setState({ 229 | page: "user", 230 | viewingUserId: this.props.client.userId, 231 | showRegisterModal: false, 232 | }); 233 | } catch (err) { 234 | console.error("Failed to register:", err); 235 | this.setState({ 236 | error: "Failed to register: " + JSON.stringify(err), 237 | }); 238 | } 239 | } 240 | 241 | async onLogoutClick(ev) { 242 | try { 243 | await this.props.client.logout(); 244 | } finally { 245 | // regardless of whether the HTTP hit worked, we'll remove creds so UI needs a kick 246 | this.forceUpdate(() => { 247 | this.registerAsGuest(); 248 | }); 249 | } 250 | } 251 | 252 | onLogoClick() { 253 | window.location.href = "/"; 254 | } 255 | 256 | onUserClick() { 257 | window.location.href = "/" + this.props.client.userId; 258 | } 259 | 260 | loginLogoutButton() { 261 | if (this.props.client.accessToken) { 262 | let logoutButton = ( 263 | 269 | ); 270 | let loginButton; 271 | let myUser; 272 | if (this.props.client.isGuest) { 273 | logoutButton = ( 274 | 280 | ); 281 | loginButton = ( 282 | 288 | ); 289 | } else { 290 | myUser = ( 291 |
295 | {this.props.client.userId} 296 |
297 | ); 298 | } 299 | 300 | return ( 301 |
302 | {myUser} 303 | filter 309 | {logoutButton} 310 | {loginButton} 311 |
312 | ); 313 | } 314 | return ( 315 |
316 | 322 | 328 |
329 | ); 330 | } 331 | 332 | /** 333 | * Render a main content page depending on this.state.page 334 | * Possible options are: 335 | * - status: A permalink to a single event with replies beneath 336 | * - timeline: The aggregated feed of all users the logged in user is following. 337 | * - user: An arbitrary user's timeline. If the user is the logged in user, an input box to post a message is also displayed. 338 | */ 339 | renderPage() { 340 | if (!this.props.client.accessToken) { 341 | if (this.state.recaptchaGuest) { 342 | return ( 343 |
347 | ); 348 | } else { 349 | return
Please wait....
; 350 | } 351 | } 352 | if (this.state.page === "user") { 353 | return ( 354 | 359 | ); 360 | } else if (this.state.page === "status") { 361 | return ( 362 | 368 | ); 369 | } else if (this.state.page === "timeline") { 370 | return ; 371 | } else { 372 | return
Whoops, how did you get here?
; 373 | } 374 | } 375 | 376 | render() { 377 | let filterPane; 378 | if (this.state.showFilterPane) { 379 | filterPane = ( 380 | 381 | ); 382 | } 383 | let errMsg; 384 | if (this.state.error) { 385 | errMsg =
{this.state.error}
; 386 | } 387 | let recaptchaReg; 388 | if (this.state.recaptcha) { 389 | recaptchaReg = ( 390 |
391 | ); 392 | } 393 | return ( 394 |
395 |
396 |
400 | logo 401 |
CERULEAN
402 |
403 | {this.loginLogoutButton()} 404 |
405 |
{this.renderPage()}
406 | {filterPane} 407 | 411 | Sign in 412 |
413 |
414 | 423 |
424 |
425 | 434 |
435 |
436 | 445 |
446 | {errMsg} 447 |
448 | 454 |
455 |
456 |
457 | 461 | Register a new account 462 |
463 |
464 | 476 |
477 |
478 | 490 |
491 |
492 | 504 |
505 | {errMsg} 506 | {recaptchaReg} 507 |
508 | 514 |
515 |
516 |
517 |
518 | ); 519 | } 520 | } 521 | 522 | export default App; 523 | -------------------------------------------------------------------------------- /src/Client.js: -------------------------------------------------------------------------------- 1 | // Client contains functions for making Matrix Client-Server API requests 2 | // https://matrix.org/docs/spec/client_server/r0.6.0 3 | class Client { 4 | // we deliberately don't use js-sdk as we want flexibility on 5 | // how we model the data (i.e. not as normal rooms + timelines 6 | // given everything is threaded) 7 | 8 | constructor(storage) { 9 | this.joinedRooms = new Map(); // room alias -> room ID 10 | this.userProfileCache = new Map(); // user_id -> {display_name; avatar;} 11 | if (!storage) { 12 | return; 13 | } 14 | this.storage = storage; 15 | this.serverUrl = storage.getItem("serverUrl"); 16 | this.userId = storage.getItem("userId"); 17 | this.accessToken = storage.getItem("accessToken"); 18 | this.isGuest = (this.userId || "").indexOf("@cerulean_guest_") === 0; 19 | this.serverName = storage.getItem("serverName"); 20 | this.recaptcha = null; 21 | } 22 | 23 | saveAuthState() { 24 | if (!this.storage) { 25 | return; 26 | } 27 | setOrDelete(this.storage, "serverUrl", this.serverUrl); 28 | setOrDelete(this.storage, "userId", this.userId); 29 | setOrDelete(this.storage, "accessToken", this.accessToken); 30 | setOrDelete(this.storage, "serverName", this.serverName); 31 | } 32 | 33 | async registerWithCaptcha(serverUrl, recaptchaToken) { 34 | if (!this.recaptcha) { 35 | throw new Error( 36 | "cannot call registerWithCaptcha without calling registerAsGuest/register first" 37 | ); 38 | } 39 | const data = await this.fetchJson(`${serverUrl}/r0/register`, { 40 | method: "POST", 41 | body: JSON.stringify({ 42 | auth: { 43 | type: "m.login.recaptcha", 44 | session: this.recaptcha.session, 45 | response: recaptchaToken, 46 | }, 47 | username: this.recaptcha.user, 48 | password: this.recaptcha.pass, 49 | }), 50 | }); 51 | this.isGuest = this.recaptcha.isGuest; 52 | this.recaptcha = null; 53 | this.serverUrl = serverUrl; 54 | this.userId = data.user_id; 55 | this.accessToken = data.access_token; 56 | this.serverName = data.home_server; 57 | this.saveAuthState(); 58 | console.log("Registered (with recaptcha) ", data.user_id); 59 | } 60 | 61 | async registerAsGuest(serverUrl) { 62 | function generateToken(len) { 63 | var arr = new Uint8Array(len / 2); 64 | window.crypto.getRandomValues(arr); 65 | return Array.from(arr, (num) => { 66 | return num.toString(16).padStart(2, "0"); 67 | }).join(""); 68 | } 69 | let username = "cerulean_guest_" + Date.now(); 70 | let password = generateToken(32); 71 | 72 | let data; 73 | try { 74 | data = await this.fetchJson(`${serverUrl}/r0/register`, { 75 | method: "POST", 76 | body: JSON.stringify({ 77 | auth: { 78 | type: "m.login.dummy", 79 | }, 80 | username: username, 81 | password: password, 82 | }), 83 | }); 84 | } catch (err) { 85 | // check if a recaptcha is required 86 | if (err.params && err.params["m.login.recaptcha"]) { 87 | this.recaptcha = { 88 | response: err.params["m.login.recaptcha"], 89 | user: username, 90 | pass: password, 91 | session: err.session, 92 | isGuest: true, 93 | }; 94 | return; 95 | } 96 | throw err; 97 | } 98 | this.serverUrl = serverUrl; 99 | this.userId = data.user_id; 100 | this.accessToken = data.access_token; 101 | this.serverName = data.home_server; 102 | this.isGuest = true; 103 | this.saveAuthState(); 104 | console.log("Registered as guest ", username); 105 | } 106 | 107 | async login(serverUrl, username, password, saveToStorage) { 108 | const data = await this.fetchJson(`${serverUrl}/r0/login`, { 109 | method: "POST", 110 | body: JSON.stringify({ 111 | type: "m.login.password", 112 | identifier: { 113 | type: "m.id.user", 114 | user: username, 115 | }, 116 | password: password, 117 | }), 118 | }); 119 | this.serverUrl = serverUrl; 120 | this.userId = data.user_id; 121 | this.accessToken = data.access_token; 122 | this.isGuest = false; 123 | this.serverName = data.home_server; 124 | if (saveToStorage) { 125 | this.saveAuthState(); 126 | } 127 | } 128 | 129 | async register(serverUrl, username, password) { 130 | let data; 131 | try { 132 | data = await this.fetchJson(`${serverUrl}/r0/register`, { 133 | method: "POST", 134 | body: JSON.stringify({ 135 | auth: { 136 | type: "m.login.dummy", 137 | }, 138 | username: username, 139 | password: password, 140 | }), 141 | }); 142 | } catch (err) { 143 | // check if a recaptcha is required 144 | if (err.params && err.params["m.login.recaptcha"]) { 145 | this.recaptcha = { 146 | response: err.params["m.login.recaptcha"], 147 | user: username, 148 | pass: password, 149 | session: err.session, 150 | isGuest: false, 151 | }; 152 | return; 153 | } 154 | throw err; 155 | } 156 | this.serverUrl = serverUrl; 157 | this.userId = data.user_id; 158 | this.accessToken = data.access_token; 159 | this.isGuest = false; 160 | this.serverName = data.home_server; 161 | this.saveAuthState(); 162 | } 163 | 164 | async getProfile(userId) { 165 | if (this.userProfileCache.has(userId)) { 166 | console.debug(`Returning cached copy of ${userId}'s profile`); 167 | return this.userProfileCache.get(userId); 168 | } 169 | console.debug(`Fetching fresh copy of ${userId}'s profile`); 170 | const data = await this.fetchJson( 171 | `${this.serverUrl}/r0/profile/${encodeURIComponent(userId)}`, 172 | { 173 | method: "GET", 174 | headers: { Authorization: `Bearer ${this.accessToken}` }, 175 | } 176 | ); 177 | this.userProfileCache.set(userId, data); 178 | return data; 179 | } 180 | 181 | async getRoomState(roomId, stateType, stateKey) { 182 | const data = await this.fetchJson( 183 | `${this.serverUrl}/r0/rooms/${encodeURIComponent( 184 | roomId 185 | )}/state/${encodeURIComponent(stateType)}/${ 186 | (stateKey && encodeURIComponent(stateKey)) || "" 187 | }`, 188 | { 189 | method: "GET", 190 | headers: { Authorization: `Bearer ${this.accessToken}` }, 191 | } 192 | ); 193 | return data; 194 | } 195 | 196 | async sendMessage(roomId, content) { 197 | const txnId = Date.now(); 198 | const data = await this.fetchJson( 199 | `${this.serverUrl}/r0/rooms/${encodeURIComponent( 200 | roomId 201 | )}/send/m.room.message/${encodeURIComponent(txnId)}`, 202 | { 203 | method: "PUT", 204 | body: JSON.stringify(content), 205 | headers: { Authorization: `Bearer ${this.accessToken}` }, 206 | } 207 | ); 208 | return data.event_id; 209 | } 210 | 211 | async getRelationships(eventId, roomId, maxBreadth, maxDepth) { 212 | const body = { 213 | event_id: eventId, 214 | room_id: roomId, 215 | max_depth: maxDepth || 6, 216 | max_breadth: maxBreadth || 5, 217 | limit: 50, 218 | depth_first: false, 219 | recent_first: true, 220 | include_parent: true, 221 | include_children: true, 222 | direction: "down", 223 | }; 224 | 225 | const data = await this.fetchJson( 226 | `${this.serverUrl}/unstable/event_relationships`, 227 | { 228 | method: "POST", 229 | body: JSON.stringify(body), 230 | headers: { Authorization: `Bearer ${this.accessToken}` }, 231 | } 232 | ); 233 | return data.events; 234 | } 235 | 236 | /** 237 | * Post a message. 238 | * @param {*} content the message to post 239 | */ 240 | async postToMyTimeline(content) { 241 | const roomId = await this.joinTimelineRoom("#" + this.userId); 242 | const eventId = await this.sendMessage(roomId, content); 243 | return eventId; 244 | } 245 | 246 | /** 247 | * Follow a user by subscribing to their room. 248 | * @param {string} userId 249 | */ 250 | followUser(userId) { 251 | return this.joinTimelineRoom("#" + userId); 252 | } 253 | 254 | async logout(suppressLogout) { 255 | try { 256 | if (!suppressLogout) { 257 | await this.fetchJson(`${this.serverUrl}/r0/logout`, { 258 | method: "POST", 259 | body: "{}", 260 | headers: { Authorization: `Bearer ${this.accessToken}` }, 261 | }); 262 | } 263 | } finally { 264 | console.log("Removing login credentials"); 265 | this.serverUrl = undefined; 266 | this.userId = undefined; 267 | this.accessToken = undefined; 268 | this.isGuest = undefined; 269 | this.serverName = undefined; 270 | this.saveAuthState(); 271 | } 272 | } 273 | 274 | // getAggregatedTimeline returns all events from all timeline rooms being followed. 275 | // This is done by calling `/sync` and keeping messages for all rooms that have an #@ alias. 276 | async getAggregatedTimeline() { 277 | let info = { 278 | timeline: [], 279 | from: null, 280 | }; 281 | if (!this.accessToken) { 282 | console.error("No access token"); 283 | return info; 284 | } 285 | const filterJson = JSON.stringify({ 286 | room: { 287 | timeline: { 288 | limit: 100, 289 | }, 290 | }, 291 | }); 292 | let syncData = await this.fetchJson( 293 | `${this.serverUrl}/r0/sync?filter=${filterJson}`, 294 | { 295 | headers: { Authorization: `Bearer ${this.accessToken}` }, 296 | } 297 | ); 298 | // filter only @# rooms then add in all timeline events 299 | const roomIds = Object.keys(syncData.rooms.join).filter((roomId) => { 300 | // try to find an #@ alias 301 | let foundAlias = false; 302 | for (let ev of syncData.rooms.join[roomId].state.events) { 303 | if (ev.type === "m.room.aliases" && ev.content.aliases) { 304 | for (let alias of ev.content.aliases) { 305 | if (alias.startsWith("#@")) { 306 | foundAlias = true; 307 | break; 308 | } 309 | } 310 | } 311 | if (foundAlias) { 312 | break; 313 | } 314 | } 315 | return foundAlias; 316 | }); 317 | let events = []; 318 | for (let roomId of roomIds) { 319 | for (let ev of syncData.rooms.join[roomId].timeline.events) { 320 | ev.room_id = roomId; 321 | events.push(ev); 322 | } 323 | } 324 | // sort by origin_server_ts 325 | info.timeline = events.sort((a, b) => { 326 | if (a.origin_server_ts === b.origin_server_ts) { 327 | return 0; 328 | } 329 | if (a.origin_server_ts < b.origin_server_ts) { 330 | return 1; 331 | } 332 | return -1; 333 | }); 334 | info.from = syncData.next_batch; 335 | return info; 336 | } 337 | 338 | async getTimeline(roomId, limit, callback) { 339 | if (!this.accessToken) { 340 | console.error("No access token"); 341 | return []; 342 | } 343 | limit = limit || 100; 344 | let seenEvents = 0; 345 | let from; 346 | while (seenEvents < limit) { 347 | let fromQuery = ``; 348 | if (from) { 349 | fromQuery = `&from=${from}`; 350 | } 351 | let data = await this.fetchJson( 352 | `${this.serverUrl}/r0/rooms/${roomId}/messages?dir=b&limit=${limit}${fromQuery}`, 353 | { 354 | headers: { Authorization: `Bearer ${this.accessToken}` }, 355 | } 356 | ); 357 | from = data.end; 358 | let msgs = []; 359 | data.chunk.forEach((ev) => { 360 | if (ev.type !== "m.room.message") { 361 | return; 362 | } 363 | ev.room_id = roomId; 364 | msgs.push(ev); 365 | }); 366 | callback(msgs); 367 | seenEvents += msgs.length; 368 | if (data.chunk.length < limit) { 369 | break; 370 | } 371 | seenEvents += 1; // just in case, to stop infinite loops 372 | } 373 | } 374 | 375 | async waitForMessageEventInRoom(roomIds, from) { 376 | if (this.isGuest) { 377 | // don't live poll for guests 378 | await sleep(24 * 60 * 60 * 1000); 379 | return from; 380 | } 381 | console.log("waitForMessageEventInRoom", roomIds); 382 | 383 | const filterJson = JSON.stringify({ 384 | room: { 385 | timeline: { 386 | limit: 5, 387 | }, 388 | }, 389 | }); 390 | if (!from) { 391 | if (roomIds && roomIds.length > 0) { 392 | // use /messages to snaffle an event rather than /sync which is slow 393 | let data = await this.fetchJson( 394 | `${this.serverUrl}/r0/rooms/${roomIds[0]}/messages?dir=b&limit=1`, 395 | { 396 | headers: { 397 | Authorization: `Bearer ${this.accessToken}`, 398 | }, 399 | } 400 | ); 401 | from = data.start_stream; // NOTSPEC 402 | } 403 | if (!from) { 404 | // fallback to slow /sync 405 | let syncData = await this.fetchJson( 406 | `${this.serverUrl}/r0/sync?filter=${filterJson}`, 407 | { 408 | headers: { 409 | Authorization: `Bearer ${this.accessToken}`, 410 | }, 411 | } 412 | ); 413 | from = syncData.next_batch; 414 | } 415 | } 416 | while (true) { 417 | try { 418 | let syncData = await this.fetchJson( 419 | `${this.serverUrl}/r0/sync?filter=${filterJson}&since=${from}&timeout=20000`, 420 | { 421 | headers: { 422 | Authorization: `Bearer ${this.accessToken}`, 423 | }, 424 | } 425 | ); 426 | from = syncData.next_batch; 427 | for (let i = 0; i < roomIds.length; i++) { 428 | const roomId = roomIds[i]; 429 | let room = syncData.rooms.join[roomId]; 430 | if (!room || !room.timeline || !room.timeline.events) { 431 | continue; 432 | } 433 | for (let i = 0; i < room.timeline.events.length; i++) { 434 | let ev = room.timeline.events[i]; 435 | if (ev.type === "m.room.message") { 436 | return from; 437 | } 438 | } 439 | } 440 | } catch (err) { 441 | console.warn( 442 | "waitForMessageEventInRoom: request failed, waiting then retrying: ", 443 | err 444 | ); 445 | await sleep(10 * 1000); // wait before retrying 446 | } 447 | } 448 | } 449 | 450 | peekRoom(roomAlias) { 451 | // For now join the room instead. 452 | // Once MSC2753 is available, to allow federated peeking 453 | return this.joinTimelineRoom(roomAlias); 454 | } 455 | 456 | async joinRoomById(roomID, serverName) { 457 | const cachedRoomId = this.joinedRooms.get(roomID); 458 | if (cachedRoomId) { 459 | return cachedRoomId; 460 | } 461 | let data = await this.fetchJson( 462 | `${this.serverUrl}/r0/join/${encodeURIComponent( 463 | roomID 464 | )}?server_name=${encodeURIComponent(serverName)}`, 465 | { 466 | method: "POST", 467 | body: "{}", 468 | headers: { Authorization: `Bearer ${this.accessToken}` }, 469 | } 470 | ); 471 | this.joinedRooms.set(roomID, data.room_id); 472 | this.sendLowPriorityTag(data.room_id); 473 | return data.room_id; 474 | } 475 | 476 | async postNewThread(text, dataUri) { 477 | // create a new room 478 | let data = await this.fetchJson(`${this.serverUrl}/r0/createRoom`, { 479 | method: "POST", 480 | body: JSON.stringify({ 481 | preset: "public_chat", 482 | name: `${this.userId}'s thread`, 483 | topic: "Cerulean", 484 | }), 485 | headers: { 486 | Authorization: `Bearer ${this.accessToken}`, 487 | }, 488 | }); 489 | text = text || ""; 490 | let content = { 491 | msgtype: "m.text", 492 | body: text, 493 | }; 494 | if (dataUri) { 495 | content = { 496 | msgtype: "m.image", 497 | body: text, 498 | url: dataUri, 499 | }; 500 | } 501 | // post the message into this new room 502 | const eventId = await this.sendMessage(data.room_id, content); 503 | 504 | // add metadata for linking to thread room 505 | content["org.matrix.cerulean.room_id"] = data.room_id; 506 | content["org.matrix.cerulean.event_id"] = eventId; 507 | content["org.matrix.cerulean.root"] = true; 508 | 509 | this.sendLowPriorityTag(data.room_id); 510 | 511 | // post a copy into our timeline 512 | await this.postToMyTimeline(content); 513 | } 514 | 515 | // replyToEvent replies to the given event by sending 2 events: one into the timeline room of the logged in user 516 | // and one into the thread room for this event. 517 | async replyToEvent(text, event, isTimelineEvent, dataUri) { 518 | let eventIdReplyingTo; 519 | let roomIdReplyingIn; 520 | if (isTimelineEvent) { 521 | eventIdReplyingTo = event.content["org.matrix.cerulean.event_id"]; 522 | roomIdReplyingIn = event.content["org.matrix.cerulean.room_id"]; 523 | } else { 524 | eventIdReplyingTo = event.event_id; 525 | roomIdReplyingIn = event.room_id; 526 | } 527 | if (!eventIdReplyingTo || !roomIdReplyingIn) { 528 | console.error( 529 | "cannot reply to event, unknown event ID for parent:", 530 | event 531 | ); 532 | return; 533 | } 534 | // ensure we're joined to the room 535 | // extract server name from sender who we know must be in the thread room: 536 | // @alice:domain.com -> [@alice, domain.com] -> [domain.com] -> domain.com 537 | // @bob:foobar.com:8448 -> [@bob, foobar.com, 8448] -> [foobar.com, 8448] -> foobar.com:8448 538 | let domain = event.sender.split(":").splice(1).join(":"); 539 | await this.joinRoomById(roomIdReplyingIn, domain); 540 | 541 | // TODO: should we be checking that the two events `event` and `eventIdReplyingTo` match content-wise? 542 | 543 | // we're uploading an image and some text 544 | if (dataUri) { 545 | const eventId = await this.sendMessage(roomIdReplyingIn, { 546 | body: text, 547 | msgtype: "m.image", 548 | url: dataUri, 549 | "m.relationship": { 550 | rel_type: "m.reference", 551 | event_id: eventIdReplyingTo, 552 | }, 553 | }); 554 | 555 | // send another message into our timeline room 556 | await this.postToMyTimeline({ 557 | msgtype: "m.image", 558 | body: text, 559 | url: dataUri, 560 | "org.matrix.cerulean.event_id": eventId, 561 | "org.matrix.cerulean.room_id": roomIdReplyingIn, 562 | }); 563 | 564 | return eventId; 565 | } 566 | 567 | // text only upload 568 | const eventId = await this.sendMessage(roomIdReplyingIn, { 569 | body: text, 570 | msgtype: "m.text", 571 | "m.relationship": { 572 | rel_type: "m.reference", 573 | event_id: eventIdReplyingTo, 574 | }, 575 | }); 576 | 577 | // send another message into our timeline room 578 | await this.postToMyTimeline({ 579 | msgtype: "m.text", 580 | body: text, 581 | "org.matrix.cerulean.event_id": eventId, 582 | "org.matrix.cerulean.room_id": roomIdReplyingIn, 583 | }); 584 | 585 | return eventId; 586 | } 587 | 588 | /** 589 | * Join a reputation room 590 | * @param {string} roomAlias The alias to join e.g #cat-lovers:matrix.org 591 | */ 592 | joinReputationRoom(roomAlias) { 593 | // just join the room alias and cache it. 594 | return this.joinTimelineRoom(roomAlias); 595 | } 596 | 597 | /** 598 | * Get reputation state events from the given room ID. 599 | * @param {string} roomId 600 | */ 601 | async getReputationState(roomId) { 602 | let roomData = await this.fetchJson( 603 | `${this.serverUrl}/r0/rooms/${encodeURIComponent(roomId)}/state`, 604 | { 605 | headers: { Authorization: `Bearer ${this.accessToken}` }, 606 | } 607 | ); 608 | // Keep only reputation events 609 | return roomData.filter((ev) => { 610 | return ev.type === "org.matrix.fama.rule.basic" && ev.state_key; 611 | }); 612 | } 613 | 614 | /** 615 | * Join a room by alias. If already joined, no-ops. If joining our own timeline room, 616 | * attempts to create it. 617 | * @param {string} roomAlias The room alias to join 618 | * @returns {string} The room ID of the joined room. 619 | */ 620 | async joinTimelineRoom(roomAlias) { 621 | const roomId = this.joinedRooms.get(roomAlias); 622 | if (roomId) { 623 | return roomId; 624 | } 625 | const isMyself = roomAlias.substr(1) === this.userId; 626 | 627 | try { 628 | let data = await this.fetchJson( 629 | `${this.serverUrl}/r0/join/${encodeURIComponent(roomAlias)}`, 630 | { 631 | method: "POST", 632 | body: "{}", 633 | headers: { Authorization: `Bearer ${this.accessToken}` }, 634 | } 635 | ); 636 | this.joinedRooms.set(roomAlias, data.room_id); 637 | return data.room_id; 638 | } catch (err) { 639 | // try to make our timeline room 640 | if (isMyself) { 641 | let data = await this.fetchJson( 642 | `${this.serverUrl}/r0/createRoom`, 643 | { 644 | method: "POST", 645 | body: JSON.stringify({ 646 | preset: "public_chat", 647 | name: `${this.userId}'s timeline`, 648 | topic: "Cerulean", 649 | room_alias_name: "@" + localpart(this.userId), 650 | }), 651 | headers: { 652 | Authorization: `Bearer ${this.accessToken}`, 653 | }, 654 | } 655 | ); 656 | this.joinedRooms.set(roomAlias, data.room_id); 657 | return data.room_id; 658 | } else { 659 | throw err; 660 | } 661 | } 662 | } 663 | 664 | async sendLowPriorityTag(roomId) { 665 | try { 666 | await this.fetchJson( 667 | `${this.serverUrl}/r0/user/${encodeURIComponent( 668 | this.userId 669 | )}/rooms/${encodeURIComponent(roomId)}/tags/m.lowpriority`, 670 | { 671 | method: "PUT", 672 | body: "{}", 673 | headers: { Authorization: `Bearer ${this.accessToken}` }, 674 | } 675 | ); 676 | } catch (err) { 677 | console.warn("failed to set low priority tag on ", roomId, err); 678 | } 679 | } 680 | 681 | async uploadFile(file) { 682 | const fileName = file.name; 683 | const mediaUrl = this.serverUrl.slice(0, -1 * "/client".length); 684 | const res = await fetch( 685 | `${mediaUrl}/media/r0/upload?filename=${encodeURIComponent( 686 | fileName 687 | )}`, 688 | { 689 | method: "POST", 690 | body: file, 691 | headers: { 692 | Authorization: `Bearer ${this.accessToken}`, 693 | }, 694 | } 695 | ); 696 | const data = await res.json(); 697 | if (!res.ok) { 698 | throw data; 699 | } 700 | return data.content_uri; 701 | } 702 | 703 | downloadLink(mxcUri) { 704 | if (!mxcUri) { 705 | return; 706 | } 707 | if (mxcUri.indexOf("mxc://") !== 0) { 708 | return; 709 | } 710 | const mediaUrl = this.serverUrl.slice(0, -1 * "/client".length); 711 | return mediaUrl + "/media/r0/download/" + mxcUri.split("mxc://")[1]; 712 | } 713 | 714 | thumbnailLink(mxcUri, method, width, height) { 715 | if (!mxcUri) { 716 | return; 717 | } 718 | if (mxcUri.indexOf("mxc://") !== 0) { 719 | return; 720 | } 721 | const mediaUrl = this.serverUrl.slice(0, -1 * "/client".length); 722 | return `${mediaUrl}/media/r0/thumbnail/${ 723 | mxcUri.split("mxc://")[1] 724 | }?method=${encodeURIComponent(method)}&width=${encodeURIComponent( 725 | width 726 | )}&height=${encodeURIComponent(height)}`; 727 | } 728 | 729 | async fetchJson(fullUrl, fetchParams) { 730 | const response = await fetch(fullUrl, fetchParams); 731 | const data = await response.json(); 732 | if (!response.ok) { 733 | if (data.errcode === "M_UNKNOWN_TOKEN") { 734 | console.log("unknown token, logging user out: ", data); 735 | // suppressLogout so we don't recursively call fetchJson 736 | await this.logout(true); 737 | } 738 | throw data; 739 | } 740 | return data; 741 | } 742 | } 743 | 744 | // maps '@foo:localhost' to 'foo' 745 | function localpart(userId) { 746 | return userId.split(":")[0].substr(1); 747 | } 748 | 749 | function setOrDelete(storage, key, value) { 750 | if (value) { 751 | storage.setItem(key, value); 752 | } else { 753 | storage.removeItem(key, value); 754 | } 755 | } 756 | 757 | function sleep(ms) { 758 | return new Promise((resolve) => setTimeout(resolve, ms)); 759 | } 760 | 761 | export default Client; 762 | -------------------------------------------------------------------------------- /src/Client.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import App from "./App"; 4 | import Client from "./Client"; 5 | 6 | // if you want to run these tests you need to configure these constants first. 7 | const username = "foo"; 8 | const password = "barbarbar"; 9 | const existingRoomAlias = "#example2:localhost"; 10 | 11 | xit("loginAsGuest works", async () => { 12 | const client = new Client({}); 13 | await client.loginAsGuest("http://localhost:8008/_matrix/client", false); 14 | expect(client.accessToken).toBeDefined(); 15 | }); 16 | 17 | xit("login works", async () => { 18 | const client = new Client({}); 19 | await client.login( 20 | "http://localhost:8008/_matrix/client", 21 | username, 22 | password, 23 | false 24 | ); 25 | expect(client.accessToken).toBeDefined(); 26 | }); 27 | 28 | xit("join room works", async () => { 29 | const client = new Client({}); 30 | await client.login( 31 | "http://localhost:8008/_matrix/client", 32 | username, 33 | password, 34 | false 35 | ); 36 | let roomId = await client.joinTimelineRoom(existingRoomAlias); 37 | expect(roomId).toBeDefined(); 38 | // should be idempotent 39 | roomId = await client.joinTimelineRoom(existingRoomAlias); 40 | expect(roomId).toBeDefined(); 41 | }); 42 | 43 | xit("sendMessage works", async () => { 44 | const client = new Client({}); 45 | await client.login( 46 | "http://localhost:8008/_matrix/client", 47 | username, 48 | password, 49 | false 50 | ); 51 | await client.joinTimelineRoom(existingRoomAlias); 52 | const eventID = await client.sendMessage(existingRoomAlias, { 53 | msgtype: "m.text", 54 | body: "Hello World!", 55 | }); 56 | expect(eventID).toBeDefined(); 57 | }); 58 | -------------------------------------------------------------------------------- /src/ClientContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Client from "./Client"; 3 | 4 | const client = new Client(window.localStorage); 5 | const ClientContext = React.createContext(); 6 | 7 | export { ClientContext, client }; 8 | -------------------------------------------------------------------------------- /src/InputPost.css: -------------------------------------------------------------------------------- 1 | .inputPostWithButton { 2 | display:flex; 3 | flex-direction:row; 4 | } 5 | 6 | .inputPostUploadButton { 7 | margin-top: 8px; 8 | margin-left: 8px; 9 | } 10 | 11 | .inputPost{ 12 | background: #FFFFFF; 13 | border: 0px; 14 | border-radius: 200px; 15 | flex-grow:2; 16 | padding-left: 16px; 17 | } 18 | .inputPost:focus { 19 | outline: none; 20 | } 21 | 22 | .inputPostSendButton{ 23 | margin: 8px; 24 | } 25 | 26 | .inputPostSendButtonActive{ 27 | margin: 8px; 28 | cursor: pointer; 29 | } -------------------------------------------------------------------------------- /src/InputPost.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./InputPost.css"; 3 | 4 | // Input box for posts 5 | // Props: 6 | // - client: Matrix client 7 | // - onPost: function() called when a post is sent. 8 | class InputPost extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | inputPost: "", 13 | uploadFile: null, 14 | loading: false, 15 | }; 16 | } 17 | 18 | handleInputChange(event) { 19 | const target = event.target; 20 | const value = 21 | target.type === "checkbox" ? target.checked : target.value; 22 | const name = target.name; 23 | this.setState({ 24 | [name]: value, 25 | }); 26 | } 27 | 28 | handleKeyDown(event) { 29 | if (event.key === "Enter") { 30 | this.onPostClick(event); 31 | } 32 | } 33 | 34 | async onPostClick(ev) { 35 | this.setState({ 36 | loading: true, 37 | }); 38 | try { 39 | let dataUri; 40 | if (this.state.uploadFile) { 41 | dataUri = await this.props.client.uploadFile( 42 | this.state.uploadFile 43 | ); 44 | console.log(dataUri); 45 | } 46 | this.setState({ 47 | uploadFile: null, 48 | }); 49 | 50 | if (this.state.inputPost.length > 0) { 51 | await this.props.client.postNewThread( 52 | this.state.inputPost, 53 | dataUri 54 | ); 55 | } 56 | this.setState({ inputPost: "" }); 57 | if (this.props.onPost) { 58 | this.props.onPost(); 59 | } 60 | } finally { 61 | this.setState({ 62 | loading: false, 63 | }); 64 | } 65 | } 66 | 67 | onUploadFileClick(event) { 68 | const file = event.target.files[0]; 69 | console.log(file); 70 | this.setState({ 71 | uploadFile: file, 72 | }); 73 | } 74 | 75 | postButton() { 76 | if (!this.props.client.accessToken) { 77 | return
; 78 | } 79 | let imgSrc = "/send.svg"; 80 | let classes = "inputPostSendButton"; 81 | if (this.state.inputPost.length > 0) { 82 | imgSrc = "/send-active.svg"; 83 | classes = "inputPostSendButtonActive"; 84 | } 85 | return ( 86 | send 92 | ); 93 | } 94 | 95 | render() { 96 | if (this.state.loading) { 97 | return
Loading...
; 98 | } 99 | return ( 100 |
101 |
102 | 111 | {this.postButton()} 112 |
113 | 120 |
121 | ); 122 | } 123 | } 124 | 125 | export default InputPost; 126 | -------------------------------------------------------------------------------- /src/Message.css: -------------------------------------------------------------------------------- 1 | .MessageHeader{ 2 | color: darkgray; 3 | display: flex; 4 | flex-wrap: wrap; 5 | width: 100%; 6 | box-sizing: border-box; 7 | } 8 | 9 | .Message{ 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | } 14 | .MessageBody{ 15 | margin-left: 24px; 16 | margin-top: 6px; 17 | margin-bottom: 8px; 18 | min-width: 0; 19 | } 20 | 21 | .moreCommentsButton { 22 | background: rgba(41, 82, 190, 0.1); 23 | border-radius: 4px; 24 | color: #2952BE; 25 | margin-right: 8px; 26 | } 27 | 28 | .MessageAuthor { 29 | /* Caption SemiBold */ 30 | font-family: Inter; 31 | font-style: normal; 32 | font-weight: 600; 33 | font-size: 12px; 34 | line-height: 15px; 35 | 36 | /* identical to box height */ 37 | 38 | color: #2952BE; 39 | 40 | order: 1; 41 | 42 | cursor: pointer; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | white-space: nowrap; 46 | box-sizing: border-box; 47 | } 48 | 49 | .DateString { 50 | /* Micro */ 51 | font-family: Inter; 52 | font-style: normal; 53 | font-weight: normal; 54 | font-size: 10px; 55 | line-height: 12px; 56 | 57 | /* Light / Secondary content */ 58 | color: #737D8C; 59 | 60 | order: 2; 61 | 62 | margin-left: 6px; 63 | box-sizing: border-box; 64 | cursor: pointer; 65 | } 66 | 67 | .MessageText { 68 | /* Body */ 69 | font-family: Inter; 70 | font-style: normal; 71 | font-weight: normal; 72 | font-size: 15px; 73 | line-height: 24px; 74 | word-break: break-word; 75 | 76 | /* or 160% */ 77 | cursor: pointer; 78 | 79 | color: #17191C; 80 | } 81 | 82 | .MessageButtons{ 83 | margin-right: 12px; 84 | } 85 | 86 | .inputReply{ 87 | background: #FFFFFF; 88 | border: 0px; 89 | border-radius: 200px; 90 | flex-grow:2; 91 | padding-left: 16px; 92 | } 93 | .inputReply:focus { 94 | outline: none; 95 | } 96 | 97 | .sendButton{ 98 | margin: 8px; 99 | } 100 | 101 | .sendButtonActive{ 102 | cursor: pointer; 103 | } 104 | 105 | .inputReplyWithButton{ 106 | display:flex; 107 | flex-direction:row; 108 | } 109 | 110 | .userImage{ 111 | max-width: 100%; 112 | margin: 5px; 113 | cursor: pointer; 114 | } 115 | -------------------------------------------------------------------------------- /src/Message.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Message.css"; 3 | import { ClientContext } from "./ClientContext"; 4 | import Modal from "./Modal"; 5 | import { 6 | createPermalinkForTimelineEvent, 7 | createPermalinkForThreadEvent, 8 | } from "./routing"; 9 | 10 | // Message renders a single event and contains the reply Modal. 11 | // Props: 12 | // - event: The matrix event to render. 13 | // - isTimelineEvent: True if this event is in a timeline room. False if in a thread room. 14 | // - numReplies: Optional number of replies to this event, to display on the UI. 15 | // - onPost: Optional callback invoked when a reply is sent. Called as onPost(parentEvent, childId) 16 | // - noReply: Optional boolean whether to show reply button or not. 17 | class Message extends React.Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | loading: false, 22 | error: null, 23 | showReplyModal: false, 24 | inputReply: "", 25 | reputationScore: 0, 26 | hidden: false, 27 | uploadFile: null, 28 | displayname: null, 29 | noReply: this.props.noReply, 30 | }; 31 | } 32 | 33 | async componentDidMount() { 34 | if (!this.props.event) { 35 | return; 36 | } 37 | try { 38 | const profile = await this.context.client.getProfile(this.props.event.sender); 39 | this.setState({ 40 | displayname: profile.displayname, 41 | }); 42 | } catch (ex) { 43 | console.debug(`Failed to fetch profile for user ${this.props.event.sender}:`, ex); 44 | } 45 | this.context.reputation.trackScore( 46 | this.props.event, 47 | (eventId, score) => { 48 | this.setState({ 49 | reputationScore: score, 50 | hidden: score < 0, 51 | }); 52 | } 53 | ); 54 | } 55 | 56 | async componentDidUpdate(oldProps) { 57 | if ( 58 | oldProps.event && 59 | this.props.event && 60 | oldProps.event.event_id !== this.props.event.event_id 61 | ) { 62 | this.context.reputation.removeTrackScoreListener( 63 | oldProps.event.event_id 64 | ); 65 | } 66 | if ( 67 | this.props.event && 68 | (oldProps.event || {}).event_id !== this.props.event.event_id 69 | ) { 70 | this.context.reputation.trackScore( 71 | this.props.event, 72 | (eventId, score) => { 73 | this.setState({ 74 | reputationScore: score, 75 | hidden: score < 0, 76 | }); 77 | } 78 | ); 79 | // Ensure we update the profile 80 | try { 81 | const profile = await this.context.client.getProfile(this.props.event.sender); 82 | this.setState({ 83 | displayname: profile.displayname, 84 | }); 85 | } catch (ex) { 86 | console.debug(`Failed to fetch profile for user ${this.props.event.sender}:`, ex); 87 | } 88 | } 89 | } 90 | 91 | componentWillUnmount() { 92 | if (!this.props.event) { 93 | return; 94 | } 95 | this.context.reputation.removeTrackScoreListener( 96 | this.props.event.event_id 97 | ); 98 | } 99 | 100 | onReplyClick() { 101 | console.log( 102 | "onReplyClick timeline=", 103 | this.props.isTimelineEvent, 104 | " for event ", 105 | this.props.event 106 | ); 107 | this.setState({ 108 | showReplyModal: true, 109 | }); 110 | } 111 | 112 | onReplyClose() { 113 | this.setState({ 114 | inputReply: "", 115 | showReplyModal: false, 116 | }); 117 | } 118 | 119 | async onSubmitReply() { 120 | const reply = this.state.inputReply; 121 | this.setState({ 122 | loading: true, 123 | inputReply: "", 124 | }); 125 | 126 | let dataUri; 127 | if (this.state.uploadFile) { 128 | dataUri = await this.context.client.uploadFile( 129 | this.state.uploadFile 130 | ); 131 | console.log(dataUri); 132 | } 133 | 134 | let postedEventId; 135 | try { 136 | postedEventId = await this.context.client.replyToEvent( 137 | reply, 138 | this.props.event, 139 | this.props.isTimelineEvent, 140 | dataUri 141 | ); 142 | } catch (err) { 143 | console.error(err); 144 | this.setState({ 145 | error: err, 146 | }); 147 | } finally { 148 | this.setState({ 149 | loading: false, 150 | showReplyModal: false, 151 | uploadFile: null, 152 | }); 153 | } 154 | if (postedEventId && this.props.onPost) { 155 | this.props.onPost(this.props.event, postedEventId); 156 | } 157 | } 158 | 159 | onAuthorClick(author) { 160 | window.location.href = `/${author}`; 161 | } 162 | 163 | onUnhideClick() { 164 | this.setState({ 165 | hidden: false, 166 | }); 167 | } 168 | 169 | renderTime(ts) { 170 | if (!ts) { 171 | return Now; 172 | } 173 | const d = new Date(ts); 174 | const dateStr = `${d.getDate()}/${ 175 | d.getMonth() + 1 176 | }/${d.getFullYear()} · ${d.toLocaleTimeString([], { 177 | hour: "2-digit", 178 | minute: "2-digit", 179 | hour12: false, 180 | })} (score: ${this.state.reputationScore.toFixed(1)})`; 181 | return ( 182 |
186 | {dateStr} 187 |
188 | ); 189 | } 190 | 191 | renderEvent() { 192 | const event = this.props.event; 193 | if (!event) { 194 | return
; 195 | } 196 | let blurStyle = {}; 197 | let hiddenTooltip; 198 | let handler = this.onMessageClick.bind(this); 199 | if (this.state.hidden) { 200 | // 0 -> -10 = 1px blur 201 | // -10 -> -20 = 2px blur 202 | // -20 -> -30 = 3px blur, etc 203 | let blur = 5; 204 | // it should be 205 | if (this.state.reputationScore < 0) { 206 | // make score positive, look at 10s and add 1. 207 | // we expect -100 to be the highest value, resulting in: 208 | // -100 * -1 = 100 209 | // 100 / 10 = 10 210 | // 10 + 1 = 11px blur 211 | blur = Math.round((this.state.reputationScore * -1) / 10) + 1; 212 | if (blur > 11) { 213 | blur = 11; 214 | } 215 | } 216 | blurStyle = { 217 | filter: "blur(" + blur + "px)", 218 | opacity: 0.8, 219 | }; 220 | handler = this.onUnhideClick.bind(this); 221 | hiddenTooltip = "Reveal filtered message"; 222 | } 223 | 224 | let image; 225 | if (event.content.msgtype === "m.image" && event.content.url) { 226 | image = ( 227 | user upload 235 | ); 236 | } 237 | return ( 238 |
239 |
240 |
245 | {this.state.displayname || event.sender}{" "} 246 |
247 | {this.renderTime(event.origin_server_ts)} 248 |
249 |
255 | {"" + event.content.body} 256 |
257 | {image} 258 |
259 | ); 260 | } 261 | 262 | onMessageClick() { 263 | if (!this.props.event || this.state.loading) { 264 | return; 265 | } 266 | let link; 267 | if (this.props.isTimelineEvent) { 268 | link = createPermalinkForTimelineEvent(this.props.event); 269 | } else { 270 | link = createPermalinkForThreadEvent(this.props.event); 271 | } 272 | if (!link) { 273 | return; 274 | } 275 | window.location.href = link; 276 | } 277 | 278 | handleInputChange(event) { 279 | const target = event.target; 280 | const value = 281 | target.type === "checkbox" ? target.checked : target.value; 282 | const name = target.name; 283 | this.setState({ 284 | [name]: value, 285 | }); 286 | } 287 | 288 | handleKeyDown(event) { 289 | if (event.key === "Enter") { 290 | this.onSubmitReply(); 291 | } 292 | } 293 | 294 | async onUploadFileClick(event) { 295 | const file = event.target.files[0]; 296 | console.log(file); 297 | this.setState({ 298 | uploadFile: file, 299 | }); 300 | } 301 | 302 | render() { 303 | let replies; 304 | if (this.props.numReplies > 1) { 305 | replies = "\uD83D\uDDE8" + (this.props.numReplies - 1); 306 | } 307 | 308 | let sendSrc = "/send.svg"; 309 | const hasEnteredText = this.state.inputReply.length > 0; 310 | if (hasEnteredText) { 311 | sendSrc = "/send-active.svg"; 312 | } 313 | 314 | let modal; 315 | if (this.state.showReplyModal) { 316 | let inputBox; 317 | let uploadBox; 318 | if (this.state.loading) { 319 | inputBox =
Loading...
; 320 | } else { 321 | inputBox = ( 322 |
323 | 333 | send 339 |
340 | ); 341 | uploadBox = ( 342 | 348 | ); 349 | } 350 | modal = ( 351 | 355 | {this.renderEvent(true)} 356 | {inputBox} 357 | {uploadBox} 358 | 359 | ); 360 | } 361 | 362 | let replyButton; 363 | if (!this.context.client.isGuest && !this.state.noReply) { 364 | replyButton = ( 365 | 372 | ); 373 | } 374 | 375 | return ( 376 |
377 | {modal} 378 | {this.renderEvent()} 379 |
380 | {replies} 381 | {replyButton} 382 | 383 | {this.state.error ? ( 384 |
Error: {JSON.stringify(this.state.error)}
385 | ) : ( 386 |
387 | )} 388 |
389 |
390 | ); 391 | } 392 | } 393 | Message.contextType = ClientContext; 394 | 395 | export default Message; 396 | -------------------------------------------------------------------------------- /src/Modal.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Modal is a way to display dialog boxes 4 | const Modal = ({ handleClose, show, children }) => { 5 | const showHideClassName = show 6 | ? "modal-overlay display-block" 7 | : "modal-overlay display-none"; 8 | 9 | return ( 10 |
11 |
12 |
13 | close 19 |
20 | {children} 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default Modal; 27 | -------------------------------------------------------------------------------- /src/Reputation.js: -------------------------------------------------------------------------------- 1 | import ReputationList from "./ReputationList"; 2 | 3 | class Reputation { 4 | constructor() { 5 | // map of list tag -> filter weightings between -1 and +1. 6 | this.listWeightings = new Map(); 7 | // map of list tag -> ReputationList 8 | this.lists = new Map(); 9 | // event ID -> { event: $event, callback: function(eventId, score), prevScore: number } 10 | this.callbacks = new Map(); 11 | } 12 | 13 | async loadWeights(localStorage, client) { 14 | let ser = localStorage.getItem("weights") || "{}"; 15 | let weights = JSON.parse(ser); 16 | for (const tag in weights) { 17 | try { 18 | let list = await ReputationList.loadFromAlias(client, tag); 19 | this.listWeightings.set(tag, weights[tag]); 20 | this.lists.set(tag, list); 21 | console.log("Adding list:", list); 22 | } catch (err) { 23 | console.error("failed to load weights for list", tag, err); 24 | } 25 | } 26 | console.log("Finished loading weightings:", weights); 27 | this.updateScores(); 28 | } 29 | 30 | saveWeights(localStorage) { 31 | let ser = {}; 32 | for (let [tag] of this.lists) { 33 | ser[tag] = this.listWeightings.get(tag) || 0; 34 | } 35 | localStorage.setItem("weights", JSON.stringify(ser)); 36 | } 37 | 38 | deleteList(tag) { 39 | this.lists.delete(tag); 40 | this.listWeightings.delete(tag); 41 | this.updateScores(); 42 | } 43 | 44 | /** 45 | * Modify the weight of a list. 46 | * @param {string} tag The tag for the list 47 | * @param {number} weight The weighting for this list. Between -100 and +100. 48 | */ 49 | modifyWeight(tag, weight) { 50 | this.listWeightings.set(tag, weight); 51 | this.updateScores(); 52 | } 53 | 54 | /** 55 | * Return a list of { name: $tag, weight: number } 56 | */ 57 | getWeightings() { 58 | let weights = []; 59 | for (let [tag] of this.lists) { 60 | weights.push({ 61 | name: tag, 62 | weight: this.listWeightings.get(tag) || 0, 63 | }); 64 | } 65 | return weights; 66 | } 67 | 68 | /** 69 | * Add a reputation list. The weighting should be 100 to fully match on it, 0 to ignore the list and -100 70 | * to do the opposite of the list. 71 | * @param {ReputationList} list 72 | * @param {number} weighting The weighting for this list. Between -100 and +100. 73 | */ 74 | addList(list, weighting) { 75 | this.listWeightings.set(list.tag, weighting); 76 | this.lists.set(list.tag, list); 77 | this.updateScores(); 78 | } 79 | 80 | updateScores() { 81 | for (let [eventId, info] of this.callbacks) { 82 | let score = this.getScore(info.event); 83 | if (score !== info.prevScore) { 84 | info.prevScore = score; 85 | info.callback(eventId, score); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Track the score of this event. The callback is immediately invoked with the current score. 92 | * @param {object} event the matrix event 93 | * @param {function} fn the callback, invoked with the event ID and the new score. 94 | */ 95 | trackScore(event, fn) { 96 | if (this.callbacks.has(event.event_id)) { 97 | console.warn("trackScore called twice for event ID ", event); 98 | } 99 | let score = this.getScore(event); 100 | this.callbacks.set(event.event_id, { 101 | event: event, 102 | callback: fn, 103 | prevScore: score, 104 | }); 105 | fn(event.event_id, score); 106 | } 107 | 108 | /** 109 | * Remove a score listener. 110 | * @param {string} eventId The event ID to stop listening for updates to. 111 | */ 112 | removeTrackScoreListener(eventId) { 113 | if (!this.callbacks.delete(eventId)) { 114 | console.warn( 115 | "removeTrackScoreListener called on event not being tracked:", 116 | eventId 117 | ); 118 | } 119 | } 120 | 121 | /** 122 | * Check if an event is filtered by the reputation lists. A negative value indicates it should be filtered. 123 | * @param {object} event The event to check. 124 | * @returns {number} The score for this event, unbounded. 125 | */ 126 | getScore(event) { 127 | let sum = 0; 128 | for (let [tag, list] of this.lists) { 129 | let weight = this.listWeightings.get(tag); 130 | if (!weight) { 131 | weight = 0; 132 | } 133 | let score = list.getReputationScore(event); 134 | sum += score * (weight / 100); // weight as a percentage between -1 and +1 135 | } 136 | return sum; 137 | } 138 | } 139 | 140 | export default Reputation; 141 | -------------------------------------------------------------------------------- /src/Reputation.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import ReputationList from "./ReputationList"; 4 | import Reputation from "./Reputation"; 5 | 6 | it("ReputationList combines rules", () => { 7 | let list = new ReputationList("test"); 8 | list.addRule("@alice:localhost", 100); 9 | list.addRule("@bob:localhost", -100); 10 | list.addRule("@zero:localhost", 0); 11 | list.addRule("!foo:localhost", 50); 12 | list.addRule("evil.domain", -100); 13 | let score = list.getReputationScore({ 14 | type: "m.room.message", 15 | content: { 16 | body: "trustworthy comment", 17 | }, 18 | sender: "@alice:localhost", 19 | room_id: "!foo:localhost", 20 | }); 21 | expect(score).toEqual(150); 22 | score = list.getReputationScore({ 23 | type: "m.room.message", 24 | content: { 25 | body: "untrustworthy comment", 26 | }, 27 | sender: "@bob:localhost", 28 | room_id: "!foo:localhost", 29 | }); 30 | expect(score).toEqual(-50); 31 | score = list.getReputationScore({ 32 | type: "m.room.message", 33 | content: { 34 | body: "very evil comment", 35 | }, 36 | sender: "@someone:evil.domain", 37 | room_id: "!foo:localhost", 38 | }); 39 | expect(score).toEqual(-50); 40 | score = list.getReputationScore({ 41 | type: "m.room.message", 42 | content: { 43 | body: "very evil comment", 44 | }, 45 | sender: "@zero:localhost", 46 | room_id: "!foo:localhost", 47 | }); 48 | expect(score).toEqual(50); 49 | }); 50 | 51 | it("ReputationList produces a score of 0 for no matches", () => { 52 | let list = new ReputationList("test2"); 53 | list.addRule("evil.domain.com", -100); 54 | list.addRule("!foo:localhostaaaaaaa", -100); 55 | let score = list.getReputationScore({ 56 | type: "m.room.message", 57 | content: { 58 | body: "very evil comment", 59 | }, 60 | sender: "@someone:evil.domain", 61 | room_id: "!foo:localhost", 62 | }); 63 | expect(score).toEqual(0); 64 | }); 65 | 66 | it("Reputation combines lists and weightings correctly when calling getScore", () => { 67 | let dogList = new ReputationList("#dog-lovers:localhost"); 68 | dogList.addRule("@sheltie:localhost", 100); 69 | dogList.addRule("@ihatedogs:localhost", -100); 70 | dogList.addRule("!lovedogs:localhost", 50); 71 | dogList.addRule("dogs.should.d.ie", -100); 72 | dogList.addRule("animals.should.d.ie", -100); // intersects with catList 73 | let catList = new ReputationList("#cat-lovers:localhost"); 74 | catList.addRule("@meow:localhost", 100); 75 | catList.addRule("@ihatecats:localhost", -100); 76 | catList.addRule("!lovecats:localhost", 50); 77 | catList.addRule("cats.should.d.ie", -100); 78 | catList.addRule("animals.should.d.ie", -100); // intersects with dogList 79 | let rep = new Reputation(); 80 | rep.addList(dogList, 100); 81 | rep.addList(catList, 50); 82 | 83 | // domain=animals.should.d.ie, dogList contributes (1*-100), catList contributes (0.5*-100) = -150 84 | expect( 85 | rep.getScore({ 86 | type: "m.room.messsage", 87 | content: { 88 | body: "he he he animals suck", 89 | }, 90 | sender: "@someone:animals.should.d.ie", 91 | room_id: "!somewhere:localhost", 92 | }) 93 | ).toBe(-150); 94 | 95 | // some negatives, some positives 96 | // domain=animals.should.d.ie, dogList contributes (1*-100), catList contributes (0.5*-100) = -150 97 | // room=!lovecats:localhost, catList contributes (0.5*50)=25 98 | // total: -125 99 | expect( 100 | rep.getScore({ 101 | type: "m.room.messsage", 102 | content: { 103 | body: "he he he cats suck", 104 | }, 105 | sender: "@someone:animals.should.d.ie", 106 | room_id: "!lovecats:localhost", 107 | }) 108 | ).toBe(-125); 109 | 110 | // no matches = no filters 111 | expect( 112 | rep.getScore({ 113 | type: "m.room.messsage", 114 | content: { 115 | body: "anything", 116 | }, 117 | sender: "@someone:localhost", 118 | room_id: "!somewhere:localhost", 119 | }) 120 | ).toBe(0); 121 | 122 | // a single zero value should not prevent other filters from matching 123 | rep.modifyWeight("#cat-lovers:localhost", 0); 124 | // sender contributes nothing, room ID contributes 50*1 125 | expect( 126 | rep.getScore({ 127 | type: "m.room.messsage", 128 | content: { 129 | body: "he he he cats suck", 130 | }, 131 | sender: "@meow:localhost", 132 | room_id: "!lovedogs:localhost", 133 | }) 134 | ).toBe(50); 135 | }); 136 | -------------------------------------------------------------------------------- /src/ReputationList.js: -------------------------------------------------------------------------------- 1 | class ReputationList { 2 | /* 3 | Construct a reputation list. 4 | @param tag {string} A human-readable identifier for this list, e.g a room alias. 5 | */ 6 | constructor(tag) { 7 | this.tag = tag; 8 | // shared map of entity -> score. No namespacing as the entities are namespaced already e.g 9 | // users -> @foo 10 | // rooms -> !bar 11 | // servers -> baz 12 | this.rules = new Map(); 13 | } 14 | 15 | /** 16 | * Load a reputation list from an alias. 17 | * @param {Client} client The matrix client to make CS API calls from. 18 | * @param {string} alias The alias which points to a room which has reputation room state. 19 | * @returns {ReputationList} 20 | */ 21 | static async loadFromAlias(client, alias) { 22 | const roomId = await client.joinReputationRoom(alias); 23 | const events = await client.getReputationState(roomId); 24 | const list = new ReputationList(alias); 25 | events.forEach((ev) => { 26 | // map state_key: "user:@alice.matrix.org" to "@alice:matrix.org" 27 | if (ev.state_key.indexOf("user:") === 0) { 28 | list.addRule( 29 | ev.state_key.slice("user:".length), 30 | ev.content.reputation, 31 | ev.content.reason 32 | ); 33 | } else if (ev.state_key.indexOf("room:") === 0) { 34 | list.addRule( 35 | ev.state_key.slice("room:".length), 36 | ev.content.reputation, 37 | ev.content.reason 38 | ); 39 | } else if (ev.state_key.indexOf("server:") === 0) { 40 | list.addRule( 41 | ev.state_key.slice("server:".length), 42 | ev.content.reputation, 43 | ev.content.reason 44 | ); 45 | } else { 46 | console.warn("reputation rule has unknown state_key: ", ev); 47 | } 48 | }); 49 | return list; 50 | } 51 | 52 | /** 53 | * Add a reputation rule. 54 | * @param {string} entity The entity involved, either a room ID, user ID or server domain. 55 | * @param {number} reputation The reputation value, a number between -100 and +100. 56 | * @param {string?} reason The reason for this reputation, optional. 57 | */ 58 | addRule(entity, reputation, reason) { 59 | if (reputation < -100 || reputation > 100) { 60 | console.error( 61 | "addRule: invalid reputation value:", 62 | reputation, 63 | entity 64 | ); 65 | return; 66 | } 67 | let rep = this.rules.get(entity); 68 | if (!rep) { 69 | rep = 0; 70 | } 71 | rep += reputation; 72 | this.rules.set(entity, rep); 73 | } 74 | 75 | /** 76 | * Return the reputation score for this event for this list. This is the sum of the user|room|server reputations. 77 | * @param {object} event The event to test. 78 | * @returns {number} Returns the score of this event. 79 | */ 80 | getReputationScore(event) { 81 | // check room 82 | let roomRep = this.rules.get(event.room_id); 83 | if (!roomRep) { 84 | roomRep = 0; 85 | } 86 | 87 | // check user 88 | let userRep = this.rules.get(event.sender); 89 | if (!userRep) { 90 | userRep = 0; 91 | } 92 | 93 | // extract server name from user: 94 | if (!event._domain) { 95 | // @alice:domain.com -> [@alice, domain.com] -> [domain.com] -> domain.com 96 | // @bob:foobar.com:8448 -> [@bob, foobar.com, 8448] -> [foobar.com, 8448] -> foobar.com:8448 97 | let domain = event.sender.split(":").splice(1).join(":"); 98 | event._domain = domain; 99 | } 100 | 101 | let serverRep = this.rules.get(event._domain); 102 | if (!serverRep) { 103 | serverRep = 0; 104 | } 105 | 106 | return userRep + serverRep + roomRep; 107 | } 108 | } 109 | 110 | export default ReputationList; 111 | -------------------------------------------------------------------------------- /src/ReputationPane.css: -------------------------------------------------------------------------------- 1 | .ReputationPane{ 2 | position: fixed; 3 | top: 47px; 4 | right: 0%; 5 | margin-right: 12px; 6 | margin-top: 12px; 7 | width: 640px; 8 | max-width: 100vw; 9 | background: #F7F7F7; 10 | box-shadow: 0px 1px 8px 1px rgba(0, 0, 0, 0.15); 11 | border-radius: 12px; 12 | padding-left: 24px; 13 | padding-bottom: 24px; 14 | box-sizing: border-box; 15 | } 16 | 17 | .repTitle { 18 | font-style: normal; 19 | font-weight: 600; 20 | font-size: 24px; 21 | line-height: 29px; 22 | 23 | /* identical to box height */ 24 | 25 | /* Light / Primary content */ 26 | color: #17191C; 27 | margin-top: 24px; 28 | } 29 | 30 | .repDescription { 31 | font-style: normal; 32 | font-weight: normal; 33 | font-size: 15px; 34 | line-height: 24px; 35 | 36 | /* identical to box height, or 160% */ 37 | 38 | /* Light / Primary content */ 39 | color: #17191C; 40 | margin-top: 12px; 41 | margin-bottom: 12px; 42 | } 43 | 44 | .addFilter { 45 | margin-top: 8px; 46 | margin-bottom: 8px; 47 | background: #8D99A5; 48 | border-radius: 6px; 49 | border: none; 50 | font-family: Inter; 51 | color: #FFFFFF; 52 | font-style: normal; 53 | font-weight: 600; 54 | font-size: 12px; 55 | line-height: 15px; 56 | padding: 4px 8px 4px 8px; 57 | cursor: pointer; 58 | } 59 | 60 | .saveChanges { 61 | float: right; 62 | margin-right: 16px; 63 | } 64 | 65 | .cancelButton{ 66 | margin-top: 8px; 67 | margin-bottom: 8px; 68 | margin-left: 8px; 69 | background: #8D99A5; 70 | border-radius: 6px; 71 | border: none; 72 | font-family: Inter; 73 | color: #FFFFFF; 74 | font-style: normal; 75 | font-weight: 600; 76 | font-size: 12px; 77 | line-height: 15px; 78 | padding: 4px 8px 4px 8px; 79 | cursor: pointer; 80 | } 81 | 82 | .listTitle { 83 | font-style: normal; 84 | font-weight: 600; 85 | font-size: 15px; 86 | line-height: 18px; 87 | 88 | /* Light / Primary content */ 89 | color: #17191C; 90 | } 91 | 92 | .listEntry { 93 | border-top: 1px solid #E3E8F0; 94 | margin-right: 24px; 95 | display: flex; 96 | align-items: center; 97 | justify-content: space-between; 98 | } 99 | 100 | .listEntryBottom { 101 | border-bottom: 1px solid #E3E8F0; 102 | margin-bottom: 16px; 103 | } 104 | 105 | .listDelete { 106 | margin-top: 20px; 107 | margin-bottom: 20px; 108 | margin-right: 16px; 109 | cursor: pointer; 110 | } 111 | 112 | .listEntryLeft{ 113 | display: flex; 114 | align-items: center; 115 | overflow: hidden; 116 | text-overflow: ellipsis; 117 | white-space: nowrap; 118 | } 119 | 120 | .listEntryRight{ 121 | } 122 | 123 | .range { 124 | width: 224px; 125 | max-width: 100%; 126 | } 127 | 128 | .rangeLabels{ 129 | display: flex; 130 | justify-content: space-between; 131 | } 132 | 133 | .rangeLabel { 134 | font-style: normal; 135 | font-weight: normal; 136 | font-size: 10px; 137 | line-height: 12px; 138 | 139 | } -------------------------------------------------------------------------------- /src/ReputationPane.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReputationList from "./ReputationList"; 3 | import "./ReputationPane.css"; 4 | import { ClientContext } from "./ClientContext"; 5 | 6 | // ReputationPane renders the filter list popup. 7 | // Props: 8 | // - onClose: a function called when this dialog should be dismissed. 9 | class ReputationPane extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | weightings: new Map(), // tag => number 14 | addingFilter: false, 15 | addFilterInput: "", 16 | error: null, 17 | }; 18 | } 19 | 20 | componentDidMount() { 21 | this.loadWeightings(); 22 | } 23 | 24 | loadWeightings() { 25 | let w = this.state.weightings; 26 | this.context.reputation.getWeightings().forEach((obj) => { 27 | w.set(obj.name, obj.weight); 28 | }); 29 | this.setState({ 30 | weightings: w, 31 | }); 32 | } 33 | 34 | handleWeightChange(event) { 35 | const target = event.target; 36 | const name = target.name; 37 | const weightings = this.state.weightings; 38 | weightings.set(name, target.value); 39 | this.setState({ 40 | weightings: weightings, 41 | }); 42 | // persist new weightings 43 | for (let [tag, weight] of this.state.weightings) { 44 | this.context.reputation.modifyWeight(tag, weight); 45 | } 46 | this.context.reputation.saveWeights(window.localStorage); 47 | } 48 | 49 | handleInputChange(event) { 50 | const target = event.target; 51 | const value = 52 | target.type === "checkbox" ? target.checked : target.value; 53 | const name = target.name; 54 | this.setState({ 55 | [name]: value, 56 | }); 57 | } 58 | 59 | onDeleteClick(tag, ev) { 60 | // leave the room 61 | // persist the new list 62 | console.log("delete ", tag); 63 | this.context.reputation.deleteList(tag); 64 | this.loadWeightings(); 65 | } 66 | 67 | onAddFilterClick(ev) { 68 | this.setState({ 69 | addingFilter: true, 70 | error: null, 71 | }); 72 | } 73 | 74 | onCancelAddFilterClick(ev) { 75 | this.setState({ 76 | addingFilter: false, 77 | addFilterInput: "", 78 | }); 79 | } 80 | 81 | async onCreateFilterClick(ev) { 82 | const val = this.state.addFilterInput; 83 | 84 | console.log("adding filter:", val); 85 | try { 86 | // join the room 87 | await this.context.client.joinReputationRoom(val); 88 | const list = await ReputationList.loadFromAlias( 89 | this.context.client, 90 | val 91 | ); 92 | // persist the new weighting 93 | this.context.reputation.addList(list, 100); 94 | this.context.reputation.saveWeights(window.localStorage); 95 | this.loadWeightings(); 96 | } catch (err) { 97 | console.error("failed to add filter: ", err); 98 | this.setState({ 99 | error: "Unable to add filter: " + JSON.stringify(err), 100 | }); 101 | } 102 | 103 | this.setState({ 104 | addingFilter: false, 105 | addFilterInput: "", 106 | }); 107 | } 108 | 109 | renderFilterLists() { 110 | return this.context.reputation.getWeightings().map((obj) => { 111 | return this.renderFilterList(obj.name); 112 | }); 113 | } 114 | 115 | renderFilterList(tag) { 116 | return ( 117 |
118 |
119 | delete 125 |
{tag}
126 |
127 |
128 | 138 |
139 | Dislike 140 | Neutral 141 | Like 142 |
143 |
144 |
145 | ); 146 | } 147 | 148 | renderAddFilter() { 149 | if (this.state.addingFilter) { 150 | return ( 151 |
152 | 159 |
160 | 166 | 172 |
173 |
174 | ); 175 | } 176 | return ( 177 |
178 | 184 |
185 | ); 186 | } 187 | 188 | render() { 189 | let errorBox; 190 | if (this.state.error) { 191 | errorBox =
{this.state.error}
; 192 | } 193 | return ( 194 |
195 |
196 | close 202 |
203 |
Filter your view
204 |
205 | Apply these filters to your view of Matrix 206 |
207 | {this.renderFilterLists()} 208 | {this.renderAddFilter()} 209 | {errorBox} 210 |
211 | ); 212 | } 213 | } 214 | ReputationPane.contextType = ClientContext; 215 | 216 | export default ReputationPane; 217 | -------------------------------------------------------------------------------- /src/StatusPage.css: -------------------------------------------------------------------------------- 1 | .child{ 2 | border-left: 1px solid #8D99A5; 3 | padding-left: 8px; 4 | } 5 | 6 | .verticalChild{ 7 | display: flex; 8 | } 9 | 10 | .threadCorner{ 11 | float: left; 12 | position: absolute; 13 | margin-top: -1px; /* so border lines merge in correctly */ 14 | } 15 | 16 | .threadFork{ 17 | float: left; 18 | position: absolute; 19 | margin-top: 18px; /* so border lines merge in correctly */ 20 | } 21 | 22 | .threadLine{ 23 | border-left: 1px solid #8D99A5; 24 | margin-left: 25px; 25 | } 26 | 27 | .threadLineHolder{ 28 | display: flex; 29 | } 30 | 31 | .messageHolder{ 32 | min-width: 0; 33 | flex-grow: 2; 34 | } 35 | 36 | .blankThreadLine{ 37 | margin-left: 25px; 38 | } 39 | 40 | .StatusPage{ 41 | background: #F7F7F7; 42 | border-radius: 12px 12px 0px 0px; 43 | padding: 20px; 44 | } 45 | 46 | .StatusMessage { 47 | background: #FFFFFF; 48 | box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); 49 | border-radius: 12px; 50 | } 51 | 52 | .BackButton{ 53 | /* Light / Element */ 54 | cursor: pointer; 55 | } 56 | 57 | .statusButtons{ 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: center; 61 | } 62 | .viewButtonWrapper{ 63 | border: 1px solid #8D99A5; 64 | border-radius: 8px; 65 | margin-top: 16px; 66 | margin-bottom: 16px; 67 | padding: 2px; 68 | display: flex; 69 | justify-content: flex-end; 70 | cursor: pointer; 71 | user-select: none; 72 | } 73 | .viewButton{ 74 | color: #737D8C; 75 | background: #FFFFFF; 76 | border-radius: 0px; 77 | border: none; 78 | font-family: Inter; 79 | font-style: normal; 80 | font-weight: 600; 81 | font-size: 12px; 82 | line-height: 15px; 83 | padding: 4px 8px 4px 8px; 84 | } -------------------------------------------------------------------------------- /src/StatusPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./StatusPage.css"; 3 | import Message from "./Message"; 4 | import { createPermalinkForThreadEvent } from "./routing"; 5 | 6 | const maxBreadth = 5; 7 | const maxDepth = 10; 8 | 9 | // StatusPage renders a thread of conversation based on a single anchor event. 10 | // Props: 11 | // - eventId: The anchor event. The parent of this event and a tree of children will be obtained from this point. 12 | // - roomId: The room ID this event belongs to. Required in case the server doesn't have this event so it knows where to look. 13 | // - userId: The user who posted the event. Required for the server name in case the logged in user needs to join the room. 14 | // - client: Client 15 | class StatusPage extends React.Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | parent: null, 20 | parentOfParent: null, 21 | parentToChildren: new Map(), 22 | eventMap: new Map(), 23 | children: [], 24 | error: null, 25 | horizontalThreading: false, 26 | }; 27 | } 28 | 29 | async componentDidMount() { 30 | if (this.props.roomId) { 31 | // extract server name from user being viewed: 32 | // @alice:domain.com -> [@alice, domain.com] -> [domain.com] -> domain.com 33 | // @bob:foobar.com:8448 -> [@bob, foobar.com, 8448] -> [foobar.com, 8448] -> foobar.com:8448 34 | let domain = this.props.userId.split(":").splice(1).join(":"); 35 | await this.props.client.joinRoomById(this.props.roomId, domain); 36 | } 37 | await this.refresh(); 38 | this.listenForNewEvents(); 39 | } 40 | 41 | listenForNewEvents(from) { 42 | let f = from; 43 | this.props.client 44 | .waitForMessageEventInRoom([this.props.roomId], from) 45 | .then((newFrom) => { 46 | f = newFrom; 47 | return this.refresh(); 48 | }) 49 | .then(() => { 50 | this.listenForNewEvents(f); 51 | }); 52 | } 53 | 54 | async refresh() { 55 | // fetch the event we're supposed to display, along with a bunch of other events which are the replies 56 | // and the replies to those replies. We go up to 6 wide and 6 deep, and stop showing >5 items (instead having) 57 | // a 'see more'. 58 | const events = await this.props.client.getRelationships( 59 | this.props.eventId, 60 | this.props.roomId, 61 | maxBreadth + 1, 62 | maxDepth + 1 63 | ); 64 | // store in a map for easy references and to find the parent 65 | let eventMap = new Map(); 66 | let parentToChildren = new Map(); 67 | for (let ev of events) { 68 | eventMap.set(ev.event_id, ev); 69 | } 70 | const parent = eventMap.get(this.props.eventId); 71 | if (!parent) { 72 | // this could be a bogus event, bail 73 | this.setState({ 74 | error: "Unknown event", 75 | }); 76 | return; 77 | } 78 | // find all events which have a relationship and store the reverse mapping 79 | for (let ev of events) { 80 | if ( 81 | ev.content["m.relationship"] && 82 | ev.content["m.relationship"].rel_type === "m.reference" 83 | ) { 84 | const parentId = ev.content["m.relationship"].event_id; 85 | let existing = parentToChildren.get(parentId); 86 | if (!existing) { 87 | existing = []; 88 | } 89 | existing.push(ev); 90 | parentToChildren.set(parentId, existing); 91 | } 92 | } 93 | 94 | // if the parent has a parent include it so you can go up the tree 95 | let parentOfParent; 96 | if ( 97 | parent.content["m.relationship"] && 98 | parent.content["m.relationship"].rel_type === "m.reference" 99 | ) { 100 | parentOfParent = eventMap.get( 101 | parent.content["m.relationship"].event_id 102 | ); 103 | } 104 | 105 | this.setState({ 106 | parent: parent, 107 | children: parentToChildren.get(parent.event_id) || [], 108 | parentToChildren: parentToChildren, 109 | parentOfParent: parentOfParent, 110 | eventMap: eventMap, 111 | }); 112 | } 113 | 114 | renderHorizontalChild(ev) { 115 | // walk the graph depth first, we want to convert graphs like: 116 | // A 117 | // / \ 118 | // B C 119 | // | 120 | // D 121 | // | 122 | // E 123 | // into: 124 | // [ Message A ] 125 | // | [ Message B ] 126 | // | [ Message C ] 127 | // | [ Message D ] 128 | // | [ Message E ] 129 | const maxItems = 200; 130 | // which item to render next, we store the event ID and the depth so we 131 | // know how much to indent by 132 | const toProcess = [ 133 | { 134 | eventId: ev.event_id, 135 | depth: 0, 136 | }, 137 | ]; 138 | const rendered = []; 139 | while (toProcess.length > 0 && rendered.length < maxItems) { 140 | const procInfo = toProcess.pop(); 141 | const eventId = procInfo.eventId; 142 | const depth = procInfo.depth; 143 | const style = { 144 | marginLeft: 20 * (1 + depth) + "px", 145 | }; 146 | const event = this.state.eventMap.get(eventId); 147 | if (!event) { 148 | continue; 149 | } 150 | if (procInfo.seeMore) { 151 | const link = createPermalinkForThreadEvent(event); 152 | rendered.push( 153 |
158 | See more... 159 |
160 | ); 161 | continue; 162 | } 163 | // this array is in the order from POST /event_relationships which is 164 | // recent first 165 | const children = this.state.parentToChildren.get(eventId); 166 | if (children) { 167 | // we only render children if we aren't going to go over (hence +1) the max depth, else 168 | // we permalink to the parent with a "see more" link. Inject this first as it's a LIFO stack 169 | if (depth + 1 >= maxDepth) { 170 | toProcess.push({ 171 | eventId: eventId, 172 | depth: depth + 1, 173 | seeMore: true, 174 | }); 175 | } else { 176 | // render up to maxBreadth children 177 | if (children.length > maxBreadth) { 178 | // only show the first 5 then add a 'see more' link which permalinks you 179 | // to the parent which has so many children (we only display all children 180 | // on the permalink for the parent). We inject this first as it's a LIFO stack 181 | toProcess.push({ 182 | eventId: eventId, 183 | depth: depth + 1, 184 | seeMore: true, 185 | }); 186 | } 187 | // The array is recent first, but we want to display the most recent message at the top of the screen 188 | // so loop backwards from our cut-off to 0 (as it's LIFO we want the most recent message pushed last) 189 | for ( 190 | let i = Math.min(children.length, maxBreadth) - 1; 191 | i >= 0; 192 | i-- 193 | ) { 194 | //for (let i = 0; i < children.length && i < maxBreadth; i++) { 195 | toProcess.push({ 196 | eventId: children[i].event_id, 197 | depth: depth + 1, 198 | }); 199 | } 200 | } 201 | } else { 202 | // just because we don't have the children doesn't mean they don't exist, 203 | // check the event for children 204 | let remoteChildCount = 205 | event.unsigned?.children?.["m.reference"]; 206 | if (remoteChildCount > 0) { 207 | toProcess.push({ 208 | eventId: eventId, 209 | depth: depth + 1, 210 | seeMore: true, 211 | }); 212 | } 213 | } 214 | rendered.push( 215 |
216 | 217 |
218 | ); 219 | } 220 | return
{rendered}
; 221 | } 222 | 223 | renderVerticalChild(ev, sibling, numSiblings) { 224 | // walk the graph depth first, we want to convert graphs like: 225 | // A 226 | // / \ 227 | // B C 228 | // | 229 | // D 230 | // | 231 | // E 232 | // into: 233 | // [ Message A ] 234 | // |-[ Message B ] 235 | // |-[ Message C ] 236 | // [ Message D ] 237 | // [ Message E ] 238 | // Indentation and thread lines occur on events which have siblings. 239 | const maxItems = 200; 240 | // which item to render next 241 | const toProcess = [ 242 | { 243 | eventId: ev.event_id, 244 | siblingDepth: 0, // how many parents have siblings up to the root node 245 | numSiblings: numSiblings, // total number of sibling this node has (incl. itself) 246 | sibling: sibling, // the 0-based index of this sibling 247 | depthsOfParentsWhoHaveMoreSiblings: [], // depth values 248 | depth: 0, // the depth of this event 249 | }, 250 | ]; 251 | const rendered = []; 252 | while (toProcess.length > 0 && rendered.length < maxItems) { 253 | const procInfo = toProcess.pop(); 254 | const eventId = procInfo.eventId; 255 | const siblingDepth = procInfo.siblingDepth; 256 | const numSiblings = procInfo.numSiblings; 257 | const sibling = procInfo.sibling; 258 | const isLastSibling = sibling === 0; 259 | const depth = procInfo.depth; 260 | const depthsOfParentsWhoHaveMoreSiblings = 261 | procInfo.depthsOfParentsWhoHaveMoreSiblings; 262 | // continue the thread line down to the next sibling, 263 | const msgStyle = { 264 | borderLeft: !isLastSibling ? "1px solid #8D99A5" : undefined, 265 | }; 266 | const event = this.state.eventMap.get(eventId); 267 | if (!event) { 268 | continue; 269 | } 270 | 271 | // We draw tube lines going down past nested events so we need to continue 272 | // the line first before we even handle the event we're processing. 273 | let parentThreadLines = []; 274 | for (let i = 0; i <= siblingDepth; i++) { 275 | let cn = "blankThreadLine"; 276 | if (depthsOfParentsWhoHaveMoreSiblings.indexOf(i) !== -1) { 277 | // add a thread line 278 | cn = "threadLine"; 279 | } 280 | parentThreadLines.push(
); 281 | } 282 | let threadLines = ( 283 |
{parentThreadLines}
284 | ); 285 | 286 | if (procInfo.seeMore) { 287 | const link = createPermalinkForThreadEvent(event); 288 | let seeMoreStyle = {}; 289 | // If we're "seeing more" due to capping the breadth we want the link to be left-aligned 290 | // with the thread line, else we want to indent it so it appears as a child (depth see more) 291 | if (procInfo.seeMoreDepth) { 292 | seeMoreStyle = { marginLeft: "20px" }; 293 | } 294 | rendered.push( 295 |
296 | {parentThreadLines} 297 | 298 | See more... 299 | 300 |
301 | ); 302 | continue; 303 | } 304 | 305 | // Copy depthsOfParentsWhoHaveMoreSiblings and add in this depth if we have more 306 | // siblings to render; this determines whether to draw outer thread lines 307 | const newDepthsOfParents = isLastSibling 308 | ? [...depthsOfParentsWhoHaveMoreSiblings] 309 | : [siblingDepth, ...depthsOfParentsWhoHaveMoreSiblings]; 310 | 311 | // this array is in the order from POST /event_relationships which is 312 | // recent first 313 | const children = this.state.parentToChildren.get(eventId); 314 | if (children) { 315 | // we only render children if we aren't going to go over (hence +1) the max depth, else 316 | // we permalink to the parent with a "see more" link. 317 | if (depth + 1 >= maxDepth) { 318 | toProcess.push({ 319 | eventId: eventId, 320 | siblingDepth: siblingDepth, 321 | seeMore: true, 322 | seeMoreDepth: true, 323 | numSiblings: children.length, 324 | sibling: maxBreadth, 325 | // we render the "see more" link directly underneath 326 | depthsOfParentsWhoHaveMoreSiblings: newDepthsOfParents, 327 | depth: depth + 1, 328 | }); 329 | } else { 330 | const newSiblingDepth = 331 | siblingDepth + (children.length > 1 ? 1 : 0); 332 | if (children.length > maxBreadth) { 333 | // only show the first maxBreadth then add a 'see more' link which permalinks you 334 | // to the parent which has so many children (we only display all children 335 | // on the permalink for the parent). We inject this first as it's a LIFO stack 336 | toProcess.push({ 337 | eventId: eventId, 338 | siblingDepth: newSiblingDepth, 339 | seeMore: true, 340 | numSiblings: children.length, 341 | sibling: maxBreadth, 342 | depthsOfParentsWhoHaveMoreSiblings: newDepthsOfParents, 343 | depth: depth + 1, 344 | }); 345 | } 346 | 347 | // The array is recent first, but we want to display the most recent message at the top of the screen 348 | // so loop backwards from our cut-off to 0 349 | for ( 350 | let i = Math.min(children.length, maxBreadth) - 1; 351 | i >= 0; 352 | i-- 353 | ) { 354 | //for (let i = 0; i < children.length && i < maxBreadth; i++) { 355 | toProcess.push({ 356 | eventId: children[i].event_id, 357 | siblingDepth: newSiblingDepth, 358 | numSiblings: children.length, 359 | // rendering relies on a stack so invert the sibling order, pretending the middle of the array is sibling 0 360 | sibling: 361 | Math.min(children.length, maxBreadth) - 1 - i, 362 | parentIsLastSibling: isLastSibling, 363 | depthsOfParentsWhoHaveMoreSiblings: newDepthsOfParents, 364 | depth: depth + 1, 365 | }); 366 | } 367 | } 368 | } else { 369 | // just because we don't have the children doesn't mean they don't exist, 370 | // check the event for children 371 | let remoteChildCount = 372 | event.unsigned?.children?.["m.reference"]; 373 | if (remoteChildCount > 0) { 374 | toProcess.push({ 375 | eventId: eventId, 376 | siblingDepth: siblingDepth, 377 | seeMore: true, 378 | seeMoreDepth: true, 379 | numSiblings: remoteChildCount, 380 | sibling: maxBreadth, 381 | // we render the "see more" link directly underneath 382 | depthsOfParentsWhoHaveMoreSiblings: newDepthsOfParents, 383 | depth: depth + 1, 384 | }); 385 | } 386 | } 387 | 388 | // if there's multiple siblings then they all get corners to fork off from the parent 389 | // if there's only 1 sibling then we just put the reply directly beneath without a corner 390 | let threadCorner; 391 | if (numSiblings > 1) { 392 | let threadCornerType = "/thread-line.svg"; 393 | let threadCornerClass = "threadFork"; 394 | if (isLastSibling) { 395 | threadCornerType = "/thread-corner.svg"; 396 | threadCornerClass = "threadCorner"; 397 | } 398 | threadCorner = ( 399 | line 404 | ); 405 | } 406 | 407 | rendered.push( 408 |
409 | {threadLines} 410 |
411 | {threadCorner} 412 | 416 |
417 |
418 | ); 419 | } 420 | return
{rendered}
; 421 | } 422 | 423 | onPost(parentEvent, eventId) { 424 | this.refresh(); 425 | } 426 | 427 | onToggleClick() { 428 | this.setState({ 429 | horizontalThreading: !this.state.horizontalThreading, 430 | }); 431 | } 432 | 433 | renderButtons() { 434 | let backButton =
; 435 | if (this.state.parentOfParent) { 436 | const link = createPermalinkForThreadEvent( 437 | this.state.parentOfParent 438 | ); 439 | backButton = ( 440 | back { 445 | window.location.href = link; 446 | }} 447 | /> 448 | ); 449 | } 450 | 451 | return ( 452 |
453 | {backButton} 454 |
455 |
463 | Vertical view 464 |
465 |
473 | Horizontal view 474 |
475 |
476 |
477 | ); 478 | } 479 | 480 | render() { 481 | let parent; 482 | if (this.state.parentOfParent) { 483 | parent = ( 484 | 489 | ); 490 | } 491 | // display the main event this hyperlink refers to then load ALL level 1 children beneath 492 | return ( 493 |
494 | {this.renderButtons()} 495 |
496 | {parent} 497 |
498 | 502 |
503 | {this.state.children.map((ev, i) => { 504 | if (this.state.horizontalThreading) { 505 | return this.renderHorizontalChild(ev); 506 | } else { 507 | return this.renderVerticalChild( 508 | ev, 509 | this.state.children.length - 1 - i, // rendering relies on a stack so invert the sibling order 510 | this.state.children.length 511 | ); 512 | } 513 | })} 514 |
515 |
516 | ); 517 | } 518 | } 519 | 520 | export default StatusPage; 521 | -------------------------------------------------------------------------------- /src/TimelinePage.css: -------------------------------------------------------------------------------- 1 | .UserPageHeader{ 2 | padding-bottom: 15px; 3 | } 4 | 5 | .UserPage{ 6 | margin-top: 16px; 7 | } 8 | 9 | .UserPageBody{ 10 | background: #F7F7F7; 11 | border-bottom-left-radius: 12px; 12 | border-bottom-right-radius: 12px; 13 | } 14 | 15 | .userName{ 16 | /* Body SemiBold */ 17 | font-family: Inter; 18 | font-style: normal; 19 | font-weight: 400; 20 | font-size: 15px; 21 | line-height: 18px; 22 | 23 | /* Light / Primary content */ 24 | color: #17191C; 25 | 26 | margin-bottom: 8px; 27 | } 28 | 29 | .sendButton{ 30 | cursor: pointer; 31 | margin: 8px; 32 | } 33 | 34 | .tabGroup{ 35 | display: flex; 36 | align-items: center; 37 | justify-content: space-around; 38 | } 39 | 40 | .tabSelected { 41 | background: #F7F7F7; 42 | border-radius: 12px 12px 0px 0px; 43 | } 44 | 45 | .tab{ 46 | font-family: Inter; 47 | font-style: normal; 48 | font-weight: 600; 49 | font-size: 15px; 50 | line-height: 18px; 51 | text-align: center; 52 | padding-top: 15px; 53 | padding-bottom: 15px; 54 | 55 | color: #2952BE; 56 | flex-grow: 1; 57 | cursor: pointer; 58 | } 59 | 60 | .emptyList { 61 | padding: 8px; 62 | text-align: center; 63 | } 64 | 65 | .timelineTitle{ 66 | padding: 8px; 67 | text-align: center; 68 | } 69 | 70 | .inputPostTimeline { 71 | padding: 20px; 72 | } -------------------------------------------------------------------------------- /src/TimelinePage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./TimelinePage.css"; 3 | import Message from "./Message"; 4 | import InputPost from "./InputPost"; 5 | import { createPermalinkForTimelineEvent } from "./routing"; 6 | 7 | // TimelinePage renders an aggregated feed of all timelines the logged in user is following. 8 | // Props: 9 | // - client: Client 10 | class TimelinePage extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | loading: true, 15 | error: null, 16 | timeline: [], 17 | fromToken: null, 18 | trackingRoomIds: [], 19 | }; 20 | } 21 | 22 | async componentDidMount() { 23 | await this.loadEvents(); 24 | this.listenForNewEvents(this.state.fromToken); 25 | } 26 | 27 | listenForNewEvents(from) { 28 | let f = from; 29 | this.props.client 30 | .waitForMessageEventInRoom(this.state.trackingRoomIds, from) 31 | .then((newFrom) => { 32 | f = newFrom; 33 | return this.loadEvents(); 34 | }) 35 | .then(() => { 36 | this.listenForNewEvents(f); 37 | }); 38 | } 39 | 40 | onPost() { 41 | // direct them to their own page so they see their message 42 | window.location.href = "/" + this.props.client.userId; 43 | } 44 | 45 | async loadEvents() { 46 | this.setState({ 47 | loading: true, 48 | }); 49 | try { 50 | let timelineInfo = await this.props.client.getAggregatedTimeline(); 51 | if (timelineInfo.timeline.length === 0) { 52 | window.location.href = "/" + this.props.client.userId; 53 | } 54 | let roomSet = new Set(); 55 | for (let ev of timelineInfo.timeline) { 56 | roomSet.add(ev.room_id); 57 | } 58 | this.setState({ 59 | timeline: timelineInfo.timeline, 60 | fromToken: timelineInfo.from, 61 | trackingRoomIds: Array.from(roomSet), 62 | }); 63 | } catch (err) { 64 | this.setState({ 65 | error: JSON.stringify(err), 66 | }); 67 | } finally { 68 | this.setState({ 69 | loading: false, 70 | }); 71 | } 72 | } 73 | 74 | onReplied(parentEvent, eventId) { 75 | const link = createPermalinkForTimelineEvent(parentEvent); 76 | if (!link) { 77 | return; 78 | } 79 | window.location.href = link; 80 | } 81 | 82 | render() { 83 | let timelineBlock; 84 | let errBlock; 85 | let hasEntries = false; 86 | if (this.state.error) { 87 | errBlock = ( 88 |
89 | Whoops! Something went wrong: {this.state.error} 90 |
91 | ); 92 | } else { 93 | if (this.state.loading) { 94 | timelineBlock = ( 95 |
Loading timeline....
96 | ); 97 | } else { 98 | timelineBlock = ( 99 |
100 | {this.state.timeline 101 | .filter((ev) => { 102 | // only messages 103 | if (ev.type !== "m.room.message") { 104 | return false; 105 | } 106 | // only messages with cerulean fields 107 | if ( 108 | !ev.content["org.matrix.cerulean.event_id"] 109 | ) { 110 | return false; 111 | } 112 | return true; 113 | }) 114 | .map((ev) => { 115 | hasEntries = true; 116 | return ( 117 | 123 | ); 124 | })} 125 |
126 | ); 127 | if (!hasEntries) { 128 | timelineBlock = ( 129 |
130 | Nothing to see yet. Check the{" "} 131 | 136 | welcome post 137 | 138 |
139 | ); 140 | } 141 | } 142 | } 143 | 144 | let title; 145 | if (hasEntries) { 146 | title =
What's going on
; 147 | } 148 | 149 | let inputPost; 150 | if (!this.props.client.isGuest) { 151 | inputPost = ( 152 |
153 | 157 |
158 | ); 159 | } 160 | 161 | let userPageBody = ( 162 |
163 |
164 | {inputPost} 165 | {title} 166 | {timelineBlock} 167 |
168 |
169 | ); 170 | 171 | return ( 172 |
173 | {errBlock} 174 | {userPageBody} 175 |
176 | ); 177 | } 178 | } 179 | 180 | export default TimelinePage; 181 | -------------------------------------------------------------------------------- /src/UserPage.css: -------------------------------------------------------------------------------- 1 | .errblock { 2 | background-color: lightcoral; 3 | padding: 15px; 4 | } 5 | 6 | .UserPageHeader{ 7 | line-height: 18px; 8 | padding-bottom: 15px; 9 | font-family: Inter; 10 | background: #F7F7F7; 11 | border-radius: 12px; 12 | padding: 20px; 13 | margin-bottom: 10px; 14 | } 15 | 16 | .UserProfile { 17 | display: grid; 18 | grid-template-columns: 1fr 10fr; 19 | column-gap: 10px; 20 | margin-bottom: 10px; 21 | } 22 | 23 | .UserPage{ 24 | margin-top: 16px; 25 | } 26 | 27 | .UserPageBody{ 28 | background: #F7F7F7; 29 | border-bottom-left-radius: 12px; 30 | border-bottom-right-radius: 12px; 31 | } 32 | 33 | .displayName{ 34 | /* Body SemiBold */ 35 | font-style: normal; 36 | font-weight: 600; 37 | font-size: 15px; 38 | line-height: 18px; 39 | 40 | /* Light / Primary content */ 41 | color: #17191C; 42 | 43 | margin-bottom: 8px; 44 | 45 | overflow: hidden; 46 | text-overflow: ellipsis; 47 | white-space: nowrap; 48 | } 49 | 50 | .userName{ 51 | /* Body SemiBold */ 52 | font-style: normal; 53 | font-weight: 400; 54 | font-size: 14px; 55 | 56 | /* Light / Primary content */ 57 | color: #17191C; 58 | 59 | margin-bottom: 8px; 60 | 61 | overflow: hidden; 62 | text-overflow: ellipsis; 63 | white-space: nowrap; 64 | } 65 | 66 | .tabGroup{ 67 | display: flex; 68 | align-items: center; 69 | justify-content: space-around; 70 | } 71 | 72 | .tabSelected { 73 | background: #F7F7F7; 74 | border-radius: 12px 12px 0px 0px; 75 | } 76 | 77 | .tab{ 78 | font-family: Inter; 79 | font-style: normal; 80 | font-weight: 600; 81 | font-size: 15px; 82 | line-height: 18px; 83 | text-align: center; 84 | padding-top: 15px; 85 | padding-bottom: 15px; 86 | 87 | color: #2952BE; 88 | flex-grow: 1; 89 | cursor: pointer; 90 | } 91 | 92 | .emptyList { 93 | padding: 8px; 94 | text-align: center; 95 | } 96 | 97 | .userAvatar{ 98 | margin-right: 1em; 99 | max-width: 64px; 100 | max-height: 64px; 101 | } 102 | 103 | .userSection { 104 | display: flex; 105 | align-items: center; 106 | margin-bottom: 8px; 107 | } -------------------------------------------------------------------------------- /src/UserPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./UserPage.css"; 3 | import Message from "./Message"; 4 | import InputPost from "./InputPost"; 5 | import { createPermalinkForTimelineEvent } from "./routing"; 6 | 7 | // UserPage renders an arbitrary user's timeline room. If the user is the logged-in user 8 | // then an input box is also displayed. 9 | // Props: 10 | // - userId: The user's timeline room to view. 11 | // - withReplies: True to show replies in addition to posts. 12 | // - client: Client 13 | class UserPage extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | this.state = { 17 | loading: false, 18 | error: null, 19 | withReplies: this.props.withReplies, 20 | timeline: [], 21 | isMe: props.userId === props.client.userId, 22 | roomId: null, 23 | userProfile: null, 24 | userBiography: null, 25 | }; 26 | } 27 | 28 | async componentDidMount() { 29 | await this.loadEvents(); 30 | this.listenForNewEvents(); 31 | } 32 | 33 | listenForNewEvents(from) { 34 | let f = from; 35 | this.props.client 36 | .waitForMessageEventInRoom([this.state.roomId], from) 37 | .then((newFrom) => { 38 | f = newFrom; 39 | return this.loadEvents(); 40 | }) 41 | .then(() => { 42 | this.listenForNewEvents(f); 43 | }); 44 | } 45 | 46 | async loadEvents() { 47 | if (this.state.loading) { 48 | return; 49 | } 50 | this.setState({ 51 | loading: true, 52 | }); 53 | // ensure we are following this user. In the future we can view without following 54 | // by using /peek but we don't have that for now. 55 | let roomId; 56 | try { 57 | roomId = await this.props.client.followUser(this.props.userId); 58 | this.loadProfile(roomId); // don't block the UI by waiting for this 59 | this.setState({ 60 | timeline: [], 61 | roomId: roomId, 62 | }); 63 | await this.props.client.getTimeline(roomId, 100, (events) => { 64 | console.log("Adding ", events.length, " items"); 65 | this.setState({ 66 | timeline: this.state.timeline.concat(events), 67 | loading: false, 68 | }); 69 | }); 70 | } catch (err) { 71 | this.setState({ 72 | error: JSON.stringify(err), 73 | }); 74 | } finally { 75 | this.setState({ 76 | loading: false, 77 | }); 78 | } 79 | } 80 | 81 | async loadProfile(roomId) { 82 | try { 83 | const userProfile = await this.props.client.getProfile( 84 | this.props.userId 85 | ); 86 | if (userProfile.avatar_url) { 87 | userProfile.avatar_url = this.props.client.thumbnailLink( 88 | userProfile.avatar_url, 89 | "scale", 90 | 64, 91 | 64 92 | ); 93 | } 94 | const topicRes = await this.props.client.getRoomState( 95 | roomId, 96 | "m.room.topic" 97 | ); 98 | this.setState({ 99 | userProfile, 100 | userBiography: topicRes?.topic || "", 101 | }); 102 | } catch (ex) { 103 | console.warn( 104 | `Failed to fetch user profile, might not be set yet`, 105 | ex 106 | ); 107 | } 108 | } 109 | 110 | onPostsClick() { 111 | this.setState({ 112 | withReplies: false, 113 | }); 114 | } 115 | 116 | onPostsAndRepliesClick() { 117 | this.setState({ 118 | withReplies: true, 119 | }); 120 | } 121 | 122 | onReplied(parentEvent, eventId) { 123 | const link = createPermalinkForTimelineEvent(parentEvent); 124 | if (!link) { 125 | return; 126 | } 127 | window.location.href = link; 128 | } 129 | 130 | render() { 131 | let timelineBlock; 132 | let errBlock; 133 | if (this.state.error) { 134 | errBlock = ( 135 |
136 | Whoops! Something went wrong: {this.state.error} 137 |
138 | ); 139 | } else { 140 | if (this.state.loading) { 141 | timelineBlock =
Loading posts...
; 142 | } else { 143 | let hasEntries = false; 144 | timelineBlock = ( 145 |
146 | {this.state.timeline 147 | .filter((ev) => { 148 | // only messages sent by this user 149 | if ( 150 | ev.type !== "m.room.message" || 151 | ev.sender !== this.props.userId 152 | ) { 153 | return false; 154 | } 155 | // only messages with cerulean fields 156 | if ( 157 | !ev.content["org.matrix.cerulean.event_id"] 158 | ) { 159 | return false; 160 | } 161 | // all posts and replies 162 | if (this.state.withReplies) { 163 | return true; 164 | } 165 | // only posts 166 | if (ev.content["org.matrix.cerulean.root"]) { 167 | return true; 168 | } 169 | return false; 170 | }) 171 | .map((ev) => { 172 | hasEntries = true; 173 | return ( 174 | 180 | ); 181 | })} 182 |
183 | ); 184 | if (!hasEntries) { 185 | // the default page is / which is TimelinePage which then directs them to 186 | // their UserPage if there are no events, so we want to suggest some content 187 | let emptyListText; 188 | if (this.state.isMe) { 189 | emptyListText = ( 190 | 191 | No posts yet. Check the{" "} 192 | 197 | welcome post 198 | 199 | . 200 | 201 | ); 202 | } else { 203 | emptyListText = ( 204 | This user hasn't posted anything yet. 205 | ); 206 | } 207 | 208 | timelineBlock = ( 209 |
{emptyListText}
210 | ); 211 | } 212 | } 213 | } 214 | 215 | let inputMessage; 216 | if (this.state.isMe && !this.props.client.isGuest) { 217 | inputMessage = ( 218 | 222 | ); 223 | } 224 | 225 | let userPageHeader; 226 | 227 | if (!this.props.client.isGuest) { 228 | userPageHeader = ( 229 |
230 |
231 | {this.state.userProfile?.avatar_url && ( 232 | User avatar 237 | )} 238 |
239 | {this.state.userProfile?.displayname && ( 240 |
241 | {this.state.userProfile?.displayname} 242 |
243 | )} 244 |
{this.props.userId}
245 | {this.state.userBiography && ( 246 |
247 | {this.state.userBiography} 248 |
249 | )} 250 |
251 |
252 | {inputMessage} 253 | {errBlock} 254 |
255 | ); 256 | } 257 | 258 | let postTab = " tab"; 259 | let postAndReplyTab = " tab"; 260 | if (this.state.withReplies) { 261 | postAndReplyTab += " tabSelected"; 262 | } else { 263 | postTab += " tabSelected"; 264 | } 265 | 266 | let userPageBody = ( 267 |
268 |
269 | 273 | Posts 274 | 275 | 279 | Posts and replies 280 | 281 |
282 |
{timelineBlock}
283 |
284 | ); 285 | 286 | return ( 287 |
288 | {userPageHeader} 289 | {userPageBody} 290 |
291 | ); 292 | } 293 | } 294 | 295 | export default UserPage; 296 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import Reputation from "./Reputation"; 5 | import { ClientContext, client } from "./ClientContext"; 6 | 7 | const reputation = new Reputation(); 8 | reputation.loadWeights(window.localStorage, client); 9 | 10 | ReactDOM.render( 11 | 12 | 18 | 19 | 20 | , 21 | document.getElementById("root") 22 | ); 23 | -------------------------------------------------------------------------------- /src/routing.js: -------------------------------------------------------------------------------- 1 | // createPermalinkForTimelineEvent links to the thread event given by this timeline event 2 | function createPermalinkForTimelineEvent(event) { 3 | // extract cerulean fields 4 | const sender = event.sender; 5 | const eventId = event.content["org.matrix.cerulean.event_id"]; 6 | const roomId = event.content["org.matrix.cerulean.room_id"]; 7 | if (!roomId || !eventId || !sender) { 8 | console.log( 9 | "event missing cerulean fields, cannot create hyperlink:", 10 | event 11 | ); 12 | return; 13 | } 14 | return `/${sender}/${roomId}/${eventId}`; 15 | } 16 | 17 | // createPermalinkForThreadEvent links to the thread event given. 18 | function createPermalinkForThreadEvent(event) { 19 | if (!event.sender || !event.room_id || !event.event_id) { 20 | console.log("event missing fields, cannot create hyperlink:", event); 21 | return; 22 | } 23 | return `/${event.sender}/${event.room_id}/${event.event_id}`; 24 | } 25 | 26 | export { createPermalinkForTimelineEvent, createPermalinkForThreadEvent }; 27 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------