Update packages to @status-im (#223)

* Downgrade to yarn v1

* Rename status-communities to status-core

* Rename chat-sdk to status-react

* Rename packages in examples

* Update readme

* Remove changelog

* Add extensions recommendations

* Update gitignore

* Rename package imports
This commit is contained in:
Pavel 2022-02-23 15:03:14 +01:00 committed by GitHub
commit f1b125cc4d
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
271 changed files with 31652 additions and 0 deletions

48
.eslintrc.json Normal file
View File

@ -0,0 +1,48 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"env": {
"es6": true,
"node": true,
"mocha": true,
"browser": true
},
"ignorePatterns": ["node_modules", "build", "coverage", "proto"],
"plugins": ["import", "eslint-comments", "functional"],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"prettier",
"prettier/@typescript-eslint"
],
"globals": { "BigInt": true, "console": true, "WebAssembly": true },
"rules": {
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{ "allowWholeFile": true }
],
"eslint-comments/no-unused-disable": "error",
"import/order": [
"error",
{ "newlines-between": "always", "alphabetize": { "order": "asc" } }
],
"no-constant-condition": ["error", { "checkLoops": false }],
"sort-imports": [
"error",
{ "ignoreDeclarationSort": true, "ignoreCase": true }
]
},
"overrides": [
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}

20
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node 16
uses: actions/setup-node@v1
with:
node-version: 16.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: DEBUG=communities:test* yarn test

74
.gitignore vendored Normal file
View File

@ -0,0 +1,74 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.DS_Store
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Build output
dist
*.tsbuildinfo
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Serverless directories
.serverless/

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

202
LICENSE-APACHE-v2 Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Status Research & Development GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 Status Research & Development GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
README.md Normal file
View File

@ -0,0 +1 @@
# Status Communities for the Web

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"private": true,
"workspaces": [
"packages/*"
],
"keywords": [],
"scripts": {
"fix": "run-s 'fix:*' && wsrun -e -c -s fix",
"fix:prettier": "prettier \"./*.json\" --write",
"build": "wsrun -e -c -s build",
"test": "wsrun -e -c -s test"
},
"devDependencies": {
"npm-run-all": "^4.1.5",
"prettier": "^2.3.2",
"wsrun": "^5.2.4"
},
"packageManager": "yarn@1.22.17"
}

View File

@ -0,0 +1,16 @@
{
"name": "@waku/preview-proxy",
"version": "0.1.0",
"main": "index.js",
"license": "MIT",
"type": "module",
"dependencies": {
"node-fetch": "^2.6.0"
},
"scripts": {
"start": "yarn node src/index.js",
"fix": "",
"build": "",
"test": ""
}
}

View File

@ -0,0 +1,59 @@
import fetch from 'node-fetch'
import https from 'https'
import fs from 'fs'
const regEx = new RegExp(/meta +(property|content)="(.+?)" +(property|content)="(.+?)"/g);
async function listener(req, res){
const origin = req?.headers?.origin
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
if (origin === 'https://0.0.0.0:8080' || origin === 'https://localhost:8080' || origin === 'https://127.0.0.1:8080') {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'POST');
res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type');
const requestBody = await new Promise((resolve) => {
if (req.method == 'POST') {
let body = '';
req.on('data', function (data) {
body += data;
if (body.length > 1e6)
req.connection.destroy();
});
req.on('end', function () {
try {
resolve(JSON.parse(body))
} catch {
resolve({})
}
});
} else {
resolve({})
}
})
const obj = {}
if ('site' in requestBody) {
try {
const response = await fetch(requestBody['site'])
const body = await response.text()
for (const match of body.matchAll(regEx)) {
if (match[1] === 'property') {
obj[match[2]] = match[4]
} else {
obj[match[4]] = match[2]
}
}
} catch {
}
}
res.end(JSON.stringify(obj));
}
const options = {
key: fs.readFileSync('../../../cert/CA/localhost/localhost.decrypted.key'),
cert: fs.readFileSync('../../../cert/CA/localhost/localhost.crt')
}
const server = https.createServer(options, listener);
server.listen(3000, () => console.log('server running at port 3000'));

View File

@ -0,0 +1,41 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"env": { "es6": true },
"ignorePatterns": ["node_modules", "dist", "coverage", "proto"],
"plugins": ["import", "eslint-comments", "functional"],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"prettier"
],
"globals": { "BigInt": true, "console": true, "WebAssembly": true },
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{ "allowWholeFile": true }
],
"eslint-comments/no-unused-disable": "error",
"import/order": [
"error",
{ "newlines-between": "always", "alphabetize": { "order": "asc" } }
],
"no-constant-condition": ["error", { "checkLoops": false }],
"sort-imports": [
"error",
{ "ignoreDeclarationSort": true, "ignoreCase": true }
]
},
"overrides": [
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}

View File

@ -0,0 +1,6 @@
{
"extension": ["ts"],
"spec": "src/**/*.spec.ts",
"require": "ts-node/register",
"exit": true
}

View File

@ -0,0 +1,2 @@
# package.json is formatted by package managers, so we ignore it here
package.json

View File

@ -0,0 +1,13 @@
#React chat example
##How to run example
1. First you need to `yarn && yarn build` in main repo folder
2. set two environment libraries
ENV and COMMUNITY_KEY
`export ENV=test` to use waku test fleet
`export ENV=prod` to use waku prod fleet
`export COMMUNITY_KEY=0x038ff8c6539ff268e024d07534a362ef69f7b13b056fcf19177fb6282b4d547bc8` to set a key to community
3. run `yarn start` in `packages/react-chat-example` folder.

View File

@ -0,0 +1,71 @@
{
"name": "@waku/react-chat-sdk-example",
"main": "index.js",
"version": "0.1.0",
"repository": "https://github.com/status-im/wakuconnect-chat-sdk/",
"license": "MIT OR Apache-2.0",
"packageManager": "yarn@3.0.1",
"scripts": {
"clean:all": "yarn clean && rimraf node_modules/",
"clean": "rimraf dist/",
"build": "rm -rf dist && webpack --mode=production --env ENV=production",
"start": "webpack serve --mode=development --env ENV=$ENV COMMUNITY_KEY=$COMMUNITY_KEY --https",
"fix": "run-s 'fix:*'",
"fix:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --write",
"fix:lint": "eslint './{src,test}/**/*.{ts,tsx}' --fix",
"test": "run-s 'test:*'",
"test:lint": "eslint './{src,test}/**/*.{ts,tsx}'",
"test:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --list-different"
},
"dependencies": {
"@status-im/react": "^0.0.0",
"assert": "^2.0.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"https-browserify": "^1.0.0",
"process": "^0.11.10",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^5.2.0",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"styled-components": "^5.3.1"
},
"devDependencies": {
"@testing-library/react-hooks": "^7.0.1",
"@types/chai": "^4.2.21",
"@types/mocha": "^9.0.0",
"@types/node": "^16.4.12",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"@types/react-router": "^5.1.16",
"@types/react-router-dom": "^5.1.8",
"@types/styled-components": "^5.1.12",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"chai": "^4.3.4",
"css-loader": "^6.3.0",
"esbuild-loader": "^2.15.1",
"eslint": "^7.32.0",
"eslint-plugin-hooks": "^0.2.0",
"eslint-plugin-react": "^7.24.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.3.1",
"html-webpack-plugin": "^5.3.2",
"jsdom": "^16.7.0",
"jsdom-global": "^3.0.2",
"mocha": "^9.0.3",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-loader": "^3.0.0",
"style-loader": "^3.3.0",
"ts-loader": "^9.2.5",
"ts-node": "^10.1.0",
"typescript": "^4.3.5",
"webpack": "^5.48.0",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "^3.11.2"
}
}

View File

@ -0,0 +1 @@
/* /index.html 200

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Waku Connect Chat</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,136 @@
import { CommunityChat, darkTheme, lightTheme } from "@waku/react-chat-sdk";
import React, { useRef, useState } from "react";
import ReactDOM from "react-dom";
import styled from "styled-components";
const fetchMetadata = async (link: string) => {
const response = await fetch("https://localhost:3000", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ site: link }),
});
const body = await response.text();
const parsedBody = JSON.parse(body);
if (
"og:image" in parsedBody &&
"og:site_name" in parsedBody &&
"og:title" in parsedBody
) {
return JSON.parse(body);
}
};
function DragDiv() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
const [width, setWidth] = useState(window.innerWidth - 50);
const [height, setHeight] = useState(window.innerHeight - 50);
const [showChat, setShowChat] = useState(true);
const ref = useRef<HTMLHeadingElement>(null);
const moved = useRef(false);
const setting = useRef("");
const [theme, setTheme] = useState(true);
const onMouseMove = (e: MouseEvent) => {
if (setting.current === "position") {
e.preventDefault();
setX(e.x - 20);
setY(e.y - 20);
}
if (setting.current === "size") {
setWidth(e.x - x);
setHeight(e.y - y);
e.preventDefault();
}
moved.current = true;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!moved.current) [setShowChat((prev) => !prev)];
moved.current = false;
};
return (
<>
<button
onClick={() => {
setTheme(!theme);
}}
>
Change theme
</button>
<Drag style={{ left: x, top: y, width: width, height: height }} ref={ref}>
<Bubble
onMouseDown={() => {
setting.current = "position";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}}
/>
<FloatingDiv className={showChat ? "" : "hide"}>
<CommunityChat
theme={theme ? lightTheme : darkTheme}
communityKey={process.env.COMMUNITY_KEY ?? ""}
config={{
environment: process.env.ENV ?? "",
dappUrl: "https://0.0.0.0:8080",
}}
fetchMetadata={fetchMetadata}
/>
</FloatingDiv>
{showChat && (
<SizeSet
onMouseDown={() => {
setting.current = "size";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}}
></SizeSet>
)}
</Drag>
</>
);
}
const FloatingDiv = styled.div`
height: calc(100% - 50px);
border: 1px solid black;
&.hide {
display: none;
}
`;
const SizeSet = styled.div`
margin-left: auto;
margin-right: 0px;
width: 10px;
height: 10px;
background-color: light-grey;
border: 1px solid;
`;
const Bubble = styled.div`
width: 50px;
height: 50px;
border-radius: 50%;
background-color: lightblue;
border: 1px solid;
`;
const Drag = styled.div`
position: absolute;
min-width: 375px;
`;
ReactDOM.render(
<div style={{ height: "100%" }}>
<DragDiv />
</div>,
document.getElementById("root")
);

View File

@ -0,0 +1,47 @@
{
"compilerOptions": {
"target": "es6",
"outDir": "dist",
"jsx": "react",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false /* to set at a later stage */,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
/* Debugging Options */
"traceResolution": false,
"listEmittedFiles": false,
"listFiles": false,
"pretty": true,
// Due to broken types in indirect dependencies
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"./src/types",
"../../node_modules/@types"
]
},
"include": ["src"],
"types": ["mocha"],
"compileOnSave": false
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const webpack = require('webpack');
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = env => {
const environment = env.ENV || 'development';
const communityKey = env.COMMUNITY_KEY || '';
return {
entry: './src/index.tsx',
output: {
filename: 'index.[fullhash].js',
path: path.join(__dirname, 'dist'),
publicPath: '/',
},
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
fallback: {
buffer: require.resolve('buffer/'),
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert/'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
zlib: require.resolve('browserify-zlib')
},
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'esbuild-loader',
exclude: /node_modules/,
options: {
loader: 'tsx',
target: 'es2020',
},
},
{
enforce: 'pre',
test: /\.js$/,
exclude: /node_modules/,
loader: 'source-map-loader',
},
{
test: /\.(png|svg|jpg|gif|woff|woff2|eot|ttf|otf|ico)$/,
use: ['file-loader'],
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
optimization: {
minimizer: [
new ESBuildMinifyPlugin({
target: 'es2020',
}),
],
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new HtmlWebpackPlugin({
template: 'src/index.html',
}),
new webpack.DefinePlugin({
'process.env.ENV': JSON.stringify(environment),
'process.env.COMMUNITY_KEY': JSON.stringify(communityKey),
}),
new webpack.ProvidePlugin({
process: 'process/browser.js',
Buffer: ['buffer', 'Buffer'],
}),
],
devServer: {
historyApiFallback: true,
host: '0.0.0.0',
stats: 'errors-only',
overlay: true,
hot: true,
},
stats: 'minimal',
};
};

View File

@ -0,0 +1,41 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"env": { "es6": true },
"ignorePatterns": ["node_modules", "dist", "coverage", "proto"],
"plugins": ["import", "eslint-comments", "functional"],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"prettier"
],
"globals": { "BigInt": true, "console": true, "WebAssembly": true },
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{ "allowWholeFile": true }
],
"eslint-comments/no-unused-disable": "error",
"import/order": [
"error",
{ "newlines-between": "always", "alphabetize": { "order": "asc" } }
],
"no-constant-condition": ["error", { "checkLoops": false }],
"sort-imports": [
"error",
{ "ignoreDeclarationSort": true, "ignoreCase": true }
]
},
"overrides": [
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}

View File

@ -0,0 +1,6 @@
{
"extension": ["ts"],
"spec": "src/**/*.spec.ts",
"require": "ts-node/register",
"exit": true
}

View File

@ -0,0 +1,2 @@
# package.json is formatted by package managers, so we ignore it here
package.json

View File

@ -0,0 +1 @@
#React chat example

View File

@ -0,0 +1,71 @@
{
"name": "@waku/react-group-chat-sdk-example",
"main": "index.js",
"version": "0.1.0",
"repository": "https://github.com/status-im/wakuconnect-chat-sdk/",
"license": "MIT OR Apache-2.0",
"packageManager": "yarn@3.0.1",
"scripts": {
"clean:all": "yarn clean && rimraf node_modules/",
"clean": "rimraf dist/",
"build": "rm -rf dist && webpack --mode=production --env ENV=production",
"start": "webpack serve --mode=development --env ENV=$ENV COMMUNITY_KEY=$COMMUNITY_KEY --https",
"fix": "run-s 'fix:*'",
"fix:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --write",
"fix:lint": "eslint './{src,test}/**/*.{ts,tsx}' --fix",
"test": "run-s 'test:*'",
"test:lint": "eslint './{src,test}/**/*.{ts,tsx}'",
"test:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --list-different"
},
"dependencies": {
"@status-im/react": "^0.0.0",
"assert": "^2.0.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"https-browserify": "^1.0.0",
"process": "^0.11.10",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^5.2.0",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"styled-components": "^5.3.1"
},
"devDependencies": {
"@testing-library/react-hooks": "^7.0.1",
"@types/chai": "^4.2.21",
"@types/mocha": "^9.0.0",
"@types/node": "^16.4.12",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"@types/react-router": "^5.1.16",
"@types/react-router-dom": "^5.1.8",
"@types/styled-components": "^5.1.12",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"chai": "^4.3.4",
"css-loader": "^6.3.0",
"esbuild-loader": "^2.15.1",
"eslint": "^7.32.0",
"eslint-plugin-hooks": "^0.2.0",
"eslint-plugin-react": "^7.24.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.3.1",
"html-webpack-plugin": "^5.3.2",
"jsdom": "^16.7.0",
"jsdom-global": "^3.0.2",
"mocha": "^9.0.3",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-loader": "^3.0.0",
"style-loader": "^3.3.0",
"ts-loader": "^9.2.5",
"ts-node": "^10.1.0",
"typescript": "^4.3.5",
"webpack": "^5.48.0",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "^3.11.2"
}
}

View File

@ -0,0 +1 @@
/* /index.html 200

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Waku Connect group chat</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,135 @@
import { darkTheme, GroupChat, lightTheme } from "@waku/react-chat-sdk";
import React, { useRef, useState } from "react";
import ReactDOM from "react-dom";
import styled from "styled-components";
const fetchMetadata = async (link: string) => {
const response = await fetch("https://localhost:3000", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ site: link }),
});
const body = await response.text();
const parsedBody = JSON.parse(body);
if (
"og:image" in parsedBody &&
"og:site_name" in parsedBody &&
"og:title" in parsedBody
) {
return JSON.parse(body);
}
};
function DragDiv() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
const [width, setWidth] = useState(window.innerWidth - 50);
const [height, setHeight] = useState(window.innerHeight - 50);
const [showChat, setShowChat] = useState(true);
const ref = useRef<HTMLHeadingElement>(null);
const moved = useRef(false);
const setting = useRef("");
const [theme, setTheme] = useState(true);
const onMouseMove = (e: MouseEvent) => {
if (setting.current === "position") {
e.preventDefault();
setX(e.x - 20);
setY(e.y - 20);
}
if (setting.current === "size") {
setWidth(e.x - x);
setHeight(e.y - y);
e.preventDefault();
}
moved.current = true;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!moved.current) [setShowChat((prev) => !prev)];
moved.current = false;
};
return (
<>
<button
onClick={() => {
setTheme(!theme);
}}
>
Change theme
</button>
<Drag style={{ left: x, top: y, width: width, height: height }} ref={ref}>
<Bubble
onMouseDown={() => {
setting.current = "position";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}}
/>
<FloatingDiv className={showChat ? "" : "hide"}>
<GroupChat
theme={theme ? lightTheme : darkTheme}
config={{
environment: process.env.ENV ?? "",
dappUrl: "https://0.0.0.0:8080",
}}
fetchMetadata={fetchMetadata}
/>
</FloatingDiv>
{showChat && (
<SizeSet
onMouseDown={() => {
setting.current = "size";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}}
></SizeSet>
)}
</Drag>
</>
);
}
const FloatingDiv = styled.div`
height: calc(100% - 50px);
border: 1px solid black;
&.hide {
display: none;
}
`;
const SizeSet = styled.div`
margin-left: auto;
margin-right: 0px;
width: 10px;
height: 10px;
background-color: light-grey;
border: 1px solid;
`;
const Bubble = styled.div`
width: 50px;
height: 50px;
border-radius: 50%;
background-color: lightblue;
border: 1px solid;
`;
const Drag = styled.div`
position: absolute;
min-width: 375px;
`;
ReactDOM.render(
<div style={{ height: "100%" }}>
<DragDiv />
</div>,
document.getElementById("root")
);

View File

@ -0,0 +1,47 @@
{
"compilerOptions": {
"target": "es6",
"outDir": "dist",
"jsx": "react",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false /* to set at a later stage */,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
/* Debugging Options */
"traceResolution": false,
"listEmittedFiles": false,
"listFiles": false,
"pretty": true,
// Due to broken types in indirect dependencies
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"./src/types",
"../../node_modules/@types"
]
},
"include": ["src"],
"types": ["mocha"],
"compileOnSave": false
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const webpack = require('webpack');
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = env => {
const environment = env.ENV || 'development';
const communityKey = env.COMMUNITY_KEY || '';
return {
entry: './src/index.tsx',
output: {
filename: 'index.[fullhash].js',
path: path.join(__dirname, 'dist'),
publicPath: '/',
},
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
fallback: {
buffer: require.resolve('buffer/'),
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert/'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
zlib: require.resolve('browserify-zlib')
},
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'esbuild-loader',
exclude: /node_modules/,
options: {
loader: 'tsx',
target: 'es2020',
},
},
{
enforce: 'pre',
test: /\.js$/,
exclude: /node_modules/,
loader: 'source-map-loader',
},
{
test: /\.(png|svg|jpg|gif|woff|woff2|eot|ttf|otf|ico)$/,
use: ['file-loader'],
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
optimization: {
minimizer: [
new ESBuildMinifyPlugin({
target: 'es2020',
}),
],
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new HtmlWebpackPlugin({
template: 'src/index.html',
}),
new webpack.DefinePlugin({
'process.env.ENV': JSON.stringify(environment),
'process.env.COMMUNITY_KEY': JSON.stringify(communityKey),
}),
new webpack.ProvidePlugin({
process: 'process/browser.js',
Buffer: ['buffer', 'Buffer'],
}),
],
devServer: {
historyApiFallback: true,
host: '0.0.0.0',
stats: 'errors-only',
overlay: true,
hot: true,
},
stats: 'minimal',
};
};

View File

@ -0,0 +1,42 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"env": { "es6": true },
"ignorePatterns": ["node_modules", "dist", "coverage", "proto"],
"plugins": ["import", "eslint-comments", "functional"],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"prettier"
],
"globals": { "BigInt": true, "console": true, "WebAssembly": true },
"rules": {
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{ "allowWholeFile": true }
],
"eslint-comments/no-unused-disable": "error",
"import/order": [
"error",
{ "newlines-between": "always", "alphabetize": { "order": "asc" } }
],
"no-constant-condition": ["error", { "checkLoops": false }],
"sort-imports": [
"error",
{ "ignoreDeclarationSort": true, "ignoreCase": true }
]
},
"overrides": [
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}

View File

@ -0,0 +1,6 @@
{
"extension": ["ts"],
"spec": "src/**/*.spec.ts",
"require": "ts-node/register",
"exit": true
}

View File

@ -0,0 +1,2 @@
# package.json is formatted by package managers, so we ignore it here
package.json

View File

@ -0,0 +1 @@
# `status-core`

View File

@ -0,0 +1,6 @@
version: v1beta1
plugins:
- name: ts_proto
out: ./src/proto
opt: grpc_js,esModuleInterop=true

View File

@ -0,0 +1,9 @@
version: v1beta1
build:
roots:
- ./proto
lint:
except:
- ENUM_ZERO_VALUE_SUFFIX
- ENUM_VALUE_PREFIX

View File

@ -0,0 +1,67 @@
{
"name": "@status-im/core",
"version": "0.0.0",
"license": "MIT OR Apache-2.0",
"repository": {
"url": "https://github.com/status-im/status-web.git",
"directory": "packages/status-core",
"type": "git"
},
"bugs": {
"url": "https://github.com/status-im/status-web/issues"
},
"main": "dist/cjs/src/index.js",
"module": "dist/esm/src/index.js",
"types": "dist/esm/src/index.d.ts",
"scripts": {
"build": "run-s 'build:*'",
"build:esm": "tsc --module es2020 --target es2017 --outDir dist/esm",
"build:cjs": "tsc --outDir dist/cjs",
"fix": "run-s 'fix:*'",
"fix:prettier": "prettier \"src/**/*.ts\" \"./*.json\" --write",
"fix:lint": "eslint src --ext .ts --fix",
"test": "run-s 'test:*'",
"test:lint": "eslint src --ext .ts",
"test:prettier": "prettier \"src/**/*.ts\" \"./*.json\" --list-different",
"test:unit": "mocha",
"proto": "run-s 'proto:*'",
"proto:lint": "buf lint",
"proto:build": "buf generate"
},
"devDependencies": {
"@types/bn.js": "^5.1.0",
"@types/chai": "^4.2.22",
"@types/elliptic": "^6.4.14",
"@types/mocha": "^9.0.0",
"@types/pbkdf2": "^3.1.0",
"@types/secp256k1": "^4.0.3",
"@types/uuid": "^8.3.3",
"@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1",
"chai": "^4.3.4",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.7.0",
"eslint-plugin-import": "^2.24.2",
"mocha": "^9.1.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.4.0",
"ts-node": "^10.2.1",
"ts-proto": "^1.83.0",
"typescript": "^4.4.3"
},
"dependencies": {
"bn.js": "^5.2.0",
"buffer": "^6.0.3",
"ecies-geth": "^1.5.3",
"elliptic": "^6.5.4",
"js-sha3": "^0.8.0",
"js-waku": "^0.16.0",
"pbkdf2": "^3.1.2",
"protobufjs": "^6.11.2",
"secp256k1": "^4.0.2",
"uuid": "^8.3.2"
}
}

View File

@ -0,0 +1,55 @@
syntax = "proto3";
package communities.v1;
import "communities/v1/enums.proto";
// ChatIdentity represents the user defined identity associated with their public chat key
message ChatIdentity {
// Lamport timestamp of the message
uint64 clock = 1;
// ens_name is the valid ENS name associated with the chat key
string ens_name = 2;
// images is a string indexed mapping of images associated with an identity
map<string, IdentityImage> images = 3;
// display name is the user set identity, valid only for organisations
string display_name = 4;
// description is the user set description, valid only for organisations
string description = 5;
string color = 6;
string emoji = 7;
}
// ProfileImage represents data associated with a user's profile image
message IdentityImage {
// payload is a context based payload for the profile image data,
// context is determined by the `source_type`
bytes payload = 1;
// source_type signals the image payload source
SourceType source_type = 2;
// image_type signals the image type and method of parsing the payload
ImageType image_type =3;
// SourceType are the predefined types of image source allowed
enum SourceType {
UNKNOWN_SOURCE_TYPE = 0;
// RAW_PAYLOAD image byte data
RAW_PAYLOAD = 1;
// ENS_AVATAR uses the ENS record's resolver get-text-data.avatar data
// The `payload` field will be ignored if ENS_AVATAR is selected
// The application will read and parse the ENS avatar data as image payload data, URLs will be ignored
// The parent `ChatMessageIdentity` must have a valid `ens_name` set
ENS_AVATAR = 2;
}
}

View File

@ -0,0 +1,105 @@
syntax = "proto3";
package communities.v1;
import "communities/v1/enums.proto";
message StickerMessage {
string hash = 1;
int32 pack = 2;
}
message ImageMessage {
bytes payload = 1;
ImageType type = 2;
}
message AudioMessage {
bytes payload = 1;
AudioType type = 2;
uint64 duration_ms = 3;
enum AudioType {
AUDIO_TYPE_UNKNOWN_UNSPECIFIED = 0;
AUDIO_TYPE_AAC = 1;
AUDIO_TYPE_AMR = 2;
}
}
message EditMessage {
uint64 clock = 1;
// Text of the message
string text = 2;
string chat_id = 3;
string message_id = 4;
// Grant for community edit messages
bytes grant = 5;
// The type of message (public/one-to-one/private-group-chat)
MessageType message_type = 6;
}
message DeleteMessage {
uint64 clock = 1;
string chat_id = 2;
string message_id = 3;
// Grant for community delete messages
bytes grant = 4;
// The type of message (public/one-to-one/private-group-chat)
MessageType message_type = 5;
}
message ChatMessage {
// Lamport timestamp of the chat message
uint64 clock = 1;
// Unix timestamps in milliseconds, currently not used as we use whisper as more reliable, but here
// so that we don't rely on it
uint64 timestamp = 2;
// Text of the message
string text = 3;
// Id of the message that we are replying to
string response_to = 4;
// Ens name of the sender
string ens_name = 5;
// Chat id, this field is symmetric for public-chats and private group chats,
// but asymmetric in case of one-to-ones, as the sender will use the chat-id
// of the received, while the receiver will use the chat-id of the sender.
// Probably should be the concatenation of sender-pk & receiver-pk in alphabetical order
string chat_id = 6;
// The type of message (public/one-to-one/private-group-chat)
MessageType message_type = 7;
// The type of the content of the message
ContentType content_type = 8;
oneof payload {
StickerMessage sticker = 9;
ImageMessage image = 10;
AudioMessage audio = 11;
bytes community = 12;
}
// Grant for community chat messages
optional bytes grant = 13;
enum ContentType {
CONTENT_TYPE_UNKNOWN_UNSPECIFIED = 0;
CONTENT_TYPE_TEXT_PLAIN = 1;
CONTENT_TYPE_STICKER = 2;
CONTENT_TYPE_STATUS = 3;
CONTENT_TYPE_EMOJI = 4;
CONTENT_TYPE_TRANSACTION_COMMAND = 5;
// Only local
CONTENT_TYPE_SYSTEM_MESSAGE_CONTENT_PRIVATE_GROUP = 6;
CONTENT_TYPE_IMAGE = 7;
CONTENT_TYPE_AUDIO = 8;
CONTENT_TYPE_COMMUNITY = 9;
// Only local
CONTENT_TYPE_SYSTEM_MESSAGE_GAP = 10;
}
}

