├── helm
├── cr.yaml
├── ct.yaml
└── cerulean
│ ├── templates
│ ├── serviceaccount.yaml
│ ├── service.yaml
│ ├── tests
│ │ └── test-connection.yaml
│ ├── hpa.yaml
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ └── ingress.yaml
│ ├── .helmignore
│ ├── Chart.yaml
│ └── values.yaml
├── public
├── Inter.ttf
├── favicon.ico
├── logo192.png
├── logo512.png
├── robots.txt
├── thread-line.svg
├── thread-corner.svg
├── filter.svg
├── manifest.json
├── delete.svg
├── send.svg
├── close.svg
├── send-active.svg
├── chevron.svg
├── index.html
└── icon.svg
├── src
├── ClientContext.js
├── setupTests.js
├── InputPost.css
├── index.js
├── Modal.js
├── routing.js
├── TimelinePage.css
├── StatusPage.css
├── Client.test.js
├── UserPage.css
├── Message.css
├── ReputationPane.css
├── InputPost.js
├── ReputationList.js
├── Reputation.test.js
├── Reputation.js
├── App.css
├── TimelinePage.js
├── ReputationPane.js
├── UserPage.js
├── Message.js
├── App.js
├── StatusPage.js
└── Client.js
├── .gitignore
├── Dockerfile
├── etc
└── nginx.conf
├── package.json
├── .github
└── workflows
│ ├── helm.yaml
│ ├── k8s.yaml
│ └── docker.yaml
├── README.md
└── LICENSE
/helm/cr.yaml:
--------------------------------------------------------------------------------
1 | release-name-template: "helm-{{ .Name }}-{{ .Version }}"
--------------------------------------------------------------------------------
/public/Inter.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matrix-org/cerulean/HEAD/public/Inter.ttf
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matrix-org/cerulean/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matrix-org/cerulean/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matrix-org/cerulean/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/helm/ct.yaml:
--------------------------------------------------------------------------------
1 | remote: origin
2 | target-branch: main
3 | chart-dirs:
4 | - helm
5 | validate-maintainers: false
--------------------------------------------------------------------------------
/public/thread-line.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/thread-corner.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/filter.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/send.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/close.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/send-active.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/chevron.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/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 |
19 |
20 | {children}
21 |
22 |
23 | );
24 | };
25 |
26 | export default Modal;
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
25 | Cerulean
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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"
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
92 | );
93 | }
94 |
95 | render() {
96 | if (this.state.loading) {
97 | return Loading...
;
98 | }
99 | return (
100 |
121 | );
122 | }
123 | }
124 |
125 | export default InputPost;
126 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/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 |
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/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 |
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 |
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 |
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/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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
370 | Reply
371 |
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/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 |
267 | Logout
268 |
269 | );
270 | let loginButton;
271 | let myUser;
272 | if (this.props.client.isGuest) {
273 | logoutButton = (
274 |
278 | Register
279 |
280 | );
281 | loginButton = (
282 |
286 | Login
287 |
288 | );
289 | } else {
290 | myUser = (
291 |
295 | {this.props.client.userId}
296 |
297 | );
298 | }
299 |
300 | return (
301 |
302 | {myUser}
303 |
309 | {logoutButton}
310 | {loginButton}
311 |
312 | );
313 | }
314 | return (
315 |
316 |
320 | Register
321 |
322 |
326 | Login
327 |
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 |
401 |
CERULEAN
402 |
403 | {this.loginLogoutButton()}
404 |
405 |
{this.renderPage()}
406 | {filterPane}
407 |
411 | Sign in
412 |
456 |
457 |
461 | Register a new account
462 |
516 |
517 |
518 | );
519 | }
520 | }
521 |
522 | export default App;
523 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
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 | {
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/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 |
--------------------------------------------------------------------------------