View File

@ -0,0 +1,80 @@
syntax = "proto3";
package communities.v1;
import "communities/v1/chat_identity.proto";
message Grant {
bytes community_id = 1;
bytes member_id = 2;
string chat_id = 3;
uint64 clock = 4;
}
message CommunityMember {
enum Roles {
UNKNOWN_ROLE = 0;
ROLE_ALL = 1;
ROLE_MANAGE_USERS = 2;
}
repeated Roles roles = 1;
}
message CommunityPermissions {
enum Access {
UNKNOWN_ACCESS = 0;
NO_MEMBERSHIP = 1;
INVITATION_ONLY = 2;
ON_REQUEST = 3;
}
bool ens_only = 1;
// https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md is a candidate for the algorithm to be used in case we want to have private communityal chats, lighter than pairwise encryption using the DR, less secure, but more efficient for large number of participants
bool private = 2;
Access access = 3;
}
message CommunityDescription {
uint64 clock = 1;
map<string,CommunityMember> members = 2;
CommunityPermissions permissions = 3;
ChatIdentity identity = 5;
map<string,CommunityChat> chats = 6;
repeated string ban_list = 7;
map<string,CommunityCategory> categories = 8;
}
message CommunityChat {
map<string,CommunityMember> members = 1;
CommunityPermissions permissions = 2;
ChatIdentity identity = 3;
string category_id = 4;
int32 position = 5;
}
message CommunityCategory {
string category_id = 1;
string name = 2;
int32 position = 3;
}
message CommunityInvitation {
bytes community_description = 1;
bytes grant = 2;
string chat_id = 3;
bytes public_key = 4;
}
message CommunityRequestToJoin {
uint64 clock = 1;
string ens_name = 2;
string chat_id = 3;
bytes community_id = 4;
}
message CommunityRequestToJoinResponse {
uint64 clock = 1;
CommunityDescription community = 2;
bool accepted = 3;
bytes grant = 4;
}

View File

@ -0,0 +1,39 @@
syntax = "proto3";
package communities.v1;
import "communities/v1/enums.proto";
message EmojiReaction {
// clock Lamport timestamp of the chat message
uint64 clock = 1;
// chat_id the ID of the chat the message belongs to, for query efficiency the chat_id is stored in the db even though the
// target message also stores the chat_id
string chat_id = 2;
// message_id the ID of the target message that the user wishes to react to
string message_id = 3;
// message_type is (somewhat confusingly) the ID of the type of chat the message belongs to
MessageType message_type = 4;
// type the ID of the emoji the user wishes to react with
Type type = 5;
enum Type {
UNKNOWN_EMOJI_REACTION_TYPE = 0;
LOVE = 1;
THUMBS_UP = 2;
THUMBS_DOWN = 3;
LAUGH = 4;
SAD = 5;
ANGRY = 6;
}
// whether this is a rectraction of a previously sent emoji
bool retracted = 6;
// Grant for organisation chat messages
bytes grant = 7;
}

View File

@ -0,0 +1,25 @@
syntax = "proto3";
package communities.v1;
enum MessageType {
MESSAGE_TYPE_UNKNOWN_UNSPECIFIED = 0;
MESSAGE_TYPE_ONE_TO_ONE = 1;
MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP = 2;
MESSAGE_TYPE_PRIVATE_GROUP = 3;
// Only local
MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP = 4;
MESSAGE_TYPE_COMMUNITY_CHAT = 5;
// Only local
MESSAGE_TYPE_SYSTEM_MESSAGE_GAP = 6;
}
enum ImageType {
IMAGE_TYPE_UNKNOWN_UNSPECIFIED = 0;
// Raster image files is payload data that can be read as a raster image
IMAGE_TYPE_PNG = 1;
IMAGE_TYPE_JPEG = 2;
IMAGE_TYPE_WEBP = 3;
IMAGE_TYPE_GIF = 4;
}

View File

@ -0,0 +1,45 @@
syntax = "proto3";
package communities.v1;
import "communities/v1/chat_message.proto";
import "communities/v1/emoji_reaction.proto";
message MembershipUpdateEvent {
// Lamport timestamp of the event
uint64 clock = 1;
// List of public keys of objects of the action
repeated string members = 2;
// Name of the chat for the CHAT_CREATED/NAME_CHANGED event types
string name = 3;
// The type of the event
EventType type = 4;
enum EventType {
UNKNOWN = 0;
CHAT_CREATED = 1;
NAME_CHANGED = 2;
MEMBERS_ADDED = 3;
MEMBER_JOINED = 4;
MEMBER_REMOVED = 5;
ADMINS_ADDED = 6;
ADMIN_REMOVED = 7;
}
}
// MembershipUpdateMessage is a message used to propagate information
// about group membership changes.
// For more information, see https://github.com/status-im/specs/blob/master/status-group-chats-spec.md.
message MembershipUpdateMessage {
// The chat id of the private group chat
string chat_id = 1;
// A list of events for this group chat, first x bytes are the signature, then is a
// protobuf encoded MembershipUpdateEvent
repeated bytes events = 2;
// An optional chat message
oneof chat_entity {
ChatMessage message = 3;
EmojiReaction emoji_reaction = 4;
}
}

View File

@ -0,0 +1,32 @@
syntax = "proto3";
package communities.v1;
/* Specs:
:AUTOMATIC
To Send - "AUTOMATIC" status ping every 5 minutes
Display - Online for up to 5 minutes from the last clock, after that Offline
:ALWAYS_ONLINE
To Send - "ALWAYS_ONLINE" status ping every 5 minutes
Display - Online for up to 2 weeks from the last clock, after that Offline
:INACTIVE
To Send - A single "INACTIVE" status ping
Display - Offline forever
Note: Only send pings if the user interacted with the app in the last x minutes. */
message StatusUpdate {
uint64 clock = 1;
StatusType status_type = 2;
string custom_text = 3;
enum StatusType {
UNKNOWN_STATUS_TYPE = 0;
AUTOMATIC = 1;
DO_NOT_DISTURB = 2;
ALWAYS_ONLINE = 3;
INACTIVE = 4;
};
}

View File

@ -0,0 +1,50 @@
syntax = "proto3";
package status.v1;
message ApplicationMetadataMessage {
// Signature of the payload field
bytes signature = 1;
// This is the encoded protobuf of the application level message, i.e ChatMessage
bytes payload = 2;
// The type of protobuf message sent
Type type = 3;
enum Type {
TYPE_UNKNOWN_UNSPECIFIED = 0;
TYPE_CHAT_MESSAGE = 1;
TYPE_CONTACT_UPDATE = 2;
TYPE_MEMBERSHIP_UPDATE_MESSAGE = 3;
TYPE_PAIR_INSTALLATION = 4;
TYPE_SYNC_INSTALLATION = 5;
TYPE_REQUEST_ADDRESS_FOR_TRANSACTION = 6;
TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION = 7;
TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION = 8;
TYPE_REQUEST_TRANSACTION = 9;
TYPE_SEND_TRANSACTION = 10;
TYPE_DECLINE_REQUEST_TRANSACTION = 11;
TYPE_SYNC_INSTALLATION_CONTACT = 12;
TYPE_SYNC_INSTALLATION_ACCOUNT = 13;
TYPE_SYNC_INSTALLATION_PUBLIC_CHAT = 14;
TYPE_CONTACT_CODE_ADVERTISEMENT = 15;
TYPE_PUSH_NOTIFICATION_REGISTRATION = 16;
TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE = 17;
TYPE_PUSH_NOTIFICATION_QUERY = 18;
TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE = 19;
TYPE_PUSH_NOTIFICATION_REQUEST = 20;
TYPE_PUSH_NOTIFICATION_RESPONSE = 21;
TYPE_EMOJI_REACTION = 22;
TYPE_GROUP_CHAT_INVITATION = 23;
TYPE_CHAT_IDENTITY = 24;
TYPE_COMMUNITY_DESCRIPTION = 25;
TYPE_COMMUNITY_INVITATION = 26;
TYPE_COMMUNITY_REQUEST_TO_JOIN = 27;
TYPE_PIN_MESSAGE = 28;
TYPE_EDIT_MESSAGE = 29;
TYPE_STATUS_UPDATE = 30;
TYPE_DELETE_MESSAGE = 31;
TYPE_SYNC_INSTALLATION_COMMUNITY = 32;
TYPE_ANONYMOUS_METRIC_BATCH = 33;
}
}

View File

@ -0,0 +1,85 @@
import { idToContentTopic } from "./contentTopic";
import { createSymKeyFromPassword } from "./encryption";
import { ChatMessage, Content } from "./wire/chat_message";
import { CommunityChat } from "./wire/community_chat";
/**
* Represent a chat room. Only public chats are currently supported.
*/
export class Chat {
private lastClockValue?: number;
private lastMessage?: ChatMessage;
private constructor(
public id: string,
public symKey: Uint8Array,
public communityChat?: CommunityChat
) {}
/**
* Create a public chat room.
* [[Community.instantiateChat]] MUST be used for chats belonging to a community.
*/
public static async create(
id: string,
communityChat?: CommunityChat
): Promise<Chat> {
const symKey = await createSymKeyFromPassword(id);
return new Chat(id, symKey, communityChat);
}
public get contentTopic(): string {
return idToContentTopic(this.id);
}
public createMessage(content: Content, responseTo?: string): ChatMessage {
const { timestamp, clock } = this._nextClockAndTimestamp();
const message = ChatMessage.createMessage(
clock,
timestamp,
this.id,
content,
responseTo
);
this._updateClockFromMessage(message);
return message;
}
public handleNewMessage(message: ChatMessage): void {
this._updateClockFromMessage(message);
}
private _nextClockAndTimestamp(): { clock: number; timestamp: number } {
let clock = this.lastClockValue;
const timestamp = Date.now();
if (!clock || clock < timestamp) {
clock = timestamp;
} else {
clock += 1;
}
return { clock, timestamp };
}
private _updateClockFromMessage(message: ChatMessage): void {
if (
!this.lastMessage ||
!this.lastMessage.clock ||
(message.clock && this.lastMessage.clock <= message.clock)
) {
this.lastMessage = message;
}
if (
!this.lastClockValue ||
(message.clock && this.lastClockValue < message.clock)
) {
this.lastClockValue = message.clock;
}
}
}

View File

@ -0,0 +1,42 @@
import { expect } from "chai";
import { Waku } from "js-waku";
import { Community } from "./community";
import { CommunityDescription } from "./wire/community_description";
describe("Community [live data]", () => {
before(function () {
if (process.env.CI) {
// Skip live data test in CI
this.skip();
}
});
it("Retrieves community description For DappConnect Test from Waku prod fleet", async function () {
this.timeout(20000);
const waku = await Waku.create({ bootstrap: { default: true } });
await waku.waitForRemotePeer();
const community = await Community.instantiateCommunity(
"0x02cf13719c8b836bebd4e430c497ee38e798a43e4d8c4760c34bbd9bf4f2434d26",
waku
);
const desc = community.description as CommunityDescription;
expect(desc).to.not.be.undefined;
expect(desc.identity?.displayName).to.eq("Test Community");
const descChats = Array.from(desc.chats.values()).map(
(chat) => chat?.identity?.displayName
);
expect(descChats).to.include("Test Chat");
expect(descChats).to.include("Additional Chat");
const chats = Array.from(community.chats.values()).map(
(chat) => chat?.communityChat?.identity?.displayName
);
expect(chats).to.include("Test Chat");
expect(chats).to.include("Additional Chat");
});
});

View File

@ -0,0 +1,92 @@
import debug from "debug";
import { Waku } from "js-waku";
import { Chat } from "./chat";
import { bufToHex, hexToBuf } from "./utils";
import { CommunityChat } from "./wire/community_chat";
import { CommunityDescription } from "./wire/community_description";
const dbg = debug("communities:community");
export class Community {
public publicKey: Uint8Array;
private waku: Waku;
public chats: Map<string, Chat>; // Chat id, Chat
public description?: CommunityDescription;
constructor(publicKey: Uint8Array, waku: Waku) {
this.publicKey = publicKey;
this.waku = waku;
this.chats = new Map();
}
/**
* Instantiate a Community by retrieving its details from the Waku network.
*
* This class is used to interact with existing communities only,
* the Status Desktop or Mobile app must be used to manage a community.
*
* @param publicKey The community's public key in hex format.
* Can be found in the community's invite link: https://join.status.im/c/<public key>
* @param waku The Waku instance, used to retrieve Community information from the network.
*/
public static async instantiateCommunity(
publicKey: string,
waku: Waku
): Promise<Community> {
const community = new Community(hexToBuf(publicKey), waku);
await community.refreshCommunityDescription();
return community;
}
public get publicKeyStr(): string {
return bufToHex(this.publicKey);
}
/**
* Retrieve and update community information from the network.
* Uses most recent community description message available.
*/
async refreshCommunityDescription(): Promise<void> {
const desc = await CommunityDescription.retrieve(
this.publicKey,
this.waku.store
);
if (!desc) {
dbg(`Failed to retrieve Community Description for ${this.publicKeyStr}`);
return;
}
this.description = desc;
await Promise.all(
Array.from(this.description.chats).map(([chatUuid, communityChat]) => {
return this.instantiateChat(chatUuid, communityChat);
})
);
}
/**
* Instantiate [[Chat]] object based on the passed chat name.
* The Chat MUST already be part of the Community and the name MUST be exact (including casing).
*
* @throws string If the Community Description is unavailable or the chat is not found;
*/
private async instantiateChat(
chatUuid: string,
communityChat: CommunityChat
): Promise<void> {
if (!this.description)
throw "Failed to retrieve community description, cannot instantiate chat";
const chatId = this.publicKeyStr + chatUuid;
if (this.chats.get(chatId)) return;
const chat = await Chat.create(chatId, communityChat);
this.chats.set(chatId, chat);
}
}

View File

@ -0,0 +1,169 @@
import { PageDirection, Waku, WakuMessage } from "js-waku";
import { idToContactCodeTopic } from "./contentTopic";
import { Identity } from "./identity";
import { StatusUpdate_StatusType } from "./proto/communities/v1/status_update";
import { bufToHex, getLatestUserNickname } from "./utils";
import { ChatIdentity } from "./wire/chat_identity";
import { StatusUpdate } from "./wire/status_update";
const STATUS_BROADCAST_INTERVAL = 30000;
const NICKNAME_BROADCAST_INTERVAL = 300000;
export class Contacts {
waku: Waku;
identity: Identity | undefined;
nickname?: string;
private callback: (publicKey: string, clock: number) => void;
private callbackNickname: (publicKey: string, nickname: string) => void;
private contacts: string[] = [];
/**
* Contacts holds a list of user contacts and listens to their status broadcast
*
* When watched user broadcast callback is called.
*
* Class also broadcasts own status on contact-code topic
*
* @param identity identity of user that is used to broadcast status message
*
* @param waku waku class used to listen to broadcast and broadcast status
*
* @param callback callback function called when user status broadcast is received
*/
public constructor(
identity: Identity | undefined,
waku: Waku,
callback: (publicKey: string, clock: number) => void,
callbackNickname: (publicKey: string, nickname: string) => void,
nickname?: string
) {
this.waku = waku;
this.identity = identity;
this.nickname = nickname;
this.callback = callback;
this.callbackNickname = callbackNickname;
this.startBroadcast();
if (identity) {
this.addContact(bufToHex(identity.publicKey));
}
}
/**
* Add contact to watch list of status broadcast
*
* When user broadcasts its status callback is called
*
* @param publicKey public key of user
*/
public addContact(publicKey: string): void {
if (!this.contacts.find((e) => publicKey === e)) {
const now = new Date();
const callback = (wakuMessage: WakuMessage): void => {
if (wakuMessage.payload) {
const msg = StatusUpdate.decode(wakuMessage.payload);
this.callback(publicKey, msg.clock ?? 0);
}
};
this.contacts.push(publicKey);
this.callback(publicKey, 0);
this.waku.store.queryHistory([idToContactCodeTopic(publicKey)], {
callback: (msgs) => msgs.forEach((e) => callback(e)),
timeFilter: {
startTime: new Date(now.getTime() - STATUS_BROADCAST_INTERVAL * 2),
endTime: now,
},
});
this.waku.store.queryHistory([idToContactCodeTopic(publicKey)], {
callback: (msgs) =>
msgs.some((e) => {
try {
if (e.payload) {
const chatIdentity = ChatIdentity.decode(e?.payload);
if (chatIdentity) {
this.callbackNickname(
publicKey,
chatIdentity?.displayName ?? ""
);
}
return true;
}
} catch {
return false;
}
}),
pageDirection: PageDirection.BACKWARD,
});
this.waku.relay.addObserver(callback, [idToContactCodeTopic(publicKey)]);
}
}
private startBroadcast(): void {
const send = async (): Promise<void> => {
if (this.identity) {
const statusUpdate = StatusUpdate.create(
StatusUpdate_StatusType.AUTOMATIC,
""
);
const msg = await WakuMessage.fromBytes(
statusUpdate.encode(),
idToContactCodeTopic(bufToHex(this.identity.publicKey))
);
this.waku.relay.send(msg);
}
};
const handleNickname = async (): Promise<void> => {
if (this.identity) {
const now = new Date().getTime();
const { clock, nickname: newNickname } = await getLatestUserNickname(
this.identity.publicKey,
this.waku
);
if (this.nickname) {
if (this.nickname !== newNickname) {
await sendNickname();
} else {
if (clock < now - NICKNAME_BROADCAST_INTERVAL) {
await sendNickname();
}
}
} else {
this.nickname = newNickname;
this.callbackNickname(bufToHex(this.identity.publicKey), newNickname);
if (clock < now - NICKNAME_BROADCAST_INTERVAL) {
await sendNickname();
}
}
}
setInterval(send, NICKNAME_BROADCAST_INTERVAL);
};
const sendNickname = async (): Promise<void> => {
if (this.identity) {
const publicKey = bufToHex(this.identity.publicKey);
if (this.nickname) {
const chatIdentity = new ChatIdentity({
clock: new Date().getTime(),
color: "",
description: "",
emoji: "",
images: {},
ensName: "",
displayName: this?.nickname ?? "",
});
const msg = await WakuMessage.fromBytes(
chatIdentity.encode(),
idToContactCodeTopic(publicKey),
{ sigPrivKey: this.identity.privateKey }
);
await this.waku.relay.send(msg);
}
}
};
handleNickname();
send();
setInterval(send, STATUS_BROADCAST_INTERVAL);
}
}

View File

@ -0,0 +1,22 @@
import { Buffer } from "buffer";
import { keccak256 } from "js-sha3";
const TopicLength = 4;
/**
* Get the content topic of for a given Chat or Community
* @param id The Chat id or Community id (hex string prefixed with 0x).
* @returns string The Waku v2 Content Topic.
*/
export function idToContentTopic(id: string): string {
const hash = keccak256.arrayBuffer(id);
const topic = Buffer.from(hash).slice(0, TopicLength);
return "/waku/1/" + "0x" + topic.toString("hex") + "/rfc26";
}
export function idToContactCodeTopic(id: string): string {
return idToContentTopic(id + "-contact-code");
}

View File

@ -0,0 +1,24 @@
import { expect } from "chai";
import { createSymKeyFromPassword } from "./encryption";
describe("Encryption", () => {
it("Generate symmetric key from password", async function () {
const str = "arbitrary data here";
const symKey = await createSymKeyFromPassword(str);
expect(Buffer.from(symKey).toString("hex")).to.eq(
"c49ad65ebf2a7b7253bf400e3d27719362a91b2c9b9f54d50a69117021666c33"
);
});
it("Generate symmetric key from password for chat", async function () {
const str =
"0x02dcec6041fb999d65f1d33363e08c93d3c1f6f0fbbb26add383e2cf46c2b921f41dc14fd8-9a8b-4df5-a358-2c3067be5439";
const symKey = await createSymKeyFromPassword(str);
expect(Buffer.from(symKey).toString("hex")).to.eq(
"76ff5bf0a74a8e724367c7fc003f066d477641f468768a8da2817addf5c2ce76"
);
});
});

View File

@ -0,0 +1,15 @@
import pbkdf2 from "pbkdf2";
const AESKeyLength = 32; // bytes
export async function createSymKeyFromPassword(
password: string
): Promise<Uint8Array> {
return pbkdf2.pbkdf2Sync(
Buffer.from(password, "utf-8"),
"",
65356,
AESKeyLength,
"sha256"
);
}

View File

@ -0,0 +1,459 @@
import { Waku, WakuMessage } from "js-waku";
import { DecryptionMethod } from "js-waku/build/main/lib/waku_message";
import { createSymKeyFromPassword } from "./encryption";
import { Identity } from "./identity";
import { MembershipUpdateEvent_EventType } from "./proto/communities/v1/membership_update_message";
import { getNegotiatedTopic, getPartitionedTopic } from "./topics";
import { bufToHex, compressPublicKey } from "./utils";
import {
MembershipSignedEvent,
MembershipUpdateMessage,
} from "./wire/membership_update_message";
import { ChatMessage, Content } from ".";
type GroupMember = {
id: string;
topic: string;
symKey: Uint8Array;
partitionedTopic: string;
};
export type GroupChat = {
chatId: string;
members: GroupMember[];
admins?: string[];
name?: string;
removed: boolean;
};
export type GroupChatsType = {
[id: string]: GroupChat;
};
/* TODO: add chat messages encryption */
class GroupChatUsers {
private users: { [id: string]: GroupMember } = {};
private identity: Identity;
public constructor(_identity: Identity) {
this.identity = _identity;
}
public async getUser(id: string): Promise<GroupMember> {
if (this.users[id]) {
return this.users[id];
}
const topic = await getNegotiatedTopic(this.identity, id);
const symKey = await createSymKeyFromPassword(topic);
const partitionedTopic = getPartitionedTopic(id);
const groupUser: GroupMember = { topic, symKey, id, partitionedTopic };
this.users[id] = groupUser;
return groupUser;
}
}
export class GroupChats {
waku: Waku;
identity: Identity;
private callback: (chats: GroupChat) => void;
private removeCallback: (chats: GroupChat) => void;
private addMessage: (message: ChatMessage, sender: string) => void;
private groupChatUsers;
public chats: GroupChatsType = {};
/**
* GroupChats holds a list of private chats and listens to their status broadcast
*
* @param identity identity of user
*
* @param waku waku class used to listen to broadcast and broadcast status
*
* @param callback callback function called when new private group chat is ceated
*
* @param removeCallback callback function when private group chat is to be removed
*
* @param addMessage callback function when
*/
public constructor(
identity: Identity,
waku: Waku,
callback: (chat: GroupChat) => void,
removeCallback: (chat: GroupChat) => void,
addMessage: (message: ChatMessage, sender: string) => void
) {
this.waku = waku;
this.identity = identity;
this.groupChatUsers = new GroupChatUsers(identity);
this.callback = callback;
this.removeCallback = removeCallback;
this.addMessage = addMessage;
this.listen();
}
/**
* Send chat message on given private chat
*
* @param chatId chat id of private group chat
*
* @param text text message to send
*/
public async sendMessage(
chatId: string,
content: Content,
responseTo?: string
): Promise<void> {
const now = Date.now();
const chat = this.chats[chatId];
if (chat) {
await Promise.all(
chat.members.map(async (member) => {
const chatMessage = ChatMessage.createMessage(
now,
now,
chatId,
content,
responseTo
);
const wakuMessage = await WakuMessage.fromBytes(
chatMessage.encode(),
member.topic,
{ sigPrivKey: this.identity.privateKey, symKey: member.symKey }
);
this.waku.relay.send(wakuMessage);
})
);
}
}
private async handleUpdateEvent(
chatId: string,
event: MembershipSignedEvent,
useCallback: boolean
): Promise<void> {
const signer = event.signer ? bufToHex(event.signer) : "";
const thisUser = bufToHex(this.identity.publicKey);
const chat: GroupChat | undefined = this.chats[chatId];
if (signer) {
switch (event.event.type) {
case MembershipUpdateEvent_EventType.CHAT_CREATED: {
const members: GroupMember[] = [];
await Promise.all(
event.event.members.map(async (member) => {
members.push(await this.groupChatUsers.getUser(member));
})
);
await this.addChat(
{
chatId: chatId,
members,
admins: [signer],
removed: false,
},
useCallback
);
break;
}
case MembershipUpdateEvent_EventType.MEMBER_REMOVED: {
if (chat) {
chat.members = chat.members.filter(
(member) => !event.event.members.includes(member.id)
);
if (event.event.members.includes(thisUser)) {
await this.removeChat(
{
...chat,
removed: true,
},
useCallback
);
} else {
if (!chat.removed && useCallback) {
this.callback(this.chats[chatId]);
}
}
}
break;
}
case MembershipUpdateEvent_EventType.MEMBERS_ADDED: {
if (chat && chat.admins?.includes(signer)) {
const members: GroupMember[] = [];
await Promise.all(
event.event.members.map(async (member) => {
members.push(await this.groupChatUsers.getUser(member));
})
);
chat.members.push(...members);
if (
chat.members.findIndex((member) => member.id === thisUser) > -1
) {
chat.removed = false;
await this.addChat(chat, useCallback);
}
}
break;
}
case MembershipUpdateEvent_EventType.NAME_CHANGED: {
if (chat) {
if (chat.admins?.includes(signer)) {
chat.name = event.event.name;
this.callback(chat);
}
}
break;
}
}
}
}
private async decodeUpdateMessage(
message: WakuMessage,
useCallback: boolean
): Promise<void> {
try {
if (message?.payload) {
const membershipUpdate = MembershipUpdateMessage.decode(
message.payload
);
await Promise.all(
membershipUpdate.events.map(
async (event) =>
await this.handleUpdateEvent(
membershipUpdate.chatId,
event,
useCallback
)
)
);
}
} catch {
return;
}
}
private handleWakuChatMessage(
message: WakuMessage,
chat: GroupChat,
member: string
): void {
try {
if (message.payload) {
const chatMessage = ChatMessage.decode(message.payload);
if (chatMessage) {
if (chatMessage.chatId === chat.chatId) {
let sender = member;
if (message.signaturePublicKey) {
sender = compressPublicKey(message.signaturePublicKey);
}
this.addMessage(chatMessage, sender);
}
}
}
} catch {
return;
}
}
private async handleChatObserver(
chat: GroupChat,
removeObserver?: boolean
): Promise<void> {
const observerFunction = removeObserver ? "deleteObserver" : "addObserver";
await Promise.all(
chat.members.map(async (member) => {
if (!removeObserver) {
this.waku.relay.addDecryptionKey(member.symKey, {
method: DecryptionMethod.Symmetric,
contentTopics: [member.topic],
});
}
this.waku.relay[observerFunction](
(message) => this.handleWakuChatMessage(message, chat, member.id),
[member.topic]
);
})
);
}
private async addChat(chat: GroupChat, useCallback: boolean): Promise<void> {
if (this.chats[chat.chatId]) {
this.chats[chat.chatId] = chat;
if (useCallback) {
this.callback(chat);
}
} else {
this.chats[chat.chatId] = chat;
if (useCallback) {
await this.handleChatObserver(chat);
this.callback(chat);
}
}
}
private async removeChat(
chat: GroupChat,
useCallback: boolean
): Promise<void> {
this.chats[chat.chatId] = chat;
if (useCallback) {
await this.handleChatObserver(chat, true);
this.removeCallback(chat);
}
}
private async listen(): Promise<void> {
const topic = getPartitionedTopic(bufToHex(this.identity.publicKey));
const messages = await this.waku.store.queryHistory([topic]);
messages.sort((a, b) =>
(a?.timestamp?.getTime() ?? 0) < (b?.timestamp?.getTime() ?? 0) ? -1 : 1
);
for (let i = 0; i < messages.length; i++) {
await this.decodeUpdateMessage(messages[i], false);
}
this.waku.relay.addObserver(
(message) => this.decodeUpdateMessage(message, true),
[topic]
);
await Promise.all(
Object.values(this.chats).map(async (chat) => {
if (!chat?.removed) {
await this.handleChatObserver(chat);
this.callback(chat);
}
})
);
}
private async sendUpdateMessage(
payload: Uint8Array,
members: GroupMember[]
): Promise<void> {
const wakuMessages = await Promise.all(
members.map(
async (member) =>
await WakuMessage.fromBytes(payload, member.partitionedTopic)
)
);
wakuMessages.forEach((msg) => this.waku.relay.send(msg));
}
/**
* Sends a change chat name chat membership update message
*
* @param chatId a chat id to which message is to be sent
*
* @param name a name which chat should be changed to
*/
public async changeChatName(chatId: string, name: string): Promise<void> {
const payload = MembershipUpdateMessage.create(chatId, this.identity);
const chat = this.chats[chatId];
if (chat && payload) {
payload.addNameChangeEvent(name);
await this.sendUpdateMessage(payload.encode(), chat.members);
}
}
/**
* Sends a add members group chat membership update message with given members
*
* @param chatId a chat id to which message is to be sent
*
* @param members a list of members to be added
*/
public async addMembers(chatId: string, members: string[]): Promise<void> {
const payload = MembershipUpdateMessage.create(chatId, this.identity);
const chat = this.chats[chatId];
if (chat && payload) {
const newMembers: GroupMember[] = [];
await Promise.all(
members
.filter(
(member) =>
!chat.members.map((chatMember) => chatMember.id).includes(member)
)
.map(async (member) => {
newMembers.push(await this.groupChatUsers.getUser(member));
})
);
payload.addMembersAddedEvent(newMembers.map((member) => member.id));
await this.sendUpdateMessage(payload.encode(), [
...chat.members,
...newMembers,
]);
}
}
/**
* Sends a create group chat membership update message with given members
*
* @param members a list of public keys of members to be included in private group chat
*/
public async createGroupChat(members: string[]): Promise<void> {
const payload = MembershipUpdateMessage.createChat(
this.identity,
members
).encode();
const newMembers: GroupMember[] = [];
await Promise.all(
members.map(async (member) => {
newMembers.push(await this.groupChatUsers.getUser(member));
})
);
await this.sendUpdateMessage(payload, newMembers);
}
/**
* Sends a remove member to private group chat
*
* @param chatId id of private group chat
*/
public async quitChat(chatId: string): Promise<void> {
const payload = MembershipUpdateMessage.create(chatId, this.identity);
const chat = this.chats[chatId];
payload.addMemberRemovedEvent(bufToHex(this.identity.publicKey));
await this.sendUpdateMessage(payload.encode(), chat.members);
}
/**
* Retrieve previous messages from a Waku Store node for the given chat Id.
*
*/
public async retrievePreviousMessages(
chatId: string,
startTime: Date,
endTime: Date
): Promise<number> {
const chat = this.chats[chatId];
if (!chat)
throw `Failed to retrieve messages, chat is not joined: ${chatId}`;
const _callback = (wakuMessages: WakuMessage[], member: string): void => {
wakuMessages.forEach((wakuMessage: WakuMessage) =>
this.handleWakuChatMessage(wakuMessage, chat, member)
);
};
const amountOfMessages: number[] = [];
await Promise.all(
chat.members.map(async (member) => {
const msgLength = (
await this.waku.store.queryHistory([member.topic], {
timeFilter: { startTime, endTime },
callback: (msg) => _callback(msg, member.id),
decryptionKeys: [member.symKey],
})
).length;
amountOfMessages.push(msgLength);
})
);
return amountOfMessages.reduce((a, b) => a + b);
}
}

View File

@ -0,0 +1,39 @@
import { Buffer } from "buffer";
import { keccak256 } from "js-sha3";
import { generatePrivateKey } from "js-waku";
import * as secp256k1 from "secp256k1";
import { hexToBuf } from "./utils";
export class Identity {
private pubKey: Uint8Array;
public constructor(public privateKey: Uint8Array) {
this.pubKey = secp256k1.publicKeyCreate(this.privateKey, true);
}
public static generate(): Identity {
const privateKey = generatePrivateKey();
return new Identity(privateKey);
}
/**
* Hashes the payload with SHA3-256 and signs the result using the internal private key.
*/
public sign(payload: Uint8Array): Uint8Array {
const hash = keccak256(payload);
const { signature, recid } = secp256k1.ecdsaSign(
hexToBuf(hash),
this.privateKey
);
return Buffer.concat([signature, Buffer.from([recid])]);
}
/**
* Returns the compressed public key.
*/
public get publicKey(): Uint8Array {
return this.pubKey;
}
}

View File

@ -0,0 +1,18 @@
export { Identity } from "./identity";
export { Messenger } from "./messenger";
export { Community } from "./community";
export { Contacts } from "./contacts";
export { Chat } from "./chat";
export * from "./groupChats";
export * as utils from "./utils";
export { ApplicationMetadataMessage } from "./wire/application_metadata_message";
export {
ChatMessage,
ContentType,
Content,
StickerContent,
ImageContent,
AudioContent,
TextContent,
} from "./wire/chat_message";
export { getNodesFromHostedJson } from "js-waku";

View File

@ -0,0 +1,178 @@
import { expect } from "chai";
import debug from "debug";
import { Protocols } from "js-waku/build/main/lib/waku";
import { Community } from "./community";
import { Identity } from "./identity";
import { Messenger } from "./messenger";
import { bufToHex } from "./utils";
import { ApplicationMetadataMessage } from "./wire/application_metadata_message";
import { ContentType } from "./wire/chat_message";
const testChatId = "test-chat-id";
const dbg = debug("communities:test:messenger");
describe("Messenger", () => {
let messengerAlice: Messenger;
let messengerBob: Messenger;
let identityAlice: Identity;
let identityBob: Identity;
beforeEach(async function () {
this.timeout(20_000);
dbg("Generate keys");
identityAlice = Identity.generate();
identityBob = Identity.generate();
dbg("Create messengers");
[messengerAlice, messengerBob] = await Promise.all([
Messenger.create(identityAlice, { bootstrap: {} }),
Messenger.create(identityBob, {
bootstrap: {},
libp2p: { addresses: { listen: ["/ip4/0.0.0.0/tcp/0/ws"] } },
}),
]);
dbg("Connect messengers");
// Connect both messengers together for test purposes
messengerAlice.waku.addPeerToAddressBook(
messengerBob.waku.libp2p.peerId,
messengerBob.waku.libp2p.multiaddrs
);
dbg("Wait for remote peer");
await Promise.all([
messengerAlice.waku.waitForRemotePeer([Protocols.Relay]),
messengerBob.waku.waitForRemotePeer([Protocols.Relay]),
]);
dbg("Messengers ready");
});
it("Sends & Receive public chat messages", async function () {
this.timeout(10_000);
await messengerAlice.joinChatById(testChatId);
await messengerBob.joinChatById(testChatId);
const text = "This is a message.";
const receivedMessagePromise: Promise<ApplicationMetadataMessage> =
new Promise((resolve) => {
messengerBob.addObserver((message) => {
resolve(message);
}, testChatId);
});
await messengerAlice.sendMessage(testChatId, {
text,
contentType: ContentType.Text,
});
const receivedMessage = await receivedMessagePromise;
expect(receivedMessage.chatMessage?.text).to.eq(text);
});
it("public chat messages have signers", async function () {
this.timeout(10_000);
await messengerAlice.joinChatById(testChatId);
await messengerBob.joinChatById(testChatId);
const text = "This is a message.";
const receivedMessagePromise: Promise<ApplicationMetadataMessage> =
new Promise((resolve) => {
messengerBob.addObserver((message) => {
resolve(message);
}, testChatId);
});
await messengerAlice.sendMessage(testChatId, {
text,
contentType: ContentType.Text,
});
const receivedMessage = await receivedMessagePromise;
expect(bufToHex(receivedMessage.signer!)).to.eq(
bufToHex(identityAlice.publicKey)
);
});
afterEach(async function () {
this.timeout(5000);
await messengerAlice.stop();
await messengerBob.stop();
});
});
describe("Messenger [live data]", () => {
before(function () {
if (process.env.CI) {
// Skip live data test in CI
this.skip();
}
});
let messenger: Messenger;
let identity: Identity;
beforeEach(async function () {
this.timeout(20_000);
dbg("Generate keys");
identity = Identity.generate();
dbg("Create messengers");
messenger = await Messenger.create(identity, {
bootstrap: { default: true },
});
dbg("Wait to be connected to a peer");
await messenger.waku.waitForRemotePeer();
dbg("Messengers ready");
});
it("Receive public chat messages", async function () {
this.timeout(20_000);
const community = await Community.instantiateCommunity(
"0x02cf13719c8b836bebd4e430c497ee38e798a43e4d8c4760c34bbd9bf4f2434d26",
messenger.waku
);
await messenger.joinChats(community.chats.values());
const startTime = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const endTime = new Date();
const chat = Array.from(community.chats.values()).find(
(chat) => chat.communityChat?.identity?.displayName === "Test Chat"
);
if (!chat) throw "Could not find foobar chat";
console.log(chat);
await messenger.retrievePreviousMessages(
chat.id,
startTime,
endTime,
(metadata) => {
metadata.forEach((m) => {
console.log("Message", m.chatMessage?.text);
});
}
);
});
afterEach(async function () {
this.timeout(5000);
await messenger.stop();
});
});

View File

@ -0,0 +1,268 @@
import debug from "debug";
import { Waku, WakuMessage } from "js-waku";
import { CreateOptions as WakuCreateOptions } from "js-waku/build/main/lib/waku";
import { DecryptionMethod } from "js-waku/build/main/lib/waku_message";
import { Chat } from "./chat";
import { Identity } from "./identity";
import { ApplicationMetadataMessage_Type } from "./proto/status/v1/application_metadata_message";
import { getLatestUserNickname } from "./utils";
import { ApplicationMetadataMessage } from "./wire/application_metadata_message";
import { ChatMessage, Content } from "./wire/chat_message";
const dbg = debug("communities:messenger");
export class Messenger {
waku: Waku;
chatsById: Map<string, Chat>;
observers: {
[chatId: string]: Set<
(
message: ApplicationMetadataMessage,
timestamp: Date,
chatId: string
) => void
>;
};
identity: Identity | undefined;
private constructor(identity: Identity | undefined, waku: Waku) {
this.identity = identity;
this.waku = waku;
this.chatsById = new Map();
this.observers = {};
}
public static async create(
identity: Identity | undefined,
wakuOptions?: WakuCreateOptions
): Promise<Messenger> {
const _wakuOptions = Object.assign(
{ bootstrap: { default: true } },
wakuOptions
);
const waku = await Waku.create(_wakuOptions);
return new Messenger(identity, waku);
}
/**
* Joins a public chat using its id.
*
* For community chats, prefer [[joinChat]].
*
* Use `addListener` to get messages received on this chat.
*/
public async joinChatById(chatId: string): Promise<void> {
const chat = await Chat.create(chatId);
await this.joinChat(chat);
}
/**
* Joins several of public chats.
*
* Use `addListener` to get messages received on these chats.
*/
public async joinChats(chats: Iterable<Chat>): Promise<void> {
await Promise.all(
Array.from(chats).map((chat) => {
return this.joinChat(chat);
})
);
}
/**
* Joins a public chat.
*
* Use `addListener` to get messages received on this chat.
*/
public async joinChat(chat: Chat): Promise<void> {
if (this.chatsById.has(chat.id))
throw `Failed to join chat, it is already joined: ${chat.id}`;
this.waku.addDecryptionKey(chat.symKey, {
method: DecryptionMethod.Symmetric,
contentTopics: [chat.contentTopic],
});
this.waku.relay.addObserver(
(wakuMessage: WakuMessage) => {
if (!wakuMessage.payload || !wakuMessage.timestamp) return;
const message = ApplicationMetadataMessage.decode(wakuMessage.payload);
switch (message.type) {
case ApplicationMetadataMessage_Type.TYPE_CHAT_MESSAGE:
this._handleNewChatMessage(chat, message, wakuMessage.timestamp);
break;
default:
dbg("Received unsupported message type", message.type);
}
},
[chat.contentTopic]
);
this.chatsById.set(chat.id, chat);
}
/**
* Sends a message on the given chat Id.
*/
public async sendMessage(
chatId: string,
content: Content,
responseTo?: string
): Promise<void> {
if (this.identity) {
const chat = this.chatsById.get(chatId);
if (!chat) throw `Failed to send message, chat not joined: ${chatId}`;
const chatMessage = chat.createMessage(content, responseTo);
const appMetadataMessage = ApplicationMetadataMessage.create(
chatMessage.encode(),
ApplicationMetadataMessage_Type.TYPE_CHAT_MESSAGE,
this.identity
);
const wakuMessage = await WakuMessage.fromBytes(
appMetadataMessage.encode(),
chat.contentTopic,
{ symKey: chat.symKey, sigPrivKey: this.identity.privateKey }
);
await this.waku.relay.send(wakuMessage);
}
}
/**
* Add an observer of new messages received on the given chat id.
*
* @throws string If the chat has not been joined first using [joinChat].
*/
public addObserver(
observer: (
message: ApplicationMetadataMessage,
timestamp: Date,
chatId: string
) => void,
chatId: string | string[]
): void {
let chats = [];
if (typeof chatId === "string") {
chats.push(chatId);
} else {
chats = [...chatId];
}
chats.forEach((id) => {
if (!this.chatsById.has(id))
throw "Cannot add observer on a chat that is not joined.";
if (!this.observers[id]) {
this.observers[id] = new Set();
}
this.observers[id].add(observer);
});
}
/**
* Delete an observer of new messages received on the given chat id.
*
* @throws string If the chat has not been joined first using [joinChat].
*/
deleteObserver(
observer: (message: ApplicationMetadataMessage) => void,
chatId: string
): void {
if (this.observers[chatId]) {
this.observers[chatId].delete(observer);
}
}
/**
* Stops the messenger.
*/
public async stop(): Promise<void> {
await this.waku.stop();
}
/**
* Retrieve previous messages from a Waku Store node for the given chat Id.
*
* Note: note sure what is the preferred interface: callback or returning all messages
* Callback is more flexible and allow processing messages as they are retrieved instead of waiting for the
* full retrieval via paging to be done.
*/
public async retrievePreviousMessages(
chatId: string,
startTime: Date,
endTime: Date,
callback?: (messages: ApplicationMetadataMessage[]) => void
): Promise<number> {
const chat = this.chatsById.get(chatId);
if (!chat)
throw `Failed to retrieve messages, chat is not joined: ${chatId}`;
const _callback = (wakuMessages: WakuMessage[]): void => {
const isDefined = (
msg: ApplicationMetadataMessage | undefined
): msg is ApplicationMetadataMessage => {
return !!msg;
};
const messages = wakuMessages.map((wakuMessage: WakuMessage) => {
if (!wakuMessage.payload || !wakuMessage.timestamp) return;
const message = ApplicationMetadataMessage.decode(wakuMessage.payload);
switch (message.type) {
case ApplicationMetadataMessage_Type.TYPE_CHAT_MESSAGE:
this._handleNewChatMessage(chat, message, wakuMessage.timestamp);
return message;
default:
dbg("Retrieved unsupported message type", message.type);
return;
}
});
if (callback) {
callback(messages.filter(isDefined));
}
};
const allMessages = await this.waku.store.queryHistory(
[chat.contentTopic],
{
timeFilter: { startTime, endTime },
callback: _callback,
}
);
return allMessages.length;
}
private _handleNewChatMessage(
chat: Chat,
message: ApplicationMetadataMessage,
timestamp: Date
): void {
if (!message.payload || !message.type || !message.signature) return;
const chatMessage = ChatMessage.decode(message.payload);
chat.handleNewMessage(chatMessage);
if (this.observers[chat.id]) {
this.observers[chat.id].forEach((observer) => {
observer(message, timestamp, chat.id);
});
}
}
async checkIfUserInWakuNetwork(publicKey: Uint8Array): Promise<boolean> {
const { clock, nickname } = await getLatestUserNickname(
publicKey,
this.waku
);
return clock > 0 && nickname !== "";
}
}

View File

@ -0,0 +1,532 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
import {
ImageType,
imageTypeFromJSON,
imageTypeToJSON,
} from "../../communities/v1/enums";
export const protobufPackage = "communities.v1";
/** ChatIdentity represents the user defined identity associated with their public chat key */
export interface ChatIdentity {
/** Lamport timestamp of the message */
clock: number;
/** ens_name is the valid ENS name associated with the chat key */
ensName: string;
/** images is a string indexed mapping of images associated with an identity */
images: { [key: string]: IdentityImage };
/** display name is the user set identity, valid only for organisations */
displayName: string;
/** description is the user set description, valid only for organisations */
description: string;
color: string;
emoji: string;
}
export interface ChatIdentity_ImagesEntry {
key: string;
value: IdentityImage | undefined;
}
/** ProfileImage represents data associated with a user's profile image */
export interface IdentityImage {
/**
* payload is a context based payload for the profile image data,
* context is determined by the `source_type`
*/
payload: Uint8Array;
/** source_type signals the image payload source */
sourceType: IdentityImage_SourceType;
/** image_type signals the image type and method of parsing the payload */
imageType: ImageType;
}
/** SourceType are the predefined types of image source allowed */
export enum IdentityImage_SourceType {
UNKNOWN_SOURCE_TYPE = 0,
/** RAW_PAYLOAD - RAW_PAYLOAD image byte data */
RAW_PAYLOAD = 1,
/**
* ENS_AVATAR - ENS_AVATAR uses the ENS record's resolver get-text-data.avatar data
* The `payload` field will be ignored if ENS_AVATAR is selected
* The application will read and parse the ENS avatar data as image payload data, URLs will be ignored
* The parent `ChatMessageIdentity` must have a valid `ens_name` set
*/
ENS_AVATAR = 2,
UNRECOGNIZED = -1,
}
export function identityImage_SourceTypeFromJSON(
object: any
): IdentityImage_SourceType {
switch (object) {
case 0:
case "UNKNOWN_SOURCE_TYPE":
return IdentityImage_SourceType.UNKNOWN_SOURCE_TYPE;
case 1:
case "RAW_PAYLOAD":
return IdentityImage_SourceType.RAW_PAYLOAD;
case 2:
case "ENS_AVATAR":
return IdentityImage_SourceType.ENS_AVATAR;
case -1:
case "UNRECOGNIZED":
default:
return IdentityImage_SourceType.UNRECOGNIZED;
}
}
export function identityImage_SourceTypeToJSON(
object: IdentityImage_SourceType
): string {
switch (object) {
case IdentityImage_SourceType.UNKNOWN_SOURCE_TYPE:
return "UNKNOWN_SOURCE_TYPE";
case IdentityImage_SourceType.RAW_PAYLOAD:
return "RAW_PAYLOAD";
case IdentityImage_SourceType.ENS_AVATAR:
return "ENS_AVATAR";
default:
return "UNKNOWN";
}
}
const baseChatIdentity: object = {
clock: 0,
ensName: "",
displayName: "",
description: "",
color: "",
emoji: "",
};
export const ChatIdentity = {
encode(
message: ChatIdentity,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.clock !== 0) {
writer.uint32(8).uint64(message.clock);
}
if (message.ensName !== "") {
writer.uint32(18).string(message.ensName);
}
Object.entries(message.images).forEach(([key, value]) => {
ChatIdentity_ImagesEntry.encode(
{ key: key as any, value },
writer.uint32(26).fork()
).ldelim();
});
if (message.displayName !== "") {
writer.uint32(34).string(message.displayName);
}
if (message.description !== "") {
writer.uint32(42).string(message.description);
}
if (message.color !== "") {
writer.uint32(50).string(message.color);
}
if (message.emoji !== "") {
writer.uint32(58).string(message.emoji);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): ChatIdentity {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = { ...baseChatIdentity } as ChatIdentity;
message.images = {};
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.clock = longToNumber(reader.uint64() as Long);
break;
case 2:
message.ensName = reader.string();
break;
case 3:
const entry3 = ChatIdentity_ImagesEntry.decode(
reader,
reader.uint32()
);
if (entry3.value !== undefined) {
message.images[entry3.key] = entry3.value;
}
break;
case 4:
message.displayName = reader.string();
break;
case 5:
message.description = reader.string();
break;
case 6:
message.color = reader.string();
break;
case 7:
message.emoji = reader.string();
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): ChatIdentity {
const message = { ...baseChatIdentity } as ChatIdentity;
message.images = {};
if (object.clock !== undefined && object.clock !== null) {
message.clock = Number(object.clock);
} else {
message.clock = 0;
}
if (object.ensName !== undefined && object.ensName !== null) {
message.ensName = String(object.ensName);
} else {
message.ensName = "";
}
if (object.images !== undefined && object.images !== null) {
Object.entries(object.images).forEach(([key, value]) => {
message.images[key] = IdentityImage.fromJSON(value);
});
}
if (object.displayName !== undefined && object.displayName !== null) {
message.displayName = String(object.displayName);
} else {
message.displayName = "";
}
if (object.description !== undefined && object.description !== null) {
message.description = String(object.description);
} else {
message.description = "";
}
if (object.color !== undefined && object.color !== null) {
message.color = String(object.color);
} else {
message.color = "";
}
if (object.emoji !== undefined && object.emoji !== null) {
message.emoji = String(object.emoji);
} else {
message.emoji = "";
}
return message;
},
toJSON(message: ChatIdentity): unknown {
const obj: any = {};
message.clock !== undefined && (obj.clock = message.clock);
message.ensName !== undefined && (obj.ensName = message.ensName);
obj.images = {};
if (message.images) {
Object.entries(message.images).forEach(([k, v]) => {
obj.images[k] = IdentityImage.toJSON(v);
});
}
message.displayName !== undefined &&
(obj.displayName = message.displayName);
message.description !== undefined &&
(obj.description = message.description);
message.color !== undefined && (obj.color = message.color);
message.emoji !== undefined && (obj.emoji = message.emoji);
return obj;
},
fromPartial(object: DeepPartial<ChatIdentity>): ChatIdentity {
const message = { ...baseChatIdentity } as ChatIdentity;
message.images = {};
if (object.clock !== undefined && object.clock !== null) {
message.clock = object.clock;
} else {
message.clock = 0;
}
if (object.ensName !== undefined && object.ensName !== null) {
message.ensName = object.ensName;
} else {
message.ensName = "";
}
if (object.images !== undefined && object.images !== null) {
Object.entries(object.images).forEach(([key, value]) => {
if (value !== undefined) {
message.images[key] = IdentityImage.fromPartial(value);
}
});
}
if (object.displayName !== undefined && object.displayName !== null) {
message.displayName = object.displayName;
} else {
message.displayName = "";
}
if (object.description !== undefined && object.description !== null) {
message.description = object.description;
} else {
message.description = "";
}
if (object.color !== undefined && object.color !== null) {
message.color = object.color;
} else {
message.color = "";
}
if (object.emoji !== undefined && object.emoji !== null) {
message.emoji = object.emoji;
} else {
message.emoji = "";
}
return message;
},
};
const baseChatIdentity_ImagesEntry: object = { key: "" };
export const ChatIdentity_ImagesEntry = {
encode(
message: ChatIdentity_ImagesEntry,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.key !== "") {
writer.uint32(10).string(message.key);
}
if (message.value !== undefined) {
IdentityImage.encode(message.value, writer.uint32(18).fork()).ldelim();
}
return writer;
},
decode(
input: _m0.Reader | Uint8Array,
length?: number
): ChatIdentity_ImagesEntry {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = {
...baseChatIdentity_ImagesEntry,
} as ChatIdentity_ImagesEntry;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.key = reader.string();
break;
case 2:
message.value = IdentityImage.decode(reader, reader.uint32());
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): ChatIdentity_ImagesEntry {
const message = {
...baseChatIdentity_ImagesEntry,
} as ChatIdentity_ImagesEntry;
if (object.key !== undefined && object.key !== null) {
message.key = String(object.key);
} else {
message.key = "";
}
if (object.value !== undefined && object.value !== null) {
message.value = IdentityImage.fromJSON(object.value);
} else {
message.value = undefined;
}
return message;
},
toJSON(message: ChatIdentity_ImagesEntry): unknown {
const obj: any = {};
message.key !== undefined && (obj.key = message.key);
message.value !== undefined &&
(obj.value = message.value
? IdentityImage.toJSON(message.value)
: undefined);
return obj;
},
fromPartial(
object: DeepPartial<ChatIdentity_ImagesEntry>
): ChatIdentity_ImagesEntry {
const message = {
...baseChatIdentity_ImagesEntry,
} as ChatIdentity_ImagesEntry;
if (object.key !== undefined && object.key !== null) {
message.key = object.key;
} else {
message.key = "";
}
if (object.value !== undefined && object.value !== null) {
message.value = IdentityImage.fromPartial(object.value);
} else {
message.value = undefined;
}
return message;
},
};
const baseIdentityImage: object = { sourceType: 0, imageType: 0 };
export const IdentityImage = {
encode(
message: IdentityImage,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.payload.length !== 0) {
writer.uint32(10).bytes(message.payload);
}
if (message.sourceType !== 0) {
writer.uint32(16).int32(message.sourceType);
}
if (message.imageType !== 0) {
writer.uint32(24).int32(message.imageType);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): IdentityImage {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = { ...baseIdentityImage } as IdentityImage;
message.payload = new Uint8Array();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.payload = reader.bytes();
break;
case 2:
message.sourceType = reader.int32() as any;
break;
case 3:
message.imageType = reader.int32() as any;
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): IdentityImage {
const message = { ...baseIdentityImage } as IdentityImage;
message.payload = new Uint8Array();
if (object.payload !== undefined && object.payload !== null) {
message.payload = bytesFromBase64(object.payload);
}
if (object.sourceType !== undefined && object.sourceType !== null) {
message.sourceType = identityImage_SourceTypeFromJSON(object.sourceType);
} else {
message.sourceType = 0;
}
if (object.imageType !== undefined && object.imageType !== null) {
message.imageType = imageTypeFromJSON(object.imageType);
} else {
message.imageType = 0;
}
return message;
},
toJSON(message: IdentityImage): unknown {
const obj: any = {};
message.payload !== undefined &&
(obj.payload = base64FromBytes(
message.payload !== undefined ? message.payload : new Uint8Array()
));
message.sourceType !== undefined &&
(obj.sourceType = identityImage_SourceTypeToJSON(message.sourceType));
message.imageType !== undefined &&
(obj.imageType = imageTypeToJSON(message.imageType));
return obj;
},
fromPartial(object: DeepPartial<IdentityImage>): IdentityImage {
const message = { ...baseIdentityImage } as IdentityImage;
if (object.payload !== undefined && object.payload !== null) {
message.payload = object.payload;
} else {
message.payload = new Uint8Array();
}
if (object.sourceType !== undefined && object.sourceType !== null) {
message.sourceType = object.sourceType;
} else {
message.sourceType = 0;
}
if (object.imageType !== undefined && object.imageType !== null) {
message.imageType = object.imageType;
} else {
message.imageType = 0;
}
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var globalThis: any = (() => {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw "Unable to locate global object";
})();
const atob: (b64: string) => string =
globalThis.atob ||
((b64) => globalThis.Buffer.from(b64, "base64").toString("binary"));
function bytesFromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
arr[i] = bin.charCodeAt(i);
}
return arr;
}
const btoa: (bin: string) => string =
globalThis.btoa ||
((bin) => globalThis.Buffer.from(bin, "binary").toString("base64"));
function base64FromBytes(arr: Uint8Array): string {
const bin: string[] = [];
for (const byte of arr) {
bin.push(String.fromCharCode(byte));
}
return btoa(bin.join(""));
}
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,328 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
import {
MessageType,
messageTypeFromJSON,
messageTypeToJSON,
} from "../../communities/v1/enums";
export const protobufPackage = "communities.v1";
export interface EmojiReaction {
/** clock Lamport timestamp of the chat message */
clock: number;
/**
* chat_id the ID of the chat the message belongs to, for query efficiency the chat_id is stored in the db even though the
* target message also stores the chat_id
*/
chatId: string;
/** message_id the ID of the target message that the user wishes to react to */
messageId: string;
/** message_type is (somewhat confusingly) the ID of the type of chat the message belongs to */
messageType: MessageType;
/** type the ID of the emoji the user wishes to react with */
type: EmojiReaction_Type;
/** whether this is a rectraction of a previously sent emoji */
retracted: boolean;
/** Grant for organisation chat messages */
grant: Uint8Array;
}
export enum EmojiReaction_Type {
UNKNOWN_EMOJI_REACTION_TYPE = 0,
LOVE = 1,
THUMBS_UP = 2,
THUMBS_DOWN = 3,
LAUGH = 4,
SAD = 5,
ANGRY = 6,
UNRECOGNIZED = -1,
}
export function emojiReaction_TypeFromJSON(object: any): EmojiReaction_Type {
switch (object) {
case 0:
case "UNKNOWN_EMOJI_REACTION_TYPE":
return EmojiReaction_Type.UNKNOWN_EMOJI_REACTION_TYPE;
case 1:
case "LOVE":
return EmojiReaction_Type.LOVE;
case 2:
case "THUMBS_UP":
return EmojiReaction_Type.THUMBS_UP;
case 3:
case "THUMBS_DOWN":
return EmojiReaction_Type.THUMBS_DOWN;
case 4:
case "LAUGH":
return EmojiReaction_Type.LAUGH;
case 5:
case "SAD":
return EmojiReaction_Type.SAD;
case 6:
case "ANGRY":
return EmojiReaction_Type.ANGRY;
case -1:
case "UNRECOGNIZED":
default:
return EmojiReaction_Type.UNRECOGNIZED;
}
}
export function emojiReaction_TypeToJSON(object: EmojiReaction_Type): string {
switch (object) {
case EmojiReaction_Type.UNKNOWN_EMOJI_REACTION_TYPE:
return "UNKNOWN_EMOJI_REACTION_TYPE";
case EmojiReaction_Type.LOVE:
return "LOVE";
case EmojiReaction_Type.THUMBS_UP:
return "THUMBS_UP";
case EmojiReaction_Type.THUMBS_DOWN:
return "THUMBS_DOWN";
case EmojiReaction_Type.LAUGH:
return "LAUGH";
case EmojiReaction_Type.SAD:
return "SAD";
case EmojiReaction_Type.ANGRY:
return "ANGRY";
default:
return "UNKNOWN";
}
}
const baseEmojiReaction: object = {
clock: 0,
chatId: "",
messageId: "",
messageType: 0,
type: 0,
retracted: false,
};
export const EmojiReaction = {
encode(
message: EmojiReaction,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.clock !== 0) {
writer.uint32(8).uint64(message.clock);
}
if (message.chatId !== "") {
writer.uint32(18).string(message.chatId);
}
if (message.messageId !== "") {
writer.uint32(26).string(message.messageId);
}
if (message.messageType !== 0) {
writer.uint32(32).int32(message.messageType);
}
if (message.type !== 0) {
writer.uint32(40).int32(message.type);
}
if (message.retracted === true) {
writer.uint32(48).bool(message.retracted);
}
if (message.grant.length !== 0) {
writer.uint32(58).bytes(message.grant);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): EmojiReaction {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = { ...baseEmojiReaction } as EmojiReaction;
message.grant = new Uint8Array();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.clock = longToNumber(reader.uint64() as Long);
break;
case 2:
message.chatId = reader.string();
break;
case 3:
message.messageId = reader.string();
break;
case 4:
message.messageType = reader.int32() as any;
break;
case 5:
message.type = reader.int32() as any;
break;
case 6:
message.retracted = reader.bool();
break;
case 7:
message.grant = reader.bytes();
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): EmojiReaction {
const message = { ...baseEmojiReaction } as EmojiReaction;
message.grant = new Uint8Array();
if (object.clock !== undefined && object.clock !== null) {
message.clock = Number(object.clock);
} else {
message.clock = 0;
}
if (object.chatId !== undefined && object.chatId !== null) {
message.chatId = String(object.chatId);
} else {
message.chatId = "";
}
if (object.messageId !== undefined && object.messageId !== null) {
message.messageId = String(object.messageId);
} else {
message.messageId = "";
}
if (object.messageType !== undefined && object.messageType !== null) {
message.messageType = messageTypeFromJSON(object.messageType);
} else {
message.messageType = 0;
}
if (object.type !== undefined && object.type !== null) {
message.type = emojiReaction_TypeFromJSON(object.type);
} else {
message.type = 0;
}
if (object.retracted !== undefined && object.retracted !== null) {
message.retracted = Boolean(object.retracted);
} else {
message.retracted = false;
}
if (object.grant !== undefined && object.grant !== null) {
message.grant = bytesFromBase64(object.grant);
}
return message;
},
toJSON(message: EmojiReaction): unknown {
const obj: any = {};
message.clock !== undefined && (obj.clock = message.clock);
message.chatId !== undefined && (obj.chatId = message.chatId);
message.messageId !== undefined && (obj.messageId = message.messageId);
message.messageType !== undefined &&
(obj.messageType = messageTypeToJSON(message.messageType));
message.type !== undefined &&
(obj.type = emojiReaction_TypeToJSON(message.type));
message.retracted !== undefined && (obj.retracted = message.retracted);
message.grant !== undefined &&
(obj.grant = base64FromBytes(
message.grant !== undefined ? message.grant : new Uint8Array()
));
return obj;
},
fromPartial(object: DeepPartial<EmojiReaction>): EmojiReaction {
const message = { ...baseEmojiReaction } as EmojiReaction;
if (object.clock !== undefined && object.clock !== null) {
message.clock = object.clock;
} else {
message.clock = 0;
}
if (object.chatId !== undefined && object.chatId !== null) {
message.chatId = object.chatId;
} else {
message.chatId = "";
}
if (object.messageId !== undefined && object.messageId !== null) {
message.messageId = object.messageId;
} else {
message.messageId = "";
}
if (object.messageType !== undefined && object.messageType !== null) {
message.messageType = object.messageType;
} else {
message.messageType = 0;
}
if (object.type !== undefined && object.type !== null) {
message.type = object.type;
} else {
message.type = 0;
}
if (object.retracted !== undefined && object.retracted !== null) {
message.retracted = object.retracted;
} else {
message.retracted = false;
}
if (object.grant !== undefined && object.grant !== null) {
message.grant = object.grant;
} else {
message.grant = new Uint8Array();
}
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var globalThis: any = (() => {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw "Unable to locate global object";
})();
const atob: (b64: string) => string =
globalThis.atob ||
((b64) => globalThis.Buffer.from(b64, "base64").toString("binary"));
function bytesFromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
arr[i] = bin.charCodeAt(i);
}
return arr;
}
const btoa: (bin: string) => string =
globalThis.btoa ||
((bin) => globalThis.Buffer.from(bin, "binary").toString("base64"));
function base64FromBytes(arr: Uint8Array): string {
const bin: string[] = [];
for (const byte of arr) {
bin.push(String.fromCharCode(byte));
}
return btoa(bin.join(""));
}
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

View File

@ -0,0 +1,125 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "communities.v1";
export enum MessageType {
MESSAGE_TYPE_UNKNOWN_UNSPECIFIED = 0,
MESSAGE_TYPE_ONE_TO_ONE = 1,
MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP = 2,
MESSAGE_TYPE_PRIVATE_GROUP = 3,
/** MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP - Only local */
MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP = 4,
MESSAGE_TYPE_COMMUNITY_CHAT = 5,
/** MESSAGE_TYPE_SYSTEM_MESSAGE_GAP - Only local */
MESSAGE_TYPE_SYSTEM_MESSAGE_GAP = 6,
UNRECOGNIZED = -1,
}
export function messageTypeFromJSON(object: any): MessageType {
switch (object) {
case 0:
case "MESSAGE_TYPE_UNKNOWN_UNSPECIFIED":
return MessageType.MESSAGE_TYPE_UNKNOWN_UNSPECIFIED;
case 1:
case "MESSAGE_TYPE_ONE_TO_ONE":
return MessageType.MESSAGE_TYPE_ONE_TO_ONE;
case 2:
case "MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP":
return MessageType.MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP;
case 3:
case "MESSAGE_TYPE_PRIVATE_GROUP":
return MessageType.MESSAGE_TYPE_PRIVATE_GROUP;
case 4:
case "MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP":
return MessageType.MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP;
case 5:
case "MESSAGE_TYPE_COMMUNITY_CHAT":
return MessageType.MESSAGE_TYPE_COMMUNITY_CHAT;
case 6:
case "MESSAGE_TYPE_SYSTEM_MESSAGE_GAP":
return MessageType.MESSAGE_TYPE_SYSTEM_MESSAGE_GAP;
case -1:
case "UNRECOGNIZED":
default:
return MessageType.UNRECOGNIZED;
}
}
export function messageTypeToJSON(object: MessageType): string {
switch (object) {
case MessageType.MESSAGE_TYPE_UNKNOWN_UNSPECIFIED:
return "MESSAGE_TYPE_UNKNOWN_UNSPECIFIED";
case MessageType.MESSAGE_TYPE_ONE_TO_ONE:
return "MESSAGE_TYPE_ONE_TO_ONE";
case MessageType.MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP:
return "MESSAGE_TYPE_MESSAGE_TYPE_PUBLIC_GROUP";
case MessageType.MESSAGE_TYPE_PRIVATE_GROUP:
return "MESSAGE_TYPE_PRIVATE_GROUP";
case MessageType.MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP:
return "MESSAGE_TYPE_SYSTEM_MESSAGE_PRIVATE_GROUP";
case MessageType.MESSAGE_TYPE_COMMUNITY_CHAT:
return "MESSAGE_TYPE_COMMUNITY_CHAT";
case MessageType.MESSAGE_TYPE_SYSTEM_MESSAGE_GAP:
return "MESSAGE_TYPE_SYSTEM_MESSAGE_GAP";
default:
return "UNKNOWN";
}
}
export enum ImageType {
IMAGE_TYPE_UNKNOWN_UNSPECIFIED = 0,
/** IMAGE_TYPE_PNG - Raster image files is payload data that can be read as a raster image */
IMAGE_TYPE_PNG = 1,
IMAGE_TYPE_JPEG = 2,
IMAGE_TYPE_WEBP = 3,
IMAGE_TYPE_GIF = 4,
UNRECOGNIZED = -1,
}
export function imageTypeFromJSON(object: any): ImageType {
switch (object) {
case 0:
case "IMAGE_TYPE_UNKNOWN_UNSPECIFIED":
return ImageType.IMAGE_TYPE_UNKNOWN_UNSPECIFIED;
case 1:
case "IMAGE_TYPE_PNG":
return ImageType.IMAGE_TYPE_PNG;
case 2:
case "IMAGE_TYPE_JPEG":
return ImageType.IMAGE_TYPE_JPEG;
case 3:
case "IMAGE_TYPE_WEBP":
return ImageType.IMAGE_TYPE_WEBP;
case 4:
case "IMAGE_TYPE_GIF":
return ImageType.IMAGE_TYPE_GIF;
case -1:
case "UNRECOGNIZED":
default:
return ImageType.UNRECOGNIZED;
}
}
export function imageTypeToJSON(object: ImageType): string {
switch (object) {
case ImageType.IMAGE_TYPE_UNKNOWN_UNSPECIFIED:
return "IMAGE_TYPE_UNKNOWN_UNSPECIFIED";
case ImageType.IMAGE_TYPE_PNG:
return "IMAGE_TYPE_PNG";
case ImageType.IMAGE_TYPE_JPEG:
return "IMAGE_TYPE_JPEG";
case ImageType.IMAGE_TYPE_WEBP:
return "IMAGE_TYPE_WEBP";
case ImageType.IMAGE_TYPE_GIF:
return "IMAGE_TYPE_GIF";
default:
return "UNKNOWN";
}
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

View File

@ -0,0 +1,436 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
import { ChatMessage } from "../../communities/v1/chat_message";
import { EmojiReaction } from "../../communities/v1/emoji_reaction";
export const protobufPackage = "communities.v1";
export interface MembershipUpdateEvent {
/** Lamport timestamp of the event */
clock: number;
/** List of public keys of objects of the action */
members: string[];
/** Name of the chat for the CHAT_CREATED/NAME_CHANGED event types */
name: string;
/** The type of the event */
type: MembershipUpdateEvent_EventType;
}
export enum MembershipUpdateEvent_EventType {
UNKNOWN = 0,
CHAT_CREATED = 1,
NAME_CHANGED = 2,
MEMBERS_ADDED = 3,
MEMBER_JOINED = 4,
MEMBER_REMOVED = 5,
ADMINS_ADDED = 6,
ADMIN_REMOVED = 7,
UNRECOGNIZED = -1,
}
export function membershipUpdateEvent_EventTypeFromJSON(
object: any
): MembershipUpdateEvent_EventType {
switch (object) {
case 0:
case "UNKNOWN":
return MembershipUpdateEvent_EventType.UNKNOWN;
case 1:
case "CHAT_CREATED":
return MembershipUpdateEvent_EventType.CHAT_CREATED;
case 2:
case "NAME_CHANGED":
return MembershipUpdateEvent_EventType.NAME_CHANGED;
case 3:
case "MEMBERS_ADDED":
return MembershipUpdateEvent_EventType.MEMBERS_ADDED;
case 4:
case "MEMBER_JOINED":
return MembershipUpdateEvent_EventType.MEMBER_JOINED;
case 5:
case "MEMBER_REMOVED":
return MembershipUpdateEvent_EventType.MEMBER_REMOVED;
case 6:
case "ADMINS_ADDED":
return MembershipUpdateEvent_EventType.ADMINS_ADDED;
case 7:
case "ADMIN_REMOVED":
return MembershipUpdateEvent_EventType.ADMIN_REMOVED;
case -1:
case "UNRECOGNIZED":
default:
return MembershipUpdateEvent_EventType.UNRECOGNIZED;
}
}
export function membershipUpdateEvent_EventTypeToJSON(
object: MembershipUpdateEvent_EventType
): string {
switch (object) {
case MembershipUpdateEvent_EventType.UNKNOWN:
return "UNKNOWN";
case MembershipUpdateEvent_EventType.CHAT_CREATED:
return "CHAT_CREATED";
case MembershipUpdateEvent_EventType.NAME_CHANGED:
return "NAME_CHANGED";
case MembershipUpdateEvent_EventType.MEMBERS_ADDED:
return "MEMBERS_ADDED";
case MembershipUpdateEvent_EventType.MEMBER_JOINED:
return "MEMBER_JOINED";
case MembershipUpdateEvent_EventType.MEMBER_REMOVED:
return "MEMBER_REMOVED";
case MembershipUpdateEvent_EventType.ADMINS_ADDED:
return "ADMINS_ADDED";
case MembershipUpdateEvent_EventType.ADMIN_REMOVED:
return "ADMIN_REMOVED";
default:
return "UNKNOWN";
}
}
/**
* MembershipUpdateMessage is a message used to propagate information
* about group membership changes.
* For more information, see https://github.com/status-im/specs/blob/master/status-group-chats-spec.md.
*/
export interface MembershipUpdateMessage {
/** The chat id of the private group chat */
chatId: string;
/**
* A list of events for this group chat, first x bytes are the signature, then is a
* protobuf encoded MembershipUpdateEvent
*/
events: Uint8Array[];
message: ChatMessage | undefined;
emojiReaction: EmojiReaction | undefined;
}
const baseMembershipUpdateEvent: object = {
clock: 0,
members: "",
name: "",
type: 0,
};
export const MembershipUpdateEvent = {
encode(
message: MembershipUpdateEvent,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.clock !== 0) {
writer.uint32(8).uint64(message.clock);
}
for (const v of message.members) {
writer.uint32(18).string(v!);
}
if (message.name !== "") {
writer.uint32(26).string(message.name);
}
if (message.type !== 0) {
writer.uint32(32).int32(message.type);
}
return writer;
},
decode(
input: _m0.Reader | Uint8Array,
length?: number
): MembershipUpdateEvent {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = { ...baseMembershipUpdateEvent } as MembershipUpdateEvent;
message.members = [];
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.clock = longToNumber(reader.uint64() as Long);
break;
case 2:
message.members.push(reader.string());
break;
case 3:
message.name = reader.string();
break;
case 4:
message.type = reader.int32() as any;
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): MembershipUpdateEvent {
const message = { ...baseMembershipUpdateEvent } as MembershipUpdateEvent;
message.members = [];
if (object.clock !== undefined && object.clock !== null) {
message.clock = Number(object.clock);
} else {
message.clock = 0;
}
if (object.members !== undefined && object.members !== null) {
for (const e of object.members) {
message.members.push(String(e));
}
}
if (object.name !== undefined && object.name !== null) {
message.name = String(object.name);
} else {
message.name = "";
}
if (object.type !== undefined && object.type !== null) {
message.type = membershipUpdateEvent_EventTypeFromJSON(object.type);
} else {
message.type = 0;
}
return message;
},
toJSON(message: MembershipUpdateEvent): unknown {
const obj: any = {};
message.clock !== undefined && (obj.clock = message.clock);
if (message.members) {
obj.members = message.members.map((e) => e);
} else {
obj.members = [];
}
message.name !== undefined && (obj.name = message.name);
message.type !== undefined &&
(obj.type = membershipUpdateEvent_EventTypeToJSON(message.type));
return obj;
},
fromPartial(
object: DeepPartial<MembershipUpdateEvent>
): MembershipUpdateEvent {
const message = { ...baseMembershipUpdateEvent } as MembershipUpdateEvent;
message.members = [];
if (object.clock !== undefined && object.clock !== null) {
message.clock = object.clock;
} else {
message.clock = 0;
}
if (object.members !== undefined && object.members !== null) {
for (const e of object.members) {
message.members.push(e);
}
}
if (object.name !== undefined && object.name !== null) {
message.name = object.name;
} else {
message.name = "";
}
if (object.type !== undefined && object.type !== null) {
message.type = object.type;
} else {
message.type = 0;
}
return message;
},
};
const baseMembershipUpdateMessage: object = { chatId: "" };
export const MembershipUpdateMessage = {
encode(
message: MembershipUpdateMessage,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.chatId !== "") {
writer.uint32(10).string(message.chatId);
}
for (const v of message.events) {
writer.uint32(18).bytes(v!);
}
if (message.message !== undefined) {
ChatMessage.encode(message.message, writer.uint32(26).fork()).ldelim();
}
if (message.emojiReaction !== undefined) {
EmojiReaction.encode(
message.emojiReaction,
writer.uint32(34).fork()
).ldelim();
}
return writer;
},
decode(
input: _m0.Reader | Uint8Array,
length?: number
): MembershipUpdateMessage {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = {
...baseMembershipUpdateMessage,
} as MembershipUpdateMessage;
message.events = [];
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.chatId = reader.string();
break;
case 2:
message.events.push(reader.bytes());
break;
case 3:
message.message = ChatMessage.decode(reader, reader.uint32());
break;
case 4:
message.emojiReaction = EmojiReaction.decode(reader, reader.uint32());
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): MembershipUpdateMessage {
const message = {
...baseMembershipUpdateMessage,
} as MembershipUpdateMessage;
message.events = [];
if (object.chatId !== undefined && object.chatId !== null) {
message.chatId = String(object.chatId);
} else {
message.chatId = "";
}
if (object.events !== undefined && object.events !== null) {
for (const e of object.events) {
message.events.push(bytesFromBase64(e));
}
}
if (object.message !== undefined && object.message !== null) {
message.message = ChatMessage.fromJSON(object.message);
} else {
message.message = undefined;
}
if (object.emojiReaction !== undefined && object.emojiReaction !== null) {
message.emojiReaction = EmojiReaction.fromJSON(object.emojiReaction);
} else {
message.emojiReaction = undefined;
}
return message;
},
toJSON(message: MembershipUpdateMessage): unknown {
const obj: any = {};
message.chatId !== undefined && (obj.chatId = message.chatId);
if (message.events) {
obj.events = message.events.map((e) =>
base64FromBytes(e !== undefined ? e : new Uint8Array())
);
} else {
obj.events = [];
}
message.message !== undefined &&
(obj.message = message.message
? ChatMessage.toJSON(message.message)
: undefined);
message.emojiReaction !== undefined &&
(obj.emojiReaction = message.emojiReaction
? EmojiReaction.toJSON(message.emojiReaction)
: undefined);
return obj;
},
fromPartial(
object: DeepPartial<MembershipUpdateMessage>
): MembershipUpdateMessage {
const message = {
...baseMembershipUpdateMessage,
} as MembershipUpdateMessage;
message.events = [];
if (object.chatId !== undefined && object.chatId !== null) {
message.chatId = object.chatId;
} else {
message.chatId = "";
}
if (object.events !== undefined && object.events !== null) {
for (const e of object.events) {
message.events.push(e);
}
}
if (object.message !== undefined && object.message !== null) {
message.message = ChatMessage.fromPartial(object.message);
} else {
message.message = undefined;
}
if (object.emojiReaction !== undefined && object.emojiReaction !== null) {
message.emojiReaction = EmojiReaction.fromPartial(object.emojiReaction);
} else {
message.emojiReaction = undefined;
}
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var globalThis: any = (() => {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw "Unable to locate global object";
})();
const atob: (b64: string) => string =
globalThis.atob ||
((b64) => globalThis.Buffer.from(b64, "base64").toString("binary"));
function bytesFromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
arr[i] = bin.charCodeAt(i);
}
return arr;
}
const btoa: (bin: string) => string =
globalThis.btoa ||
((bin) => globalThis.Buffer.from(bin, "binary").toString("base64"));
function base64FromBytes(arr: Uint8Array): string {
const bin: string[] = [];
for (const byte of arr) {
bin.push(String.fromCharCode(byte));
}
return btoa(bin.join(""));
}
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

View File

@ -0,0 +1,212 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "communities.v1";
/**
* Specs:
* :AUTOMATIC
* To Send - "AUTOMATIC" status ping every 5 minutes
* Display - Online for up to 5 minutes from the last clock, after that Offline
* :ALWAYS_ONLINE
* To Send - "ALWAYS_ONLINE" status ping every 5 minutes
* Display - Online for up to 2 weeks from the last clock, after that Offline
* :INACTIVE
* To Send - A single "INACTIVE" status ping
* Display - Offline forever
* Note: Only send pings if the user interacted with the app in the last x minutes.
*/
export interface StatusUpdate {
clock: number;
statusType: StatusUpdate_StatusType;
customText: string;
}
export enum StatusUpdate_StatusType {
UNKNOWN_STATUS_TYPE = 0,
AUTOMATIC = 1,
DO_NOT_DISTURB = 2,
ALWAYS_ONLINE = 3,
INACTIVE = 4,
UNRECOGNIZED = -1,
}
export function statusUpdate_StatusTypeFromJSON(
object: any
): StatusUpdate_StatusType {
switch (object) {
case 0:
case "UNKNOWN_STATUS_TYPE":
return StatusUpdate_StatusType.UNKNOWN_STATUS_TYPE;
case 1:
case "AUTOMATIC":
return StatusUpdate_StatusType.AUTOMATIC;
case 2:
case "DO_NOT_DISTURB":
return StatusUpdate_StatusType.DO_NOT_DISTURB;
case 3:
case "ALWAYS_ONLINE":
return StatusUpdate_StatusType.ALWAYS_ONLINE;
case 4:
case "INACTIVE":
return StatusUpdate_StatusType.INACTIVE;
case -1:
case "UNRECOGNIZED":
default:
return StatusUpdate_StatusType.UNRECOGNIZED;
}
}
export function statusUpdate_StatusTypeToJSON(
object: StatusUpdate_StatusType
): string {
switch (object) {
case StatusUpdate_StatusType.UNKNOWN_STATUS_TYPE:
return "UNKNOWN_STATUS_TYPE";
case StatusUpdate_StatusType.AUTOMATIC:
return "AUTOMATIC";
case StatusUpdate_StatusType.DO_NOT_DISTURB:
return "DO_NOT_DISTURB";
case StatusUpdate_StatusType.ALWAYS_ONLINE:
return "ALWAYS_ONLINE";
case StatusUpdate_StatusType.INACTIVE:
return "INACTIVE";
default:
return "UNKNOWN";
}
}
const baseStatusUpdate: object = { clock: 0, statusType: 0, customText: "" };
export const StatusUpdate = {
encode(
message: StatusUpdate,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.clock !== 0) {
writer.uint32(8).uint64(message.clock);
}
if (message.statusType !== 0) {
writer.uint32(16).int32(message.statusType);
}
if (message.customText !== "") {
writer.uint32(26).string(message.customText);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): StatusUpdate {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = { ...baseStatusUpdate } as StatusUpdate;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.clock = longToNumber(reader.uint64() as Long);
break;
case 2:
message.statusType = reader.int32() as any;
break;
case 3:
message.customText = reader.string();
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): StatusUpdate {
const message = { ...baseStatusUpdate } as StatusUpdate;
if (object.clock !== undefined && object.clock !== null) {
message.clock = Number(object.clock);
} else {
message.clock = 0;
}
if (object.statusType !== undefined && object.statusType !== null) {
message.statusType = statusUpdate_StatusTypeFromJSON(object.statusType);
} else {
message.statusType = 0;
}
if (object.customText !== undefined && object.customText !== null) {
message.customText = String(object.customText);
} else {
message.customText = "";
}
return message;
},
toJSON(message: StatusUpdate): unknown {
const obj: any = {};
message.clock !== undefined && (obj.clock = message.clock);
message.statusType !== undefined &&
(obj.statusType = statusUpdate_StatusTypeToJSON(message.statusType));
message.customText !== undefined && (obj.customText = message.customText);
return obj;
},
fromPartial(object: DeepPartial<StatusUpdate>): StatusUpdate {
const message = { ...baseStatusUpdate } as StatusUpdate;
if (object.clock !== undefined && object.clock !== null) {
message.clock = object.clock;
} else {
message.clock = 0;
}
if (object.statusType !== undefined && object.statusType !== null) {
message.statusType = object.statusType;
} else {
message.statusType = 0;
}
if (object.customText !== undefined && object.customText !== null) {
message.customText = object.customText;
} else {
message.customText = "";
}
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var globalThis: any = (() => {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw "Unable to locate global object";
})();
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(long: Long): number {
if (long.gt(Number.MAX_SAFE_INTEGER)) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
return long.toNumber();
}
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

View File

@ -0,0 +1,409 @@
/* eslint-disable */
import Long from "long";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "status.v1";
export interface ApplicationMetadataMessage {
/** Signature of the payload field */
signature: Uint8Array;
/** This is the encoded protobuf of the application level message, i.e ChatMessage */
payload: Uint8Array;
/** The type of protobuf message sent */
type: ApplicationMetadataMessage_Type;
}
export enum ApplicationMetadataMessage_Type {
TYPE_UNKNOWN_UNSPECIFIED = 0,
TYPE_CHAT_MESSAGE = 1,
TYPE_CONTACT_UPDATE = 2,
TYPE_MEMBERSHIP_UPDATE_MESSAGE = 3,
TYPE_PAIR_INSTALLATION = 4,
TYPE_SYNC_INSTALLATION = 5,
TYPE_REQUEST_ADDRESS_FOR_TRANSACTION = 6,
TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION = 7,
TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION = 8,
TYPE_REQUEST_TRANSACTION = 9,
TYPE_SEND_TRANSACTION = 10,
TYPE_DECLINE_REQUEST_TRANSACTION = 11,
TYPE_SYNC_INSTALLATION_CONTACT = 12,
TYPE_SYNC_INSTALLATION_ACCOUNT = 13,
TYPE_SYNC_INSTALLATION_PUBLIC_CHAT = 14,
TYPE_CONTACT_CODE_ADVERTISEMENT = 15,
TYPE_PUSH_NOTIFICATION_REGISTRATION = 16,
TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE = 17,
TYPE_PUSH_NOTIFICATION_QUERY = 18,
TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE = 19,
TYPE_PUSH_NOTIFICATION_REQUEST = 20,
TYPE_PUSH_NOTIFICATION_RESPONSE = 21,
TYPE_EMOJI_REACTION = 22,
TYPE_GROUP_CHAT_INVITATION = 23,
TYPE_CHAT_IDENTITY = 24,
TYPE_COMMUNITY_DESCRIPTION = 25,
TYPE_COMMUNITY_INVITATION = 26,
TYPE_COMMUNITY_REQUEST_TO_JOIN = 27,
TYPE_PIN_MESSAGE = 28,
TYPE_EDIT_MESSAGE = 29,
TYPE_STATUS_UPDATE = 30,
TYPE_DELETE_MESSAGE = 31,
TYPE_SYNC_INSTALLATION_COMMUNITY = 32,
TYPE_ANONYMOUS_METRIC_BATCH = 33,
UNRECOGNIZED = -1,
}
export function applicationMetadataMessage_TypeFromJSON(
object: any
): ApplicationMetadataMessage_Type {
switch (object) {
case 0:
case "TYPE_UNKNOWN_UNSPECIFIED":
return ApplicationMetadataMessage_Type.TYPE_UNKNOWN_UNSPECIFIED;
case 1:
case "TYPE_CHAT_MESSAGE":
return ApplicationMetadataMessage_Type.TYPE_CHAT_MESSAGE;
case 2:
case "TYPE_CONTACT_UPDATE":
return ApplicationMetadataMessage_Type.TYPE_CONTACT_UPDATE;
case 3:
case "TYPE_MEMBERSHIP_UPDATE_MESSAGE":
return ApplicationMetadataMessage_Type.TYPE_MEMBERSHIP_UPDATE_MESSAGE;
case 4:
case "TYPE_PAIR_INSTALLATION":
return ApplicationMetadataMessage_Type.TYPE_PAIR_INSTALLATION;
case 5:
case "TYPE_SYNC_INSTALLATION":
return ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION;
case 6:
case "TYPE_REQUEST_ADDRESS_FOR_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_REQUEST_ADDRESS_FOR_TRANSACTION;
case 7:
case "TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION;
case 8:
case "TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION;
case 9:
case "TYPE_REQUEST_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_REQUEST_TRANSACTION;
case 10:
case "TYPE_SEND_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_SEND_TRANSACTION;
case 11:
case "TYPE_DECLINE_REQUEST_TRANSACTION":
return ApplicationMetadataMessage_Type.TYPE_DECLINE_REQUEST_TRANSACTION;
case 12:
case "TYPE_SYNC_INSTALLATION_CONTACT":
return ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_CONTACT;
case 13:
case "TYPE_SYNC_INSTALLATION_ACCOUNT":
return ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_ACCOUNT;
case 14:
case "TYPE_SYNC_INSTALLATION_PUBLIC_CHAT":
return ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_PUBLIC_CHAT;
case 15:
case "TYPE_CONTACT_CODE_ADVERTISEMENT":
return ApplicationMetadataMessage_Type.TYPE_CONTACT_CODE_ADVERTISEMENT;
case 16:
case "TYPE_PUSH_NOTIFICATION_REGISTRATION":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REGISTRATION;
case 17:
case "TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE;
case 18:
case "TYPE_PUSH_NOTIFICATION_QUERY":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_QUERY;
case 19:
case "TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE;
case 20:
case "TYPE_PUSH_NOTIFICATION_REQUEST":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REQUEST;
case 21:
case "TYPE_PUSH_NOTIFICATION_RESPONSE":
return ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_RESPONSE;
case 22:
case "TYPE_EMOJI_REACTION":
return ApplicationMetadataMessage_Type.TYPE_EMOJI_REACTION;
case 23:
case "TYPE_GROUP_CHAT_INVITATION":
return ApplicationMetadataMessage_Type.TYPE_GROUP_CHAT_INVITATION;
case 24:
case "TYPE_CHAT_IDENTITY":
return ApplicationMetadataMessage_Type.TYPE_CHAT_IDENTITY;
case 25:
case "TYPE_COMMUNITY_DESCRIPTION":
return ApplicationMetadataMessage_Type.TYPE_COMMUNITY_DESCRIPTION;
case 26:
case "TYPE_COMMUNITY_INVITATION":
return ApplicationMetadataMessage_Type.TYPE_COMMUNITY_INVITATION;
case 27:
case "TYPE_COMMUNITY_REQUEST_TO_JOIN":
return ApplicationMetadataMessage_Type.TYPE_COMMUNITY_REQUEST_TO_JOIN;
case 28:
case "TYPE_PIN_MESSAGE":
return ApplicationMetadataMessage_Type.TYPE_PIN_MESSAGE;
case 29:
case "TYPE_EDIT_MESSAGE":
return ApplicationMetadataMessage_Type.TYPE_EDIT_MESSAGE;
case 30:
case "TYPE_STATUS_UPDATE":
return ApplicationMetadataMessage_Type.TYPE_STATUS_UPDATE;
case 31:
case "TYPE_DELETE_MESSAGE":
return ApplicationMetadataMessage_Type.TYPE_DELETE_MESSAGE;
case 32:
case "TYPE_SYNC_INSTALLATION_COMMUNITY":
return ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_COMMUNITY;
case 33:
case "TYPE_ANONYMOUS_METRIC_BATCH":
return ApplicationMetadataMessage_Type.TYPE_ANONYMOUS_METRIC_BATCH;
case -1:
case "UNRECOGNIZED":
default:
return ApplicationMetadataMessage_Type.UNRECOGNIZED;
}
}
export function applicationMetadataMessage_TypeToJSON(
object: ApplicationMetadataMessage_Type
): string {
switch (object) {
case ApplicationMetadataMessage_Type.TYPE_UNKNOWN_UNSPECIFIED:
return "TYPE_UNKNOWN_UNSPECIFIED";
case ApplicationMetadataMessage_Type.TYPE_CHAT_MESSAGE:
return "TYPE_CHAT_MESSAGE";
case ApplicationMetadataMessage_Type.TYPE_CONTACT_UPDATE:
return "TYPE_CONTACT_UPDATE";
case ApplicationMetadataMessage_Type.TYPE_MEMBERSHIP_UPDATE_MESSAGE:
return "TYPE_MEMBERSHIP_UPDATE_MESSAGE";
case ApplicationMetadataMessage_Type.TYPE_PAIR_INSTALLATION:
return "TYPE_PAIR_INSTALLATION";
case ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION:
return "TYPE_SYNC_INSTALLATION";
case ApplicationMetadataMessage_Type.TYPE_REQUEST_ADDRESS_FOR_TRANSACTION:
return "TYPE_REQUEST_ADDRESS_FOR_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION:
return "TYPE_ACCEPT_REQUEST_ADDRESS_FOR_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION:
return "TYPE_DECLINE_REQUEST_ADDRESS_FOR_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_REQUEST_TRANSACTION:
return "TYPE_REQUEST_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_SEND_TRANSACTION:
return "TYPE_SEND_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_DECLINE_REQUEST_TRANSACTION:
return "TYPE_DECLINE_REQUEST_TRANSACTION";
case ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_CONTACT:
return "TYPE_SYNC_INSTALLATION_CONTACT";
case ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_ACCOUNT:
return "TYPE_SYNC_INSTALLATION_ACCOUNT";
case ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_PUBLIC_CHAT:
return "TYPE_SYNC_INSTALLATION_PUBLIC_CHAT";
case ApplicationMetadataMessage_Type.TYPE_CONTACT_CODE_ADVERTISEMENT:
return "TYPE_CONTACT_CODE_ADVERTISEMENT";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REGISTRATION:
return "TYPE_PUSH_NOTIFICATION_REGISTRATION";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE:
return "TYPE_PUSH_NOTIFICATION_REGISTRATION_RESPONSE";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_QUERY:
return "TYPE_PUSH_NOTIFICATION_QUERY";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE:
return "TYPE_PUSH_NOTIFICATION_QUERY_RESPONSE";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_REQUEST:
return "TYPE_PUSH_NOTIFICATION_REQUEST";
case ApplicationMetadataMessage_Type.TYPE_PUSH_NOTIFICATION_RESPONSE:
return "TYPE_PUSH_NOTIFICATION_RESPONSE";
case ApplicationMetadataMessage_Type.TYPE_EMOJI_REACTION:
return "TYPE_EMOJI_REACTION";
case ApplicationMetadataMessage_Type.TYPE_GROUP_CHAT_INVITATION:
return "TYPE_GROUP_CHAT_INVITATION";
case ApplicationMetadataMessage_Type.TYPE_CHAT_IDENTITY:
return "TYPE_CHAT_IDENTITY";
case ApplicationMetadataMessage_Type.TYPE_COMMUNITY_DESCRIPTION:
return "TYPE_COMMUNITY_DESCRIPTION";
case ApplicationMetadataMessage_Type.TYPE_COMMUNITY_INVITATION:
return "TYPE_COMMUNITY_INVITATION";
case ApplicationMetadataMessage_Type.TYPE_COMMUNITY_REQUEST_TO_JOIN:
return "TYPE_COMMUNITY_REQUEST_TO_JOIN";
case ApplicationMetadataMessage_Type.TYPE_PIN_MESSAGE:
return "TYPE_PIN_MESSAGE";
case ApplicationMetadataMessage_Type.TYPE_EDIT_MESSAGE:
return "TYPE_EDIT_MESSAGE";
case ApplicationMetadataMessage_Type.TYPE_STATUS_UPDATE:
return "TYPE_STATUS_UPDATE";
case ApplicationMetadataMessage_Type.TYPE_DELETE_MESSAGE:
return "TYPE_DELETE_MESSAGE";
case ApplicationMetadataMessage_Type.TYPE_SYNC_INSTALLATION_COMMUNITY:
return "TYPE_SYNC_INSTALLATION_COMMUNITY";
case ApplicationMetadataMessage_Type.TYPE_ANONYMOUS_METRIC_BATCH:
return "TYPE_ANONYMOUS_METRIC_BATCH";
default:
return "UNKNOWN";
}
}
const baseApplicationMetadataMessage: object = { type: 0 };
export const ApplicationMetadataMessage = {
encode(
message: ApplicationMetadataMessage,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.signature.length !== 0) {
writer.uint32(10).bytes(message.signature);
}
if (message.payload.length !== 0) {
writer.uint32(18).bytes(message.payload);
}
if (message.type !== 0) {
writer.uint32(24).int32(message.type);
}
return writer;
},
decode(
input: _m0.Reader | Uint8Array,
length?: number
): ApplicationMetadataMessage {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = {
...baseApplicationMetadataMessage,
} as ApplicationMetadataMessage;
message.signature = new Uint8Array();
message.payload = new Uint8Array();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.signature = reader.bytes();
break;
case 2:
message.payload = reader.bytes();
break;
case 3:
message.type = reader.int32() as any;
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},
fromJSON(object: any): ApplicationMetadataMessage {
const message = {
...baseApplicationMetadataMessage,
} as ApplicationMetadataMessage;
message.signature = new Uint8Array();
message.payload = new Uint8Array();
if (object.signature !== undefined && object.signature !== null) {
message.signature = bytesFromBase64(object.signature);
}
if (object.payload !== undefined && object.payload !== null) {
message.payload = bytesFromBase64(object.payload);
}
if (object.type !== undefined && object.type !== null) {
message.type = applicationMetadataMessage_TypeFromJSON(object.type);
} else {
message.type = 0;
}
return message;
},
toJSON(message: ApplicationMetadataMessage): unknown {
const obj: any = {};
message.signature !== undefined &&
(obj.signature = base64FromBytes(
message.signature !== undefined ? message.signature : new Uint8Array()
));
message.payload !== undefined &&
(obj.payload = base64FromBytes(
message.payload !== undefined ? message.payload : new Uint8Array()
));
message.type !== undefined &&
(obj.type = applicationMetadataMessage_TypeToJSON(message.type));
return obj;
},
fromPartial(
object: DeepPartial<ApplicationMetadataMessage>
): ApplicationMetadataMessage {
const message = {
...baseApplicationMetadataMessage,
} as ApplicationMetadataMessage;
if (object.signature !== undefined && object.signature !== null) {
message.signature = object.signature;
} else {
message.signature = new Uint8Array();
}
if (object.payload !== undefined && object.payload !== null) {
message.payload = object.payload;
} else {
message.payload = new Uint8Array();
}
if (object.type !== undefined && object.type !== null) {
message.type = object.type;
} else {
message.type = 0;
}
return message;
},
};
declare var self: any | undefined;
declare var window: any | undefined;
declare var global: any | undefined;
var globalThis: any = (() => {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw "Unable to locate global object";
})();
const atob: (b64: string) => string =
globalThis.atob ||
((b64) => globalThis.Buffer.from(b64, "base64").toString("binary"));
function bytesFromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
arr[i] = bin.charCodeAt(i);
}
return arr;
}
const btoa: (bin: string) => string =
globalThis.btoa ||
((bin) => globalThis.Buffer.from(bin, "binary").toString("base64"));
function base64FromBytes(arr: Uint8Array): string {
const bin: string[] = [];
for (const byte of arr) {
bin.push(String.fromCharCode(byte));
}
return btoa(bin.join(""));
}
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}

View File

@ -0,0 +1,46 @@
import { BN } from "bn.js";
import { derive } from "ecies-geth";
import { ec } from "elliptic";
import { bufToHex } from "js-waku/build/main/lib/utils";
import { idToContentTopic } from "./contentTopic";
import { hexToBuf } from "./utils";
import { Identity } from ".";
const EC = new ec("secp256k1");
const partitionsNum = new BN(5000);
/**
* Get the partitioned topic https://specs.status.im/spec/3#partitioned-topic
* @param publicKey Public key of recipient
* @returns string The Waku v2 Content Topic.
*/
export function getPartitionedTopic(publicKey: string): string {
const key = EC.keyFromPublic(publicKey.slice(2), "hex");
const X = key.getPublic().getX();
const partition = X.mod(partitionsNum);
const partitionTopic = `contact-discovery-${partition.toString()}`;
return idToContentTopic(partitionTopic);
}
/**
* Get the negotiated topic https://specs.status.im/spec/3#negotiated-topic
* @param identity identity of user
* @param publicKey Public key of recipient
* @returns string The Waku v2 Content Topic.
*/
export async function getNegotiatedTopic(
identity: Identity,
publicKey: string
): Promise<string> {
const key = EC.keyFromPublic(publicKey.slice(2), "hex");
const sharedSecret = await derive(
Buffer.from(identity.privateKey),
hexToBuf(key.getPublic("hex"))
);
return idToContentTopic(bufToHex(sharedSecret));
}

View File

@ -0,0 +1,57 @@
import { ec } from "elliptic";
import { PageDirection, utils, Waku } from "js-waku";
import { idToContactCodeTopic } from "./contentTopic";
import { ChatIdentity } from "./proto/communities/v1/chat_identity";
const EC = new ec("secp256k1");
const hexToBuf = utils.hexToBuf;
export { hexToBuf };
/**
* Return hex string with 0x prefix (commonly used for string format of a community id/public key.
*/
export function bufToHex(buf: Uint8Array): string {
return "0x" + utils.bufToHex(buf);
}
export function compressPublicKey(key: Uint8Array): string {
const PubKey = EC.keyFromPublic(key);
return "0x" + PubKey.getPublic(true, "hex");
}
export function genPrivateKeyWithEntropy(key: string): Uint8Array {
const pair = EC.genKeyPair({ entropy: key });
return hexToBuf("0x" + pair.getPrivate("hex"));
}
export async function getLatestUserNickname(
key: Uint8Array,
waku: Waku
): Promise<{ clock: number; nickname: string }> {
const publicKey = bufToHex(key);
let nickname = "";
let clock = 0;
await waku.store.queryHistory([idToContactCodeTopic(publicKey)], {
callback: (msgs) =>
msgs.some((e) => {
try {
if (e.payload) {
const chatIdentity = ChatIdentity.decode(e?.payload);
if (chatIdentity) {
if (chatIdentity?.displayName) {
clock = chatIdentity?.clock ?? 0;
nickname = chatIdentity?.displayName;
}
}
return true;
}
} catch {
return false;
}
}),
pageDirection: PageDirection.BACKWARD,
});
return { clock, nickname };
}

View File

@ -0,0 +1,75 @@
import { keccak256 } from "js-sha3";
import { Reader } from "protobufjs";
import secp256k1 from "secp256k1";
import { Identity } from "../identity";
import * as proto from "../proto/status/v1/application_metadata_message";
import { ApplicationMetadataMessage_Type } from "../proto/status/v1/application_metadata_message";
import { hexToBuf } from "../utils";
import { ChatMessage } from "./chat_message";
export class ApplicationMetadataMessage {
private constructor(public proto: proto.ApplicationMetadataMessage) {}
/**
* Create a chat message to be sent to an Open (permission = no membership) community
*/
public static create(
payload: Uint8Array,
type: ApplicationMetadataMessage_Type,
identity: Identity
): ApplicationMetadataMessage {
const signature = identity.sign(payload);
const proto = {
signature,
payload,
type,
};
return new ApplicationMetadataMessage(proto);
}
static decode(bytes: Uint8Array): ApplicationMetadataMessage {
const protoBuf = proto.ApplicationMetadataMessage.decode(
Reader.create(bytes)
);
return new ApplicationMetadataMessage(protoBuf);
}
encode(): Uint8Array {
return proto.ApplicationMetadataMessage.encode(this.proto).finish();
}
public get signature(): Uint8Array | undefined {
return this.proto.signature;
}
public get payload(): Uint8Array | undefined {
return this.proto.payload;
}
public get type(): ApplicationMetadataMessage_Type | undefined {
return this.proto.type;
}
/**
* Returns a chat message if the type is [TYPE_CHAT_MESSAGE], undefined otherwise.
*/
public get chatMessage(): ChatMessage | undefined {
if (!this.payload) return;
return ChatMessage.decode(this.payload);
}
public get signer(): Uint8Array | undefined {
if (!this.signature || !this.payload) return;
const signature = this.signature.slice(0, 64);
const recid = this.signature.slice(64)[0];
const hash = keccak256(this.payload);
return secp256k1.ecdsaRecover(signature, recid, hexToBuf(hash));
}
}

View File

@ -0,0 +1,51 @@
import { Reader } from "protobufjs";
import * as proto from "../proto/communities/v1/chat_identity";
import { IdentityImage } from "../proto/communities/v1/chat_identity";
export class ChatIdentity {
public constructor(public proto: proto.ChatIdentity) {}
static decode(bytes: Uint8Array): ChatIdentity {
const protoBuf = proto.ChatIdentity.decode(Reader.create(bytes));
return new ChatIdentity(protoBuf);
}
encode(): Uint8Array {
return proto.ChatIdentity.encode(this.proto).finish();
}
/** Lamport timestamp of the message */
get clock(): number | undefined {
return this.proto.clock;
}
/** ens_name is the valid ENS name associated with the chat key */
get ensName(): string | undefined {
return this.proto.ensName;
}
/** images is a string indexed mapping of images associated with an identity */
get images(): { [key: string]: IdentityImage } | undefined {
return this.proto.images;
}
/** display name is the user set identity, valid only for organisations */
get displayName(): string | undefined {
return this.proto.displayName;
}
/** description is the user set description, valid only for organisations */
get description(): string | undefined {
return this.proto.description;
}
get color(): string | undefined {
return this.proto.color;
}
get emoji(): string | undefined {
return this.proto.emoji;
}
}

View File

@ -0,0 +1,78 @@
import { expect } from "chai";
import {
AudioMessage_AudioType,
ChatMessage_ContentType,
} from "../proto/communities/v1/chat_message";
import { ImageType } from "../proto/communities/v1/enums";
import {
AudioContent,
ChatMessage,
ContentType,
ImageContent,
StickerContent,
} from "./chat_message";
describe("Chat Message", () => {
it("Encode & decode Image message", () => {
const payload = Buffer.from([1, 1]);
const imageContent: ImageContent = {
image: payload,
imageType: ImageType.IMAGE_TYPE_PNG,
contentType: ContentType.Image,
};
const message = ChatMessage.createMessage(1, 1, "chat-id", imageContent);
const buf = message.encode();
const dec = ChatMessage.decode(buf);
expect(dec.contentType).eq(ChatMessage_ContentType.CONTENT_TYPE_IMAGE);
expect(dec.image?.payload?.toString()).eq(payload.toString());
expect(dec.image?.type).eq(ImageType.IMAGE_TYPE_PNG);
});
it("Encode & decode Audio message", () => {
const payload = Buffer.from([1, 1]);
const durationMs = 12345;
const audioContent: AudioContent = {
audio: payload,
audioType: AudioMessage_AudioType.AUDIO_TYPE_AAC,
durationMs,
contentType: ContentType.Audio,
};
const message = ChatMessage.createMessage(1, 1, "chat-id", audioContent);
const buf = message.encode();
const dec = ChatMessage.decode(buf);
expect(dec.contentType).eq(ChatMessage_ContentType.CONTENT_TYPE_AUDIO);
expect(dec.audio?.payload?.toString()).eq(payload.toString());
expect(dec.audio?.type).eq(ImageType.IMAGE_TYPE_PNG);
expect(dec.audio?.durationMs).eq(durationMs);
});
it("Encode & decode Sticker message", () => {
const hash = "deadbeef";
const pack = 12345;
const stickerContent: StickerContent = {
hash,
pack,
contentType: ContentType.Sticker,
};
const message = ChatMessage.createMessage(1, 1, "chat-id", stickerContent);
const buf = message.encode();
const dec = ChatMessage.decode(buf);
expect(dec.contentType).eq(ChatMessage_ContentType.CONTENT_TYPE_STICKER);
expect(dec.sticker?.hash).eq(hash);
expect(dec.sticker?.pack).eq(pack);
});
});

View File

@ -0,0 +1,226 @@
import { Reader } from "protobufjs";
import * as proto from "../proto/communities/v1/chat_message";
import {
AudioMessage,
AudioMessage_AudioType,
ChatMessage_ContentType,
ImageMessage,
StickerMessage,
} from "../proto/communities/v1/chat_message";
import { ImageType, MessageType } from "../proto/communities/v1/enums";
export type Content =
| TextContent
| StickerContent
| ImageContent
| AudioContent;
export enum ContentType {
Text,
Sticker,
Image,
Audio,
}
export interface TextContent {
text: string;
contentType: ContentType.Text;
}
export interface StickerContent {
hash: string;
pack: number;
contentType: ContentType.Sticker;
}
export interface ImageContent {
image: Uint8Array;
imageType: ImageType;
contentType: ContentType.Image;
}
export interface AudioContent {
audio: Uint8Array;
audioType: AudioMessage_AudioType;
durationMs: number;
contentType: ContentType.Audio;
}
function isText(content: Content): content is TextContent {
return content.contentType === ContentType.Text;
}
function isSticker(content: Content): content is StickerContent {
return content.contentType === ContentType.Sticker;
}
function isImage(content: Content): content is ImageContent {
return content.contentType === ContentType.Image;
}
function isAudio(content: Content): content is AudioContent {
return content.contentType === ContentType.Audio;
}
export class ChatMessage {
private constructor(public proto: proto.ChatMessage) {}
/**
* Create a chat message to be sent to an Open (permission = no membership) community.
*
* @throws string If mediaContent is malformed
*/
public static createMessage(
clock: number,
timestamp: number,
chatId: string,
content: Content,
responseTo?: string
): ChatMessage {
let sticker,
image,
audio,
text = "Upgrade to the latest version to see this media content.";
let contentType = ChatMessage_ContentType.CONTENT_TYPE_TEXT_PLAIN;
if (isText(content)) {
if (!content.text) throw "Malformed Text Content";
text = content.text;
contentType = ChatMessage_ContentType.CONTENT_TYPE_TEXT_PLAIN;
} else if (isSticker(content)) {
if (!content.hash || !content.pack) throw "Malformed Sticker Content";
sticker = {
hash: content.hash,
pack: content.pack,
};
contentType = ChatMessage_ContentType.CONTENT_TYPE_STICKER;
} else if (isImage(content)) {
if (!content.image || !content.imageType) throw "Malformed Image Content";
image = {
payload: content.image,
type: content.imageType,
};
contentType = ChatMessage_ContentType.CONTENT_TYPE_IMAGE;
} else if (isAudio(content)) {
if (!content.audio || !content.audioType || !content.durationMs)
throw "Malformed Audio Content";
audio = {
payload: content.audio,
type: content.audioType,
durationMs: content.durationMs,
};
contentType = ChatMessage_ContentType.CONTENT_TYPE_AUDIO;
}
const proto = {
clock, // ms?
timestamp, //ms?
text,
/** Id of the message that we are replying to */
responseTo: responseTo ?? "",
/** Ens name of the sender */
ensName: "",
/** Public Key of the community (TBC) **/
chatId,
/** The type of message (public/one-to-one/private-group-chat) */
messageType: MessageType.MESSAGE_TYPE_COMMUNITY_CHAT,
/** The type of the content of the message */
contentType,
sticker,
image,
audio,
community: undefined, // Used to share a community
grant: undefined,
};
return new ChatMessage(proto);
}
static decode(bytes: Uint8Array): ChatMessage {
const protoBuf = proto.ChatMessage.decode(Reader.create(bytes));
return new ChatMessage(protoBuf);
}
encode(): Uint8Array {
return proto.ChatMessage.encode(this.proto).finish();
}
/** Lamport timestamp of the chat message */
public get clock(): number | undefined {
return this.proto.clock;
}
/**
* Unix timestamps in milliseconds, currently not used as we use whisper as more reliable, but here
* so that we don't rely on it
*/
public get timestamp(): number | undefined {
return this.proto.timestamp;
}
/**
* Text of the message
*/
public get text(): string | undefined {
return this.proto.text;
}
/**
* Id of the message that we are replying to
*/
public get responseTo(): string | undefined {
return this.proto.responseTo;
}
/**
* Ens name of the sender
*/
public get ensName(): string | undefined {
return this.proto.ensName;
}
/**
* Chat id, this field is symmetric for public-chats and private group chats,
* but asymmetric in case of one-to-ones, as the sender will use the chat-id
* of the received, while the receiver will use the chat-id of the sender.
* Probably should be the concatenation of sender-pk & receiver-pk in alphabetical order
*/
public get chatId(): string {
return this.proto.chatId;
}
/**
* The type of message (public/one-to-one/private-group-chat)
*/
public get messageType(): MessageType | undefined {
return this.proto.messageType;
}
/**
* The type of the content of the message
*/
public get contentType(): ChatMessage_ContentType | undefined {
return this.proto.contentType;
}
public get sticker(): StickerMessage | undefined {
return this.proto.sticker;
}
public get image(): ImageMessage | undefined {
return this.proto.image;
}
public get audio(): AudioMessage | undefined {
return this.proto.audio;
}
/**
* Used when sharing a community via a chat message.
*/
public get community(): Uint8Array | undefined {
return this.proto.community;
}
}

View File

@ -0,0 +1,59 @@
import { Reader } from "protobufjs";
import * as proto from "../proto/communities/v1/communities";
import {
CommunityMember,
CommunityPermissions,
} from "../proto/communities/v1/communities";
import { ChatIdentity } from "./chat_identity";
export class CommunityChat {
public constructor(public proto: proto.CommunityChat) {}
/**
* Decode the payload as CommunityChat message.
*
* @throws
*/
static decode(bytes: Uint8Array): CommunityChat {
const protoBuf = proto.CommunityChat.decode(Reader.create(bytes));
return new CommunityChat(protoBuf);
}
encode(): Uint8Array {
return proto.CommunityChat.encode(this.proto).finish();
}
// TODO: check and document what is the key of the returned Map;
public get members(): Map<string, CommunityMember> {
const map = new Map();
for (const key of Object.keys(this.proto.members)) {
map.set(key, this.proto.members[key]);
}
return map;
}
public get permissions(): CommunityPermissions | undefined {
return this.proto.permissions;
}
public get identity(): ChatIdentity | undefined {
if (!this.proto.identity) return;
return new ChatIdentity(this.proto.identity);
}
// TODO: Document this
public get categoryId(): string | undefined {
return this.proto.categoryId;
}
// TODO: Document this
public get position(): number | undefined {
return this.proto.position;
}
}

View File

@ -0,0 +1,101 @@
import debug from "debug";
import { WakuMessage, WakuStore } from "js-waku";
import { Reader } from "protobufjs";
import { idToContentTopic } from "../contentTopic";
import { createSymKeyFromPassword } from "../encryption";
import * as proto from "../proto/communities/v1/communities";
import { bufToHex } from "../utils";
import { ApplicationMetadataMessage } from "./application_metadata_message";
import { ChatIdentity } from "./chat_identity";
import { CommunityChat } from "./community_chat";
const dbg = debug("communities:wire:community_description");
export class CommunityDescription {
private constructor(public proto: proto.CommunityDescription) {}
static decode(bytes: Uint8Array): CommunityDescription {
const protoBuf = proto.CommunityDescription.decode(Reader.create(bytes));
return new CommunityDescription(protoBuf);
}
encode(): Uint8Array {
return proto.CommunityDescription.encode(this.proto).finish();
}
/**
* Retrieves the most recent Community Description it can find on the network.
*/
public static async retrieve(
communityPublicKey: Uint8Array,
wakuStore: WakuStore
): Promise<CommunityDescription | undefined> {
const hexCommunityPublicKey = bufToHex(communityPublicKey);
const contentTopic = idToContentTopic(hexCommunityPublicKey);
let communityDescription: CommunityDescription | undefined;
const callback = (messages: WakuMessage[]): void => {
// Value found, stop processing
if (communityDescription) return;
// Process most recent message first
const orderedMessages = messages.reverse();
orderedMessages.forEach((message: WakuMessage) => {
if (!message.payload) return;
try {
const metadata = ApplicationMetadataMessage.decode(message.payload);
if (!metadata.payload) return;
const _communityDescription = CommunityDescription.decode(
metadata.payload
);
if (!_communityDescription.identity) return;
communityDescription = _communityDescription;
} catch (e) {
dbg(
`Failed to decode message as CommunityDescription found on content topic ${contentTopic}`,
e
);
}
});
};
const symKey = await createSymKeyFromPassword(hexCommunityPublicKey);
await wakuStore
.queryHistory([contentTopic], {
callback,
decryptionKeys: [symKey],
})
.catch((e) => {
dbg(
`Failed to retrieve community description for ${hexCommunityPublicKey}`,
e
);
});
return communityDescription;
}
get identity(): ChatIdentity | undefined {
if (!this.proto.identity) return;
return new ChatIdentity(this.proto.identity);
}
get chats(): Map<string, CommunityChat> {
const map = new Map();
for (const key of Object.keys(this.proto.chats)) {
map.set(key, this.proto.chats[key]);
}
return map;
}
}

View File

@ -0,0 +1,201 @@
import { keccak256 } from "js-sha3";
import { Reader } from "protobufjs";
import * as secp256k1 from "secp256k1";
import { v4 as uuidV4 } from "uuid";
import { Identity } from "..";
import * as proto from "../proto/communities/v1/membership_update_message";
import { bufToHex, hexToBuf } from "../utils";
export class MembershipUpdateEvent {
public constructor(public proto: proto.MembershipUpdateEvent) {}
static decode(bytes: Uint8Array): MembershipUpdateEvent {
const protoBuf = proto.MembershipUpdateEvent.decode(Reader.create(bytes));
return new MembershipUpdateEvent(protoBuf);
}
encode(): Uint8Array {
return proto.MembershipUpdateEvent.encode(this.proto).finish();
}
public get members(): string[] {
return this.proto.members;
}
public get name(): string {
return this.proto.name;
}
public get clock(): number {
return this.proto.clock;
}
public get type(): proto.MembershipUpdateEvent_EventType {
return this.proto.type;
}
}
export class MembershipSignedEvent {
public sig: Uint8Array;
public event: MembershipUpdateEvent;
private chatId: string;
public constructor(
sig: Uint8Array,
event: MembershipUpdateEvent,
chatId: string
) {
this.sig = sig;
this.event = event;
this.chatId = chatId;
}
public get signer(): Uint8Array | undefined {
const encEvent = this.event.encode();
const eventToSign = Buffer.concat([hexToBuf(this.chatId), encEvent]);
if (!this.sig || !eventToSign) return;
const signature = this.sig.slice(0, 64);
const recid = this.sig.slice(64)[0];
const hash = keccak256(eventToSign);
return secp256k1.ecdsaRecover(signature, recid, hexToBuf(hash));
}
}
export class MembershipUpdateMessage {
private clock: number = Date.now();
private identity: Identity = Identity.generate();
public constructor(public proto: proto.MembershipUpdateMessage) {}
public static create(
chatId: string,
identity: Identity
): MembershipUpdateMessage {
const partial = proto.MembershipUpdateMessage.fromPartial({
chatId,
events: [],
});
const newMessage = new MembershipUpdateMessage(partial);
newMessage.clock = Date.now();
newMessage.identity = identity;
return newMessage;
}
private addEvent(event: MembershipUpdateEvent): void {
const encEvent = event.encode();
const eventToSign = Buffer.concat([hexToBuf(this.proto.chatId), encEvent]);
const signature = this.identity.sign(eventToSign);
this.proto.events.push(Buffer.concat([signature, encEvent]));
}
public static createChat(
identity: Identity,
members: string[],
name?: string
): MembershipUpdateMessage {
const chatId = `${uuidV4()}-${bufToHex(identity.publicKey)}`;
const message = this.create(chatId, identity);
const type = proto.MembershipUpdateEvent_EventType.CHAT_CREATED;
const event = new MembershipUpdateEvent({
clock: message.clock,
members,
name: name ?? "",
type,
});
message.addEvent(event);
return message;
}
public addNameChangeEvent(name: string): void {
const type = proto.MembershipUpdateEvent_EventType.NAME_CHANGED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members: [],
name: name,
type,
});
this.addEvent(event);
}
public addMembersAddedEvent(members: string[]): void {
const type = proto.MembershipUpdateEvent_EventType.MEMBERS_ADDED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members,
name: "",
type,
});
this.addEvent(event);
}
public addMemberJoinedEvent(member: string): void {
const type = proto.MembershipUpdateEvent_EventType.MEMBER_JOINED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members: [member],
name: "",
type,
});
this.addEvent(event);
}
public addMemberRemovedEvent(member: string): void {
const type = proto.MembershipUpdateEvent_EventType.MEMBER_REMOVED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members: [member],
name: "",
type,
});
this.addEvent(event);
}
public addAdminsAddedEvent(members: string[]): void {
const type = proto.MembershipUpdateEvent_EventType.ADMINS_ADDED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members,
name: "",
type,
});
this.addEvent(event);
}
public addAdminRemovedEvent(member: string): void {
const type = proto.MembershipUpdateEvent_EventType.ADMINS_ADDED;
const event = new MembershipUpdateEvent({
clock: this.clock,
members: [member],
name: "",
type,
});
this.addEvent(event);
}
static decode(bytes: Uint8Array): MembershipUpdateMessage {
const protoBuf = proto.MembershipUpdateMessage.decode(Reader.create(bytes));
return new MembershipUpdateMessage(protoBuf);
}
public get events(): MembershipSignedEvent[] {
return this.proto.events.map((bufArray) => {
return new MembershipSignedEvent(
bufArray.slice(0, 65),
MembershipUpdateEvent.decode(bufArray.slice(65)),
this.chatId
);
});
}
public get chatId(): string {
return this.proto.chatId;
}
encode(): Uint8Array {
return proto.MembershipUpdateMessage.encode(this.proto).finish();
}
}

View File

@ -0,0 +1,49 @@
import { Reader } from "protobufjs";
import * as proto from "../proto/communities/v1/status_update";
export class StatusUpdate {
public constructor(public proto: proto.StatusUpdate) {}
public static create(
statusType: proto.StatusUpdate_StatusType,
customText: string
): StatusUpdate {
const clock = Date.now();
const proto = {
clock,
statusType,
customText,
};
return new StatusUpdate(proto);
}
/**
* Decode the payload as CommunityChat message.
*
* @throws
*/
static decode(bytes: Uint8Array): StatusUpdate {
const protoBuf = proto.StatusUpdate.decode(Reader.create(bytes));
return new StatusUpdate(protoBuf);
}
encode(): Uint8Array {
return proto.StatusUpdate.encode(this.proto).finish();
}
public get clock(): number | undefined {
return this.proto.clock;
}
public get statusType(): proto.StatusUpdate_StatusType | undefined {
return this.proto.statusType;
}
public get customText(): string | undefined {
return this.proto.customText;
}
}

View File

@ -0,0 +1,51 @@
{
"compilerOptions": {
"incremental": true,
"target": "es6",
"outDir": "dist",
"rootDir": "src",
"moduleResolution": "node",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false /* to set at a later stage */,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
/* Debugging Options */
"traceResolution": false,
"listEmittedFiles": false,
"listFiles": false,
"pretty": true,
// Due to broken types in indirect dependencies
"skipLibCheck": true,
"lib": ["es6", "dom"],
"typeRoots": [
"./node_modules/@types",
"./src/types",
"../../node_modules/@types"
]
},
"include": ["src"],
"exclude": ["node_modules/**"],
"types": ["mocha"],
"compileOnSave": false
}

View File

@ -0,0 +1,42 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"env": { "es6": true },
"ignorePatterns": ["node_modules", "dist", "coverage", "proto"],
"plugins": ["import", "eslint-comments", "functional"],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"prettier"
],
"globals": { "BigInt": true, "console": true, "WebAssembly": true },
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{ "allowWholeFile": true }
],
"eslint-comments/no-unused-disable": "error",
"import/order": [
"error",
{ "newlines-between": "always", "alphabetize": { "order": "asc" } }
],
"no-constant-condition": ["error", { "checkLoops": false }],
"sort-imports": [
"error",
{ "ignoreDeclarationSort": true, "ignoreCase": true }
]
},
"overrides": [
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
}

View File

@ -0,0 +1,6 @@
{
"extension": ["ts"],
"spec": "src/**/*.spec.ts",
"require": "ts-node/register",
"exit": true
}

View File

@ -0,0 +1,2 @@
# package.json is formatted by package managers, so we ignore it here
package.json

View File

@ -0,0 +1 @@
# `status-react`

View File

@ -0,0 +1,6 @@
version: v1beta1
plugins:
- name: ts_proto
out: ./src/proto
opt: grpc_js,esModuleInterop=true

View File

@ -0,0 +1,5 @@
version: v1beta1
build:
roots:
- ./proto

View File

@ -0,0 +1,69 @@
{
"name": "@status-im/react",
"version": "0.0.0",
"license": "MIT OR Apache-2.0",
"homepage": "https://github.com/status-im/status-web",
"repository": {
"url": "https://github.com/status-im/status-web.git",
"directory": "packages/status-react",
"type": "git"
},
"bugs": {
"url": "https://github.com/status-im/status-web/issues"
},
"main": "dist/cjs/src/index.js",
"module": "dist/esm/src/index.js",
"types": "dist/esm/src/index.d.ts",
"scripts": {
"build": "run-s 'build:*'",
"build:esm": "tsc --module es2020 --target es2017 --outDir dist/esm",
"build:cjs": "tsc --outDir dist/cjs",
"fix": "run-s 'fix:*'",
"fix:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --write",
"fix:lint": "eslint './{src,test}/**/*.{ts,tsx}' --fix",
"test": "run-s 'test:*'",
"test:lint": "eslint './{src,test}/**/*.{ts,tsx}'",
"test:prettier": "prettier './{src,test}/**/*.{ts,tsx}' \"./*.json\" --list-different",
"proto": "run-s 'proto:*'",
"proto:lint": "buf lint",
"proto:build": "buf generate"
},
"devDependencies": {
"@hcaptcha/react-hcaptcha": "^1.0.0",
"@types/chai": "^4.2.21",
"@types/emoji-mart": "^3.0.6",
"@types/hcaptcha__react-hcaptcha": "^0.1.5",
"@types/mocha": "^9.0.0",
"@types/node": "^16.9.6",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^17.0.16",
"@types/styled-components": "^5.1.12",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"chai": "^4.3.4",
"copyfiles": "^2.4.1",
"eslint": "^7.32.0",
"eslint-plugin-react-hooks": "^4.3.0",
"jsdom": "^16.7.0",
"jsdom-global": "^3.0.2",
"mocha": "^9.0.3",
"npm-run-all": "^4.1.5",
"npm-watch": "^0.11.0",
"prettier": "^2.3.2",
"qrcode.react": "^1.0.1",
"rimraf": "^3.0.2",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
},
"dependencies": {
"@status-im/core": "^0.0.0",
"emoji-mart": "^3.0.1",
"html-entities": "^2.3.2",
"js-sha3": "^0.8.0",
"js-waku": "^0.16.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-is": "^17.0.2",
"styled-components": "^5.3.1"
}
}

View File

@ -0,0 +1,116 @@
import React, { useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { useIdentity } from "../../contexts/identityProvider";
import { useActivities } from "../../hooks/useActivities";
import { useClickOutside } from "../../hooks/useClickOutside";
import { TopBtn } from "../Chat/ChatTopbar";
import { ActivityIcon } from "../Icons/ActivityIcon";
import { ActivityCenter } from "./ActivityCenter";
interface ActivityButtonProps {
className?: string;
}
export function ActivityButton({ className }: ActivityButtonProps) {
const { activities, activityDispatch } = useActivities();
const identity = useIdentity();
const disabled = useMemo(() => !identity, [identity]);
const ref = useRef(null);
useClickOutside(ref, () => setShowActivityCenter(false));
const [showActivityCenter, setShowActivityCenter] = useState(false);
const badgeAmount = useMemo(
() => activities.filter((activity) => !activity.isRead).length,
[activities]
);
return (
<ActivityWrapper ref={ref} className={className}>
<TopBtn
onClick={() => setShowActivityCenter(!showActivityCenter)}
disabled={disabled}
>
<ActivityIcon />
{badgeAmount > 0 && (
<NotificationBagde
className={
badgeAmount > 99
? "countless"
: badgeAmount > 9
? "wide"
: undefined
}
>
{badgeAmount < 100 ? badgeAmount : "∞"}
</NotificationBagde>
)}
</TopBtn>
{showActivityCenter && (
<ActivityCenter
activities={activities}
setShowActivityCenter={setShowActivityCenter}
activityDispatch={activityDispatch}
/>
)}
</ActivityWrapper>
);
}
export const ActivityWrapper = styled.div`
padding-left: 10px;
margin-left: 10px;
position: relative;
&:before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 2px;
height: 24px;
transform: translateY(-50%);
border-radius: 1px;
background: ${({ theme }) => theme.primary};
opacity: 0.1;
}
&.creation {
padding-left: 0px;
margin-left: 16px;
&:before {
width: 0px;
height: 0px;
}
}
`;
const NotificationBagde = styled.div`
width: 18px;
height: 18px;
position: absolute;
top: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
font-size: 12px;
line-height: 16px;
font-weight: 500;
background-color: ${({ theme }) => theme.notificationColor};
color: ${({ theme }) => theme.bodyBackgroundColor};
border-radius: 9px;
&.wide {
width: 26px;
right: -7px;
}
&.countless {
width: 22px;
}
`;

View File

@ -0,0 +1,189 @@
import React, { useMemo, useState } from "react";
import styled from "styled-components";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { ActivityAction } from "../../hooks/useActivities";
import { Activity } from "../../models/Activity";
import { buttonTransparentStyles } from "../Buttons/buttonStyle";
import { Tooltip } from "../Form/Tooltip";
import { HideIcon } from "../Icons/HideIcon";
import { ReadIcon } from "../Icons/ReadIcon";
import { ShowIcon } from "../Icons/ShowIcon";
import { ActivityMessage } from "./ActivityMessage";
interface ActivityCenterProps {
activities: Activity[];
setShowActivityCenter: (val: boolean) => void;
activityDispatch: React.Dispatch<ActivityAction>;
}
export function ActivityCenter({
activities,
setShowActivityCenter,
activityDispatch,
}: ActivityCenterProps) {
const { contacts } = useMessengerContext();
const shownActivities = useMemo(
() =>
activities.filter(
(activity) => !contacts?.[activity.user]?.blocked ?? true
),
[contacts, activities]
);
const [hideRead, setHideRead] = useState(false);
const [filter, setFilter] = useState("");
const filteredActivities = shownActivities.filter((activity) =>
filter
? activity.type === filter
: hideRead
? activity.isRead !== true
: activity
);
return (
<ActivityBlock>
<ActivityFilter>
<FlexDiv>
<FilterBtn onClick={() => setFilter("")}>All</FilterBtn>
<FilterBtn onClick={() => setFilter("mention")}>Mentions</FilterBtn>
<FilterBtn onClick={() => setFilter("reply")}>Replies</FilterBtn>
<FilterBtn onClick={() => setFilter("request")}>
Contact requests
</FilterBtn>
</FlexDiv>
<Btns>
<BtnWrapper>
<ActivityBtn
onClick={() => activityDispatch({ type: "setAllAsRead" })}
>
<ReadIcon />
</ActivityBtn>
<Tooltip tip="Mark all as Read" />
</BtnWrapper>
<BtnWrapper>
<ActivityBtn onClick={() => setHideRead(!hideRead)}>
{hideRead ? <ShowIcon /> : <HideIcon />}
</ActivityBtn>
<Tooltip tip={hideRead ? "Show read" : "Hide read"} />
</BtnWrapper>
</Btns>
</ActivityFilter>
{filteredActivities.length > 0 ? (
<Activities>
{filteredActivities.map((activity) => (
<ActivityMessage
key={activity.id}
activity={activity}
setShowActivityCenter={setShowActivityCenter}
activityDispatch={activityDispatch}
/>
))}
</Activities>
) : (
<EmptyActivities>Notifications will appear here</EmptyActivities>
)}
</ActivityBlock>
);
}
const ActivityBlock = styled.div`
width: 600px;
height: 770px;
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.bodyBackgroundColor};
box-shadow: 0px 12px 24px rgba(0, 34, 51, 0.1);
border-radius: 8px;
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 100;
`;
const ActivityFilter = styled.div`
display: flex;
justify-content: space-between;
padding: 13px 16px;
`;
export const FlexDiv = styled.div`
display: flex;
`;
const FilterBtn = styled.button`
${buttonTransparentStyles}
& + & {
margin-left: 8px;
}
`;
const BtnWrapper = styled.div`
position: relative;
&:hover > div {
visibility: visible;
}
`;
export const ActivityBtn = styled.button`
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
align-self: center;
&:hover {
background: ${({ theme }) => theme.buttonBgHover};
}
&.read {
&:hover {
background: ${({ theme }) => theme.bodyBackgroundColor};
}
}
&.accept {
&:hover {
background: rgba(78, 188, 96, 0.1);
}
}
&.decline {
&:hover {
background: rgba(255, 45, 85, 0.1);
}
}
& + & {
margin-left: 8px;
}
`;
const Activities = styled.div`
display: flex;
flex-direction: column;
width: 100%;
overflow: auto;
`;
const EmptyActivities = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex: 1;
width: 100%;
color: ${({ theme }) => theme.secondary};
`;
const Btns = styled.div`
display: flex;
align-items: center;
`;

View File

@ -0,0 +1,386 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useModal } from "../../contexts/modalProvider";
import { useScrollToMessage } from "../../contexts/scrollProvider";
import { ActivityAction } from "../../hooks/useActivities";
import { useClickOutside } from "../../hooks/useClickOutside";
import { Activity } from "../../models/Activity";
import { equalDate } from "../../utils/equalDate";
import { DownloadButton } from "../Buttons/DownloadButton";
import { Mention } from "../Chat/ChatMessageContent";
import { Logo } from "../CommunityIdentity";
import { ContactMenu } from "../Form/ContactMenu";
import { Tooltip } from "../Form/Tooltip";
import { CheckIcon } from "../Icons/CheckIcon";
import { ClearSvg } from "../Icons/ClearIcon";
import { CommunityIcon } from "../Icons/CommunityIcon";
import { GroupIcon } from "../Icons/GroupIcon";
import { MoreIcon } from "../Icons/MoreIcon";
import { ReadMessageIcon } from "../Icons/ReadMessageIcon";
import { ReplyIcon } from "../Icons/ReplyActivityIcon";
import { UntrustworthIcon } from "../Icons/UntrustworthIcon";
import { UserIcon } from "../Icons/UserIcon";
import {
ContentWrapper,
DateSeparator,
MessageHeaderWrapper,
MessageOuterWrapper,
MessageText,
TimeWrapper,
UserAddress,
UserName,
UserNameWrapper,
} from "../Messages/Styles";
import { ProfileModalName } from "../Modals/ProfileModal";
import { textMediumStyles, textSmallStyles } from "../Text";
import { ActivityBtn, FlexDiv } from "./ActivityCenter";
const today = new Date();
type ActivityMessageProps = {
activity: Activity;
setShowActivityCenter: (val: boolean) => void;
activityDispatch: React.Dispatch<ActivityAction>;
};
export function ActivityMessage({
activity,
setShowActivityCenter,
activityDispatch,
}: ActivityMessageProps) {
const { contacts, channelsDispatch } = useMessengerContext();
const scroll = useScrollToMessage();
const { setModal } = useModal(ProfileModalName);
const showChannel = () => {
"channel" in activity &&
channelsDispatch({ type: "ChangeActive", payload: activity.channel.id }),
setShowActivityCenter(false);
};
const [showMenu, setShowMenu] = useState(false);
const type = activity.type;
const contact = useMemo(
() => contacts[activity.user],
[activity.user, contacts]
);
const [elements, setElements] = useState<
(string | React.ReactElement | undefined)[]
>(["message" in activity ? activity.message?.content : undefined]);
useEffect(() => {
if ("message" in activity) {
const split = activity.message?.content.split(" ");
const newSplit = split.flatMap((element, idx) => {
if (element.startsWith("@")) {
return [
<Mention
key={idx}
id={element}
setMentioned={() => true}
className="activity"
/>,
" ",
];
}
return [element, " "];
});
newSplit.pop();
setElements(newSplit);
}
}, [activity]);
const ref = useRef(null);
useClickOutside(ref, () => setShowMenu(false));
return (
<MessageOuterWrapper>
<ActivityDate>
{equalDate(activity.date, today)
? "Today"
: activity.date.toLocaleDateString()}
</ActivityDate>
<MessageWrapper className={`${!activity.isRead && "unread"}`}>
<>
<UserIcon />
<ActivityContent>
<MessageHeaderWrapper>
<UserNameWrapper>
<ActivityUserName
onClick={() => {
setModal({
id: activity.user,
renamingState: false,
requestState: false,
});
}}
>
{" "}
{contact?.customName ?? activity.user.slice(0, 10)}
</ActivityUserName>
{contact?.customName && (
<UserAddress>
{activity.user.slice(0, 5)}...{activity.user.slice(-3)}
</UserAddress>
)}
{contact.isUntrustworthy && <UntrustworthIcon />}
</UserNameWrapper>
<TimeWrapper>
{activity.date.toLocaleString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})}
</TimeWrapper>
</MessageHeaderWrapper>
{type === "request" && (
<ContextHeading>
Contact request
{activity.requestType === "outcome"
? ` to ${activity.user.slice(0, 10)}`
: ": "}
</ContextHeading>
)}
{type === "invitation" && (
<FlexDiv>
<ContextHeading>{`Invited you to join a community `}</ContextHeading>
<Tag>
<CommunityIcon width={17} height={16} />
<CommunityLogo
style={{
backgroundImage: activity.invitation?.icon
? `url(${activity.invitation?.icon}`
: "",
}}
>
{activity.invitation?.icon === undefined &&
activity.invitation?.name.slice(0, 1).toUpperCase()}
</CommunityLogo>
<span>{activity.invitation?.name}</span>
</Tag>
</FlexDiv>
)}
<ActivityText>
{"message" in activity && activity.message?.content && (
<div
onClick={() => {
scroll(activity.message, activity.channel.id);
setShowActivityCenter(false);
}}
>
{elements.map((el) => el)}
</div>
)}
{activity.type === "request" &&
activity.requestType === "income" &&
activity.request}
</ActivityText>
{type === "mention" &&
activity.channel &&
activity.channel.type !== "dm" && (
<Tag onClick={showChannel}>
{activity.channel.type === "group" ? <GroupIcon /> : "#"}{" "}
<span>{` ${activity.channel.name.slice(0, 10)}`}</span>
</Tag>
)}
{type === "reply" && activity.quote && (
<ReplyWrapper>
{activity.quote.image && (
<ContextHeading>Posted an image in</ContextHeading>
)}
<Tag onClick={showChannel}>
<ReplyIcon /> <span>{activity.quote.content}</span>
</Tag>
</ReplyWrapper>
)}
{type === "invitation" && (
<InviteDiv>
<ContextHeading>{`To access other communities, `}</ContextHeading>
<DownloadButton className="activity" />
</InviteDiv>
)}
</ActivityContent>
</>
{type === "request" &&
!activity.status &&
activity.requestType === "income" && (
<>
<ActivityBtn
onClick={() => {
activityDispatch({
type: "setStatus",
payload: { id: activity.id, status: "accepted" },
});
}}
className="accept"
>
<CheckIcon width={20} height={20} className="accept" />
</ActivityBtn>
<ActivityBtn
onClick={() => {
activityDispatch({
type: "setStatus",
payload: { id: activity.id, status: "declined" },
});
}}
className="decline"
>
<ClearSvg width={20} height={20} className="decline" />
</ActivityBtn>
<ActivityBtn
onClick={() => {
setShowMenu((e) => !e);
}}
ref={ref}
>
{showMenu && (
<ContactMenu id={activity.user} setShowMenu={setShowMenu} />
)}
<MoreIcon />
</ActivityBtn>
</>
)}
{type === "request" && activity.status === "accepted" && (
<RequestStatus className="accepted">Accepted</RequestStatus>
)}
{type === "request" && activity.status === "declined" && (
<RequestStatus className="declined">Declined</RequestStatus>
)}
{type === "request" && activity.status === "sent" && (
<RequestStatus>Sent</RequestStatus>
)}
{(type === "mention" || type === "reply") && (
<BtnWrapper>
<ActivityBtn
onClick={() =>
activityDispatch({ type: "setAsRead", payload: activity.id })
}
className={`${activity.isRead && "read"}`}
>
<ReadMessageIcon isRead={activity.isRead} />
</ActivityBtn>
<Tooltip tip="Mark Read" className="read" />
</BtnWrapper>
)}
</MessageWrapper>
</MessageOuterWrapper>
);
}
const InviteDiv = styled.div`
display: flex;
margin-top: -4px;
`;
const BtnWrapper = styled.div`
position: relative;
&:hover > div {
visibility: visible;
}
`;
const ActivityDate = styled(DateSeparator)`
justify-content: flex-start;
padding: 8px 16px;
margin: 0;
`;
const MessageWrapper = styled.div`
width: 100%;
display: flex;
align-items: flex-start;
padding: 8px 16px;
&.unread {
background: ${({ theme }) => theme.buttonBgHover};
}
`;
const ActivityText = styled(MessageText)`
white-space: unset;
margin-bottom: 8px;
`;
const Tag = styled.div`
width: fit-content;
max-width: 200px;
display: flex;
align-items: center;
border: 1px solid ${({ theme }) => theme.secondary};
border-radius: 11px;
padding: 0 6px;
cursor: pointer;
font-weight: 500;
color: ${({ theme }) => theme.secondary};
${textSmallStyles}
& > span {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
`;
const ContextHeading = styled.p`
font-style: italic;
color: ${({ theme }) => theme.secondary};
flex-shrink: 0;
white-space: pre-wrap;
${textMediumStyles}
`;
const RequestStatus = styled.p`
font-weight: 500;
align-self: center;
text-align: end;
color: ${({ theme }) => theme.secondary};
${textSmallStyles}
&.accepted {
color: ${({ theme }) => theme.greenColor};
}
&.declined {
color: ${({ theme }) => theme.redColor};
}
`;
const ActivityContent = styled(ContentWrapper)`
max-width: calc(100% - 80px);
flex: 1;
`;
const ActivityUserName = styled(UserName)`
cursor: pointer;
&:hover {
text-decoration: underline;
}
`;
const ReplyWrapper = styled.div`
max-width: 100%;
display: flex;
align-items: center;
& > p {
margin-right: 4px;
}
`;
const CommunityLogo = styled(Logo)`
width: 16px;
height: 16px;
margin: 0 2px 0 4px;
${textSmallStyles}
`;

View File

@ -0,0 +1,31 @@
import React from "react";
import styled from "styled-components";
import { LeftIcon } from "../Icons/LeftIcon";
interface BackButtonProps {
onBtnClick: () => void;
className?: string;
}
export function BackButton({ onBtnClick, className }: BackButtonProps) {
return (
<BackBtn onClick={onBtnClick} className={className}>
<LeftIcon width={24} height={24} className="black" />
</BackBtn>
);
}
const BackBtn = styled.button`
position: absolute;
left: 0;
top: 8px;
width: 32px;
height: 44px;
padding: 0;
&.narrow {
position: static;
margin-right: 13px;
}
`;

View File

@ -0,0 +1,84 @@
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import { buttonStyles } from "./buttonStyle";
const userAgent = window.navigator.userAgent;
const platform = window.navigator.platform;
const macosPlatforms = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"];
const windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"];
const iosPlatforms = ["iPhone", "iPad", "iPod"];
interface DownloadButtonProps {
className?: string;
}
export const DownloadButton = ({ className }: DownloadButtonProps) => {
const [link, setlink] = useState("https://status.im/get/");
const [os, setOs] = useState<string | null>(null);
useEffect(() => {
if (macosPlatforms.includes(platform)) {
setlink(
"https://status-im-files.ams3.cdn.digitaloceanspaces.com/StatusIm-Desktop-v0.3.0-beta-a8c37d.dmg"
);
setOs("Mac");
} else if (iosPlatforms.includes(platform)) {
setlink(
"https://apps.apple.com/us/app/status-private-communication/id1178893006"
);
setOs("iOS");
} else if (windowsPlatforms.includes(platform)) {
setlink(
"https://status-im-files.ams3.cdn.digitaloceanspaces.com/StatusIm-Desktop-v0.3.0-beta-a8c37d.exe"
);
setOs("Windows");
} else if (/Android/.test(userAgent)) {
setlink(
"https://play.google.com/store/apps/details?id=im.status.ethereum"
);
setOs("Android");
} else if (/Linux/.test(platform)) {
setlink(
"https://status-im-files.ams3.cdn.digitaloceanspaces.com/StatusIm-Desktop-v0.3.0-beta-a8c37d.tar.gz"
);
setOs("Linux");
}
}, []);
return (
<Link
className={className}
href={link}
target="_blank"
rel="noopener noreferrer"
>
{os
? `${className === "activity" ? "d" : "D"}ownload Status for ${os}`
: `${className === "activity" ? "d" : "D"}ownload Status`}
</Link>
);
};
const Link = styled.a`
margin-top: 24px;
padding: 11px 32px;
${buttonStyles}
&.activity {
margin: 0;
padding: 0;
color: ${({ theme }) => theme.secondary};
font-style: italic;
border-radius: 0;
font-weight: 400;
text-decoration: underline;
background: inherit;
&:hover {
background: inherit;
color: ${({ theme }) => theme.tertiary};
}
}
`;

View File

@ -0,0 +1,59 @@
import styled, { css } from "styled-components";
export const buttonStyles = css`
font-family: "Inter";
font-weight: 500;
font-size: 15px;
line-height: 22px;
text-align: center;
border-radius: 8px;
color: ${({ theme }) => theme.tertiary};
background: ${({ theme }) => theme.buttonBg};
&:hover {
background: ${({ theme }) => theme.buttonBgHover};
}
&:focus {
background: ${({ theme }) => theme.buttonBg};
}
`;
export const buttonTransparentStyles = css`
font-family: "Inter";
font-weight: 500;
font-size: 13px;
line-height: 18px;
text-align: center;
color: ${({ theme }) => theme.tertiary};
background: inherit;
padding: 10px 12px;
border-radius: 8px;
&:hover {
background: ${({ theme }) => theme.buttonBgHover};
}
&:focus {
background: ${({ theme }) => theme.buttonBg};
}
`;
export const ButtonNo = styled.button`
padding: 11px 24px;
margin-right: 16px;
${buttonStyles}
background: ${({ theme }) => theme.buttonNoBg};
color: ${({ theme }) => theme.redColor};
&:hover {
background: ${({ theme }) => theme.buttonNoBgHover};
}
`;
export const ButtonYes = styled.button`
padding: 11px 24px;
${buttonStyles}
`;

View File

@ -0,0 +1,211 @@
import React from "react";
import styled from "styled-components";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useNarrow } from "../../contexts/narrowProvider";
import { ChannelData } from "../../models/ChannelData";
import { ChannelMenu } from "../Form/ChannelMenu";
import { Tooltip } from "../Form/Tooltip";
import { GroupIcon } from "../Icons/GroupIcon";
import { MutedIcon } from "../Icons/MutedIcon";
import { textMediumStyles } from "../Text";
import { ChannelIcon } from "./ChannelIcon";
function RenderChannelName({
channel,
activeView,
className,
}: {
channel: ChannelData;
activeView?: boolean;
className?: string;
}) {
const { activeChannel } = useMessengerContext();
switch (channel.type) {
case "group":
return (
<div className={className}>
{!activeView && (
<GroupIcon active={channel.id === activeChannel?.id} />
)}
{` ${channel.name}`}
</div>
);
case "channel":
return <div className={className}>{`# ${channel.name}`}</div>;
case "dm":
return <div className={className}>{channel.name.slice(0, 20)}</div>;
}
}
interface ChannelProps {
channel: ChannelData;
notified?: boolean;
mention?: number;
isActive: boolean;
activeView?: boolean;
onClick?: () => void;
setEditGroup?: React.Dispatch<React.SetStateAction<boolean>>;
}
export function Channel({
channel,
isActive,
activeView,
onClick,
notified,
mention,
setEditGroup,
}: ChannelProps) {
const narrow = useNarrow();
const { channelsDispatch } = useMessengerContext();
return (
<ChannelWrapper
className={`${isActive && "active"}`}
isNarrow={narrow && activeView}
onClick={onClick}
id={!activeView ? `${channel.id + "contextMenu"}` : ""}
>
<ChannelInfo activeView={activeView}>
<ChannelIcon channel={channel} activeView={activeView} />
<ChannelTextInfo activeView={activeView && !narrow}>
<ChannelNameWrapper>
<ChannelName
channel={channel}
active={isActive || activeView || narrow}
activeView={activeView}
muted={channel?.isMuted}
notified={notified}
/>
{channel?.isMuted && activeView && !narrow && (
<MutedBtn
onClick={() =>
channelsDispatch({ type: "ToggleMuted", payload: channel.id })
}
>
<MutedIcon />
<Tooltip tip="Unmute" className="muted" />
</MutedBtn>
)}
</ChannelNameWrapper>
{activeView && (
<ChannelDescription>{channel.description}</ChannelDescription>
)}
</ChannelTextInfo>
</ChannelInfo>
{!activeView && !!mention && !channel?.isMuted && (
<NotificationBagde>{mention}</NotificationBagde>
)}
{channel?.isMuted && !activeView && <MutedIcon />}
{!activeView && (
<ChannelMenu
channel={channel}
setEditGroup={setEditGroup}
className={narrow ? "sideNarrow" : "side"}
/>
)}
</ChannelWrapper>
);
}
const ChannelWrapper = styled.div<{ isNarrow?: boolean }>`
width: ${({ isNarrow }) => (isNarrow ? "calc(100% - 162px)" : "100%")};
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-radius: 8px;
position: relative;
cursor: pointer;
&.active,
&:active {
background-color: ${({ theme }) => theme.activeChannelBackground};
}
&:hover {
background-color: ${({ theme, isNarrow }) => isNarrow && theme.border};
}
`;
export const ChannelInfo = styled.div<{ activeView?: boolean }>`
display: flex;
align-items: ${({ activeView }) => (activeView ? "flex-start" : "center")};
overflow-x: hidden;
`;
const ChannelTextInfo = styled.div<{ activeView?: boolean }>`
display: flex;
flex-direction: column;
text-overflow: ellipsis;
overflow-x: hidden;
white-space: nowrap;
padding: ${({ activeView }) => activeView && "0 24px 24px 0"};
`;
const ChannelNameWrapper = styled.div`
display: flex;
align-items: center;
`;
export const ChannelName = styled(RenderChannelName)<{
muted?: boolean;
notified?: boolean;
active?: boolean;
activeView?: boolean;
}>`
font-weight: ${({ notified, muted, active }) =>
notified && !muted && !active ? "600" : "500"};
opacity: ${({ notified, muted, active }) =>
muted ? "0.4" : notified || active ? "1.0" : "0.7"};
color: ${({ theme }) => theme.primary};
margin-right: ${({ muted, activeView }) =>
muted && activeView ? "8px" : ""};
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
${textMediumStyles}
`;
const ChannelDescription = styled.p`
font-size: 12px;
line-height: 16px;
letter-spacing: 0.1px;
color: ${({ theme }) => theme.secondary};
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
const NotificationBagde = styled.div`
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 12px;
line-height: 16px;
font-weight: 500;
background-color: ${({ theme }) => theme.notificationColor};
color: ${({ theme }) => theme.bodyBackgroundColor};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
const MutedBtn = styled.button`
padding: 0;
border: none;
outline: none;
position: relative;
&:hover > svg {
fill-opacity: 1;
}
&:hover > div {
visibility: visible;
}
`;

View File

@ -0,0 +1,54 @@
import React from "react";
import styled from "styled-components";
import { useNarrow } from "../../contexts/narrowProvider";
import { ChannelData } from "../../models/ChannelData";
interface ChannelIconProps {
channel: ChannelData;
activeView?: boolean;
}
export function ChannelIcon({ channel, activeView }: ChannelIconProps) {
const narrow = useNarrow();
return (
<ChannelLogo
icon={channel.icon}
className={activeView ? "active" : narrow ? "narrow" : ""}
>
{!channel.icon && channel.name.slice(0, 1).toUpperCase()}
</ChannelLogo>
);
}
export const ChannelLogo = styled.div<{ icon?: string }>`
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 10px;
border-radius: 50%;
font-weight: bold;
font-size: 15px;
line-height: 20px;
background-color: ${({ theme }) => theme.iconColor};
background-size: cover;
background-repeat: no-repeat;
background-image: ${({ icon }) => icon && `url(${icon}`};
color: ${({ theme }) => theme.iconTextColor};
&.active {
width: 36px;
height: 36px;
font-size: 20px;
}
&.narrow {
width: 40px;
height: 40px;
font-size: 20px;
}
`;

View File

@ -0,0 +1,164 @@
import React, { useMemo } from "react";
import styled from "styled-components";
import { ChatState, useChatState } from "../../contexts/chatStateProvider";
import { useIdentity } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { CreateIcon } from "../Icons/CreateIcon";
import { UserCreation } from "../UserCreation/UserCreation";
import { Channel } from "./Channel";
interface ChannelsProps {
onCommunityClick?: () => void;
setEditGroup?: React.Dispatch<React.SetStateAction<boolean>>;
}
type GenerateChannelsProps = ChannelsProps & {
type: string;
};
function GenerateChannels({
type,
onCommunityClick,
setEditGroup,
}: GenerateChannelsProps) {
const { mentions, notifications, activeChannel, channelsDispatch, channels } =
useMessengerContext();
const channelList = useMemo(() => Object.values(channels), [channels]);
const setChatState = useChatState()[1];
return (
<>
{channelList
.filter((channel) => channel.type === type)
.map((channel) => (
<Channel
key={channel.id}
channel={channel}
isActive={channel.id === activeChannel?.id}
notified={notifications?.[channel.id] > 0}
mention={mentions?.[channel.id]}
onClick={() => {
channelsDispatch({ type: "ChangeActive", payload: channel.id });
if (onCommunityClick) {
onCommunityClick();
}
setChatState(ChatState.ChatBody);
}}
setEditGroup={setEditGroup}
/>
))}
</>
);
}
type ChatsListProps = {
onCommunityClick?: () => void;
setEditGroup?: React.Dispatch<React.SetStateAction<boolean>>;
};
function ChatsSideBar({ onCommunityClick, setEditGroup }: ChatsListProps) {
const setChatState = useChatState()[1];
return (
<>
<ChatsBar>
<Heading>Messages</Heading>
<EditBtn onClick={() => setChatState(ChatState.ChatCreation)}>
<CreateIcon />
</EditBtn>
</ChatsBar>
<ChatsList>
<GenerateChannels
type={"group"}
onCommunityClick={onCommunityClick}
setEditGroup={setEditGroup}
/>
<GenerateChannels type={"dm"} onCommunityClick={onCommunityClick} />
</ChatsList>
</>
);
}
export function Channels({ onCommunityClick, setEditGroup }: ChannelsProps) {
const identity = useIdentity();
return (
<ChannelList>
<GenerateChannels type={"channel"} onCommunityClick={onCommunityClick} />
<Chats>
{identity ? (
<ChatsSideBar
onCommunityClick={onCommunityClick}
setEditGroup={setEditGroup}
/>
) : (
<UserCreation permission={true} />
)}
</Chats>
</ChannelList>
);
}
export const ChannelList = styled.div`
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0;
}
`;
const Chats = styled.div`
display: flex;
flex-direction: column;
padding-top: 16px;
margin-top: 16px;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 24px);
height: 1px;
background-color: ${({ theme }) => theme.primary};
opacity: 0.1;
}
`;
const ChatsBar = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
`;
const ChatsList = styled.div`
display: flex;
flex-direction: column;
`;
const Heading = styled.p`
font-weight: bold;
font-size: 17px;
line-height: 24px;
color: ${({ theme }) => theme.primary};
`;
const EditBtn = styled.button`
width: 32px;
height: 32px;
border-radius: 8px;
padding: 0;
&:hover {
background: ${({ theme }) => theme.inputColor};
}
&:active {
background: ${({ theme }) => theme.sectionBackgroundColor};
}
`;

View File

@ -0,0 +1,129 @@
import React, { useMemo } from "react";
import styled from "styled-components";
import { useUserPublicKey } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useNarrow } from "../../contexts/narrowProvider";
import { ChannelData } from "../../models/ChannelData";
import { textMediumStyles } from "../Text";
import { ChannelInfo, ChannelName } from "./Channel";
import { ChannelLogo } from "./ChannelIcon";
type ChannelBeggingTextProps = {
channel: ChannelData;
};
function ChannelBeggingText({ channel }: ChannelBeggingTextProps) {
const userPK = useUserPublicKey();
const { contacts } = useMessengerContext();
const members = useMemo(() => {
if (channel?.members && userPK) {
return channel.members
.filter((contact) => contact.id !== userPK)
.map((member) => contacts?.[member.id] ?? member);
}
return [];
}, [channel, contacts, userPK]);
switch (channel.type) {
case "dm":
return (
<EmptyText>
Any messages you send here are encrypted and can only be read by you
and <br />
<span>{channel.name.slice(0, 10)}</span>.
</EmptyText>
);
case "group":
return (
<EmptyTextGroup>
{userPK && <span>{userPK}</span>} created a group with{" "}
{members.map((contact, idx) => (
<span key={contact.id}>
{contact?.customName ?? contact.trueName.slice(0, 10)}
{idx < members.length - 1 && <> and </>}
</span>
))}
</EmptyTextGroup>
);
case "channel":
return (
<EmptyText>
Welcome to the beginning of the <span>#{channel.name}</span> channel!
</EmptyText>
);
}
return null;
}
type EmptyChannelProps = {
channel: ChannelData;
};
export function EmptyChannel({ channel }: EmptyChannelProps) {
const narrow = useNarrow();
return (
<Wrapper className={`${!narrow && "wide"}`}>
<ChannelInfoEmpty>
<ChannelLogoEmpty icon={channel.icon}>
{" "}
{!channel.icon && channel.name.slice(0, 1).toUpperCase()}
</ChannelLogoEmpty>
<ChannelNameEmpty active={true} channel={channel} />
</ChannelInfoEmpty>
<ChannelBeggingText channel={channel} />
</Wrapper>
);
}
const Wrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
&.wide {
margin-top: 24px;
}
`;
const ChannelInfoEmpty = styled(ChannelInfo)`
flex-direction: column;
`;
const ChannelLogoEmpty = styled(ChannelLogo)`
width: 120px;
height: 120px;
font-weight: bold;
font-size: 51px;
line-height: 62px;
margin-bottom: 16px;
`;
const ChannelNameEmpty = styled(ChannelName)`
font-weight: bold;
font-size: 22px;
line-height: 30px;
margin-bottom: 16px;
`;
const EmptyText = styled.p`
display: inline-block;
color: ${({ theme }) => theme.secondary};
max-width: 310px;
text-align: center;
& > span {
color: ${({ theme }) => theme.primary};
}
${textMediumStyles}
`;
const EmptyTextGroup = styled(EmptyText)`
& > span {
word-break: break-all;
}
`;

View File

@ -0,0 +1,182 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useNarrow } from "../../contexts/narrowProvider";
import { Reply } from "../../hooks/useReply";
import { ChannelData } from "../../models/ChannelData";
import { TokenRequirement } from "../Form/TokenRequirement";
import { MessagesList } from "../Messages/MessagesList";
import { NarrowChannels } from "../NarrowMode/NarrowChannels";
import { NarrowMembers } from "../NarrowMode/NarrowMembers";
import { LoadingSkeleton } from "../Skeleton/LoadingSkeleton";
import { ChatCreation } from "./ChatCreation";
import { ChatInput } from "./ChatInput";
import { ChatTopbar, ChatTopbarLoading } from "./ChatTopbar";
export enum ChatBodyState {
Chat,
Channels,
Members,
}
function ChatBodyLoading() {
const narrow = useNarrow();
return (
<Wrapper>
<ChatBodyWrapper className={narrow ? "narrow" : ""}>
<ChatTopbarLoading />
<LoadingSkeleton />
<ChatInput reply={undefined} setReply={() => undefined} />
</ChatBodyWrapper>
</Wrapper>
);
}
type ChatBodyContentProps = {
showState: ChatBodyState;
switchShowState: (state: ChatBodyState) => void;
channel: ChannelData;
};
function ChatBodyContent({
showState,
switchShowState,
channel,
}: ChatBodyContentProps) {
const [reply, setReply] = useState<Reply | undefined>(undefined);
switch (showState) {
case ChatBodyState.Chat:
return (
<>
<MessagesList setReply={setReply} channel={channel} />
<ChatInput reply={reply} setReply={setReply} />
</>
);
case ChatBodyState.Channels:
return (
<NarrowChannels
setShowChannels={() => switchShowState(ChatBodyState.Channels)}
/>
);
case ChatBodyState.Members:
return (
<NarrowMembers
switchShowMembersList={() => switchShowState(ChatBodyState.Members)}
/>
);
}
}
interface ChatBodyProps {
onClick: () => void;
showMembers: boolean;
permission: boolean;
editGroup: boolean;
setEditGroup: React.Dispatch<React.SetStateAction<boolean>>;
}
export function ChatBody({
onClick,
showMembers,
permission,
editGroup,
setEditGroup,
}: ChatBodyProps) {
const { activeChannel, loadingMessenger } = useMessengerContext();
const narrow = useNarrow();
const className = useMemo(() => (narrow ? "narrow" : ""), [narrow]);
const [showState, setShowState] = useState<ChatBodyState>(ChatBodyState.Chat);
const switchShowState = useCallback(
(state: ChatBodyState) => {
if (narrow) {
setShowState((prev) => (prev === state ? ChatBodyState.Chat : state));
}
},
[narrow]
);
useEffect(() => {
if (!narrow) {
setShowState(ChatBodyState.Chat);
}
}, [narrow]);
if (!loadingMessenger && activeChannel) {
return (
<Wrapper>
<ChatBodyWrapper className={className}>
{editGroup ? (
<ChatCreation
setEditGroup={setEditGroup}
activeChannel={activeChannel}
/>
) : (
<>
<ChatTopbar
onClick={onClick}
setEditGroup={setEditGroup}
showMembers={showMembers}
showState={showState}
switchShowState={switchShowState}
/>
<ChatBodyContent
showState={showState}
switchShowState={switchShowState}
channel={activeChannel}
/>
</>
)}
</ChatBodyWrapper>
{!permission && (
<BluredWrapper>
<TokenRequirement />
</BluredWrapper>
)}
</Wrapper>
);
}
return <ChatBodyLoading />;
}
export const Wrapper = styled.div`
width: 61%;
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
background: ${({ theme }) => theme.bodyBackgroundColor};
position: relative;
&.narrow {
width: 100%;
}
`;
const ChatBodyWrapper = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
background: ${({ theme }) => theme.bodyBackgroundColor};
`;
const BluredWrapper = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
position: absolute;
bottom: 0;
left: 0;
background: ${({ theme }) => theme.bodyBackgroundGradient};
backdrop-filter: blur(4px);
z-index: 2;
`;

View File

@ -0,0 +1,387 @@
import React, { useCallback, useMemo, useState } from "react";
import styled from "styled-components";
import { ChatState, useChatState } from "../../contexts/chatStateProvider";
import { useUserPublicKey } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useNarrow } from "../../contexts/narrowProvider";
import { ChannelData } from "../../models/ChannelData";
import { ActivityButton } from "../ActivityCenter/ActivityButton";
import { BackButton } from "../Buttons/BackButton";
import { buttonStyles } from "../Buttons/buttonStyle";
import { CrossIcon } from "../Icons/CrossIcon";
import { Member } from "../Members/Member";
import { SearchBlock } from "../SearchBlock";
import { textMediumStyles } from "../Text";
import { ChatInput } from "./ChatInput";
interface ChatCreationProps {
setEditGroup?: (val: boolean) => void;
activeChannel?: ChannelData;
}
export function ChatCreation({
setEditGroup,
activeChannel,
}: ChatCreationProps) {
const narrow = useNarrow();
const userPK = useUserPublicKey();
const [query, setQuery] = useState("");
const [groupChatMembersIds, setGroupChatMembersIds] = useState<string[]>(
activeChannel?.members?.map((member) => member.id) ?? []
);
const { contacts, createGroupChat, addMembers } = useMessengerContext();
const groupChatMembers = useMemo(
() => groupChatMembersIds.map((id) => contacts[id]).filter((e) => !!e),
[groupChatMembersIds, contacts]
);
const contactsList = useMemo(() => {
return Object.values(contacts)
.filter(
(member) =>
member.id.includes(query) ||
member?.customName?.includes(query) ||
member.trueName.includes(query)
)
.filter((member) => !groupChatMembersIds.includes(member.id));
}, [query, groupChatMembersIds, contacts]);
const setChatState = useChatState()[1];
const addMember = useCallback(
(member: string) => {
setGroupChatMembersIds((prevMembers: string[]) => {
if (
prevMembers.find((mem) => mem === member) ||
prevMembers.length >= 5
) {
return prevMembers;
} else {
return [...prevMembers, member];
}
});
setQuery("");
},
[setGroupChatMembersIds]
);
const removeMember = useCallback(
(member: string) => {
setGroupChatMembersIds((prev) => prev.filter((e) => e != member));
},
[setGroupChatMembersIds]
);
const createChat = useCallback(
(group: string[]) => {
if (userPK) {
const newGroup = group.slice();
newGroup.push(userPK);
createGroupChat(newGroup);
setChatState(ChatState.ChatBody);
}
},
[userPK, createGroupChat, setChatState]
);
const handleCreationClick = useCallback(() => {
if (!activeChannel) {
createChat(groupChatMembers.map((member) => member.id));
} else {
addMembers(
groupChatMembers.map((member) => member.id),
activeChannel.id
);
}
setEditGroup?.(false);
}, [activeChannel, groupChatMembers, createChat, addMembers, setEditGroup]);
return (
<CreationWrapper className={`${narrow && "narrow"}`}>
<CreationBar
className={`${groupChatMembers.length === 5 && narrow && "limit"}`}
>
{narrow && (
<BackButton
onBtnClick={() =>
setEditGroup
? setEditGroup?.(false)
: setChatState(ChatState.ChatBody)
}
className="narrow"
/>
)}
<Column>
<InputBar>
<InputText>To:</InputText>
<StyledList>
{groupChatMembers.map((member) => (
<StyledMember key={member.id}>
<StyledName>
{member?.customName?.slice(0, 10) ??
member.trueName.slice(0, 10)}
</StyledName>
<CloseButton onClick={() => removeMember(member.id)}>
<CrossIcon memberView={true} />
</CloseButton>
</StyledMember>
))}
</StyledList>
{groupChatMembers.length < 5 && (
<SearchMembers>
<Input
value={query}
onInput={(e) => setQuery(e.currentTarget.value)}
/>
</SearchMembers>
)}
{!narrow && groupChatMembers.length === 5 && (
<LimitAlert>5 user Limit reached</LimitAlert>
)}
</InputBar>
{narrow && groupChatMembers.length === 5 && (
<LimitAlert className="narrow">5 user Limit reached</LimitAlert>
)}
</Column>
<CreationBtn
disabled={groupChatMembers.length === 0}
onClick={handleCreationClick}
>
Confirm
</CreationBtn>
{!narrow && <ActivityButton className="creation" />}
{!narrow && (
<SearchBlock
query={query}
discludeList={groupChatMembersIds}
onClick={addMember}
/>
)}
</CreationBar>
{((!setEditGroup && groupChatMembers.length === 0) || narrow) &&
Object.keys(contacts).length > 0 && (
<Contacts>
<ContactsHeading>Contacts</ContactsHeading>
<ContactsList>
{userPK && narrow
? contactsList.map((contact) => (
<Contact key={contact.id}>
<Member
contact={contact}
isOnline={contact.online}
onClick={() => addMember(contact.id)}
/>
</Contact>
))
: Object.values(contacts)
.filter(
(e) =>
e.id != userPK && !groupChatMembersIds.includes(e.id)
)
.map((contact) => (
<Contact key={contact.id}>
<Member
contact={contact}
isOnline={contact.online}
onClick={() => addMember(contact.id)}
/>
</Contact>
))}
</ContactsList>
</Contacts>
)}
{!setEditGroup && Object.keys(contacts).length === 0 && (
<EmptyContacts>
<EmptyContactsHeading>
You only can send direct messages to your Contacts.{" "}
</EmptyContactsHeading>
<EmptyContactsHeading>
{" "}
Send a contact request to the person you would like to chat with,
you will be able to chat with them once they have accepted your
contact request.
</EmptyContactsHeading>
</EmptyContacts>
)}
{!activeChannel && (
<ChatInput
createChat={createChat}
group={groupChatMembers.map((member) => member.id)}
/>
)}
</CreationWrapper>
);
}
const CreationWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
background-color: ${({ theme }) => theme.bodyBackgroundColor};
padding: 8px 16px;
&.narrow {
width: 100%;
max-width: 100%;
}
`;
const CreationBar = styled.div`
display: flex;
align-items: center;
margin-bottom: 24px;
position: relative;
&.limit {
align-items: flex-start;
}
`;
const Column = styled.div`
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
margin-right: 16px;
overflow-x: hidden;
`;
const InputBar = styled.div`
display: flex;
align-items: center;
width: 100%;
height: 44px;
background-color: ${({ theme }) => theme.inputColor};
border: 1px solid ${({ theme }) => theme.inputColor};
border-radius: 8px;
padding: 6px 16px;
${textMediumStyles}
`;
const Input = styled.input`
width: 100%;
min-width: 20px;
background-color: ${({ theme }) => theme.inputColor};
border: 1px solid ${({ theme }) => theme.inputColor};
outline: none;
resize: none;
${textMediumStyles}
&:focus {
outline: none;
caret-color: ${({ theme }) => theme.notificationColor};
}
`;
const InputText = styled.div`
color: ${({ theme }) => theme.secondary};
margin-right: 8px;
`;
const CreationBtn = styled.button`
padding: 11px 24px;
${buttonStyles}
&:disabled {
background: ${({ theme }) => theme.inputColor};
color: ${({ theme }) => theme.secondary};
}
`;
const StyledList = styled.div`
display: flex;
overflow-x: scroll;
margin-right: 8px;
&::-webkit-scrollbar {
display: none;
}
`;
const StyledMember = styled.div`
display: flex;
align-items: center;
padding: 4px 4px 4px 8px;
background: ${({ theme }) => theme.tertiary};
color: ${({ theme }) => theme.bodyBackgroundColor};
border-radius: 8px;
& + & {
margin-left: 8px;
}
`;
const StyledName = styled.p`
color: ${({ theme }) => theme.bodyBackgroundColor};
${textMediumStyles}
`;
const CloseButton = styled.button`
width: 20px;
height: 20px;
`;
const Contacts = styled.div`
height: calc(100% - 44px);
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
`;
const Contact = styled.div`
display: flex;
align-items: center;
padding: 12px 12px 0 16px;
border-radius: 8px;
&:hover {
background: ${({ theme }) => theme.inputColor};
}
`;
const ContactsHeading = styled.p`
color: ${({ theme }) => theme.secondary};
${textMediumStyles}
`;
export const ContactsList = styled.div`
display: flex;
flex-direction: column;
`;
const EmptyContacts = styled(Contacts)`
justify-content: center;
align-items: center;
`;
const EmptyContactsHeading = styled(ContactsHeading)`
max-width: 550px;
margin-bottom: 24px;
text-align: center;
`;
const SearchMembers = styled.div`
position: relative;
flex: 1;
`;
const LimitAlert = styled.p`
text-transform: uppercase;
margin-left: auto;
color: ${({ theme }) => theme.redColor};
white-space: nowrap;
&.narrow {
margin: 8px 0 0;
}
`;

View File

@ -0,0 +1,562 @@
import { EmojiData } from "emoji-mart";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import styled from "styled-components";
import { ChatState, useChatState } from "../../contexts/chatStateProvider";
import { useIdentity } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useModal } from "../../contexts/modalProvider";
import { useNarrow } from "../../contexts/narrowProvider";
import { useClickOutside } from "../../hooks/useClickOutside";
import { Reply } from "../../hooks/useReply";
import { uintToImgUrl } from "../../utils/uintToImgUrl";
import { ClearBtn } from "../Form/inputStyles";
import { ClearSvg } from "../Icons/ClearIcon";
import { ClearSvgFull } from "../Icons/ClearIconFull";
import { EmojiIcon } from "../Icons/EmojiIcon";
import { GifIcon } from "../Icons/GifIcon";
import { PictureIcon } from "../Icons/PictureIcon";
import { ReplySvg } from "../Icons/ReplyIcon";
import { StickerIcon } from "../Icons/StickerIcon";
import "emoji-mart/css/emoji-mart.css";
import { SizeLimitModal, SizeLimitModalName } from "../Modals/SizeLimitModal";
import { UserCreationStartModalName } from "../Modals/UserCreationStartModal";
import { SearchBlock } from "../SearchBlock";
import { textMediumStyles, textSmallStyles } from "../Text";
import { EmojiPicker } from "./EmojiPicker";
interface ChatInputProps {
reply?: Reply | undefined;
setReply?: (val: Reply | undefined) => void;
createChat?: (group: string[]) => void;
group?: string[];
}
export function ChatInput({
reply,
setReply,
createChat,
group,
}: ChatInputProps) {
const narrow = useNarrow();
const identity = useIdentity();
const setChatState = useChatState()[1];
const disabled = useMemo(() => !identity, [identity]);
const { sendMessage, contacts } = useMessengerContext();
const [content, setContent] = useState("");
const [clearComponent, setClearComponent] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const [inputHeight, setInputHeight] = useState(40);
const [imageUint, setImageUint] = useState<undefined | Uint8Array>(undefined);
const { setModal } = useModal(SizeLimitModalName);
const { setModal: setCreationStartModal } = useModal(
UserCreationStartModalName
);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLDivElement>(null);
const ref = useRef(null);
useClickOutside(ref, () => setShowEmoji(false));
const image = useMemo(
() => (imageUint ? uintToImgUrl(imageUint) : ""),
[imageUint]
);
const addEmoji = useCallback(
(e: EmojiData) => {
if ("unified" in e) {
const sym = e.unified.split("-");
const codesArray: string[] = [];
sym.forEach((el: string) => codesArray.push("0x" + el));
const emoji = String.fromCodePoint(
...(codesArray as unknown as number[])
);
if (inputRef.current) {
inputRef.current.appendChild(document.createTextNode(emoji));
}
setContent((p) => p + emoji);
}
},
[setContent]
);
const resizeTextArea = useCallback((target: HTMLDivElement) => {
target.style.height = "40px";
target.style.height = `${Math.min(target.scrollHeight, 438)}px`;
setInputHeight(target.scrollHeight);
}, []);
const rowHeight = inputHeight + (image ? 73 : 0);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLDivElement>) => {
const element = document.getSelection();
const inputElement = inputRef.current;
if (inputElement && element && element.rangeCount > 0) {
const selection = element?.getRangeAt(0)?.startOffset;
const parentElement = element.anchorNode?.parentElement;
if (parentElement && parentElement.tagName === "B") {
parentElement.outerHTML = parentElement.innerText;
const range = document.createRange();
const sel = window.getSelection();
if (element.anchorNode.firstChild) {
const childNumber =
element.focusOffset === 0 ? 0 : element.focusOffset - 1;
range.setStart(
element.anchorNode.childNodes[childNumber],
selection
);
}
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const target = e.target;
resizeTextArea(target);
setContent(target.textContent ?? "");
},
[resizeTextArea]
);
const onInputKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Enter" && !e.getModifierState("Shift")) {
e.preventDefault();
(e.target as HTMLDivElement).style.height = "40px";
setInputHeight(40);
sendMessage(content, imageUint, reply?.id);
setImageUint(undefined);
setClearComponent("");
if (inputRef.current) {
inputRef.current.innerHTML = "";
}
setContent("");
if (setReply) setReply(undefined);
if (createChat && group) {
createChat(group);
setChatState(ChatState.ChatBody);
}
}
},
[
content,
imageUint,
createChat,
group,
sendMessage,
reply?.id,
setChatState,
setReply,
]
);
const [selectedElement, setSelectedElement] = useState<{
element: Selection | null;
start: number;
end: number;
text: string;
node: Node | null;
}>({ element: null, start: 0, end: 0, text: "", node: null });
const handleCursorChange = useCallback(() => {
const element = document.getSelection();
if (element && element.rangeCount > 0) {
const selection = element?.getRangeAt(0)?.startOffset;
const text = element?.anchorNode?.textContent;
if (selection && text) {
const end = text.indexOf(" ", selection);
const start = text.lastIndexOf(" ", selection - 1);
setSelectedElement({
element,
start,
end,
text,
node: element.anchorNode,
});
const substring = text.substring(
start > -1 ? start + 1 : 0,
end > -1 ? end : undefined
);
if (substring.startsWith("@")) {
setQuery(substring.slice(1));
} else {
setQuery("");
}
}
}
}, []);
useEffect(handleCursorChange, [content, handleCursorChange]);
const addMention = useCallback(
(contact: string) => {
if (inputRef?.current) {
const { element, start, end, text, node } = selectedElement;
if (element && text && node) {
const firstSlice = text.slice(0, start > -1 ? start : 0);
const secondSlice = text.slice(end > -1 ? end : content.length);
const replaceContent = `${firstSlice} @${contact}${secondSlice}`;
const spaceElement = document.createTextNode(" ");
const contactElement = document.createElement("span");
contactElement.innerText = `@${contact}`;
if (contactElement && element.rangeCount > 0) {
const range = element.getRangeAt(0);
range.setStart(node, start > -1 ? start : 0);
if (end === -1 || end > text.length) {
range.setEnd(node, text.length);
} else {
range.setEnd(node, end);
}
range.deleteContents();
if (end === -1) {
range.insertNode(spaceElement.cloneNode());
}
range.insertNode(contactElement);
if (start > -1) {
range.insertNode(spaceElement.cloneNode());
}
range.collapse();
}
inputRef.current.focus();
setQuery("");
setContent(replaceContent);
resizeTextArea(inputRef.current);
}
}
},
[inputRef, content, selectedElement, resizeTextArea]
);
return (
<View className={`${createChat && "creation"}`}>
<SizeLimitModal />
<AddPictureInputWrapper>
<PictureIcon />
<AddPictureInput
disabled={disabled}
type="file"
multiple={true}
accept="image/png, image/jpeg"
onChange={(e) => {
const fileReader = new FileReader();
fileReader.onloadend = (s) => {
const arr = new Uint8Array(s.target?.result as ArrayBuffer);
setImageUint(arr);
};
if (e?.target?.files?.[0]) {
if (e.target.files[0].size < 1024 * 1024) {
fileReader.readAsArrayBuffer(e.target.files[0]);
} else {
setModal(true);
}
}
}}
/>
</AddPictureInputWrapper>
<InputArea>
{reply && (
<ReplyWrapper>
<ReplyTo>
{" "}
<ReplySvg width={18} height={18} className="input" />{" "}
{contacts[reply.sender]?.customName ??
contacts[reply.sender].trueName}
</ReplyTo>
<ReplyOn>{reply.content}</ReplyOn>
{reply.image && <ImagePreview src={reply.image} />}
<CloseButton
onClick={() => {
if (setReply) setReply(undefined);
}}
>
{" "}
<ClearSvg width={20} height={20} className="input" />
</CloseButton>
</ReplyWrapper>
)}
<Row style={{ height: `${rowHeight}px` }}>
<InputWrapper>
{image && (
<ImageWrapper>
<ImagePreview src={image} />
<ClearImgBtn onClick={() => setImageUint(undefined)}>
<ClearSvgFull width={20} height={20} />
</ClearImgBtn>
</ImageWrapper>
)}
{narrow && !identity ? (
<JoinBtn onClick={() => setCreationStartModal(true)}>
Click here to join discussion
</JoinBtn>
) : (
<Input
aria-disabled={disabled}
contentEditable={!disabled}
onInput={onInputChange}
onKeyDown={onInputKeyPress}
onKeyUp={handleCursorChange}
ref={inputRef}
onClick={handleCursorChange}
dangerouslySetInnerHTML={{
__html: disabled
? "You need to join this community to send messages"
: clearComponent,
}}
className={`${disabled && "disabled"} `}
/>
)}
{query && (
<SearchBlock
query={query}
discludeList={[]}
onClick={addMention}
onBotttom
/>
)}
</InputWrapper>
<InputButtons>
<EmojiWrapper ref={ref}>
<ChatButton
onClick={() => {
if (!disabled) setShowEmoji(!showEmoji);
}}
disabled={disabled}
>
<EmojiIcon isActive={showEmoji} />
</ChatButton>
<EmojiPicker
addEmoji={addEmoji}
showEmoji={showEmoji}
bottom={rowHeight - 24}
/>
</EmojiWrapper>
<ChatButton disabled={disabled}>
<StickerIcon />
</ChatButton>
<ChatButton disabled={disabled}>
<GifIcon />
</ChatButton>
</InputButtons>
</Row>
</InputArea>
</View>
);
}
const InputWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
position: relative;
`;
const EmojiWrapper = styled.div`
position: relative;
`;
const View = styled.div`
display: flex;
align-items: flex-end;
padding: 6px 8px 6px 10px;
position: relative;
&.creation {
padding: 0;
}
`;
const InputArea = styled.div`
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-height: 438px;
padding: 2px;
background: ${({ theme }) => theme.inputColor};
border-radius: 16px 16px 4px 16px;
`;
const Row = styled.div`
position: relative;
display: flex;
align-items: center;
width: 100%;
max-height: 438px;
padding-right: 6px;
background: ${({ theme }) => theme.inputColor};
border-radius: 16px 16px 4px 16px;
`;
const InputButtons = styled.div`
display: flex;
align-self: flex-end;
button + button {
margin-left: 4px;
}
`;
const ImageWrapper = styled.div`
width: 64px;
position: relative;
`;
const ImagePreview = styled.img`
width: 64px;
height: 64px;
border-radius: 16px 16px 4px 16px;
margin-left: 8px;
margin-top: 9px;
`;
const ClearImgBtn = styled(ClearBtn)`
width: 24px;
height: 24px;
top: 4px;
right: -20px;
transform: none;
padding: 0;
border: 2px solid ${({ theme }) => theme.inputColor};
background-color: ${({ theme }) => theme.inputColor};
`;
const Input = styled.div`
display: block;
width: 100%;
height: 40px;
max-height: 438px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 8px 0 8px 12px;
background: ${({ theme }) => theme.inputColor};
border: 1px solid ${({ theme }) => theme.inputColor};
color: ${({ theme }) => theme.primary};
border-radius: 16px 16px 4px 16px;
outline: none;
${textMediumStyles};
&.disabled {
color: ${({ theme }) => theme.secondary};
cursor: default;
}
&:focus {
outline: none;
caret-color: ${({ theme }) => theme.notificationColor};
}
&::-webkit-scrollbar {
width: 0;
}
& > span {
display: inline;
color: ${({ theme }) => theme.mentionColor};
background: ${({ theme }) => theme.mentionBg};
border-radius: 4px;
font-weight: 500;
position: relative;
padding: 0 2px;
cursor: pointer;
&:hover {
background: ${({ theme }) => theme.mentionBgHover};
cursor: default;
}
}
`;
const AddPictureInputWrapper = styled.div`
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
margin-right: 4px;
& > input[type="file"]::-webkit-file-upload-button {
cursor: pointer;
}
& > input:disabled::-webkit-file-upload-button {
cursor: default;
}
`;
const AddPictureInput = styled.input`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
`;
const ChatButton = styled.button`
width: 32px;
height: 32px;
&:disabled {
cursor: default;
}
`;
const CloseButton = styled(ChatButton)`
position: absolute;
top: 0;
right: 0;
`;
const ReplyWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.1);
color: ${({ theme }) => theme.primary};
border-radius: 14px 14px 4px 14px;
position: relative;
`;
export const ReplyTo = styled.div`
display: flex;
align-items: center;
font-weight: 500;
${textSmallStyles};
`;
export const ReplyOn = styled.div`
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
${textSmallStyles};
`;
const JoinBtn = styled.button`
color: ${({ theme }) => theme.secondary};
background: ${({ theme }) => theme.inputColor};
border: none;
outline: none;
padding: 0 10px;
text-align: start;
${textMediumStyles};
`;

View File

@ -0,0 +1,233 @@
import { decode } from "html-entities";
import React, { useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { useFetchMetadata } from "../../contexts/fetchMetadataProvider";
import { useUserPublicKey } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useClickOutside } from "../../hooks/useClickOutside";
import { ChatMessage } from "../../models/ChatMessage";
import { Metadata } from "../../models/Metadata";
import { ContactMenu } from "../Form/ContactMenu";
import { ImageMenu } from "../Form/ImageMenu";
import { textMediumStyles, textSmallStyles } from "../Text";
interface MentionProps {
id: string;
setMentioned: (val: boolean) => void;
className?: string;
}
export function Mention({ id, setMentioned, className }: MentionProps) {
const { contacts } = useMessengerContext();
const contact = useMemo(() => contacts[id.slice(1)], [id, contacts]);
const [showMenu, setShowMenu] = useState(false);
const userPK = useUserPublicKey();
useEffect(() => {
if (userPK && contact) {
if (contact.id === userPK) setMentioned(true);
}
}, [contact, userPK, setMentioned]);
const ref = useRef(null);
useClickOutside(ref, () => setShowMenu(false));
if (!contact) return <>{id}</>;
return (
<MentionBLock
onClick={() => setShowMenu(!showMenu)}
className={className}
ref={ref}
>
{`@${contact?.customName ?? contact.trueName}`}
{showMenu && <ContactMenu id={id.slice(1)} setShowMenu={setShowMenu} />}
</MentionBLock>
);
}
type ChatMessageContentProps = {
message: ChatMessage;
setImage: (image: string) => void;
setLinkOpen: (link: string) => void;
setMentioned: (val: boolean) => void;
};
export function ChatMessageContent({
message,
setImage,
setLinkOpen,
setMentioned,
}: ChatMessageContentProps) {
const fetchMetadata = useFetchMetadata();
const { content, image } = useMemo(() => message, [message]);
const [elements, setElements] = useState<(string | React.ReactElement)[]>([
content,
]);
const [link, setLink] = useState<string | undefined>(undefined);
const [openGraph, setOpenGraph] = useState<Metadata | undefined>(undefined);
useEffect(() => {
let link;
const split = content.split(" ");
const newSplit = split.flatMap((element, idx) => {
if (element.startsWith("http://") || element.startsWith("https://")) {
link = element;
return [
<Link key={idx} onClick={() => setLinkOpen(element)}>
{element}
</Link>,
" ",
];
}
if (element.startsWith("@")) {
return [
<Mention key={idx} id={element} setMentioned={setMentioned} />,
" ",
];
}
return [element, " "];
});
newSplit.pop();
setLink(link);
setElements(newSplit);
}, [content, setLink, setMentioned, setElements, setLinkOpen]);
useEffect(() => {
const updatePreview = async () => {
if (link && fetchMetadata) {
try {
const metadata = await fetchMetadata(link);
if (metadata) {
setOpenGraph(metadata);
}
} catch {
return;
}
}
};
updatePreview();
}, [link, fetchMetadata]);
return (
<ContentWrapper>
<div>{elements.map((el) => el)}</div>
{image && (
<MessageImageWrapper>
<MessageImage
src={image}
id={image}
onClick={() => {
setImage(image);
}}
/>
<ImageMenu imageId={image} />
</MessageImageWrapper>
)}
{openGraph && (
<PreviewWrapper onClick={() => setLinkOpen(link ?? "")}>
<PreviewImage src={decodeURI(decode(openGraph["og:image"]))} />
<PreviewTitleWrapper>{openGraph["og:title"]}</PreviewTitleWrapper>
<PreviewSiteNameWrapper>
{openGraph["og:site_name"]}
</PreviewSiteNameWrapper>
</PreviewWrapper>
)}
</ContentWrapper>
);
}
const MessageImageWrapper = styled.div`
width: 147px;
height: 196px;
margin-top: 8px;
position: relative;
`;
const MessageImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 16px;
cursor: pointer;
`;
const PreviewSiteNameWrapper = styled.div`
font-family: "Inter";
font-style: normal;
font-weight: normal;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.1px;
margin-top: 2px;
color: #939ba1;
margin-left: 12px;
`;
const PreviewTitleWrapper = styled.div`
margin-top: 7px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-family: Inter;
font-style: normal;
font-weight: 500;
width: 290px;
margin-left: 12px;
${textSmallStyles}
`;
const PreviewImage = styled.img`
border-radius: 15px 15px 15px 4px;
width: 305px;
height: 170px;
`;
const PreviewWrapper = styled.div`
margin-top: 9px;
background: #ffffff;
width: 305px;
height: 224px;
border: 1px solid #eef2f5;
box-sizing: border-box;
border-radius: 16px 16px 16px 4px;
display: flex;
flex-direction: column;
padding: 0px;
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
`;
const MentionBLock = styled.div`
display: inline-flex;
color: ${({ theme }) => theme.mentionColor};
background: ${({ theme }) => theme.mentionBgHover};
border-radius: 4px;
font-weight: 500;
position: relative;
padding: 0 2px;
cursor: pointer;
&:hover {
background: ${({ theme }) => theme.mentionHover};
}
&.activity {
max-width: 488px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
${textMediumStyles}
`;
const Link = styled.a`
text-decoration: underline;
cursor: pointer;
color: ${({ theme }) => theme.memberNameColor};
`;

Some files were not shown because too many files have changed in this diff Show More