Compare commits

..
5 Commits
Author SHA1 Message Date
dev f6b3b135d5 chore: seed migration objects batch 5 2026-10-18 13:59:57 +08:00
dev 7bbcf2b7b9 chore: seed migration objects batch 4 2026-10-18 13:59:42 +08:00
dev 3984e098b3 chore: seed migration objects batch 3 2026-10-18 13:59:37 +08:00
dev 365e3284c2 chore: seed migration objects batch 2 2026-10-18 13:59:32 +08:00
dev 3e00c3422d chore: seed migration objects batch 1 2026-10-18 13:59:23 +08:00
278 changed files with 60324 additions and 23367 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"comments": false,
"plugins": [
[
"formatjs",
{
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
"ast": true
}
]
]
}
+8
View File
@@ -0,0 +1,8 @@
index.dev.js binary
index.dev.js.map binary
index.prod.js binary
index.prod.js.map binary
base.min.css binary
tinode.dev.js binary
tinode.prod.js binary
package-lock.json binary
-73
View File
@@ -1,73 +0,0 @@
name: Deploy im-webapp production
on:
push:
branches:
- pro
workflow_dispatch:
concurrency:
group: production-im-webapp
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/im_webapp
SERVICE: im-webapp
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs:
deploy:
runs-on: deploy-host
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: Build and push image
env:
TINODE_API_KEY: ${{ secrets.TINODE_API_KEY }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
TINODE_API_KEY="${TINODE_API_KEY#\'}"
TINODE_API_KEY="${TINODE_API_KEY%\'}"
docker build \
--build-arg TINODE_API_KEY="${TINODE_API_KEY}" \
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
install -d -m 700 "$HOME/.ssh"
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
cleanup() {
shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"cd /opt/olns && docker compose pull im-webapp && docker compose up -d --no-deps --wait im-webapp"
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve TinodeWeb
title: ''
labels: bug
assignees: ''
---
If you want to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Mac OS, Windows 10 Home]
- Browser [e.g. Chrome, Safari]
- TinodeWeb Version [e.g. 0.16.6]
**Console log**
Please attach or insert console log which illustrates the problem.
**Additional context**
Add any other context about the problem here.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
If you want to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+1 -7
View File
@@ -1,9 +1,3 @@
.DS_Store
.git.worktree-pointer.bak
node_modules
.idea/
*.code-workspace
# Local developer overrides — never commit real keys.
.env.local
.claude/
src/i18n/src/
+25
View File
@@ -0,0 +1,25 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "web",
"site": "https://web.tinode.co"
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
}
}]
-43
View File
@@ -1,43 +0,0 @@
# ---- Stage 1: build static assets ----
# The active frontend lives under web/ (Vite + React + TypeScript). The legacy
# root-level Webpack application has been removed; production must only build web/.
FROM node:22-alpine AS build
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/. .
# VITE_TINODE_API_KEY is read by src/lib/config.ts (import.meta.env.VITE_TINODE_API_KEY) at build
# time -- Vite only exposes env vars prefixed VITE_ to client code, so the ARG is renamed on its
# way into the build's environment rather than passed through under its old webpack-era name.
ARG TINODE_API_KEY=
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
# The production web UI and IM REST API use different origins. Local development leaves this unset
# and continues to use Vite's same-origin /v1/im proxy.
ARG TINODE_API_BASE=
ENV VITE_TINODE_API_BASE=$TINODE_API_BASE
# Leave the file origin unset in production so downloads use the current site's /v0/file route.
# A build-time override remains available for isolated development deployments.
ARG TINODE_FILE_BASE=
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
RUN npm run build
# ---- Stage 2: serve with nginx ----
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /usr/share/nginx/html
# Vite's build output is fully self-contained: index.html, public assets and
# content-hashed JS/CSS chunks.
COPY --from=build /app/web/dist/. ./
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null http://localhost/ || exit 1
EXPOSE 80
+69 -31
View File
@@ -1,44 +1,82 @@
# 迅工捷使 Web
# Tinode Web
当前前端位于 `web/`,技术栈为 React、TypeScript 和 Vite。仓库根目录不再保留旧版 Tinode Webpack 应用及其构建产物。
## Briefing
## 本地开发
Single-page web chat application for [Tinode](https://github.com/tinode/chat/). The app is built on
[React](https://reactjs.org/). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
Overall, it's a lot like open source WhatsApp or Telegram web apps.
```bash
cd web
npm ci
npm run dev
```
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
## 检查与构建
Try possibly newer or older version live at https://web.tinode.co/.
```bash
cd web
node --test tests/*.test.mjs tests/*.test.ts
npm run lint
npm run build
```
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
生产构建输出到 `web/dist/`。
For demo access and other instructions [see here](https://github.com/tinode/chat/#demosandbox).
## Docker 部署
## Installing and running
根目录 `Dockerfile` 只复制并构建 `web/`:
This is NOT a standalone app, this is just a frontend, a client. It requires a [backend](https://github.com/tinode/chat/). See [installation instructions](https://github.com/tinode/chat/blob/master/INSTALL.md).
```bash
docker build \
--build-arg TINODE_API_KEY="<部署密钥>" \
--build-arg TINODE_FILE_BASE="https://api.118im.com/v0/file" \
-t im-webapp .
```
## Getting support
生产环境通过 `TINODE_FILE_BASE` 固定附件下载入口;本地开发不设置该变量时,继续使用租户解析结果和 Vite 代理。
* Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
* For bugs and feature requests [open an issue](https://github.com/tinode/webchat/issues/new).
测试集群通过 Gitea Actions 的 `Deploy im-webapp (k3s test cluster)` 工作流手动发布。
## Internationalization
## 目录
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL when starting the app, e.g. https://web.tinode.co/#?hl=ru.
- `web/`:当前 Vite 前端源码、测试和静态资源
- `docker/`:Nginx 配置
- `docs/`:设计及实施文档
- `.gitea/workflows/`:测试集群部署工作流
The app is currently available in the following languages:
* English
* Chinese (simplified)
* Chinese (traditional)
* German
* Korean
* Romanian
* Russian
* Spanish
More translations are welcome. See [instructions](https://github.com/tinode/chat/blob/devel/docs/translations.md#webapp).
## Not done yet
* End-to-End encryption.
* Emoji support is weak.
* Mentions, hashtags.
* Previews not generated for videos, audio, links or docs.
## Other
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
* Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
* [Fonts](https://fonts.google.com/) and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web) from Google under [license](https://developers.google.com/terms) which permits commercial and non-commercial use.
* Push notifications built with https://firebase.google.com/.
## Push notifications
If you want to use the app with your own server and want web push notification to work you have to set them up:
* Register at https://firebase.google.com/, set up the project if you have not done so already.
* Follow instructions to create a web application https://support.google.com/firebase/answer/9326094 in your project.
* Follow instructions at https://support.google.com/firebase/answer/7015592 to get a Firebase configuration object ("Firebase SDK snippet").
* Locate `firebase-init.js` in the root folder of your copy of TinodeWeb app. Copy-paste the following keys from the configuration object to `firebase-init.js`: `apiKey`, `messagingSenderId`, `projectId`, `appId` (you may copy all keys).
* Copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field in `firebase-init.js`.
* Double check that `firebase-init.js` contains the following keys: `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. The file may contain other optional keys.
* Copy Google-provided server key to `tinode.conf`, see details [here](https://github.com/tinode/chat/blob/master/docs/faq.md#q-how-to-setup-fcm-push-notifications).
## Responsive design
### Desktop screenshot
<p align="center">
<img src="web-desktop.jpg" alt="Desktop web: full app" width=810 />
</p>
### Mobile screenshots
<p align="center">
<kbd><img src="web-mob-contacts.png" alt="Mobile web: contacts" width=250 /></kbd> <kbd><img src="web-mob-chat.png" alt="Mobile web: chat" width=250 /></kbd> <kbd><img src="web-mob-info.png" alt="Mobile web: topic info" width=250 /></kbd> <kbd><img src="web-mob-new-chat.png" alt="Mobile web: start new chat" width=250 /></kbd> <kbd><img src="web-mob-acc-settings.png" alt="Mobile web: account settings" width=250 /></kbd> <kbd><img src="web-mob-avatar.png" alt="Mobile web: change avatar" width=250 /></kbd>
</p>
BIN
View File
Binary file not shown.
+2579
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The webapp uses real (History API) client-side routing, so any unknown path can safely fall
# back to index.html -- the app resolves the actual view/topic client-side from the path itself
# once index.html loads. index.html itself is never content-hashed, so it must always be
# revalidated -- otherwise a browser holding a cached copy would keep requesting the *previous*
# deploy's now-deleted /assets/*.js filenames after a new deploy replaces them.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# Vite content-hashes every filename under assets/ (e.g. index-DYjopaLI.js) -- a new build never
# reuses an old asset's URL, so these can be cached aggressively and indefinitely; a stale cached
# copy simply never gets requested again once index.html points at the new hashed filenames.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
@@ -1,92 +0,0 @@
# Web Clipboard Image Compatibility Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Broaden the new Web composer's image-paste formats without changing immediate send or native text paste.
**Architecture:** Extract clipboard inspection into a small dependency-free module. The React paste callback synchronously snapshots event data and calls `preventDefault()` only for an image or an image-looking fallback; asynchronous data-URL/Clipboard API decoding then feeds the existing `sendImage` path.
**Tech Stack:** React 19, TypeScript, Vite, Node 25 built-in `node:test`, Playwright CLI.
---
## File map and boundaries
- Create `web/src/features/messages/clipboard-image.ts`: clipboard format selection, HTML data-image extraction, async Blob conversion, Clipboard API fallback. It must not import React, Tinode, or UI state.
- Create `web/tests/clipboard-image.test.ts`: direct behavior tests for the helper; no new test dependency.
- Modify `web/src/features/messages/ConversationView.tsx:939-962`: replace the narrow paste loop and reuse existing `sendImage`/error toast. Do not change `handlePickImage`, `sendImage`, drafts, or other views.
- Modify `web/package.json`: add `test:clipboard` invoking `node --test tests/clipboard-image.test.ts`.
## Task 1: Clipboard extraction with test-first coverage
**Files:** Create `web/tests/clipboard-image.test.ts`; create `web/src/features/messages/clipboard-image.ts`; modify `web/package.json`.
- [ ] **Step 1: Add a failing assertion and API stubs.** In the test, import `firstImageFile`, `htmlDataImageUrl`, `shouldReadClipboard`, `fileFromDataImageUrl`, and `readClipboardImage` from `../src/features/messages/clipboard-image.ts`. Use `node:test`/`node:assert/strict`. Start with `new File(['png'], 'shot.png', {type:'image/png'})` in `items[0].getAsFile()` and assert `firstImageFile(data) === file`. Export typed stub functions returning `null`/`false` in the new helper solely so the test runs to an assertion failure.
```ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { firstImageFile } from '../src/features/messages/clipboard-image.ts';
test('reads an image file from clipboard items', () => {
const file = new File(['png'], 'shot.png', { type: 'image/png' });
const data = { items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], files: [] } as unknown as DataTransfer;
assert.equal(firstImageFile(data), file);
});
```
- [ ] **Step 2: Run `cd web && node --test tests/clipboard-image.test.ts`; confirm the first assertion fails (`actual: null`, expected File), not a loader/type error.**
- [ ] **Step 3: Implement the smallest `firstImageFile(data: DataTransfer | null): File | null` that checks each `kind=file` item via `getAsFile()`, accepts `file.type` or an image `item.type`, then checks `data.files` even when `items` exists. If only `item.type` supplies MIME, wrap the File with that MIME. Return the first image only.**
- [ ] **Step 4: Rerun the focused test; confirm pass. Add one failing test at a time for JPEG/BMP, empty `item.type` with valid File MIME, null `getAsFile()` followed by `files`, a non-image file, and two images selecting the first. Run red then green for each.**
- [ ] **Step 5: Add failing HTML tests:** an `<img src="data:image/png;base64,...">` URL is returned; a remote `https:` image, plain HTML text, and a `data-src` attribute are not. Implement `htmlDataImageUrl(data)` by reading `getData('text/html')` synchronously and matching only an actual `src` attribute on an `<img>`, with `data:image/` scheme. Add a failing test that `fileFromDataImageUrl()` converts a valid data URL to an image File and rejects malformed data. Implement it with `fetch(dataUrl)`, `Blob`, and `new File(...)`; never fetch a non-data URL.
- [ ] **Step 6: Add failing fallback-decision tests:** `shouldReadClipboard(data)` is false for plain text and ordinary HTML, true for a file/image hint without usable file or a completely empty event. Implement with `types`, `items`, and `getData('text/plain')`, without asynchronous event reads.
- [ ] **Step 7: Add failing Clipboard API tests:** injected `read()` returning a `ClipboardItem` with `image/png` produces a File; a text-only item returns null; permission rejection propagates. Implement `readClipboardImage(clipboard?: Pick<Clipboard,'read'>): Promise<File | null>` by trying image MIME types, `getType()`, and wrapping the Blob. No navigator access inside this helper, so tests can inject a stub.
```ts
const clipboard = { read: async () => [{ types: ['image/png'], getType: async () => new Blob(['png'], { type: 'image/png' }) }] } as unknown as Pick<Clipboard, 'read'>;
const image = await readClipboardImage(clipboard);
assert.equal(image?.type, 'image/png');
```
- [ ] **Step 8: Add `"test:clipboard": "node --test tests/clipboard-image.test.ts"` to `web/package.json`. Run `npm run test:clipboard`; expected all tests pass. Commit only these files with `git add web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts web/package.json && git commit -m 'test: cover clipboard image formats'`.**
## Task 2: Integrate with the composer
**Files:** Modify `web/src/features/messages/ConversationView.tsx:939-962`; test `web/tests/clipboard-image.test.ts`.
- [ ] **Step 1: Add a failing orchestration test:** introduce `handleImagePaste(data, preventDefault, onImage, onError, clipboard)` as a named export and test that PNG calls `preventDefault` synchronously and then `onImage`, plain text does neither, HTML data-image claims before awaiting conversion, and Clipboard API failure calls `onError`. Run this test red (first as a typed stub returning `Promise.resolve(false)`), then implement the orchestrator in `clipboard-image.ts`. It must call `preventDefault` before its first `await`, and must not catch an exception thrown by `onImage` as a clipboard-read failure.
```ts
let prevented = false;
let sent: File | null = null;
const pending = handleImagePaste(data, () => { prevented = true; }, file => { sent = file; }, () => { throw new Error('unexpected'); });
assert.equal(prevented, true);
await pending;
assert.equal(sent, file);
```
- [ ] **Step 2: Replace the existing `handleComposerPaste` loop with a thin call to `handleImagePaste`.** Pass `e.clipboardData`, `() => e.preventDefault()`, a callback that calls `consumePendingReply()` then the existing `sendImage(topic,file,reply).catch(...)`, a callback that calls `showError(undefined, '无法读取剪贴板图片')`, and `navigator.clipboard`. This preserves existing send behavior and does not retain the React event across an asynchronous boundary. Do not broaden to previews or multi-image sends.
```tsx
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
void handleImagePaste(
e.clipboardData,
() => e.preventDefault(),
(file) => {
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
},
() => showError(undefined, '无法读取剪贴板图片'),
navigator.clipboard,
);
};
```
- [ ] **Step 3: Run `cd web && npm run test:clipboard && npm run build && npm run lint`; all commands must exit 0. Review `git diff --check` and `git diff -- web/src/features/messages/ConversationView.tsx` for scope and native-text behavior. Commit only the touched files with `git add web/src/features/messages/ConversationView.tsx web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts && git commit -m 'fix: support more clipboard image formats'`.**
## Task 3: Browser verification and handoff
**Files:** No production changes unless a targeted test finds a defect.
- [ ] **Step 1: Start local Vite (`cd web && npm run dev -- --host 127.0.0.1`) and use Playwright CLI on the local origin. Import `/src/features/messages/clipboard-image.ts` in the browser, paste a real PNG ClipboardItem, and verify it appears as `kind=file`, `type=image/png`, and is selected. Separately dispatch a text-only ClipboardEvent and confirm `shouldReadClipboard` is false and native default is not prevented. Test HTML data image selection and remote HTML exclusion.**
- [ ] **Step 2: Rerun `npm run test:clipboard`, `npm run build`, `npm run lint`, and `git diff --check` fresh after any fixes. Check `git status --short` and report exact commits/files. Do not push or deploy. Explain that macOS Chromium/browser-event simulation is not a real Windows WeChat end-to-end test.**
## Self-review against the approved spec
Items/files, MIME fallback, HTML data image, Clipboard API, text passthrough, error feedback, first-image-only behavior, no remote fetch, test-first, browser verification, and no deployment are all assigned above. The only unavoidable limit is native Windows clipboard conversion before JavaScript receives the event; the final report must not claim that was verified.
@@ -1,151 +0,0 @@
# Web Brand And Message Sound Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将生产 Web 品牌统一为“迅工捷使”,精简登录页,并为符合提醒条件的新消息播放用户提供的提示音。
**Architecture:** 继续以 `web/src/lib/config.ts` 作为显示品牌名来源,认证页和系统消息只消费该常量;品牌图标和 MP3 作为 Vite `public/` 静态资源。消息声音接入现有 `push.ts` 新消息扫描链路,在浏览器通知权限判断之前复用“本人消息、当前可见会话、会话免打扰”的过滤结果。
**Tech Stack:** React 19、TypeScript、Vite、Node test runner、Jiti。
---
### Task 1: 写品牌和提示音红测
**Files:**
- Create: `web/tests/brand-and-notification-sound.test.mjs`
- [ ] **Step 1: 写失败测试**
创建 Node 测试,核心断言如下;可按实际导出补齐 fake `Audio` 的类型字段,但不得降低断言:
```js
const webRoot = new URL('../', import.meta.url);
assert.match(await readFile(new URL('index.html', webRoot), 'utf8'), /<title>迅工捷使<\/title>/);
assert.doesNotMatch(await readFile(new URL('src/features/auth/LoginPage.tsx', webRoot), 'utf8'), /桌面端|下载桌面客户端|iOS \/ Android|帮助中心/);
assert.doesNotMatch(await readFile(new URL('src/features/auth/TenantEntryPage.tsx', webRoot), 'utf8'), /MeetingIM/);
assert.doesNotMatch(await readFile(new URL('src/features/system/SystemMessagesView.tsx', webRoot), 'utf8'), /MeetingIM 安全团队/);
assert.equal(audioSource, '/message-notification.mp3');
assert.equal(playCount, 1);
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node --experimental-strip-types --test tests/brand-and-notification-sound.test.mjs`
Expected: FAIL,因为品牌仍是 `MeetingIM`,提示音模块和静态资源尚不存在。
### Task 2: 更新品牌和登录页
**Files:**
- Modify: `web/index.html`
- Modify: `web/src/lib/config.ts`
- Modify: `web/src/features/auth/LoginPage.tsx`
- Modify: `web/src/features/auth/TenantEntryPage.tsx`
- Modify: `web/src/features/system/SystemMessagesView.tsx`
- Create: `web/public/app-icon.png`
- Delete: `web/public/favicon.svg`
- Modify: `web/src/lib/notifications/tabIndicator.ts`
- Modify: `web/src/lib/notifications/push.ts`
- [ ] **Step 1: 复制官方品牌图标**
将 `../im-ios-website/dist/assets/app-icon.png` 原样复制为 `web/public/app-icon.png`,所有 Web 图标引用统一指向它。
- [ ] **Step 2: 最小化更新可见品牌**
把浏览器标题、认证页和系统消息署名改为“迅工捷使”;`APP_NAME` 保留 `MeetingIM/<version>` 作为现有 Tinode 客户端技术标识,避免把显示品牌替换扩大成协议改名。
```ts
export const BASE_APP_NAME = '迅工捷使';
export const APP_NAME = `MeetingIM/${__APP_VERSION__}`;
```
认证页统一使用:
```tsx
<img src="/app-icon.png" alt="" className="h-full w-full rounded-[inherit]" />
<strong>{BASE_APP_NAME}</strong>
```
- [ ] **Step 3: 精简登录页**
说明文案改为“单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。”,删除底部三个入口。
```tsx
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
</p>
```
### Task 3: 接入消息提示音
**Files:**
- Create: `web/src/lib/notifications/sound.ts`
- Modify: `web/src/lib/notifications/push.ts`
- Create: `web/public/message-notification.mp3`
- [ ] **Step 1: 复制用户提供的提示音**
将 `/Users/aa/Downloads/universfield-new-notification-012-363675.mp3` 原样复制为 `web/public/message-notification.mp3`。
- [ ] **Step 2: 实现安全播放器**
首次需要时创建 `Audio('/message-notification.mp3')`,每次播放前设置 `currentTime = 0`;对同步异常和 `play()` Promise 拒绝均静默处理。
```ts
let messageAudio: HTMLAudioElement | null = null;
export function playMessageSound(): void {
try {
messageAudio ||= new Audio('/message-notification.mp3');
messageAudio.currentTime = 0;
void messageAudio.play().catch(() => {});
} catch {
// Optional audio must never interrupt message delivery.
}
}
```
- [ ] **Step 3: 接入新消息过滤链路**
把“本人消息、当前可见会话、会话免打扰”抽成通知与声音共享的判断。通过判断后先播放声音;仅当 Notification API 可用且权限为 `granted` 时再显示系统通知。
```ts
if (!shouldAlert(topicName, latest)) return;
playMessageSound();
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
fireNotification(topicName, latest);
}
```
- [ ] **Step 4: 跑绿测**
Run: `node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs`
Expected: 现有 6 项和新增测试全部 PASS。
### Task 4: 验证、提交与部署
**Files:**
- Verify only
- [ ] **Step 1: 静态验证**
Run: `npm run build && npm run lint`
Expected: 构建退出码 0;lint 无 error,允许记录既有 warning。
- [ ] **Step 2: 完整性验证**
Run: `git diff --check`
Expected: 无输出,退出码 0;MP3 和应用图标的 SHA-256 与来源文件一致。
- [ ] **Step 3: 提交并推送**
提交精确修改文件到 `dev`,推送前先 fetch 并确认无远端分叉。
- [ ] **Step 4: 部署和公网回归**
触发 Gitea `deploy.yml`,等待成功,确认 K3s Deployment 2/2;访问 `https://web.118im.com` 验证标题、品牌、登录页删减及双账号新消息提醒路径。
@@ -1,23 +0,0 @@
# Web 聊天图片粘贴兼容设计
日期:2026-09-29。范围仅限 `web/` 新版聊天输入框的图片粘贴;不改旧版、后端、图片选择器或部署配置。
## 现状与目标
当前输入框只读取 `clipboardData.items` 中 `kind=file` 且 `item.type` 为 `image/*` 的首个项目。Windows 微信截图在不同浏览器中的剪贴板暴露形式尚无现场记录,因此不能断言根因;但 `files`、文件本身 MIME、内嵌 HTML 图片和浏览器 Clipboard API 均是当前实现未覆盖的可读路径。线上已经部署了现有粘贴功能。此前“多图暂存并配文字发送”按用户反馈撤回,本次保持**识别到首张图片后立即发送**,不加入预览、多图或图文合并。
## 方案
把图片提取逻辑放入一个可独立测试的小模块,输入为粘贴事件中的 `DataTransfer`,输出为首个可发送的 `File` 或“未取得图片”。按顺序尝试:
1. 同步检查 `items`:对 `kind=file` 调用 `getAsFile()`,以所得文件的 MIME 为准,`item.type` 仅作补充;然后检查 `files`,避免浏览器只填充其中一个集合。
2. 同步检查 `text/html` 中的 `<img src="data:image/...">`;只解码本地内嵌图片,不请求远程 URL,不执行 HTML。纯文字和普通 HTML 粘贴保持浏览器原生行为。
3. 对没有可用同步图片、也没有纯文字,且事件带图片/文件线索或完全没有数据类型的情况,尝试 `navigator.clipboard.read()` 读取浏览器提供的 `image/*` Blob。仅含普通 HTML 的事件不走此兜底。此路径可能受权限或浏览器支持限制;网页无法越过浏览器直接读取 Windows 原生 CF_DIB 等格式。
只有确定要接管图片粘贴或进入无文字的异步兜底时,才在事件回调内同步 `preventDefault()`。不得在 `await` 之后访问原粘贴事件的数据。提取成功后仍调用现有 `sendImage`;提取或读取失败时对明确的图片粘贴给出简短提示,发送失败继续沿用现有错误提示。不得让纯文字粘贴触发发送或权限请求。
## 验收与测试
先写失败测试,再改实现。覆盖:`items` 的 PNG/JPEG/BMP、`item.type` 缺失但文件 MIME 正确、仅 `files`、`getAsFile()` 为空时回退、HTML 内嵌图片、Clipboard API 返回图片、权限拒绝、纯文字原生粘贴、远程 HTML 图片不抓取、多个图片只取首张。运行定向测试、前端构建和 lint;再用真实浏览器派发图片及文字粘贴事件,核对是否接管和既有即时发送路径。若无 Windows/微信运行环境,最终明确说明这一端到端组合尚未实测,不宣称已覆盖所有微信版本。
本次只做本地代码与验证;不推送、不部署。
@@ -1,34 +0,0 @@
# Web 品牌与消息提示音设计
## 目标
将当前生产 Web 的用户可见品牌统一为“迅工捷使”,精简登录页指定文案,并使用用户提供的 MP3 为新收到的消息播放提示音。
## 范围
- 当前 Dockerfile 实际构建的 `web/` 目录。
- 浏览器标题、企业码页、登录页、系统消息署名和应用图标。
- 登录页说明改为“消息与回执在手机端保持一致”,删除底部三个入口。
- 复用 Android/iOS 已有蓝底扳手图标。
- 新消息提示音使用用户提供的 `universfield-new-notification-012-363675.mp3`。
## 行为
- 只为他人发来的新消息播放一次提示音。
- 正在可见地查看对应会话时不播放。
- 会话设置为消息免打扰时不播放。
- 声音播放失败不得影响消息接收、列表更新或浏览器通知。
- 浏览器系统通知权限不控制声音;声音与现有通知共用同一条新消息判定链路。
## 明确不改
- 后端代码和后端默认推送、短信名称。
- `X-MeetingIM-APIKey`、`X-MeetingIM-Auth` 等协议头。
- `meetingim-session` 等已有存储键。
- 仓库名、镜像名、域名和旧版未部署 Web 入口。
## 验证
- 自动测试覆盖品牌文字、登录页删减、品牌图片引用和提示音播放器。
- Web 全量测试、构建、lint 和 `git diff --check`。
- 部署后实测企业码页、登录页、浏览器标题、图标,以及双账号新消息提示音路径。
+13
View File
@@ -0,0 +1,13 @@
// Get these values for your project from the https://console.firebase.google.com/
const FIREBASE_INIT = {
// Set up a web application in console then get the config object.
// The following keys are copied from the object:
apiKey: "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ",
messagingSenderId: "114126160546",
projectId: "tinode-1000",
appId: "1:114126160546:web:aca6ea2981feb81fb44dfb",
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
// This value IS NOT included in the generated js config you get from
// the firebase console. It needs to be added separately.
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+53
View File
@@ -0,0 +1,53 @@
<!-- DEVELOPMENT version of the web app. -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tinode</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web, version for developers" />
<meta name="description" content="Tinode instant messenger, development version" />
<meta name="application-url" content="https://web.tinode.co/">
<!-- Browser styling for Chrome, FF, Opera -->
<meta name="theme-color" content="#3949AB" />
<!-- Ask Google not to offer page translation -->
<meta name="google" value="notranslate" />
<!-- Open Graph description -->
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web Development">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<!-- Google's Roboto font -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<!-- Google's material design icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<!-- Styles for local elements -->
<link rel="stylesheet" href="css/base.css" />
<!-- ReactJS -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17/umd/react.development.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.development.js"></script>
<!-- React-Intl for translations -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
<!-- Firebase for push notifications -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.
</div>
</div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/tinode.dev.js"></script>
<script src="umd/index.dev.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>Tinode</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web" />
<meta name="description" content="Tinode instant messaging in a browser" />
<meta name="application-url" content="https://web.tinode.co/">
<meta name="theme-color" content="#3949AB" />
<meta name="google" value="notranslate" />
<meta name="format-detection" content="telephone=no" />
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="css/base.min.css" />
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5.20.7/react-intl.iife.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.</div></div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/tinode.prod.js"></script>
<script src="umd/index.prod.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "Tinode",
"short_name": "Tinode",
"description": "Tinode Web App.\nSee more at https://tinode.co/",
"categories": ["chat", "communication", "productivity"],
"icons": [
{
"src":"img/logo96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src":"img/logo192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "img/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"start_url": "/",
"background_color": "silver",
"theme_color": "#3949AB",
"display": "standalone",
"gcm_sender_id": "103953800507",
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=co.tinode.tindroidx",
"id": "co.tinode.tindroidx"
},
{
"platform": "itunes",
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
}
]
}
+12220
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.18.0-beta1",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
},
"scripts": {
"build": "npm run vers && npm run build:css && npm run extract && npm run build:i18n && npm run build:prod && npm run build:dev",
"build:css": "postcss css/base.css > css/base.min.css",
"build:dev": "webpack --mode development",
"build:prod": "webpack --mode production",
"build:i18n": "node scripts/i18n.js",
"i18n:missing": "node scripts/i18n.js missing",
"i18n:obsolete": "node scripts/i18n.js obsolete",
"vers": "node scripts/gen-version.js",
"extract": "formatjs extract \"src/**/*.{js,jsx}\" --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
"compile": "formatjs compile"
},
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
"files": [
"umd/index.prod.js",
"umd/index.prod.js.map",
"umd/index.dev.js",
"umd/index.dev.js.map",
"umd/tinode.prod.js",
"umd/tinode.dev.js",
"audio/msg.mp3",
"css/base.css",
"css/base.min.css",
"img/*.png",
"img/*.svg",
"img/*.jpeg",
"index-dev.html",
"index.html",
"manifest.json",
"service-worker.js",
"firebase-init.js",
"version.js"
],
"keywords": [
"chat",
"instant-messenger",
"message",
"messenger",
"react",
"web"
],
"locales": [
"de",
"en",
"es",
"ko",
"ro",
"ru",
"zh",
"zh-TW"
],
"main": "umd/index.prod.js",
"bugs": {
"url": "https://github.com/tinode/example-react-js/issues"
},
"homepage": "https://github.com/tinode/chat",
"email": "info@tinode.co",
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^9.4.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intl": "^5.20.2",
"tinode-sdk": "^0.18.0-beta1"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-env": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@formatjs/cli": "^4.2.20",
"babel-loader": "^8.2.2",
"babel-plugin-formatjs": "^10.2.19",
"browserslist": "^4.16.5",
"copy-webpack-plugin": "^9.0.0",
"cssnano": "^5.0.6",
"glob": "^7.1.7",
"mkdirp": "^1.0.4",
"postcss": "^8.3.2",
"postcss-cli": "^9.0.2",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: [
require('cssnano')({
preset: 'default',
}),
],
};
+18
View File
@@ -0,0 +1,18 @@
const fs = require('fs');
const version = require('../package.json').version;
function writeVersion(fileName, useExport) {
fs.writeFile(fileName,
'// This is a generated file. Don\'t edit.\n\n'+
`${useExport ? 'export ' : ''}const PACKAGE_VERSION = "${version}";\n`,
{ flag: 'w' },
(err) => {
if (err) {
return console.log(err);
}
}
);
}
writeVersion('./src/version.js', true);
writeVersion('./version.js', false);
+127
View File
@@ -0,0 +1,127 @@
// 1. Updates translations files with new messages, mark no longer needed translations as obsolete.
// 2. Takes all translations and groups them into a single file.
// 3. When called with parameters 'missing' or 'obsolete' writes missing and obsolete translations to console.
const fs = require('fs');
const path = require('path');
const globSync = require('glob').sync;
const mkdirpSync = require('mkdirp').sync;
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
const BASE_LANG = 'en';
const LANG_DIR = './src/i18n/';
const LANG_PATTERN = './src/i18n/*.json';
const LANG_PATTERN_ONE = './src/i18n/%s.json';
const FINAL_FILE = './src/messages.json';
const args = process.argv.slice(2);
function getLocaleFromFileName(filename) {
const parts = filename.split('/');
return parts[parts.length - 1].split('.json')[0];
}
function printf(str) {
const args = [].slice.call(arguments, 1);
let i = 0;
return str.replace(/%s/g, () => args[i++]);
}
// Called with one or two parameters?
if (args[0] == 'missing' || args[0] == 'obsolete') {
// Extract missing or obsolete translations as requested.
// Read either all or just one translation.
const pattern = args[1] ? printf(LANG_PATTERN_ONE, args[1]) : LANG_PATTERN;
const extracted = globSync(pattern)
.map((filename) => {
const locale = getLocaleFromFileName(filename);
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
const dest = {};
// Copy messages while skipping missing and obsolete ones.
for (let key in translated) {
if ((args[0] == 'missing' && translated[key].missing) ||
(args[0] == 'obsolete' && translated[key].obsolete)) {
dest[key] = translated[key];
}
}
return { [locale]: dest };
})
.reduce((collection, descriptors) => {
return { ...collection, ...descriptors };
}, {});
console.log(JSON.stringify(extracted, null, 2)+"\n");
return;
}
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
// Read master-set of messages in default language.
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
// Find messages present in baseMessages but missing in translations and vice versa.
// Mark missing or obsolete messages as such.
globSync(LANG_PATTERN)
.map((filename) => {
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
const locale = getLocaleFromFileName(filename);
// Refresh existing entries with possible new descriptions and default messages, add missing.
Object.entries(baseMessages).forEach((ent) => {
const [key, value] = ent;
let translation = translated[key] ? translated[key].translation : "";
if (!translation && locale == BASE_LANG) {
translation = value.defaultMessage;
}
translated[key] = {
translation: translation,
defaultMessage: value.defaultMessage,
description: value.description,
missing: !translation,
obsolete: false
};
});
// Find no longer needed messages.
Object.entries(translated).forEach((ent) => {
const [key, value] = ent;
if (!baseMessages[key] && translated[key]) {
// Obsolete, no longer missing.
translated[key].missing = !translated[key].translation;
translated[key].obsolete = true;
}
});
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
});
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
// while removing messages marked as missing or obsolete.
// Read all translations.
const messages = globSync(LANG_PATTERN)
.map((filename) => {
const locale = getLocaleFromFileName(filename);
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
const dest = {};
// Copy messages while skipping missing and obsolete ones.
for (let key in translated) {
if (!translated[key].missing && !translated[key].obsolete) {
dest[key] = translated[key].translation;
}
}
return { [locale]: dest };
})
.reduce((collection, descriptors) => {
return { ...collection, ...descriptors };
}, {});
// Write a file with all translations combined.
mkdirpSync(path.dirname(FINAL_FILE));
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages));
+165
View File
@@ -0,0 +1,165 @@
// Must be located at the root.
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js');
importScripts('firebase-init.js');
importScripts('version.js');
// Channel to notify the webapp. There is no BroadcastChannel in Safari.
const webAppChannel = (typeof BroadcastChannel == 'function') ?
new BroadcastChannel('tinode-sw') : null;
// Basic internationalization.
const i18n = {
'de': {
'new_message': "Neue Nachricht",
'new_chat': "Neuer Chat",
},
'en': {
'new_message': "New message",
'new_chat': "New chat",
},
'es': {
'new_message': "Nuevo mensaje",
'new_chat': "Nueva conversación",
},
'ko': {
'new_message': "새로운 메시지",
'new_chat': "새로운 채팅",
},
'ro': {
'new_message': "Mesaj nou",
'new_chat': "Chat nou",
},
'ru': {
'new_message': "Новое сообщение",
'new_chat': "Новый чат",
},
'zh': {
'new_message': "新讯息",
'new_chat': "新聊天",
},
'zh-TW': {
'new_message': "新訊息",
'new_chat': "新聊天",
}
};
self.i18nMessage = function(id) {
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
// Try finding string by id in the specified language, if missing try English, otherwise use the id itself
// as the last resort.
return lang[id] || i18n['en'][id] || id;
}
firebase.initializeApp(FIREBASE_INIT);
const fbMessaging = firebase.messaging();
// This method shows the push notifications while the window is in background.
fbMessaging.onBackgroundMessage((payload) => {
if (payload.data.silent == 'true') {
// TODO: if this is an 'msg', fetch the new message in the background.
return;
}
// Notify webapp that a message was received.
if (webAppChannel) {
webAppChannel.postMessage(payload.data);
}
const pushType = payload.data.what || 'msg';
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
const options = {
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
badge: '/img/badge96.png',
tag: payload.data.topic || undefined,
data: {
topic: payload.data.topic
}
};
return self.registration.showNotification(title, options);
});
// Update service worker immediately for both the current client
// and all other active clients.
self.addEventListener('install', event => {
self.skipWaiting();
});
// This code handles a click on notification: takes
// the user to the browser tab with the chat or opens a new tab.
self.addEventListener('notificationclick', event => {
const notification = event.notification;
notification.close();
const urlHash = '#/' + notification.data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((windowClients) => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(notification.data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
// This will be the least recently used tab.
anyClient = windowClients[i];
}
}
// Found tab with Tinode on a different topic,
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = urlHash;
return anyClient.focus().then(thisClient => {
return thisClient.navigate(url);
});
}
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = urlHash;
return clients.openWindow(url);
}));
});
// This is needed for 'Add to Home Screen'.
self.addEventListener('fetch', event => {
if (event.request.method != 'GET') {
return;
}
event.respondWith((async () => {
// Try to find the response in the cache.
const cache = await caches.open(PACKAGE_VERSION);
const reqUrl = new URL(event.request.url);
// Using ignoreSearch=true to read cached images and docs despite different auth signatures.
const cachedResponse = await cache.match(event.request, {ignoreSearch: (self.location.origin == reqUrl.origin)});
if (cachedResponse) {
return cachedResponse;
}
// Not found in cache.
const response = await fetch(event.request);
if (!response || response.status != 200 || response.type != 'basic') {
return response;
}
if (reqUrl.protocol == 'http:' || reqUrl.protocol == 'https:') {
await cache.put(event.request, response.clone());
}
return response;
})());
});
// This code gets the human language from the webapp.
self.addEventListener('message', event => {
const data = JSON.parse(event.data);
// The locale is used for selecting strings in an appropriate language.
self.locale = data.locale || '';
self.baseLocale = self.locale.toLowerCase().split(/[-_]/)[0];
});
+108
View File
@@ -0,0 +1,108 @@
import { PACKAGE_VERSION } from './version.js';
// Name of this application, used in the User-Agent.
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.17');
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// The array of possible hosts to connect to.
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
// Default host name and port to connect to.
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
// Enable console logging.
export const LOGGING_ENABLED = true;
// Minimum time between two keypress notifications, milliseconds.
export const KEYPRESS_DELAY = 3*1000;
// Delay before sending a {note} for reciving a message, milliseconds.
export const RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
export const READ_DELAY = 1000;
// The default shortest allowed tag length. Matches the value on the server.
export const MIN_TAG_LENGTH = 2;
// The default greatest allowed tag length. Matches the value on the server.
export const MAX_TAG_LENGTH = 96;
// The default maximum number of tags allowed. Matches the value on the server.
export const MAX_TAG_COUNT = 16;
// Access mode for P2P subscriptions initiated by the current user.
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
// Access mode for new group topics created by the current user.
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
// Access mode for a channel.
export const CHANNEL_ACCESS_MODE = 'JR';
// Access mode for no access.
export const NO_ACCESS_MODE = 'N';
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
// in @media (max-size: 640px) in base.css
export const MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
export const REM_SIZE = 13;
// Size of the avatar image: when image dimensions are greater or the image is not square, it's resized to
// a square of this size or less.
export const AVATAR_SIZE = 384;
// Maximum size of an avatar in bytes for sending in-band. Bigger avatars will be sent out of band (as uploads).
export const MAX_AVATAR_BYTES = 4096;
// Size of the broken_image shown in MessagesView
export const BROKEN_IMAGE_SIZE = 32;
// Number of chat messages to fetch in one call.
export const MESSAGES_PAGE = 24;
// Default maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size. Used when the server-provided value is unavailable. The actual
// binary size of the attachment should be smaller due to base64 encoding expansion and some overhead,
// for instance 1024 bytes.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in the server config.
export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
// Default absolute maximum attachment size to be used with the server = 8MB.
// Used when the server-provided value is unavailable. Increase to something like 100MB in production.
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension (pixels) of an image sent inline. Larger images will be shrunk
// to make the larger dimension fit under this size. You may want to adjust it to 1600 or
// 2400 for production.
export const MAX_IMAGE_DIM = 1024;
// Linear dimensions of image preview: shrink image under this size for previews.
export const IMAGE_PREVIEW_DIM = 64;
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
export const IMAGE_THUMBNAIL_DIM = 36;
// Maximum number of online users to be shown in a topic title bar. Others will be
// hidden under "+X more"
export const MAX_ONLINE_IN_TOPIC = 4;
// Maximum length of user name, topic title, and private comment.
export const MAX_TITLE_LENGTH = 60;
// Maximum length of topic description.
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
// Length of message previews in chat list, in characters.
export const MESSAGE_PREVIEW_LENGTH = 80;
// Length of a quote in quoted reply (for outgoing messages).
export const QUOTED_REPLY_LENGTH = 30;
// Length of a preview of a forwarded message.
export const FORWARDED_PREVIEW_LENGTH = 96;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'email:support@tinode.co';
// Link to Privacy Policy.
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
// Link to Terms of Service.
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
+1357
View File
File diff suppressed because it is too large Load Diff
+1413
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+775
View File
@@ -0,0 +1,775 @@
{
"action_block_contact": {
"defaultMessage": "Block Contact",
"description": "Flat button [Block Contact]"
},
"action_cancel": {
"defaultMessage": "cancel",
"description": "Call to action [cancel]"
},
"action_clear_messages": {
"defaultMessage": "Clear Messages",
"description": "Flat button [Clear Messages] (soft-delete messages)"
},
"action_delete_messages": {
"defaultMessage": "Clear Messages for All",
"description": "Flat button [Clear for All] (hard-delete all messages)"
},
"action_leave_chat": {
"defaultMessage": "Leave Conversation",
"description": "Flat button [Leave Conversation]"
},
"action_report_chat": {
"defaultMessage": "Report Conversation",
"description": "Flat button [Report Group]"
},
"archived_contacts": {
"defaultMessage": "Archived contacts ({count})",
"description": "Label for archived chats"
},
"badge_danger": {
"defaultMessage": "Untrustworthy",
"description": "Suspicious or untrustworthy account or topic badge"
},
"badge_owner": {
"defaultMessage": "owner",
"description": "Badge for indicating the owner"
},
"badge_staff": {
"defaultMessage": "Staff-managed",
"description": "Explanation of a staff-managed account or topic badge"
},
"badge_verified": {
"defaultMessage": "Verified/official",
"description": "Explanation of a verified account or topic badge"
},
"badge_you": {
"defaultMessage": "you",
"description": "Badge for indicating the current user"
},
"block_contact_warning": {
"defaultMessage": "Are you sure you want to block this contact?",
"description": "Alert dialog warning when blocking a contact."
},
"blocked_contacts_link": {
"defaultMessage": "Blocked contacts ({count})",
"description": "Blocked contacts link"
},
"button_add_another": {
"defaultMessage": "Add another",
"description": "Call to action [+ add another]"
},
"button_add_members": {
"defaultMessage": "Add members",
"description": "Flat button [Add members] (to topic)"
},
"button_cancel": {
"defaultMessage": "Cancel",
"description": "Button [Cancel]"
},
"button_confirm": {
"defaultMessage": "Confirm",
"description": "Button [Confirm]"
},
"button_create": {
"defaultMessage": "Create",
"description": "Button [Create]"
},
"button_delete_account": {
"defaultMessage": "Delete account",
"description": "Button [Delete account]"
},
"button_edit": {
"defaultMessage": "Edit",
"description": "Call to action [Edit]"
},
"button_logout": {
"defaultMessage": "Logout",
"description": "Button [Logout]"
},
"button_ok": {
"defaultMessage": "OK",
"description": "Button [OK]"
},
"button_reset": {
"defaultMessage": "Reset",
"description": "Button [Reset]"
},
"button_security": {
"defaultMessage": "Security",
"description": "Navigaton button for security panel."
},
"button_send_request": {
"defaultMessage": "Send request",
"description": "Button [Send request]"
},
"button_sign_in": {
"defaultMessage": "Sign in",
"description": "Button [Sign In]"
},
"button_sign_up": {
"defaultMessage": "Sign up",
"description": "Create account button [Sign Up]"
},
"button_subscribe": {
"defaultMessage": "Subscribe",
"description": "Button [Subscribe]"
},
"button_update": {
"defaultMessage": "Update",
"description": "Button [Update]"
},
"cannot_initiate_file_upload": {
"defaultMessage": "Cannot initiate file upload.",
"description": "Generic error messagewhen attachment fails"
},
"channel": {
"defaultMessage": "channel",
"description": "Subtitle shown for channels in MessagesView instead of last seen"
},
"channel_prompt": {
"defaultMessage": "This is a channel",
"description": "Checkbox label when creating a channel"
},
"chat_invitation": {
"defaultMessage": "You are invited to start a new chat. What would you like to do?",
"description": "New chat invitation message: [Accept] [Ignore] [Block]."
},
"chat_invitation_accept": {
"defaultMessage": "Accept",
"description": "Action [Accept] for chat invitation."
},
"chat_invitation_block": {
"defaultMessage": "Block",
"description": "Action [Block] for chat invitation."
},
"chat_invitation_ignore": {
"defaultMessage": "Ignore",
"description": "Action [Ignore] for chat invitation."
},
"clear_messages_warning": {
"defaultMessage": "Are you sure you want to clear all messages? It cannot be undone.",
"description": "Alert dialog warning when deleting all messages."
},
"code_doesnot_match": {
"defaultMessage": "Code does not match",
"description": "Error message when the credential validation code is incorrect."
},
"contacts_not_found": {
"defaultMessage": "You have no chats<br />¯∖_(ツ)_/¯",
"description": "HTML message shown in ContactList when no contacts are found"
},
"contacts_not_found_short": {
"defaultMessage": "No contacts match ''{query}''",
"description": "Shown in ContactsView when search returned no results"
},
"credential_email_prompt": {
"defaultMessage": "Your registration email",
"description": "Placeholder for entering email"
},
"delete_account": {
"defaultMessage": "Delete account",
"description": "Title for delete account warning"
},
"delete_account_warning": {
"defaultMessage": "Are you sure you want to delete your account? It cannot be undone.",
"description": "Warning message when deleting an account"
},
"delete_messages_warning": {
"defaultMessage": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
"description": "Alert dialog warning when hard-deleting all messages."
},
"deleted_content": {
"defaultMessage": "content deleted",
"description": "Shown when messages are deleted"
},
"description_editing_placeholder": {
"defaultMessage": "Description (optional)",
"description": "Placeholder for editing topic description"
},
"download_action": {
"defaultMessage": "download",
"description": "Call to action [download]"
},
"drafty_attachment": {
"defaultMessage": "Attachment",
"description": "Comment for attachment in drafty preview"
},
"drafty_form": {
"defaultMessage": "Form:",
"description": "Comment for form in drafty preview"
},
"drafty_image": {
"defaultMessage": "Picture",
"description": "Comment for embedded images in drafty preview"
},
"email_dative": {
"defaultMessage": "email",
"description": "Dative case of 'email', i.e. 'email' in 'by email'"
},
"email_prompt": {
"defaultMessage": "Email, e.g. jdoe@example.com",
"description": "Input placeholder for email entry"
},
"enable_peers_messaging": {
"defaultMessage": "Enable",
"description": "Call to action to enable peer's messaging"
},
"enter_confirmation_code_prompt": {
"defaultMessage": "Enter confirmation code sent to you by {method}:",
"description": "Request to enter confirmation code"
},
"error_invalid_id": {
"defaultMessage": "Invalid ID",
"description": "Error message"
},
"file_attachment_too_large": {
"defaultMessage": "The file size {size} exceeds the {limit} limit.",
"description": "Error message when attachment is too large"
},
"forgot_password_link": {
"defaultMessage": "Forgot password?",
"description": "Link to Reset password form"
},
"forward_to": {
"defaultMessage": "Forward to"
},
"forward_to_search_placeholder": {
"defaultMessage": "Search contacts",
"description": "Contact search prompt when forwarding a message."
},
"full_name_prompt": {
"defaultMessage": "Full name, e.g. John Doe",
"description": "Input placeholder for person's full name"
},
"granted_permissions": {
"defaultMessage": "Granted",
"description": "Title for permissions"
},
"group_has_no_members": {
"defaultMessage": "No members",
"description": "Shown in place of group members"
},
"group_user_id_prompt": {
"defaultMessage": "Group or User ID",
"description": "Prompt for entering user or group ID"
},
"image_caption_prompt": {
"defaultMessage": "Image caption",
"description": "Prompt in SendMessage for attached image"
},
"invalid_content": {
"defaultMessage": "invalid content",
"description": "Shown when the message is unreadable"
},
"invalid_security_token": {
"defaultMessage": "Invalid security token",
"description": "Error message when resetting password."
},
"label_client": {
"defaultMessage": "Client:",
"description": "Label for a client version"
},
"label_content_type": {
"defaultMessage": "Content type:",
"description": "Label for file content type (mime)"
},
"label_default_access_mode": {
"defaultMessage": "Default access mode:",
"description": "Label for default access mode"
},
"label_description": {
"defaultMessage": "Description",
"description": "Label for editing topic description"
},
"label_file_name": {
"defaultMessage": "File name:",
"description": "Label for a file name"
},
"label_group_members": {
"defaultMessage": "Group members:",
"description": "Section title or label"
},
"label_incognito_mode": {
"defaultMessage": "Incognito mode:",
"description": "Label for incognito mode toggle"
},
"label_message_sound": {
"defaultMessage": "Message sound:",
"description": "Label for message sounds toggle"
},
"label_muting_topic": {
"defaultMessage": "Muted:",
"description": "Label for Muting/unmuting the topic"
},
"label_other_user": {
"defaultMessage": "Other",
"description": "Label for the other user when the user is unnamed"
},
"label_password": {
"defaultMessage": "Password",
"description": "Label for password editing"
},
"label_permissions": {
"defaultMessage": "Permissions:",
"description": "Section title"
},
"label_private": {
"defaultMessage": "Private comment",
"description": "Label for editing 'private'"
},
"label_push_notifications": {
"defaultMessage": "Notification alerts:",
"description": "Label for push notifications switch"
},
"label_push_notifications_disabled": {
"defaultMessage": "Notification alerts (requires HTTPS):",
"description": "Label for push notifications switch"
},
"label_reset_password": {
"defaultMessage": "Send a password reset email:",
"description": "Label for password reset field"
},
"label_sdk": {
"defaultMessage": "SDK:"
},
"label_server": {
"defaultMessage": "Server:",
"description": "Label for a server version"
},
"label_server_address": {
"defaultMessage": "Server address:"
},
"label_server_to_use": {
"defaultMessage": "Server to use:",
"description": "Label for server selector in SettingsView"
},
"label_size": {
"defaultMessage": "Size:",
"description": "Label for file size"
},
"label_topic_name": {
"defaultMessage": "Name",
"description": "Label for editing topic name"
},
"label_unarchive_topic": {
"defaultMessage": "Archived:",
"description": "Label for unarchiving the topic"
},
"label_user_contacts": {
"defaultMessage": "Contacts:",
"description": "Label for user contacts"
},
"label_user_id": {
"defaultMessage": "ID:",
"description": "Label for user address (ID)"
},
"label_wire_transport": {
"defaultMessage": "Wire transport:",
"description": "Label for wire transport selection in SettingsView"
},
"label_you": {
"defaultMessage": "You:",
"description": "Label for the current user"
},
"label_your_name": {
"defaultMessage": "Your name",
"description": "Label for full name editing"
},
"label_your_permissions": {
"defaultMessage": "Your permissions:",
"description": "Label for current user permissions"
},
"last_seen_timestamp": {
"defaultMessage": "Last seen",
"description": "Label for the timestamp of when the user or topic was last online"
},
"leave_chat_warning": {
"defaultMessage": "Are you sure you want to leave this conversation?",
"description": "Alert dialog warning when unsubscribing from a chat."
},
"link_contact_us": {
"defaultMessage": "Contact Us",
"description": "Ancor text for contacting us by email"
},
"link_privacy_policy": {
"defaultMessage": "Privacy Policy",
"description": "Ancor text for privacy policy link"
},
"link_terms_of_service": {
"defaultMessage": "Terms of Service",
"description": "Ancor text for terms of service link"
},
"login_prompt": {
"defaultMessage": "Login",
"description": "Placeholer for username/login"
},
"menu_item_archive_topic": {
"defaultMessage": "Archive",
"description": "Move topic from the list of active chats to archive"
},
"menu_item_block": {
"defaultMessage": "Block",
"description": "Block topic or user"
},
"menu_item_clear_messages": {
"defaultMessage": "Clear messages",
"description": "Delete all messages"
},
"menu_item_clear_messages_for_all": {
"defaultMessage": "Clear for All",
"description": "Delete all message(s) for all members"
},
"menu_item_delete": {
"defaultMessage": "Delete",
"description": "Delete selected messages"
},
"menu_item_delete_for_all": {
"defaultMessage": "Delete for All",
"description": "Delete selected message(s) for all members"
},
"menu_item_delete_topic": {
"defaultMessage": "Delete",
"description": "Delete entire topic"
},
"menu_item_edit_permissions": {
"defaultMessage": "Edit permissions",
"description": "Menu item [Edit permissions]"
},
"menu_item_forward": {
"defaultMessage": "Forward",
"description": "Forward message"
},
"menu_item_info": {
"defaultMessage": "Info",
"description": "Show extended topic information"
},
"menu_item_member_delete": {
"defaultMessage": "Remove",
"description": "Remove user from topic"
},
"menu_item_mute": {
"defaultMessage": "Mute",
"description": "Turn off notifications"
},
"menu_item_reply": {
"defaultMessage": "Reply",
"description": "Reply to message"
},
"menu_item_restore_topic": {
"defaultMessage": "Restore",
"description": "Restore topic from archive"
},
"menu_item_send_retry": {
"defaultMessage": "Retry",
"description": "Retry sending message"
},
"menu_item_unblock": {
"defaultMessage": "Unblock",
"description": "Unblock topic or user"
},
"menu_item_unmute": {
"defaultMessage": "Unmute",
"description": "Turn notifications on"
},
"message_sending": {
"defaultMessage": "sending...",
"description": "Message being sent, in place of time stamp"
},
"message_sending_failed": {
"defaultMessage": "failed",
"description": "Failed to send message, in place of time stamp"
},
"messages_not_readable": {
"defaultMessage": "no access to messages",
"description": "Message shown in topic without the read access"
},
"messaging_disabled_prompt": {
"defaultMessage": "Messaging disabled",
"description": "Prompt in SendMessage in read-only topic"
},
"more_online_members": {
"defaultMessage": "+{overflow} more",
"description": "Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
},
"new_message_prompt": {
"defaultMessage": "New message",
"description": "Prompt in send message field"
},
"new_password_placeholder": {
"defaultMessage": "Enter new password",
"description": "Placeholder for entering new password"
},
"no_connection": {
"defaultMessage": "No connection",
"description": "Warning that the user is offline."
},
"no_contacts": {
"defaultMessage": "You have no contacts :-(",
"description": "Shown in ContactsView when the user has no contacts"
},
"numeric_confirmation_code_prompt": {
"defaultMessage": "Numbers only",
"description": "Prompt for numeric conformation code"
},
"online_now": {
"defaultMessage": "online now",
"description": "Indicator that the user or topic is currently online"
},
"panel_title_crop": {
"defaultMessage": "Drag to Adjust",
"description": "Title for AvatarCropView."
},
"panel_title_general": {
"defaultMessage": "General",
"description": "Title for TopicCommon."
},
"panel_title_info": {
"defaultMessage": "Info",
"description": "Title for InfoView"
},
"panel_title_members": {
"defaultMessage": "Members",
"description": "Title for managing group members view."
},
"panel_title_security": {
"defaultMessage": "Security",
"description": "Title for TopicSecirity and AccSecurity."
},
"password_prompt": {
"defaultMessage": "Password",
"description": "Placeholder/prompt for entering password"
},
"password_reset_email_sent": {
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
"description": "Notification that the email with password reset instructions has been sent"
},
"password_unchanged_prompt": {
"defaultMessage": "Unchanged",
"description": "Message in editor while password is unchanged"
},
"peers_messaging_disabled": {
"defaultMessage": "Peer's messaging is disabled.",
"description": "Shown when the p2p peer's messaging is disabled"
},
"permission_admin": {
"defaultMessage": "Approve ({val})",
"description": "Name of A permission"
},
"permission_delete": {
"defaultMessage": "Delete ({val})",
"description": "Name of D permission"
},
"permission_join": {
"defaultMessage": "Join ({val})",
"description": "Name of J permission"
},
"permission_owner": {
"defaultMessage": "Owner ({val})",
"description": "Name of O permission"
},
"permission_pres": {
"defaultMessage": "Get notified ({val})",
"description": "Name of P permission"
},
"permission_read": {
"defaultMessage": "Read ({val})",
"description": "Name of R permission"
},
"permission_share": {
"defaultMessage": "Share ({val})",
"description": "Name of S permission"
},
"permission_write": {
"defaultMessage": "Write ({val})",
"description": "Name of W permission"
},
"permissions_anonymous": {
"defaultMessage": "Anonymous",
"description": "Title for editing default anonymous permissions"
},
"permissions_authenticated": {
"defaultMessage": "Authenticated",
"description": "Title for editing default authenticated permissions"
},
"permissions_user": {
"defaultMessage": "User's Permissions",
"description": "Title for editing user's permissions"
},
"phone_dative": {
"defaultMessage": "phone",
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
},
"private_editing_placeholder": {
"defaultMessage": "Visible to you only",
"description": "Placeholder for editing 'private'"
},
"push_init_failed": {
"defaultMessage": "Failed to initialize push notifications",
"description": "Error message when push notifications have failed to initialize."
},
"reconnect_countdown": {
"defaultMessage": "Disconnected. Reconnecting in {seconds}…",
"description": "Message shown when an app update is available."
},
"reconnect_now": {
"defaultMessage": "Try now",
"description": "Prompt for reconnecting now"
},
"reload_update": {
"defaultMessage": "Reload",
"description": "Call to action to reload application when update is available."
},
"report_chat_warning": {
"defaultMessage": "Are you sure you want to block and report this conversation?",
"description": "Alert dialog warning when reporting a conversation for abuse"
},
"requested_permissions": {
"defaultMessage": "Requested",
"description": "Title for permissions"
},
"save_attachment": {
"defaultMessage": "save",
"description": "Call to save an attachment"
},
"search_for_contacts": {
"defaultMessage": "Use search to find contacts",
"description": "Text shown in contacts view when user entered no search query."
},
"search_no_results": {
"defaultMessage": "Search returned no results",
"description": "Text shown in contacts view when query returned no results."
},
"search_placeholder": {
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
"description": "Placeholder in contacts search field"
},
"sidepanel_title_acc_notifications": {
"defaultMessage": "Notifications",
"description": "Sidepanel title for AccNotificationsView."
},
"sidepanel_title_acc_support": {
"defaultMessage": "Support",
"description": "Sidepanel title for AccSupportView."
},
"sidepanel_title_account_settings": {
"defaultMessage": "Account Settings",
"description": "Sidepanel title for AccountSettingsView."
},
"sidepanel_title_archive": {
"defaultMessage": "Archived Chats",
"description": "Sidepanel title for ContactsView-Archive."
},
"sidepanel_title_blocked": {
"defaultMessage": "Blocked Chats",
"description": "Sidepanel title for ContactsView-Blocked."
},
"sidepanel_title_cred": {
"defaultMessage": "Confirm Credentials",
"description": "Sidepanel title for ValidationView."
},
"sidepanel_title_login": {
"defaultMessage": "Sign In",
"description": "Sidepanel title for LoginView."
},
"sidepanel_title_newtpk": {
"defaultMessage": "Start New Chat",
"description": "Sidepanel title for NewTopicView."
},
"sidepanel_title_register": {
"defaultMessage": "Create Account",
"description": "Sidepanel title for CreateAccountView."
},
"sidepanel_title_reset": {
"defaultMessage": "Reset Password",
"description": "Sidepanel title for PasswordResetView."
},
"sidepanel_title_settings": {
"defaultMessage": "Settings",
"description": "Sidepanel title for SettingsView."
},
"stay_logged_in": {
"defaultMessage": "Stay logged in",
"description": "Label for a checkbox"
},
"tabtitle_find_user": {
"defaultMessage": "find",
"description": "Tab title Find"
},
"tabtitle_group_by_id": {
"defaultMessage": "by id",
"description": "Tab title Find topic by ID"
},
"tabtitle_new_group": {
"defaultMessage": "new group",
"description": "Tab title New Group"
},
"tags_editor_no_tags": {
"defaultMessage": "Add some tags",
"description": "Tag editor prompt when no tags are found."
},
"tags_not_found": {
"defaultMessage": "No tags defined. Add some.",
"description": ""
},
"title_all_contacts": {
"defaultMessage": "All Contacts",
"description": "Section title [All Contacts]"
},
"title_group_members": {
"defaultMessage": "Group Members",
"description": "Section title"
},
"title_manage_tags": {
"defaultMessage": "Manage",
"description": "Section title for the list of tags"
},
"title_not_found": {
"defaultMessage": "Not found",
"description": "Title shown when topic is not found"
},
"title_permissions": {
"defaultMessage": "Permissions",
"description": "Section title"
},
"title_tag_manager": {
"defaultMessage": "Tags (search & discovery)",
"description": "Section title for TagManager"
},
"topic_block_warning": {
"defaultMessage": "Are you sure you want to block this conversation?",
"description": "Alert warning when blocking a topic."
},
"topic_delete": {
"defaultMessage": "Delete Conversation",
"description": "Alert title when deleting the topic."
},
"topic_delete_warning": {
"defaultMessage": "Are you sure you want to delete this conversation? It cannot be undone.",
"description": "Alert warning when deleting entire topic"
},
"topic_name_editing_placeholder": {
"defaultMessage": "Freeform name of the group",
"description": "Prompt for entering topic name"
},
"unnamed_topic": {
"defaultMessage": "Unnamed",
"description": "Title shown when the topic has no name"
},
"update_available": {
"defaultMessage": "Update available.",
"description": "Message shown when an app update is available."
},
"upload_finishing": {
"defaultMessage": "finishing...",
"description": "Notification that upload is finishing"
},
"user_not_found": {
"defaultMessage": "Not found",
"description": "In place of a user's full name when the user is not found."
},
"validate_credential_action": {
"defaultMessage": "confirm",
"description": "Validate credentail call to action"
}
}
+1357
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+1357
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
// Put all packages together.
// Used to generate umd/index.prod.js
import React from 'react';
import ReactDOM from 'react-dom';
import { IntlProvider } from 'react-intl';
import allMessages from './messages.json';
import TinodeWeb from './views/tinode-web.jsx';
import HashNavigation from './lib/navigation.js';
// Detect human language to use in the UI:
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
const { params } = HashNavigation.parseUrlHash(window.location.hash);
const language = (params && params.hl) ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator.userLanguage ||
'en';
// Get the base language 'en' from a more specific 'en_GB' or 'en-US' as a partial fallback.
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
// Try the full locale first, then the locale without the region code, fallback to 'en'.
const htmlLang = allMessages[language] ? langauge : allMessages[baseLanguage] ? baseLanguage : 'en';
const messages = allMessages[htmlLang];
// Set lang attribute of the HTML element: <html lang="XX">
document.getElementsByTagName('html')[0].setAttribute('lang', htmlLang);
ReactDOM.render(
<React.StrictMode>
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
<TinodeWeb />
</IntlProvider>
</React.StrictMode>,
document.getElementById('mountPoint')
);
+283
View File
@@ -0,0 +1,283 @@
// File and image helper functions.
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
import { bytesToHumanSize } from './strformat.js'
// Supported image MIME types and corresponding file extensions.
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Get an URL from a theCard photo: either create a data: URL or return reference URL.
export function makeImageUrl(photo) {
if (photo && typeof photo == 'object') {
if (photo.data && photo.type) {
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
return 'data:' + mime + ';base64,' + photo.data;
}
return photo.ref;
}
return null;
}
// Calculate linear dimensions for scaling image down to fit under a certain size.
// Returns an object which contains destination sizes, source sizes, and offsets
// into source (when making square images).
export function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
// Sanitize input
width = width | 0;
height = height | 0;
maxWidth = maxWidth | 0;
maxHeight = maxHeight | 0;
if (width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0) {
return null;
}
if (forceSquare) {
maxWidth = maxHeight = Math.min(maxWidth, maxHeight);
}
const scale = Math.min(
Math.min(width, maxWidth) / width,
Math.min(height, maxHeight) / height
);
const size = {
dstWidth: (width * scale) | 0,
dstHeight: (height * scale) | 0,
};
if (forceSquare) {
// Also calculate parameters for making the image square.
size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);
size.srcWidth = size.srcHeight = Math.min(width, height);
size.xoffset = ((width - size.srcWidth) / 2) | 0;
size.yoffset = ((height - size.srcWidth) / 2) | 0;
} else {
size.xoffset = size.yoffset = 0;
size.srcWidth = width;
size.srcHeight = height;
}
return size;
}
// Ensure file's extension matches mime content type
export function fileNameForMime(fname, mime) {
const idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
if (idx < 0 || !fname) {
// Unknown mime or empty name.
return fname;
}
const ext = MIME_EXTENSIONS[idx];
const at = fname.lastIndexOf('.');
if (at >= 0) {
fname = fname.substring(0, at);
}
return fname + '.' + ext;
}
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
// Returns a promise which is resolven on success or rejected on failure.
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = function(err) {
reject(new Error("Image format unrecognized"));
}
img.onload = async function() {
// Once the image is loaded, the URL is no longer needed.
URL.revokeObjectURL(img.src);
// Calculate the desired image dimensions.
const dim = fitImageSize(img.width, img.height, maxWidth, maxHeight, forceSquare);
if (!dim) {
reject(new Error("Invalid image"));
return;
}
let canvas = document.createElement('canvas');
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
// Generate blob to check size of the image.
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
if (!blob) {
reject(new Error("Unsupported image format"));
return;
}
// Ensure the image is not too large. Shrink the image keeping the aspect ratio.
// Do nothing if maxsize is <= 0.
while (maxSize > 0 && blob.length > maxSize) {
dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
}
canvas = null;
resolve({mime: mime, blob: blob, width: dim.dstWidth, height: dim.dstHeight, name: fileNameForMime(fileOrBlob.name, mime)});
};
img.src = URL.createObjectURL(fileOrBlob);
});
}
// Scale and crop image according to specified dimensions.
// The coordinates are in unscaled image pixels, i.e. cut the rectangle first then scale it.
// returns a promise.
export function imageCrop(mime, objURL, left, top, width, height, scale) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = (err) => {
reject(new Error("Image format unrecognized"));
};
img.onload = () => {
// Once the image is loaded, the URL is no longer needed.
URL.revokeObjectURL(img.src);
let canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
let ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, left, top, width, height, 0, 0, canvas.width, canvas.height);
mime = SUPPORTED_IMAGE_FORMATS.includes(mime) ? mime : 'image/jpeg';
// Generate blob to check size of the image.
canvas.toBlob((blob) => {
// Allow GC.
canvas = null;
if (blob) {
resolve({mime: mime, blob: blob, width: width, height: height});
} else {
reject(new Error("Unsupported image format"));
}
}, mime);
};
img.src = objURL;
});
}
// Convert file to base64 string.
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (evt) => {
reject(reader.error);
};
reader.onload = () => {
resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});
};
reader.readAsDataURL(file);
});
}
// Convert Blob to base64 string. Returns a promise resolved with the base64 string and mime.
export function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = (evt) => {
reject(reader.error);
};
reader.onload = () => {
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
};
reader.readAsDataURL(blob);
});
}
// File pasted from the clipboard. It's either an inline image or a file attachment.
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
if (!Array.isArray(items)) {
return false;
}
for (let i in items) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (!file) {
console.error("Failed to get file object from pasted file item", item.kind, item.type);
onError("Failed to get file object from pasted file item");
continue;
}
if (file.type && file.type.split('/')[0] == 'image') {
onImageSuccess(file);
} else {
onAttachmentSuccess(file);
}
// Indicate that the pasted data contains a file.
return true;
}
}
// No file found.
return false;
}
// Get mime type from data URL header.
export function getMimeType(header) {
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
return (mime && mime.length > 1) ? mime[1] : null;
}
// Given length of a binary object in bytes, calculate the length after
// base64 encoding.
export function base64EncodedLen(n) {
return Math.floor((n + 2) / 3) * 4;
}
// Given length of a base64-encoded object, calculate decoded size of the
// pbject in bytes.
export function base64DecodedLen(n) {
return Math.floor(n / 4) * 3;
}
// Re-encode string to standard base64 encoding with padding.
// The string may be base64-URL encoded without the padding.
export function base64ReEncode(str) {
if (str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
try {
str = btoa(atob(str));
} catch(err) {
console.error("Failed to base64 re-encode string.", err);
str = null;
}
}
return str;
}
// Convert a base64 encoded string with the provided mime type into a Blob.
export function base64ToBlob(str, mime) {
if (!str) {
return null;
}
try {
// Make blob.
const bin = atob(str);
const length = bin.length;
const buf = new ArrayBuffer(length);
const arr = new Uint8Array(buf);
for (let i = 0; i < length; i++) {
arr[i] = bin.charCodeAt(i);
}
return new Blob([buf], { type: mime });
} catch(err) {
console.error("Failed to convert base64 to blob: ", err);
}
return null;
}
+395
View File
@@ -0,0 +1,395 @@
import React from 'react';
import { defineMessages } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import LazyImage from '../widgets/lazy-image.jsx'
import UploadingImage from '../widgets/uploading-image.jsx'
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
import { idToColorClass, shortenFileName } from './strformat.js';
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
const messages = defineMessages({
drafty_form: {
id: 'drafty_form',
defaultMessage: 'Form: ',
description: 'Comment for form in drafty preview'
},
drafty_attachment: {
id: 'drafty_attachment',
defaultMessage: 'Attachment',
description: 'Comment for attachment in drafty preview'
},
drafty_image: {
id: 'drafty_image',
defaultMessage: 'Picture',
description: 'Comment for embedded images in drafty preview'
}
});
// Size the already scaled image.
function handleImageData(el, data, attr) {
if (!data) {
attr.src = 'img/broken_image.png';
attr.style = {
width: IMAGE_THUMBNAIL_DIM + 'px',
height: IMAGE_THUMBNAIL_DIM + 'px',
};
return el;
}
attr.className = 'inline-image';
const dim = fitImageSize(data.width, data.height,
this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :
REM_SIZE * 34.5, REM_SIZE * 24, false) ||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
attr.style = {
width: dim.dstWidth + 'px',
height: dim.dstHeight + 'px',
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
minWidth: dim.dstWidth + 'px',
minHeight: dim.dstHeight + 'px'
};
if (!Drafty.isProcessing(data)) {
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
attr.alt = data.name;
if (attr.src) {
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
// Allow previews for large enough images.
attr.onClick = this.onImagePreview;
attr.className += ' image-clickable';
}
attr.loading = 'lazy';
} else {
attr.src = 'img/broken_image.png';
}
} else {
// Use custom element instead of <img>.
el = UploadingImage;
}
return el;
}
function quotedImage(data) {
let promise;
// Get the blob from the image data.
if (data.val) {
const blob = base64ToBlob(data.val, data.mime);
promise = blob ? Promise.resolve(blob) : Prmise.reject(new Error("Invalid image"));
} else {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
if (evt.ok) {
return evt.blob();
} else {
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
}
});
}
// Scale the blob.
return promise
.then(blob => {
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
data.width = scaled.width;
data.height = scaled.height;
delete data.ref;
// Keeping the original file name, if provided: ex.data.name;
return blobToBase64(scaled.blob);
}).then(b64 => {
data.val = b64.bits;
return data;
}).catch(err => {
delete data.val;
delete data.name;
data.width = IMAGE_THUMBNAIL_DIM;
data.height = IMAGE_THUMBNAIL_DIM;
data.maxWidth = IMAGE_THUMBNAIL_DIM;
data.maxHeight = IMAGE_THUMBNAIL_DIM;
// Rethrow.
throw err;
});
}
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
// 'this' must contain:
// viewportWidth: this.props.viewportWidth;
// authorizeURL: this.props.tinode.authorizeURL
// onImagePreview: this.handleImagePreview
// onFormButtonClick: this.handleFormButtonClick
// onQuoteClick: this.handleQuoteClick (optional)
export function fullFormatter(style, data, values, key) {
if (!style) {
// Unformatted.
return values;
}
if (style == 'HD') {
// Hidden.
return null;
}
let el = Drafty.tagName(style);
const attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'HL':
// Highlighted text. Assign class name.
attr.className = 'highlight';
break;
case 'IM':
// Additional processing for images
el = handleImageData.call(this, el, data, attr);
// Image element cannot have content.
values = null;
break;
case 'BN':
// Button
attr.onClick = this.onFormButtonClick;
let inner = React.Children.map(values, (child) => {
return typeof child == 'string' ? child : undefined;
});
if (!inner || inner.length == 0) {
inner = [attr.name]
}
// Get text which will be sent back when the button is clicked.
attr['data-title'] = inner.join('');
break;
case 'MN':
// Mention
attr.className = 'mention'
if (data) {
attr.className += ' ' + idToColorClass(data.val, false, true);
}
break;
case 'FM':
// Form
attr.className = 'bot-form';
break;
case 'RW':
// Form element formatting is dependent on element content.
break;
case 'QQ':
// Quote/citation.
attr.className = 'reply-quote'
attr.onClick = this.onQuoteClick;
break;
default:
if (el == '_UNKN') {
// Unknown element.
el = React.Fragment;
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
}
break;
}
if (!el) {
return values;
}
return React.createElement(el, attr, values);
};
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
// 'this' must contain:
// formatMessage: this.props.intl.formatMessage
// messages: formatjs messages defined with defineMessages.
export function previewFormatter(style, data, values, key) {
if (!style) {
// Unformatted.
return values;
}
if (style == 'HD') {
// Hidden.
return null;
}
let el = Drafty.tagName(style);
const attr = { key: key };
switch (style) {
case 'BR':
// Replace new line with a space.
el = React.Fragment;
values = [' '];
break;
case 'HL':
// Make highlight less prominent in preview.
attr.className = 'highlight preview';
break;
case 'LN':
case 'MN':
// Disable links in previews.
el = 'span';
break;
case 'IM':
// Replace image with '[icon] Image'.
el = React.Fragment;
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
break;
case 'BN':
el = 'span';
attr.className = 'flat-button faux';
break;
case 'FM':
el = React.Fragment;
values = [<i key="fm" className="material-icons">dashboard</i>,
this.formatMessage(messages.drafty_form)].concat(' ', values || []);
break;
case 'RW':
el = React.Fragment;
break;
case 'EX':
if (data) {
if (data.mime == 'application/json') {
// Ignore JSON attachments: they are form response payloads.
return null;
}
// Clear payload.
delete data.val;
delete data.ref;
}
el = React.Fragment;
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
break;
default:
if (el == '_UNKN') {
el = React.Fragment;
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
}
break;
}
if (!el) {
return values;
}
return React.createElement(el, attr, values);
};
// Converts Drafty object into a quoted reply. 'this' is set by the caller.
function inlineImageAttr(attr, data) {
attr.style = {
width: IMAGE_THUMBNAIL_DIM + 'px',
height: IMAGE_THUMBNAIL_DIM + 'px',
maxWidth: IMAGE_THUMBNAIL_DIM + 'px',
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
}
attr.className = 'inline-image';
attr.alt = this.formatMessage(messages.drafty_image);
if (!data) {
attr.src = 'img/broken_image.png';
}
attr.title = attr.alt;
return attr;
}
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
// 'this' must contain:
// formatMessage: this.props.intl.formatMessage
// messages: formatjs messages defined with defineMessages.
// authorizeURL: this.props.tinode.authorizeURL
// onQuoteClick: this.handleQuoteClick (optional)
export function quoteFormatter(style, data, values, key) {
if (['BR', 'EX', 'IM', 'MN', 'QQ'].includes(style)) {
let el = Drafty.tagName(style);
let attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch(style) {
case 'IM':
attr = inlineImageAttr.call(this, attr, data);
values = [React.createElement('img', attr, null), ' ', attr.alt];
el = React.Fragment;
// Fragment attributes.
attr = {key: key};
break;
case 'MN':
el = 'span';
attr.className = 'mention'
if (data) {
attr.className += ' ' + idToColorClass(data.val, false, true);
}
break;
case 'QQ':
attr.className = 'reply-quote';
attr.onClick = this.onQuoteClick;
break;
case 'EX':
let fname;
if (data) {
if (data.mime == 'application/json') {
// Ignore JSON attachments: they are form response payloads.
return null;
}
fname = data.name;
// Clear payload.
delete data.val;
delete data.ref;
}
el = React.Fragment;
values = [<i key="ex" className="material-icons">attachment</i>,
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
break;
}
return React.createElement(el, attr, values);
}
return previewFormatter.call(this, style, data, values, key);
}
// Create image thumbnail suitable for inclusion in a quote.
function quoteImage(data) {
let promise;
// Get the blob from the image data.
if (data.val) {
const blob = base64ToBlob(data.val, data.mime);
promise = blob ? Promise.resolve(blob) : Promise.reject(new Error("Invalid image"));
} else {
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
if (evt.ok) {
return evt.blob();
} else {
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
}
});
}
// Scale the blob.
return promise
.then(blob => {
// Cut the square from the center of the image and shrink it.
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
}).then(scaled => {
data.mime = scaled.mime;
data.size = scaled.blob.size;
data.width = scaled.width;
data.height = scaled.height;
delete data.ref;
// Keeping the original file name, if provided: ex.data.name;
data.src = URL.createObjectURL(scaled.blob);
return blobToBase64(scaled.blob);
}).then(b64 => {
data.val = b64.bits;
return data;
}).catch(err => {
delete data.val;
delete data.src;
data.width = IMAGE_THUMBNAIL_DIM;
data.height = IMAGE_THUMBNAIL_DIM;
// Rethrow.
throw err;
});
}
// Create a preview of a reply.
export function replyFormatter(style, data, values, key) {
if (style != 'IM') {
return quoteFormatter.call(this, style, data, values, key);
}
const attr = inlineImageAttr.call(this, {key: key}, data);
attr.whenDone = cancelablePromise(quoteImage.call(this, data));
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
return React.createElement(React.Fragment, {key: key}, values);
}
+29
View File
@@ -0,0 +1,29 @@
import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
// Detect server address from the URL
export function detectServerAddress() {
let host = DEFAULT_HOST;
if (typeof window.location == 'object') {
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
host = KNOWN_HOSTS.local;
} else if (window.location.hostname) {
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
}
}
return host;
}
// Detect if the page is served over HTTPS.
export function isSecureConnection() {
if (typeof window.location == 'object') {
return window.location.protocol == 'https:';
}
return false;
}
export function isLocalHost() {
if (typeof window.location == 'object') {
return window.location.hostname == 'localhost';
}
return false;
}
+26
View File
@@ -0,0 +1,26 @@
// Helper functions for storing values in localStorage.
// By default localStorage can store only strings, not objects or other types.
export default class LocalStorageUtil {
// Replace old object with the new one.
static setObject(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
// Get stored object.
static getObject(key) {
const value = localStorage.getItem(key);
return value && JSON.parse(value);
}
// Partially or wholly update stored object.
static updateObject(key, value) {
const oldVal = this.getObject(key);
this.setObject(key, Object.assign(oldVal || {}, value));
}
// Just a wrapper.
static removeItem(key) {
localStorage.removeItem(key);
}
}
+78
View File
@@ -0,0 +1,78 @@
// Utility class for hash navigation.
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
export default class HashNavigation {
static parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1]
const parts = hash.split('?', 2);
const params = {};
let path = [];
if (parts[0]) {
path = parts[0].replace('#', '').split('/');
}
if (parts[1]) {
parts[1].split('&').forEach(function(part) {
const item = part.split('=');
if (item[0]) {
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
}
});
}
return {path: path, params: params};
}
static navigateTo(url) {
window.location.hash = url;
}
static composeUrlHash(path, params) {
let url = path.join('/');
const args = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]);
}
}
if (args.length > 0) {
url += '?' + args.join('&');
}
return url;
}
static addUrlParam(hash, key, value) {
const parsed = this.parseUrlHash(hash);
parsed.params[key] = value;
return this.composeUrlHash(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
const parsed = this.parseUrlHash(hash);
delete parsed.params[key];
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
const parsed = this.parseUrlHash(hash);
parsed.path[0] = sidepanel;
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlInfoPanel(hash, infopanel) {
const parsed = this.parseUrlHash(hash);
if (infopanel) {
parsed.params.info = infopanel;
} else {
delete parsed.params.info;
}
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
const parsed = this.parseUrlHash(hash);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
return this.composeUrlHash(parsed.path, parsed.params);
}
}
+73
View File
@@ -0,0 +1,73 @@
// Short representation of time in the past.
export function shortDateFormat(then, locale) {
locale = locale || window.navigator.userLanguage || window.navigator.language;
const now = new Date();
if (then.getFullYear() == now.getFullYear()) {
// Same year.
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
// Same month and day, show time only.
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
}
// Different month and/or day, show month day, time.
return then.toLocaleDateString(locale,
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
// Different year: just show the date.
return then.toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
}
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
export function secondsToTime(seconds) {
const min = Math.floor(seconds / 60);
let sec = seconds % 60;
sec = sec < 10 ? `0${sec}` : sec;
return `${min}:${sec}`;
}
// Convert a number of bytes to human-readable format.
export function bytesToHumanSize(bytes) {
if (!bytes || bytes == 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
const count = bytes / Math.pow(1024, bucket);
const round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
// Shorten a file name to be under maxLength by clipping out the middle.
export function shortenFileName(filename, maxLength) {
if (typeof filename != 'string') {
return filename;
}
return filename.length > maxLength ?
filename.slice(0, maxLength/2 - 1) + '…' + filename.slice(1 - maxLength/2) : filename;
}
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
function stringToColorHash(value) {
let hash = 0;
value = '' + value;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
// User avatar letter color id.
export function letterTileColorId(userId) {
return Math.abs(stringToColorHash(userId)) % 16;
}
// Converts user or topic ID to a CSS color class. Ideally it should produce the same color value as Java version.
// params:
// - {string} id: user or topic ID
// - {boolean} light: light or dark version of the color.
// - {boolean} fg: foreground (text) or background color.
export function idToColorClass(id, light, fg) {
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
}
+202
View File
@@ -0,0 +1,202 @@
// Odds and ends
import Tinode from 'tinode-sdk';
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon');
const head = document.head || document.getElementsByTagName('head')[0];
const newIcon = document.createElement('link');
newIcon.type = 'image/png';
newIcon.id = 'shortcut-icon';
newIcon.rel = 'shortcut icon';
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
if (oldIcon) {
head.removeChild(oldIcon);
}
head.appendChild(newIcon);
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
}
// Create theCard which represents user's or topic's "public" info.
export function theCard(fn, imageUrl, imageMimeType, note) {
let card = null;
fn = fn && fn.trim();
note = note && note.trim();
if (fn) {
card = {
fn: fn
};
}
if (typeof note == 'string') {
card = card || {};
card.note = note ? note : Tinode.DEL_CHAR;
}
if (imageUrl) {
card = card || {};
let mimeType = imageMimeType;
// Is this a data URL "data:[<mediatype>][;base64],<data>"?
const matches = /^data:(image\/[-a-z0-9+.]+)?(;base64)?,/i.exec(imageUrl);
if (matches) {
mimeType = matches[1];
card.photo = {
data: imageUrl.substring(imageUrl.indexOf(',') + 1),
ref: Tinode.DEL_CHAR
};
} else {
card.photo = {
data: Tinode.DEL_CHAR,
ref: imageUrl
};
}
card.photo.type = (mimeType || 'image/jpeg').substring('image/'.length);
}
return card;
}
// Deep-shallow compare two arrays: shallow compare each element.
export function arrayEqual(a, b) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
// Compare lengths first.
if (a.length != b.length) {
return false;
}
// Order of elements is ignored.
a.sort();
b.sort();
for (let i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Checks (loosely) if the given string is a phone. If so, returns the phone number in a format
// as close to E.164 as possible.
export function asPhone(val) {
val = val.trim();
if (/^(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?$/.test(val)) {
return val.replace(/[- ().]*/, '');
}
return null;
}
// Checks (loosely) if the given string is an email. If so returns the email.
export function asEmail(val) {
val = val.trim();
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
return val;
}
return null;
}
// Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
// The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
// example.html - ok
// https:example.com - not ok.
// http:/example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
export function isUrlRelative(url) {
return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
}
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
export function sanitizeUrl(url, allowedSchemes) {
if (typeof url != 'string') {
return url;
}
// Strip control characters and whitespace. They are not valid URL characters anyway.
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
// Relative URLs are safe.
// Relative URL does not start with ':', abcd123: or '//'.
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
return url;
}
// Blob URLs are safe.
if (/^blob:http/.test(url)) {
return url;
}
// Absolute URL. Accept only safe schemes, or no scheme.
const schemes = Array.isArray(allowedSchemes) ? allowedSchemes.join('|') : 'http|https';
const re = new RegExp('^((' + schemes + '):|//)', 'i');
if (!re.test(url)) {
return null;
}
return url;
}
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
// have http:, https:, blob: or data: scheme.
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
export function sanitizeImageUrl(url) {
if (!url) {
return null;
}
const sanitizedUrl = sanitizeUrl(url);
if (sanitizedUrl) {
return sanitizedUrl;
}
// Is this a data: URL of an image?
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
return url;
}
return null;
}
// Given message's received status, return name and color of a delivery indicator icon.
export function deliveryMarker(received) {
switch (received) {
case Tinode.MESSAGE_STATUS_SENDING:
return { name: 'access_time' }; // watch face
case Tinode.MESSAGE_STATUS_FAILED:
return { name: 'warning', color: 'danger-color' }; // yellow icon /!\
case Tinode.MESSAGE_STATUS_SENT:
return { name: 'done' }; // checkmark
case Tinode.MESSAGE_STATUS_RECEIVED:
return { name: 'done_all' }; // double checkmark
case Tinode.MESSAGE_STATUS_READ:
return { name: 'done_all', color: 'blue' }; // blue double checkmark
}
return null;
}
// Wraps a promise to make it cancelable.
export function cancelablePromise(promise) {
let hasCanceled = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
result => hasCanceled ? reject({isCanceled: true}) : resolve(result),
error => hasCanceled ? reject({isCanceled: true}) : reject(error)
);
});
return {
promise: wrappedPromise,
cancel( ) {
hasCanceled = true;
},
};
};
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.18.0-beta1";
+61
View File
@@ -0,0 +1,61 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CheckBox from '../widgets/checkbox.jsx';
export default class EditAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
}
handleCheckboxClick(what, checked) {
if (what == 'sound') {
this.props.onToggleMessageSounds(checked);
} else if (what == 'alert') {
this.props.onTogglePushNotifications(checked);
} else if (what == 'incognito') {
this.props.onToggleIncognitoMode(checked);
}
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-row">
<label htmlFor="message-sound">
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
description="Label for message sounds toggle" />
</label>
<CheckBox name="sound" id="message-sound"
checked={this.props.messageSounds} onChange={this.handleCheckboxClick} />
</div>
<div className="panel-form-row">
<label htmlFor="desktop-alerts">
{this.props.desktopAlertsEnabled ?
<FormattedMessage id="label_push_notifications" defaultMessage="Notification alerts:"
description="Label for push notifications switch" />
:
<FormattedMessage id="label_push_notifications_disabled"
defaultMessage="Notification alerts (requires HTTPS):"
description="Label for push notifications switch" />
}
</label>
<CheckBox name="alert" id="desktop-alerts"
checked={this.props.desktopAlerts}
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
</div>
<div className="panel-form-row">
<label htmlFor="incognito-mode">
<FormattedMessage id="label_incognito_mode" defaultMessage="Incognito mode:"
description="Label for incognito mode toggle" />
</label>
<CheckBox name="incognito" id="incognito-mode"
checked={this.props.incognitoMode} onChange={this.handleCheckboxClick} />
</div>
</div>
);
}
};
+155
View File
@@ -0,0 +1,155 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
const messages = defineMessages({
delete_account: {
id: 'delete_account',
defaultMessage: 'Delete account',
description: 'Title for delete account warning'
},
delete_account_warning: {
id: 'delete_account_warning',
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
description: 'Warning message when deleting an account'
}
});
class AccSecurityView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
let blockedCount = 0;
me.contacts((c) => {
if (c.acs && !c.acs.isJoiner()) {
blockedCount ++;
}
});
const defacs = me.getDefaultAccess();
this.state = {
auth: defacs ? defacs.auth : null,
anon: defacs ? defacs.anon : null,
showPermissionEditorFor: undefined,
blockedCount: blockedCount,
};
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
}
handlePasswordUpdate(pwd) {
this.setState({password: pwd});
this.props.onUpdatePassword(pwd);
}
handleLaunchPermissionsEditor(which) {
this.setState({
showPermissionEditorFor: which,
editedPermissions: this.state[which]
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handlePermissionsChanged(perm) {
let defacs = {};
defacs[this.state.showPermissionEditorFor] = perm;
this.props.onUpdateAccountDesc('me', undefined, undefined, defacs);
let newState = {showPermissionEditorFor: undefined};
newState[this.state.showPermissionEditorFor] = perm;
this.setState(newState);
}
handleDeleteAccount(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.delete_account), // title
formatMessage(messages.delete_account_warning), // content
(() => { this.props.onDeleteAccount(); }), // onConfirm
null, // use default text "OK"
true, // Show Reject button
null // use default text "Cancel"
);
}
render() {
return (
<>{this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
skip="O"
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor} />
:
<div className="scrollable-panel">
<div className="panel-form-column">
<label className="small">
<FormattedMessage id="label_password" defaultMessage="Password"
description="Label for password editing" />
</label>
<div>
<FormattedMessage id="password_unchanged_prompt" defaultMessage="Unchanged"
description="Message in editor while password is unchanged">{
(password_unchanged) => <InPlaceEdit
placeholder={password_unchanged}
type="password"
onFinished={this.handlePasswordUpdate} />
}</FormattedMessage>
</div>
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
<i className="material-icons">exit_to_app</i> &nbsp;<FormattedMessage id="button_logout"
defaultMessage="Logout" description="Button [Logout]" />
</a>
<a href="#" className="danger flat-button" onClick={(e) => {this.handleDeleteAccount(e);}}>
<i className="material-icons">delete</i> &nbsp;<FormattedMessage id="button_delete_account"
defaultMessage="Delete account" description="Button [Delete account]" />
</a>
</div>
<div className="hr" />
<div className="panel-form-column">
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
<div>Auth: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>{this.state.auth}</tt></div>
<div>Anon: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>{this.state.anon}</tt></div>
</div>
</div>
{this.state.blockedCount > 0 ?
<>
<div className="hr" />
<div className="panel-form-row">
<i className="material-icons">block</i>&nbsp;
<a href="#" className="gray" onClick={(e) => {e.preventDefault(); this.props.onShowBlocked();}}>
<FormattedMessage id="blocked_contacts_link" defaultMessage="Blocked contacts ({count})"
values={{ count: this.state.blockedCount }} description="Blocked contacts link" />
</a>
</div>
</>
: null }
</div>
}</>
);
}
};
export default injectIntl(AccSecurityView);
+51
View File
@@ -0,0 +1,51 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
export default class AccSupportView extends React.PureComponent {
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
<i className="material-icons">email</i> &nbsp;<FormattedMessage id="link_contact_us"
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</a>
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
<i className="material-icons">description</i> &nbsp;<FormattedMessage id="link_terms_of_service"
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</a>
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
<i className="material-icons">policy</i> &nbsp;<FormattedMessage id="link_privacy_policy"
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</a>
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_client" defaultMessage="Client:"
description="Label for a client version" /></label>
{APP_NAME}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
{Tinode.getLibrary()}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /></label>
{this.props.serverVersion}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server_address" defaultMessage="Server address:" /></label>
{this.props.serverAddress}
</div>
</div>
</div>
);
}
};
+85
View File
@@ -0,0 +1,85 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
function _clip(str, length) {
return str && str.substring(0, length);
}
export default class AccountSettingsView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: _clip(me.public ? me.public.fn : undefined, MAX_TITLE_LENGTH),
description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: makeImageUrl(me.public ? me.public.photo : null)
};
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
<center>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={true}
uid={this.props.myUserId}
title={this.state.fullName} />
</center>
<div className="group">
<label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label>
<div className="large ellipsized">{this.state.fullName}</div>
</div>
<div className="group">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.props.myUserId}</tt>
</div>
<div className="group">
<BadgeList trustedBadges={this.props.trustedBadges} />
</div>
{this.state.description ?
<div className="group">
<label className="small">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label>
<div>{this.state.description}</div>
</div> : null}
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('notif');}}>
<i className="material-icons">notifications</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_notifications"
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
<i className="material-icons">security</i>&nbsp;<FormattedMessage id="button_security"
defaultMessage="Security" description="Navigaton button for security panel." />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('support');}}>
<i className="material-icons">contact_support</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_support"
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
</a>
</div>
</div>
);
}
};
+106
View File
@@ -0,0 +1,106 @@
/* ContactsView holds all contacts-related stuff */
import React from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';
import ContactList from '../widgets/contact-list.jsx';
import { updateFavicon } from '../lib/utils.js';
const messages = defineMessages({
archived_contacts_title: {
id: "archived_contacts",
defaultMessage: "Archived contacts ({count})",
description: "Label for archived chats"
}
});
export default class ContactsView extends React.Component {
constructor(props) {
super(props);
this.handleAction = this.handleAction.bind(this);
this.state = ContactsView.deriveStateFromProps(props);
}
static deriveStateFromProps(props) {
const contacts = [];
let unreadThreads = 0;
let archivedCount = 0;
props.chatList.map((c) => {
const blocked = c.acs && !c.acs.isJoiner();
// Show only blocked contacts only when props.blocked == true.
if (blocked && props.blocked) {
contacts.push(c);
}
if (blocked || props.blocked) {
return;
}
if (c.private && c.private.arch) {
if (props.archive) {
contacts.push(c);
} else {
archivedCount ++;
}
} else if (!props.archive) {
contacts.push(c);
unreadThreads += c.unread > 0 ? 1 : 0;
}
});
contacts.sort((a, b) => {
return (b.touched || 0) - (a.touched || 0);
});
if (archivedCount > 0) {
contacts.push({
action: 'archive',
title: messages.archived_contacts_title,
values: {count: archivedCount}
});
}
return {
contactList: contacts,
unreadThreads: unreadThreads
};
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.chatList != this.props.chatList ||
prevProps.archive != this.props.archive ||
prevProps.blocked != this.props.blocked) {
const newState = ContactsView.deriveStateFromProps(this.props);
this.setState(newState);
if (newState.unreadThreads != prevState.unreadThreads) {
updateFavicon(newState.unreadThreads);
}
}
}
handleAction(action_ignored) {
this.props.onShowArchive();
}
render() {
return (
<FormattedMessage id="contacts_not_found"
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
description="HTML message shown in ContactList when no contacts are found">{
(no_contacts) => <ContactList
tinode={this.props.tinode}
connected={this.props.connected}
contacts={this.state.contactList}
emptyListMessage={no_contacts}
topicSelected={this.props.topicSelected}
myUserId={this.props.myUserId}
showOnline={true}
showUnread={true}
onTopicSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
onAction={this.handleAction} />
}</FormattedMessage>
);
}
};
+130
View File
@@ -0,0 +1,130 @@
// Account registration.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
import { theCard } from '../lib/utils.js';
import { MAX_TITLE_LENGTH } from '../config.js';
export default class CreateAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
email: '',
fn: '', // full/formatted name
imageDataUrl: null,
errorCleared: false,
saveToken: LocalStorageUtil.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(password) {
this.setState({password: password});
}
handleEmailChange(e) {
this.setState({email: e.target.value})
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleToggleSaveToken() {
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.setState({errorCleared: false});
this.props.onCreateAccount(
this.state.login.trim(),
this.state.password.trim(),
theCard(this.state.fn.trim().substring(0, MAX_TITLE_LENGTH), this.state.imageDataUrl),
{'meth': 'email', 'val': this.state.email});
}
render() {
let submitClasses = 'primary';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Placeholer for username/login">{
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
}</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Placeholder/prompt for entering password">{
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
value={this.state.password} onFinished={this.handlePasswordChange}
required={true} />
}</FormattedMessage>
</div>
<AvatarUpload
tinode={this.props.tinode}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="panel-form-row">
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
description="Input placeholder for person's full name">{
(full_name_prompt) => <input type="text" placeholder={full_name_prompt} autoComplete="name"
value={this.state.fn} onChange={this.handleFnChange} required/>
}</FormattedMessage>
</div>
<div className="panel-form-row">
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
description="Input placeholder for email entry">{
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
value={this.state.email} onChange={this.handleEmailChange} required/>
}</FormattedMessage>
</div>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
description="Label for a checkbox">{
(stay_logged_in) => <label htmlFor="save-token">&nbsp;{stay_logged_in}</label>
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
description="Create account button [Sign Up]" />
</button>
</div>
</form>
);
}
};
+638
View File
@@ -0,0 +1,638 @@
// InfoView: panel with topic/user info.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TopicCommon from '../widgets/topic-common.jsx';
import TopicSecurity from '../widgets/topic-security.jsx';
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, theCard } from '../lib/utils.js';
const messages = defineMessages({
info: {
id: 'panel_title_info',
description: 'Title for InfoView',
defaultMessage: 'Info'
},
general: {
id: 'panel_title_general',
description: 'Title for TopicCommon.',
defaultMessage: 'General'
},
security: {
id: 'panel_title_security',
description: 'Title for TopicSecirity and AccSecurity.',
defaultMessage: 'Security'
},
members: {
id: 'panel_title_members',
description: 'Title for managing group members view.',
defaultMessage: 'Members'
},
crop: {
id: 'panel_title_crop',
description: 'Title for AvatarCropView.',
defaultMessage: 'Drag to Adjust'
},
perm_want: {
id: 'requested_permissions',
defaultMessage: 'Requested',
description: 'Title for permissions'
},
perm_given: {
id: 'granted_permissions',
defaultMessage: 'Granted',
description: 'Title for permissions'
},
perm_auth: {
id: 'permissions_authenticated',
defaultMessage: 'Authenticated',
description: 'Title for editing default authenticated permissions'
},
perm_anon: {
id: 'permissions_anonymous',
defaultMessage: 'Anonymous',
description: 'Title for editing default anonymous permissions'
},
perm_user: {
id: 'permissions_user',
defaultMessage: 'User\'s Permissions',
description: 'Title for editing user\'s permissions'
},
edit_permissions: {
id: 'menu_item_edit_permissions',
defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]'
},
});
function _clip(str, length) {
return str && str.substring(0, length);
}
class InfoView extends React.Component {
constructor(props) {
super(props);
this.state = {
topic: null,
owner: false,
admin: false,
sharer: false,
deleter: false,
muted: false,
address: null,
groupTopic: undefined,
channel: undefined,
fullName: undefined,
description: undefined,
avatar: null,
private: null,
selectedContact: null,
access: null,
modeGiven: null,
modeWant: null,
modeGiven2: null, // P2P topic, the other user mode given
modeWant2: null, // P2P topic, the other user mode want
auth: null,
anon: null,
contactList: [],
trustedBadges: [],
previousMetaDesc: undefined,
previousSubsUpdated: undefined,
};
this.resetSubs = this.resetSubs.bind(this);
this.resetDesc = this.resetDesc.bind(this);
this.resetTags = this.resetTags.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this);
this.onSubsUpdated = this.onSubsUpdated.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleMuted = this.handleMuted.bind(this);
this.handleUnarchive = this.handleUnarchive.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
this.handleBackNavigate = this.handleBackNavigate.bind(this);
}
// No need to separately handle component mount.
componentDidUpdate(props) {
const topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
if (this.onMetaDesc != topic.onMetaDesc) {
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = this.onMetaDesc;
this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = this.onSubsUpdated;
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
this.resetDesc(topic, props);
this.resetSubs(topic, props);
this.resetTags(topic);
}
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated;
}
resetSubs(topic, props) {
const newState = {contactList: []};
if (topic.getType() == 'p2p') {
// Fetch the other party in the p2p conversation.
// Topic may not be ready yet, so check if user is found.
const user2 = topic.subscriber(props.topic);
if (user2) {
newState.modeGiven2 = user2.acs.getGiven();
newState.modeWant2 = user2.acs.getWant();
} else {
newState.modeGiven2 = NO_ACCESS_MODE;
newState.modeWant2 = NO_ACCESS_MODE;
}
} else {
topic.subscribers((sub) => {
newState.contactList.push(sub);
}, this);
}
this.setState(newState);
}
resetDesc(topic, props) {
const defacs = topic.getDefaultAccess() || {};
const acs = topic.getAccessMode();
const badges = [];
if (topic.trusted) {
for (const [key, val] of Object.entries(topic.trusted)) {
if (val) {
badges.push(key);
}
}
}
this.setState({
owner: acs && acs.isOwner(),
admin: acs && acs.isAdmin(),
sharer: acs && acs.isSharer(),
deleter: acs && acs.isDeleter(),
muted: acs && acs.isMuted(),
fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
trustedBadges: badges,
private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH),
archived: topic.isArchived(),
address: topic.name,
groupTopic: topic.isGroupType(),
channel: topic.isChannelType() || topic.chan,
access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined,
modeWant: acs ? acs.getWant() : undefined,
auth: defacs.auth,
anon: defacs.anon
});
}
resetTags(topic) {
if (topic.getType() != 'grp') {
return;
}
const acs = topic.getAccessMode();
if (acs && acs.isOwner()) {
// Requesting tags: owner is editing the topic.
topic.getMeta(topic.startMetaQuery().withTags().build());
}
}
onMetaDesc(desc) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
this.previousMetaDesc(desc);
}
}
onSubsUpdated(subs) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetSubs(topic, this.props);
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
this.previousSubsUpdated(subs);
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
}
handleMuted(ignored, checked) {
this.setState({muted: checked});
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
}
handleUnarchive(ignored, ignored2) {
this.props.onTopicUnArchive(this.props.topic);
}
handlePermissionsChanged(which, perm) {
switch (which) {
case 'auth':
this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {auth: perm});
break;
case 'anon':
this.props.onTopicDescUpdateRequest(this.props.topic, null, null, {anon: perm});
break;
case 'mode':
case 'want':
this.props.onChangePermissions(this.props.topic, perm);
break;
case 'given':
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
break;
case 'user':
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
break;
}
this.handleBackNavigate();
}
handleLaunchPermissionsEditor(which, uid) {
const {formatMessage} = this.props.intl;
let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
switch (which) {
case 'mode':
toEdit = this.state.access;
break;
case 'want':
toEdit = this.state.modeWant;
toCompare = this.state.modeGiven;
if (this.state.owner) {
// Don't allow owner to unset 'O' permission. All other permissions are OK.
toSkip = 'O';
} else {
// Allow accepting any of 'ASDO' permissions but don't allow asking for them.
toSkip = Tinode.AccessMode.encode(Tinode.AccessMode.diff('ASDO', this.state.modeGiven));
if (this.state.channel) {
// Channels are read-only.
toSkip += 'W';
}
}
titleEdit = formatMessage(messages.perm_want);
titleCompare = formatMessage(messages.perm_given);
break;
case 'given':
toEdit = this.state.modeGiven2;
toCompare = this.state.modeWant2;
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
titleEdit = formatMessage(messages.perm_given);
titleCompare = formatMessage(messages.perm_want);
break;
case 'auth':
toEdit = this.state.auth;
toSkip = 'O';
break;
case 'anon':
toEdit = this.state.anon;
toSkip = 'O';
break;
case 'user': {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
const user = topic.subscriber(uid);
if (!user || !user.acs) {
return;
}
toEdit = user.acs.getGiven();
toCompare = user.acs.getWant();
toSkip = this.state.owner ? '' : 'O';
titleEdit = formatMessage(messages.perm_given);
titleCompare = formatMessage(messages.perm_want);
if (user.public) {
userTitle = user.public.fn;
userAvatar = user.public.photo;
}
break;
}
default:
console.error("Unknown permission editing mode '" + which + "'");
return;
}
this.setState({
userPermissionsEdited: uid,
userPermissionsTitle: userTitle,
userPermissionsAvatar: userAvatar,
editedPermissions: toEdit,
immutablePermissions: toCompare,
editedPermissionsTitle: titleEdit,
immutablePermissionsTitle: titleCompare,
editedPermissionsSkipped: toSkip,
});
this.props.onNavigate(`perm/${which}`);
}
handleShowAddMembers(e) {
e.preventDefault();
this.props.onInitFind();
this.props.onNavigate('members');
}
handleMemberUpdateRequest(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.props.onNavigate('info');
}
handleMemberSelected(uid) {
this.setState({selectedContact: uid});
}
handleBackNavigate() {
const args = (this.props.panel || 'info').split('/');
if (args[0] == 'info') {
this.props.onNavigate(null);
} else if (args[0] == 'perm') {
if (args[1] == 'user') {
this.props.onNavigate('info');
} else {
this.props.onNavigate('security');
}
} else {
this.props.onNavigate('info');
}
}
handleContextMenu(params) {
const {formatMessage} = this.props.intl;
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
const user = topic.subscriber(params.topicName);
if (!user || !user.acs) {
return;
}
const isMe = this.props.tinode.isMe(params.topicName);
const menuItems = [
{title: formatMessage(messages.edit_permissions), handler: () => {
this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName);
}}
];
if (!isMe) {
menuItems.push('member_delete');
}
menuItems.push(user.acs.isMuted() ? 'member_unmute' : 'member_mute');
if (!isMe) {
menuItems.push(user.acs.isJoiner() ? 'member_block' : 'member_unblock');
}
this.props.showContextMenu({
topicName: this.props.topic,
x: params.x,
y: params.y,
user: params.topicName}, menuItems);
}
render() {
const args = (this.props.panel || 'info').split('/');
const view = args[0];
args.shift();
const {formatMessage} = this.props.intl;
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view])
|| messages['info']);
return (
<div id="info-view">
<div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title">{panelTitle}</div>
<div>
<MenuCancel onCancel={this.handleBackNavigate} />
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} /> : null}
{view == 'members' ?
<GroupManager
tinode={this.props.tinode}
members={this.state.contactList}
requiredMember={this.props.myUserId}
keepInitialMembers={!this.state.admin && !this.state.owner}
myUserId={this.props.myUserId}
contacts={this.props.searchableContacts}
onCancel={this.handleBackNavigate}
onSubmit={this.handleMemberUpdateRequest} />
:
view == 'perm' && args.length > 0 ?
<PermissionsEditor
tinode={this.props.tinode}
mode={this.state.editedPermissions}
compare={this.state.immutablePermissions}
skip={this.state.editedPermissionsSkipped}
modeTitle={this.state.editedPermissionsTitle}
compareTitle={this.state.immutablePermissionsTitle}
userTitle={this.state.userPermissionsTitle}
item={this.state.userPermissionsEdited}
userAvatar={this.state.userPermissionsAvatar}
onSubmit={(mode) => this.handlePermissionsChanged(args[0], mode)}
onCancel={this.handleBackNavigate} />
:
view == 'general' ?
<TopicCommon
tinode={this.props.tinode}
topic={this.props.topic}
onCredAdd={this.props.onCredAdd}
onTopicTagsUpdateRequest={this.props.onTopicTagsUpdateRequest}
onCredConfirm={this.props.onCredConfirm}
onCredDelete={this.props.onCredDelete}
onUpdateTopicDesc={this.props.onTopicDescUpdateRequest}
onError={this.props.onError} />
:
view == 'security' ?
<TopicSecurity
topic={this.props.topic}
owner={this.state.owner}
admin={this.state.admin}
sharer={this.state.sharer}
deleter={this.state.deleter}
muted={this.state.muted}
groupTopic={this.state.groupTopic}
channel={this.state.channel}
access={this.state.access}
modeGiven={this.state.modeGiven}
modeWant={this.state.modeWant}
modeGiven2={this.state.modeGiven2}
modeWant2={this.state.modeWant2}
auth={this.state.auth}
anon={this.state.anon}
onShowAlert={this.props.onShowAlert}
onDeleteMessages={this.props.onDeleteMessages}
onLeaveTopic={this.props.onLeaveTopic}
onBlockTopic={this.props.onBlockTopic}
onReportTopic={this.props.onReportTopic}
onLaunchPermissionsEditor={this.handleLaunchPermissionsEditor}
onNavigate={this.props.onNavigate} />
:
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-column">
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
<center>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={true}
uid={this.props.topic}
title={this.state.fullName} />
</center>
<div className="group">
<div><label className="small">
<FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" />
</label></div>
<div className="large ellipsized">{this.state.fullName}
{this.state.channel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}</div>
</div>
{this.state.private ?
<div className="group">
<div><label className="small">
<FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" />
</label></div>
<div className="large ellipsized">{this.state.private}</div>
</div>
: null
}
<div className="group">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.state.address}</tt>
</div>
<div className="group">
<BadgeList trustedBadges={this.state.trustedBadges} />
</div>
{this.state.description ?
<div className="group">
<label className="small">
<FormattedMessage id="label_description" defaultMessage="Description"
description="Label for editing topic description" />
</label>
<div>{this.state.description}</div>
</div> : null}
</div>
<div className="hr" />
<div className="panel-form-row">
<label>
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
description="Label for Muting/unmuting the topic" />
</label>
<CheckBox name="P" checked={this.state.muted} onChange={this.handleMuted} />
</div>
{this.state.archived ?
<div className="panel-form-row">
<label>
<FormattedMessage id="label_unarchive_topic" defaultMessage="Archived:"
description="Label for unarchiving the topic" />
</label>
<CheckBox name="archived" checked={true} onChange={this.handleUnarchive} />
</div>
:
null
}
<div className="hr" />
<div className="panel-form-row">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
<i className="material-icons">security</i>&nbsp;<FormattedMessage id="button_security"
defaultMessage="Security" description="Navigaton button for security panel." />
</a>
</div>
{this.state.groupTopic && this.state.sharer ?
<>
<div className="hr" />
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
description="Section title or label" />
</label>
</div>
<div className="panel-form-row">
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> &nbsp;<FormattedMessage id="button_add_members"
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
</a>
</div>
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
description="Shown in place of group members">{
(no_members) => <ContactList
tinode={this.props.tinode}
contacts={this.state.contactList}
myUserId={this.props.myUserId}
emptyListMessage={no_members}
topicSelected={this.state.selectedContact}
showOnline={false}
showUnread={false}
showMode={true}
noScroll={true}
onTopicSelected={this.handleMemberSelected}
showContextMenu={this.state.admin ? this.handleContextMenu : false}
/>
}</FormattedMessage>
</>
:
null
}
</div>
}
</div>
);
}
};
export default injectIntl(InfoView);
+98
View File
@@ -0,0 +1,98 @@
// Login form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
export default class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
login: props.login,
password: '',
hostName: props.serverAddress,
saveToken: props.persist
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
handleToggleSaveToken() {
this.props.onPersistenceChange(!this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
}
render() {
let submitClasses = 'primary';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form id="login-form" onSubmit={this.handleSubmit}>
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Placeholer for username/login">
{
(login_prompt) => <input type="text" id="inputLogin"
placeholder={login_prompt}
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={this.state.login}
onChange={this.handleLoginChange}
required autoFocus />
}
</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Placeholder/prompt for entering password">
{
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
placeholder={password_prompt}
autoComplete="current-password"
value={this.state.password}
onChange={this.handlePasswordChange}
required={true} />
}
</FormattedMessage>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<label htmlFor="save-token">&nbsp;
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
description="Label for a checkbox" />
</label>
<a href="#reset">
<FormattedMessage id="forgot_password_link" defaultMessage="Forgot password?"
description="Link to Reset password form" />
</a>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_in" defaultMessage="Sign in"
description="Button [Sign In]" />
</button>
</div>
</form>
);
}
};
/* END Login */
+27
View File
@@ -0,0 +1,27 @@
/* This is just a static page to display when no conversation is selected. */
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { APP_NAME } from '../config.js';
export default class LogoView extends React.PureComponent {
render() {
const version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
return (
<div id="dummy-view">
<div>
<a href="https://github.com/tinode/chat/">
<img id="logo" alt="logo" src="img/logo.svg" />
<h2>Tinode Web</h2>
</a>
<p><FormattedMessage id="label_client" defaultMessage="Client:"
description="Label for a client version" /> {version}</p>
<p><FormattedMessage id="label_server" defaultMessage="Server:"
description="Label for a server version" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
</div>
</div>
);
}
};
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
// Create new topic and invite users or send an invite.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import ContactList from '../widgets/contact-list.jsx';
import NewTopicById from '../widgets/new-topic-by-id.jsx';
import NewTopicGroup from '../widgets/new-topic-group.jsx';
import SearchContacts from '../widgets/search-contacts.jsx';
import HashNavigation from '../lib/navigation.js';
import { theCard } from '../lib/utils.js';
const messages = defineMessages({
search_for_contacts: {
id: "search_for_contacts",
defaultMessage: "Use search to find contacts",
description: "Text shown in contacts view when user entered no search query."
},
search_no_results: {
id: "search_no_results",
defaultMessage: "Search returned no results",
description: "Text shown in contacts view when query returned no results."
},
search_placeholder: {
id: "search_placeholder",
defaultMessage: "List like email:alice@example.com, tel:17025550003...",
description: "Placeholder in contacts search field"
},
});
class NewTopicView extends React.Component {
constructor(props) {
super(props);
this.state = {
tabSelected: 'p2p',
searchQuery: null
};
this.handleTabClick = this.handleTabClick.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
handleTabClick(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
handleSearchContacts(query) {
this.props.onSearchContacts(query);
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
}
handleContactSelected(uid) {
if (this.state.tabSelected == 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(uid);
}
}
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined,
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
}
handleGroupByID(topicName) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(topicName);
}
render() {
const {formatMessage} = this.props.intl;
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
messages.search_no_results : messages.search_for_contacts);
const search_placeholder = formatMessage(messages.search_placeholder);
return (
<div className="flex-column">
<ul className="tabbar">
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
<a href="#" data-id="p2p" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
description="Tab title Find" />
</a>
</li>
<li className={this.state.tabSelected === "grp" ? "active" : null}>
<a href="#" data-id="grp" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_new_group" defaultMessage="new group"
description="Tab title New Group" />
</a>
</li>
<li className={this.state.tabSelected === "byid" ? "active" : null}>
<a href="#" data-id="byid" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_group_by_id" defaultMessage="by id"
description="Tab title Find topic by ID" />
</a>
</li>
</ul>
{this.state.tabSelected === 'grp' ?
<NewTopicGroup
tinode={this.props.tinode}
onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === 'byid' ?
<NewTopicById
onSubmit={this.handleGroupByID}
onError={this.props.onError} /> :
<div className="flex-column">
<SearchContacts
type="p2p"
placeholder={search_placeholder}
onSearchContacts={this.handleSearchContacts} />
<ContactList
tinode={this.props.tinode}
contacts={this.props.searchResults}
myUserId={this.props.myUserId}
emptyListMessage={no_contacts_placeholder}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
</div>}
</div>
);
}
};
export default injectIntl(NewTopicView);
+108
View File
@@ -0,0 +1,108 @@
// A password reset form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import VisiblePassword from '../widgets/visible-password.jsx';
import HashNavigation from '../lib/navigation.js';
export default class PasswordResetView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
email: '',
password: '',
sent: false
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
}
componentDidMount() {
const parsed = HashNavigation.parseUrlHash(window.location.hash);
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
} else if (this.state.sent) {
this.props.onCancel();
} else {
const email = this.state.email.trim();
this.setState({email: email});
this.props.onRequest('email', email).then(() => {
this.setState({sent: true});
});
}
}
handleEmailChange(e) {
this.setState({email: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
render() {
return (
<form id="password-reset-form" onSubmit={this.handleSubmit}>
{(this.state.token && this.state.scheme) ?
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage>
: this.state.sent ?
<>
<br/>
<center><i className="material-icons huge green">task_alt</i></center>
<br/>
<center><FormattedMessage id="password_reset_email_sent"
defaultMessage="An email has been sent to {email}. Follow the directions in the email to reset your password."
values={{ email: <tt>{this.state.email}</tt> }}
description="Notification that the email with password reset instructions has been sent" /></center>
</>
:
<>
<label htmlFor="inputEmail">
<FormattedMessage id="label_reset_password"
defaultMessage="Send a password reset email:"
description="Label for password reset field" />
</label>
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
description="Placeholder for entering email">{
(placeholder) => <input type="email" id="inputEmail"
placeholder={placeholder}
autoComplete="email"
value={this.state.email}
onChange={this.handleEmailChange}
required autoFocus />
}</FormattedMessage>
</>
}
<div className="dialog-buttons">
<button className="primary" type="submit">{
(this.state.token && this.state.scheme) ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
: this.state.sent ?
<FormattedMessage id="button_ok" defaultMessage="OK"
description="Button [OK]" />
:
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
}</button>
</div>
</form>
);
}
}
+83
View File
@@ -0,0 +1,83 @@
// Tinode config panel.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import HostSelector from '../widgets/host-selector.jsx';
export default class SettingsView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
transport: props.transport || 'def',
serverAddress: props.serverAddress,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleTransportSelected = this.handleTransportSelected.bind(this);
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
}
handleSubmit(e) {
e.preventDefault();
this.props.onUpdate({
transport: this.state.transport,
serverAddress: this.state.serverAddress
});
}
handleTransportSelected(e) {
this.setState({transport: e.currentTarget.value});
}
handleServerAddressChange(name) {
this.setState({serverAddress: name});
}
render() {
const names = {def: "default", ws: "websocket", lp: "long polling"};
const transportOptions = [];
['def', 'ws', 'lp'].map((item) => {
const id = 'transport-' + item;
const name = names[item];
transportOptions.push(
<li key={item}>
<input type="radio" id={id} name="transport-select" value={item}
checked={this.state.transport === item}
onChange={this.handleTransportSelected} />
<label htmlFor={id}>{name}</label>
</li>
);
});
return (
<form id="settings-form" className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_server_to_use" defaultMessage="Server to use:"
description="Label for server selector in SettingsView" />
</label>
</div>
<HostSelector serverAddress={this.state.serverAddress}
onServerAddressChange={this.handleServerAddressChange} />
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_wire_transport" defaultMessage="Wire transport:"
description="Label for wire transport selection in SettingsView" />
</label>
</div>
<div className="panel-form-row">
<ul className="quoted">
{transportOptions}
</ul>
</div>
<div className="dialog-buttons">
<button type="submit" className="primary">
<FormattedMessage id="button_update" defaultMessage="Update"
description="Button [Update]" />
</button>
</div>
</form>
);
}
};
+256
View File
@@ -0,0 +1,256 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import ErrorPanel from '../widgets/error-panel.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx';
import SideNavbar from '../widgets/side-navbar.jsx';
import TopicCommon from '../widgets/topic-common.jsx';
import AccountSettingsView from './account-settings-view.jsx';
import ContactsView from './contacts-view.jsx';
import CreateAccountView from './create-account-view.jsx';
import AccNotificationsView from './acc-notifications-view.jsx';
import AccSecurityView from './acc-security-view.jsx';
import AccSupportView from './acc-support-view.jsx';
import LoginView from './login-view.jsx';
import NewTopicView from './new-topic-view.jsx';
import PasswordResetView from './password-reset-view.jsx';
import SettingsView from './settings-view.jsx';
import ValidationView from './validation-view.jsx';
// Panel titles for translation.
const messages = defineMessages({
login: {
id: 'sidepanel_title_login',
description: 'Sidepanel title for LoginView.',
defaultMessage: 'Sign In'
},
register: {
id: 'sidepanel_title_register',
description: 'Sidepanel title for CreateAccountView.',
defaultMessage: 'Create Account'
},
settings: {
id: 'sidepanel_title_settings',
description: 'Sidepanel title for SettingsView.',
defaultMessage: 'Settings'
},
edit: {
id: 'sidepanel_title_account_settings',
description: 'Sidepanel title for AccountSettingsView.',
defaultMessage: 'Account Settings'
},
general: {
id: 'panel_title_general',
description: 'Title for TopicCommon.',
defaultMessage: 'General'
},
security: {
id: 'panel_title_security',
description: 'Title for TopicSecirity and AccSecurity.',
defaultMessage: 'Security'
},
crop: {
id: 'panel_title_crop',
description: 'Title for AvatarCropView.',
defaultMessage: 'Drag to Adjust'
},
notif: {
id: 'sidepanel_title_acc_notifications',
description: 'Sidepanel title for AccNotificationsView.',
defaultMessage: 'Notifications'
},
support: {
id: 'sidepanel_title_acc_support',
description: 'Sidepanel title for AccSupportView.',
defaultMessage: 'Support'
},
newtpk: {
id: 'sidepanel_title_newtpk',
description: 'Sidepanel title for NewTopicView.',
defaultMessage: 'Start New Chat'
},
cred: {
id: 'sidepanel_title_cred',
description: 'Sidepanel title for ValidationView.',
defaultMessage: 'Confirm Credentials'
},
reset: {
id: 'sidepanel_title_reset',
description: 'Sidepanel title for PasswordResetView.',
defaultMessage: 'Reset Password'
},
archive: {
id: 'sidepanel_title_archive',
description: 'Sidepanel title for ContactsView-Archive.',
defaultMessage: 'Archived Chats'
},
blocked: {
id: 'sidepanel_title_blocked',
description: 'Sidepanel title for ContactsView-Blocked.',
defaultMessage: 'Blocked Chats'
}
});
class SidepanelView extends React.Component {
constructor(props) {
super(props);
this.handleNewTopic = this.handleNewTopic.bind(this);
}
handleNewTopic() {
this.props.onNavigate('newtpk');
}
render() {
const {formatMessage} = this.props.intl;
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
let title, avatar, badges;
if (view == 'contacts') {
title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true;
badges = this.props.trustedBadges;
} else {
title = formatMessage(messages[view]);
avatar = false;
badges = null;
}
let onCancel;
if (['login', 'contacts'].indexOf(view) == -1) {
onCancel = this.props.onCancel;
}
return (
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
<SideNavbar
state={view}
title={title}
avatar={avatar}
tinode={this.props.tinode}
trustedBadges={badges}
myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp}
onSettings={this.props.onSettings}
onNewTopic={this.handleNewTopic}
onCancel={onCancel} />
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
action={this.props.errorAction}
actionText={this.props.errorActionText}
onClearError={this.props.onError} />
<LoadSpinner show={this.props.loadSpinnerVisible} />
{view === 'login' ?
<LoginView
login={this.props.login}
disabled={this.props.loginDisabled}
persist={this.props.persist}
onLogin={this.props.onLoginRequest}
onPersistenceChange={this.props.onPersistenceChange} /> :
view === 'register' ?
<CreateAccountView
tinode={this.props.tinode}
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'settings' ?
<SettingsView
transport={this.props.transport}
serverAddress={this.props.serverAddress}
onCancel={this.props.onCancel}
onUpdate={this.props.onGlobalSettings} /> :
view === 'edit' ?
<AccountSettingsView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
trustedBadges={this.props.trustedBadges}
onNavigate={this.props.onNavigate} /> :
(view === 'general' || view === 'crop') ?
<TopicCommon
topic="me"
tinode={this.props.tinode}
myUserId={this.props.myUserId}
onUpdateTopicDesc={this.props.onUpdateAccountDesc}
onUpdateTags={this.props.onUpdateAccountTags}
onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm}
onError={this.props.onError} /> :
view === 'notif' ?
<AccNotificationsView
messageSounds={this.props.messageSounds}
desktopAlerts={this.props.desktopAlerts}
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
incognitoMode={this.props.incognitoMode}
onTogglePushNotifications={this.props.onTogglePushNotifications}
onToggleMessageSounds={this.props.onToggleMessageSounds}
onToggleIncognitoMode={this.props.onToggleIncognitoMode} /> :
view === 'security' ?
<AccSecurityView
tinode={this.props.tinode}
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
onUpdatePassword={this.props.onUpdatePassword}
onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert}
onShowBlocked={this.props.onShowBlocked} /> :
view === 'support' ?
<AccSupportView
serverAddress={this.props.serverAddress}
serverVersion={this.props.serverVersion} /> :
(view === 'contacts' || view == 'archive' || view == 'blocked') ?
<ContactsView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
connected={this.props.connected}
topicSelected={this.props.topicSelected}
archive={view == 'archive'}
blocked={view == 'blocked'}
chatList={this.props.chatList}
showContextMenu={this.props.showContextMenu}
onTopicSelected={this.props.onTopicSelected}
onShowArchive={this.props.onShowArchive} /> :
view === 'newtpk' ?
<NewTopicView
tinode={this.props.tinode}
searchResults={this.props.searchResults}
onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts}
onCreateTopic={this.props.onCreateTopic}
onError={this.props.onError} /> :
view === 'cred' ?
<ValidationView
credCode={this.props.credCode}
credMethod={this.props.credMethod}
onSubmit={this.props.onValidateCredentials}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'reset' ?
<PasswordResetView
onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword}
onCancel={this.props.onCancel} /> :
null}
</div>
);
}
};
export default injectIntl(SidepanelView);
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
// ValidationView: panel for confirming credentials, like email or phone.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
phone: {
id: 'phone_dative',
defaultMessage: 'phone',
description: "Dative case of 'phone', i.e. 'phone' in 'by phone'",
},
email: {
id: 'email_dative',
defaultMessage: 'email',
description: "Dative case of 'email', i.e. 'email' in 'by email'",
}
});
class ValidationView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
code: props.credCode || ''
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleChange(e) {
this.setState({code: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
} else if (e.key == 'Escape') {
this.handleCancel(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.code && this.state.code.trim()) {
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
}
}
handleCancel(e) {
e.preventDefault();
this.props.onCancel();
}
render() {
const { formatMessage } = this.props.intl;
const methods = {'email': formatMessage(messages.email), 'tel': formatMessage(messages.phone)};
let method = methods[this.props.credMethod] || this.props.credMethod;
return (
<div className="panel-form">
<div className="panel-form-row">
<label className="small" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Enter confirmation code sent to you by {method}:"
description="Request to enter confirmation code"
values={{method: method}} />
</label>
</div>
<div className="panel-form-row">
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only}
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="secondary" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(ValidationView);
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class Alert extends React.PureComponent {
render() {
return this.props.visible ?
<div className="alert-container">
<div className="alert">
<div className="title">{this.props.title}</div>
<div className="content">{this.props.content}</div>
<div className="dialog-buttons">
{this.props.onReject ?
<button className="outline" onClick={this.props.onReject}>
{this.props.reject || <FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />}
</button>
:
null
}
<button className="primary" onClick={this.props.onConfirm}>
{this.props.confirm || <FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />}
</button>
</div>
</div>
</div>
:
null;
}
};
+99
View File
@@ -0,0 +1,99 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import FileProgress from './file-progress.jsx';
import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
export default class Attachment extends React.Component {
constructor(props) {
super(props);
this.state = {
downloader: null,
progress: 0
};
this.downloadFile = this.downloadFile.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
downloadFile(url, filename, mimetype) {
if (!url) {
this.props.onError("Invalid download URL '" + url + "'");
return;
}
const downloader = this.props.tinode.getLargeFileHelper();
this.setState({downloader: downloader});
downloader.download(url, filename, mimetype,
(loaded) => {
this.setState({progress: loaded / this.props.size});
},
(err) => {
this.props.onError(err, 'err');
}
).then(() => {
this.setState({downloader: null, progress: 0});
}).catch((err) => {
if (err) {
this.props.onError("Error downloading file: " + err.message, 'err');
}
this.setState({downloader: null, progress: 0});
});
}
handleCancel() {
if (this.props.uploading) {
this.props.onCancelUpload();
} else if (this.state.downloader) {
this.state.downloader.cancel();
}
}
render() {
let filename = this.props.filename || 'file_attachment';
if (filename.length > 36) {
filename = filename.substr(0, 16) + '...' + filename.substr(-16);
}
let size = this.props.size > 0 ?
<span className="small gray">({bytesToHumanSize(this.props.size)})</span> :
null;
// Detect if the download URL is relative or absolute.
// If the URL is relative use LargeFileHelper to attach authentication
// credentials to the request.
let url, helperFunc;
if (!this.props.uploading && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) {
// Relative URL. Use download helper.
url = '#';
helperFunc = (e) => {
e.preventDefault();
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
};
} else {
url = sanitizeUrl(this.props.downloadUrl);
helperFunc = null;
}
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" /></>;
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploading || this.state.downloader ?
<FileProgress progress={this.props.uploading ? this.props.progress : this.state.progress}
onCancel={this.handleCancel} />
:
<div>{url ?
<a href={url} download={this.props.filename} onClick={helperFunc}>{downloadWidget}</a>
:
<span className="light-gray">{downloadWidget}</span>
}</div>
}
</div>
</div>
);
}
};
+68
View File
@@ -0,0 +1,68 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Cropper from './cropper.jsx';
import { imageCrop } from '../lib/blob-helpers.js';
export default class AvatarCrop extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
top: 0,
left: 0,
width: 0,
height: 0,
scale: 1
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
}
// Cut out in image coordinates and desired scale factor.
handleChange(left, top, width, height, scale) {
this.setState({
left: left,
top: top,
width: width,
height: height,
scale: scale
});
}
handleSubmit() {
imageCrop(this.props.mime, this.props.avatar,
this.state.left, this.state.top, this.state.width, this.state.height,
this.state.scale)
.then(img => {
this.props.onSubmit(img.mime, img.blob, img.width, img.height);
})
.catch(err => {
this.props.onError(err);
});
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<Cropper
source={this.props.avatar}
onChange={this.handleChange} />
</div>
<div className="dialog-buttons">
{this.props.onCancel ?
<button className="secondary" onClick={this.props.onCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
: null}
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div>
</div>
);
}
};
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import LoadSpinner from './load-spinner.jsx';
import { sanitizeImageUrl } from '../lib/utils.js';
import { AVATAR_SIZE } from '../config.js';
export default class AvatarUpload extends React.Component {
constructor(props) {
super(props);
this.state = {
source: props.avatar
};
this.handleFileReceived = this.handleFileReceived.bind(this);
}
componentDidUpdate(prevProps) {
if (this.props.avatar != prevProps.avatar) {
this.setState({source: this.props.avatar});
}
}
handleFileReceived(e) {
const image = e.target.files[0];
this.props.onImageUpdated(image.type, URL.createObjectURL(image), image.name);
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
render() {
// Randomize id value in case more than one AvatarUpload is shown
// at the same time.
const randId = 'file-input-avatar-' + (Math.random() + '').substr(2);
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
return (
<div className={className}>
{this.props.readOnly || !this.state.source ?
null :
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageUpdated();}}>
<i className="material-icons">clear</i>
</a>}
{this.state.source ?
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile
tinode={this.props.tinode}
avatar={true}
topic={this.props.uid}
title={this.props.title} />
</div>
:
<div className="blank">{AVATAR_SIZE}&times;{AVATAR_SIZE}</div>}
{this.props.readOnly ? null :
<input type="file" id={randId} className="inputfile hidden"
accept="image/*" onChange={this.handleFileReceived} />}
{this.props.readOnly ? null :
<label htmlFor={randId} className="round">
<i className="material-icons">file_upload</i>
</label>}
<LoadSpinner show={this.props.uploading} large={true} clear={true} centered={true} />
</div>
);
}
};
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
const icon_mapping = {'staff': 'verified_user'};
const messages = defineMessages({
badge_verified: {
id: 'badge_verified',
defaultMessage: 'Verified/official',
description: 'Explanation of a verified account or topic badge'
},
badge_staff: {
id: 'badge_staff',
defaultMessage: 'Staff-managed',
description: 'Explanation of a staff-managed account or topic badge'
},
badge_danger: {
id: 'badge_danger',
defaultMessage: 'Untrustworthy',
description: 'Suspicious or untrustworthy account or topic badge'
},
});
/* Trusted badges with labels: verified, staff, danger */
class BadgeList extends React.PureComponent {
render() {
const {formatMessage} = this.props.intl;
let badges = null;
if (this.props.trustedBadges && this.props.trustedBadges.length > 0) {
badges = [];
this.props.trustedBadges.map((b) => {
const comment = formatMessage(messages['badge_' + b]);
const style = 'material-icons ' + b + '-color';
badges.push(<div className="trusted-badge" key={b}><i className={style}>{icon_mapping[b] || b}</i> {comment}</div>);
});
return <>{badges}</>;
}
return null;
}
};
export default injectIntl(BadgeList);
+12
View File
@@ -0,0 +1,12 @@
// The <- button to be displayed in title bars.
import React from 'react';
export default class ButtonBack extends React.PureComponent {
render() {
return (
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onBack();}}>
<i className="material-icons">arrow_back</i>
</a>
);
}
}
+210
View File
@@ -0,0 +1,210 @@
// Single message, sent or received.
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import { fullFormatter, quoteFormatter } from '../lib/formatters.js';
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
class BaseChatMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
progress: 0
};
if (props.uploader) {
props.uploader.onProgress = this.handleProgress.bind(this);
}
this.handleImagePreview = this.handleImagePreview.bind(this);
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.formatterContext = {
getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; },
formatMessage: props.intl.formatMessage.bind(props.intl),
viewportWidth: props.viewportWidth,
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
onImagePreview: this.handleImagePreview,
onFormButtonClick: this.handleFormButtonClick,
onQuoteClick: this.handleQuoteClick
};
}
handleImagePreview(e) {
e.preventDefault();
this.props.onImagePreview({
url: e.target.src,
filename: e.target.title,
width: e.target.dataset.width,
height: e.target.dataset.height,
size: e.target.dataset.size,
type: e.target.dataset.mime
});
}
handleFormButtonClick(e) {
e.preventDefault();
const data = {
seq: this.props.seq
};
data.resp = {};
if (e.target.dataset.name) {
data.resp[e.target.dataset.name] = e.target.dataset.val ? e.target.dataset.val :
e.target.dataset.val === undefined ? 1 : '' + e.target.dataset.val;
}
if (e.target.dataset.act == 'url') {
data.ref = sanitizeUrl(e.target.dataset.ref) || 'about:blank';
}
const text = e.target.dataset.title || 'unknown';
this.props.onFormResponse(e.target.dataset.act, text, data);
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
const menuItems = [];
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
menuItems.push('menu_item_send_retry');
}
if (this.props.userIsWriter &&
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
menuItems.push('menu_item_reply');
}
menuItems.push('menu_item_forward');
this.props.showContextMenu({
seq: this.props.seq,
content: this.props.content,
userFrom: this.props.userFrom,
userName: this.props.userName,
forwarded: this.props.forwarded,
y: e.pageY,
x: e.pageX,
pickReply: this.props.pickReply
}, menuItems);
}
handleProgress(ratio) {
this.setState({progress: ratio});
}
handleCancelUpload() {
this.props.onCancelUpload(this.props.seq, this.props.uploader);
}
handleQuoteClick(e) {
e.preventDefault();
e.stopPropagation();
const replyToSeq = this.props.replyToSeq;
if (replyToSeq) {
this.props.onQuoteClick(replyToSeq);
}
}
render() {
const sideClass = this.props.deleted ? 'center' :
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content;
const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, (att, i) => {
if (att.mime == 'application/json') {
// Don't show json objects as attachments.
// They are not meant for users.
return;
}
attachments.push(<Attachment
tinode={this.props.tinode}
downloadUrl={Drafty.getDownloadUrl(att)}
filename={att.name}
uploading={Drafty.isProcessing(att)}
mimetype={att.mime}
size={Drafty.getEntitySize(att)}
progress={this.state.progress}
onCancelUpload={this.handleCancelUpload}
onError={this.props.onError}
key={i} />);
}, this);
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
content = React.createElement(React.Fragment, null, tree);
} else if (this.props.deleted) {
// Message represents a range of deleted messages.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="deleted_content"
defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></>
} else if (typeof content != 'string') {
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i></>
}
return (
<li ref={this.props.innerRef} className={sideClass}>
{this.props.isGroup && this.props.response ?
<div className="avatar-box">
{fullDisplay ?
<LetterTile
tinode={this.props.tinode}
topic={this.props.userFrom}
title={this.props.userName}
avatar={avatar} /> :
null}
</div> :
null}
<div>
<div className={bubbleClass}>
<div className="content-meta">
<div className="message-content">
{content}
{attachments}
</div>
{this.props.timestamp ?
<ReceivedMarker
timestamp={this.props.timestamp}
received={this.props.received} />
: null}
</div>
{this.props.deleted || !this.props.showContextMenu ?
null :
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span>
}
</div>
{fullDisplay ?
<div className="author">
{this.props.userName ||
<i><FormattedMessage id="user_not_found" defaultMessage="Not found"
description="In place of a user's full name when the user is not found." /></i>
}
</div>
: null
}
</div>
</li>
);
}
};
const IntlChatMessage = injectIntl(BaseChatMessage);
const ChatMessage = React.forwardRef((props, ref) => <IntlChatMessage innerRef = {ref} {...props} />);
export default ChatMessage;
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
/* BEGIN CheckBox: styled checkbox */
export default class CheckBox extends React.PureComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange() {
this.props.onChange(this.props.name, !this.props.checked);
}
render() {
return (
this.props.onChange ? (
this.props.checked === true ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
this.props.checked === false ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i> :
<i className="material-icons lt-blue">indeterminate_check_box</i>
) : (
this.props.checked ?
<i className="material-icons">check_box</i> :
<i className="material-icons">check_box_outline_blank</i>
)
);
}
}
/* END CheckBox */
+151
View File
@@ -0,0 +1,151 @@
import React from 'react';
import Chip from './chip.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
/* BEGIN ChipInput: group membership widget */
export default class ChipInput extends React.Component {
constructor(props) {
super(props);
this.state = ChipInput.deriveStateFromProps(props);
this.state.input = '';
this.state.focused = false;
this.handleTextInput = this.handleTextInput.bind(this);
this.removeChipAt = this.removeChipAt.bind(this);
this.handleChipCancel = this.handleChipCancel.bind(this);
this.handleFocusGained = this.handleFocusGained.bind(this);
this.handleFocusLost = this.handleFocusLost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
static deriveStateFromProps(props) {
return {
placeholder: props.chips ? '' : props.prompt,
sortedChips: ChipInput.sortChips(props.chips, props.staticMembers),
chipIndex: ChipInput.indexChips(props.chips)
};
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.chips != this.props.chips ||
prevProps.staticMembers != this.props.staticMembers ||
prevProps.prompt != this.props.prompt) {
this.setState(ChipInput.deriveStateFromProps(this.props));
}
if (!prevState || this.props.chips.length > prevState.sortedChips.length) {
this.setState({input: ''});
}
}
// Map chip index to user name
static indexChips(chips) {
const index = {};
let count = 0;
chips.map((item) => {
index[item.user] = count;
count ++;
});
return index;
}
// Have non-removable chips appear before all other chips.
static sortChips(chips, keep) {
const required = [];
const normal = [];
chips.map((item) => {
if (keep && keep.includes(item.user)) {
required.push(item);
} else {
normal.push(item);
}
});
return required.concat(normal);
}
handleTextInput(e) {
this.setState({input: e.target.value});
if (this.props.filterFunc) {
this.props.filterFunc(e.target.value);
}
}
removeChipAt(idx) {
const removed = this.state.sortedChips[idx];
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
}
handleChipCancel(item, idx) {
this.removeChipAt(idx);
}
handleFocusGained() {
this.setState({focused: true});
}
handleFocusLost() {
this.setState({focused: false});
if (this.props.onFocusLost) {
this.props.onFocusLost(this.state.input);
}
}
handleKeyDown(e) {
if (e.key === 'Backspace') {
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
const at = this.state.sortedChips.length - 1;
if (this.state.sortedChips[at].user !== this.props.staticMembers) {
this.removeChipAt(at);
}
}
} else if (e.key === 'Enter') {
if (this.props.onEnter) {
this.props.onEnter(this.state.input);
}
} else if (e.key === 'Escape') {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
render() {
const chips = [];
let count = 0;
const staticMembers = this.props.staticMembers || [];
this.state.sortedChips.map((item) => {
chips.push(
<Chip
tinode={this.props.tinode}
onCancel={this.handleChipCancel}
avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined}
noAvatar={this.props.avatarDisabled}
topic={item.user}
required={staticMembers.includes(item.user)}
invalid={item.invalid}
index={count}
key={item.user} />
);
count++;
});
const className = "chip-input" + (this.state.focused ? ' focused' : '');
const autoFocus = !(this.props.tabIndex > 0);
return (
<div className={className}>
{chips}
<input type="text"
placeholder={this.state.placeholder}
onChange={this.handleTextInput}
onFocus={this.handleFocusGained}
onBlur={this.handleFocusLost}
onKeyDown={this.handleKeyDown}
value={this.state.input}
tabIndex={this.props.tabIndex}
autoFocus={autoFocus} />
</div>
);
}
};
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
export default class Chip extends React.PureComponent {
constructor(props) {
super(props);
this.handleCancel = this.handleCancel.bind(this);
}
handleCancel(e) {
e.preventDefault();
this.props.onCancel(this.props.topic, this.props.index);
}
render() {
const title = this.props.title || this.props.topic;
const className = this.props.invalid ? 'chip invalid' : 'chip';
return (
<div className={className}>
{this.props.noAvatar ?
<span className="spacer" /> :
<div className="avatar-box">
<LetterTile
tinode={this.props.tinode}
avatar={this.props.avatar || true}
topic={this.props.topic}
title={this.props.title} />
</div>
}
<span>{title}</span>
{this.props.onCancel && !this.props.required ?
<a href="#" onClick={this.handleCancel} >&times;</a>
: <span className="spacer" />}
</div>
);
}
};
+28
View File
@@ -0,0 +1,28 @@
// A panel included into a list of contacts with an action text.
import React from 'react';
import { injectIntl } from 'react-intl';
class ContactAction extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.onAction(this.props.action);
}
render() {
const {formatMessage} = this.props.intl;
return (
<li onClick={this.handleClick} className="action">
<div className="action-text">{formatMessage(this.props.title, this.props.values)}</div>
</li>
);
}
};
export default injectIntl(ContactAction);
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block', 'staff': 'verified_user'};
/* Contact's labels: [you], or icons [muted], [blocked] */
export default class ContactBadges extends React.PureComponent {
render() {
let badges = null;
if (this.props.badges && this.props.badges.length > 0) {
badges = [];
this.props.badges.map((b) => {
const color = (b.color ? ' ' + b.color : '');
if (b.icon) {
badges.push(<i className={'material-icons as-badge' + color} key={b.key || b.icon}>{icon_mapping[b.icon] || b.icon}</i>);
} else {
badges.push(<span className={'badge' + color} key={b.key || b.name}>{b.name}</span>);
}
});
return <>{badges}</>;
}
return null;
}
};
+144
View File
@@ -0,0 +1,144 @@
// ContactList: component for showing a list of contacts,
// such as a list of group members in a group chat.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import Tinode from 'tinode-sdk';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
const messages = defineMessages({
badge_you: {
id: 'badge_you',
defaultMessage: 'you',
description: 'Badge for indicating the current user'
},
badge_owner: {
id: 'badge_owner',
defaultMessage: 'owner',
description: 'Badge for indicating the owner'
}
});
class ContactList extends React.Component {
render() {
const { formatMessage } = this.props.intl;
const showCheckmark = Array.isArray(this.props.topicSelected);
const contactNodes = [];
let contactsCount = 0;
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map((c) => {
if (c.action) {
// Action item
contactNodes.push(
<ContactAction
title={c.title} action={c.action} values={c.values}
key={c.action}
onAction={this.props.onAction}
/>);
} else {
// Normal contact
const key = this.props.showMode ? c.user : (c.topic || c.user);
// If filter function is provided, filter out the items
// which don't satisfy the condition.
if (this.props.filterFunc && this.props.filter) {
const filterOn = [key];
if (c.private && c.private.comment) {
filterOn.push(('' + c.private.comment).toLowerCase());
}
if (c.public && c.public.fn) {
filterOn.push(('' + c.public.fn).toLowerCase());
}
if (!this.props.filterFunc(this.props.filter, filterOn)) {
return;
}
}
const isChannel = Tinode.isChannelTopicName(key);
const selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
const badges = [];
if (this.props.showMode) {
if (key == this.props.myUserId) {
badges.push({name: formatMessage(messages.badge_you), color: 'green'});
}
if (c.acs && c.acs.isOwner()) {
badges.push({name: formatMessage(messages.badge_owner), color: 'blue'});
}
}
const comment = Array.isArray(c.private) ?
c.private.join(',') : (c.private ? c.private.comment : null);
let preview;
let forwarded;
let deliveryStatus;
if (!this.props.showMode && c.latestMessage) {
const msg = c.latestMessage(true);
if (msg) {
forwarded = msg.head ? msg.head.forwarded : null;
deliveryStatus = msg._status || c.msgStatus(msg, true);
preview = typeof msg.content == 'string' ?
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH, undefined, forwarded != null);
}
}
contactNodes.push(
<Contact
tinode={this.props.tinode}
title={c.public ? c.public.fn : null}
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
preview={preview}
forwarded={forwarded}
received={deliveryStatus}
unread={this.props.showUnread ? c.unread : 0}
now={c.online && this.props.connected}
acs={c.acs}
showMode={this.props.showMode}
badges={badges}
showCheckmark={showCheckmark}
selected={selected}
showOnline={this.props.showOnline && !isChannel}
isChannel={isChannel}
onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
isVerified={c.trusted && c.trusted.verified}
isStaff={c.trusted && c.trusted.staff}
isDangerous={c.trusted && c.trusted.danger}
item={key}
index={contactNodes.length}
key={key} />
);
contactsCount ++;
}
}, this);
}
return (
<div className={this.props.noScroll ? null : "scrollable-panel"}>
{contactsCount == 0 ?
<div className="center-medium-text"
dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
:
null
}
{contactNodes.length > 0 ?
<ul className="contact-box">
{contactNodes}
</ul>
:
null
}
</div>
);
}
};
export default injectIntl(ContactList);
+121
View File
@@ -0,0 +1,121 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import ContactBadges from './contact-badges.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import UnreadBadge from './unread-badge.jsx';
import { Drafty } from 'tinode-sdk';
import { previewFormatter } from '../lib/formatters.js';
import { deliveryMarker } from '../lib/utils.js';
class Contact extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.onSelected) {
// Index this.props.index is used by GroupManager.
this.props.onSelected(this.props.item, this.props.index);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
}
render() {
let title = this.props.title;
if (!title) {
title = <i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>;
} else if (title.length > 30) {
// FIXME: this is probably wrong for RTL languages.
title = title.substring(0, 28) + '…';
}
const online = this.props.now ? 'online' : 'offline';
const avatar = this.props.avatar ? this.props.avatar : true;
const badges = this.props.badges ? this.props.badges.slice() : [];
const icon_badges = [];
if (this.props.isVerified) {
icon_badges.push({icon: 'verified', color: 'verified-color'});
}
if (this.props.isStaff) {
icon_badges.push({icon: 'staff', color: 'staff-color'});
}
if (this.props.isDangerous) {
icon_badges.push({icon: 'dangerous', color: 'danger-color'});
}
if (this.props.acs) {
if (this.props.showMode) {
badges.push({name: this.props.acs.getMode(), key: 'mode'});
}
if (this.props.acs.isMuted()) {
icon_badges.push({icon: 'muted'});
}
if (!this.props.acs.isJoiner()) {
icon_badges.push({icon: 'banned'});
}
}
const subtitle = this.props.preview ?
(typeof this.props.preview == 'string' ? this.props.preview :
Drafty.isValid(this.props.preview) ?
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, previewFormatter,
{formatMessage: this.props.intl.formatMessage})) :
<><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i>
</>
) :
this.props.comment;
const icon = deliveryMarker(this.props.received);
const marker = icon ? <i className={'material-icons small space-right' +
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
return (
<li className={!this.props.showCheckmark && this.props.selected ? 'selected' : null} onClick={this.handleClick}>
<div className="avatar-box">
<LetterTile
tinode={this.props.tinode}
avatar={avatar}
title={this.props.title}
topic={this.props.item} />
{this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected ?
<i className="checkmark material-icons">check_circle</i>
: null)}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
{this.props.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
<ContactBadges badges={icon_badges} /><UnreadBadge count={this.props.unread} />
</div>
{this.props.showMode ?
<span><ContactBadges badges={badges} /></span> :
this.props.small ? null : <div className="contact-comment">{marker}<span>{subtitle || '\u00A0'}</span></div>}
</div>
{this.props.showContextMenu ?
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span> : null}
</li>
);
}
};
export default injectIntl(Contact);
+481
View File
@@ -0,0 +1,481 @@
// Context Menu: popup/dropdown menu.
import React from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { REM_SIZE } from '../config.js';
const messages = defineMessages({
info: {
id: 'menu_item_info',
defaultMessage: 'Info',
description: 'Show extended topic information'
},
clear_messages: {
id: 'menu_item_clear_messages',
defaultMessage: 'Clear messages',
description: 'Delete all messages'
},
clear_for_all: {
id: 'menu_item_clear_messages_for_all',
defaultMessage: 'Clear for All',
description: 'Delete all message(s) for all members'
},
delete: {
id: 'menu_item_delete',
defaultMessage: 'Delete',
description: 'Delete selected messages'
},
delete_for_all: {
id: 'menu_item_delete_for_all',
defaultMessage: 'Delete for All',
description: 'Delete selected message(s) for all members'
},
send_retry: {
id: 'menu_item_send_retry',
defaultMessage: 'Retry',
description: 'Retry sending message'
},
mute: {
id: 'menu_item_mute',
defaultMessage: 'Mute',
description: 'Turn off notifications'
},
unmute: {
id: 'menu_item_unmute',
defaultMessage: 'Unmute',
description: 'Turn notifications on'
},
reply: {
id: 'menu_item_reply',
defaultMessage: 'Reply',
description: 'Reply to message'
},
forward: {
id: 'menu_item_forward',
defaultMessage: 'Forward',
description: 'Forward message'
},
topic_delete: {
id: 'menu_item_delete_topic',
defaultMessage: 'Delete',
description: 'Delete entire topic'
},
topic_delete_warning: {
id: 'topic_delete_warning',
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
description: 'Alert warning when deleting entire topic'
},
delete_messages_warning: {
id: 'delete_messages_warning',
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
description: 'Alert dialog warning when hard-deleting all messages.'
},
unblock: {
id: 'menu_item_unblock',
defaultMessage: 'Unblock',
description: 'Unblock topic or user'
},
// Generic string suitable for either topic or user.
block: {
id: 'menu_item_block',
defaultMessage: 'Block',
description: 'Block topic or user'
},
topic_block_warning: {
id: 'topic_block_warning',
defaultMessage: 'Are you sure you want to block this conversation?',
description: 'Alert warning when blocking a topic.'
},
member_delete: {
id: 'menu_item_member_delete',
defaultMessage: 'Remove',
description: 'Remove user from topic'
},
archive: {
id: 'menu_item_archive_topic',
defaultMessage: 'Archive',
description: 'Move topic from the list of active chats to archive'
},
unarchive: {
id: 'menu_item_restore_topic',
defaultMessage: 'Restore',
description: 'Restore topic from archive'
},
edit_permissions: {
id: 'menu_item_edit_permissions',
defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]'
},
clear_messages_warning: {
id: 'clear_messages_warning',
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
description: 'Alert dialog warning when deleting all messages.'
},
});
class ContextMenu extends React.Component {
constructor(props) {
super(props);
this.selfRef = React.createRef();
const {formatMessage} = props.intl;
this.handlePageClick = this.handlePageClick.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClick = this.handleClick.bind(this);
// Preconfigured menu items.
this.MenuItems = {
'topic_info': {
id: 'topic_info',
title: formatMessage(messages.info),
handler: null
},
'messages_clear': {
id: 'messages_clear',
title: formatMessage(messages.clear_messages),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage(messages.clear_messages), // title
formatMessage(messages.clear_messages_warning), // content
(() => { this.deleteMessages(true, false, params, errorHandler); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'messages_clear_hard': {
id: 'messages_clear_hard',
title: formatMessage(messages.clear_for_all),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage(messages.clear_for_all), // title
formatMessage(message.delete_messages_warning), // content
(() => { return this.deleteMessages(true, true, params, errorHandler); }),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'message_delete': {
id: 'message_delete',
title: formatMessage(messages.delete),
handler: (params, errorHandler) => {
return this.deleteMessages(false, false, params, errorHandler);
}
},
'message_delete_hard': {
id: 'message_delete_hard',
title: formatMessage(messages.delete_for_all),
handler: (params, errorHandler) => {
return this.deleteMessages(false, true, params, errorHandler);
}
},
'menu_item_send_retry': {
id: 'menu_item_send_retry',
title: formatMessage(messages.send_retry),
handler: (params, errorHandler) => {
return this.retryMessage(params, errorHandler);
}
},
'menu_item_reply': {
id: 'menu_item_reply',
title: formatMessage(messages.reply),
handler: (params, errorHandler) => {
return this.replyToMessage(params, errorHandler);
}
},
'menu_item_forward': {
id: 'menu_item_forward',
title: formatMessage(messages.forward),
handler: () => {} /* the action is taken directly in tinode-web */
},
'topic_unmute': {
id: 'topic_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'topic_mute': {
id: 'topic_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'topic_unblock': {
id: 'topic_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+JP')
},
'topic_block': {
id: 'topic_block',
title: formatMessage(messages.block),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage(messages.block), // title
formatMessage(messages.topic_block_warning), // content
(() => {
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
this.props.onTopicRemoved(params.topicName);
return ctrl;
});
}),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'topic_delete': {
id: 'topic_delete',
title: formatMessage(messages.topic_delete),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage(messages.topic_delete), // title
formatMessage(messages.topic_delete_warning), // content
(() => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.delTopic(true).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'topic_archive': {
id: 'topic_archive',
title: formatMessage(messages.archive),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.archive(true).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
'topic_restore': {
id: 'topic_restore',
title: formatMessage(messages.unarchive),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.archive(false).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
// menu_item_edit_permissions is defined elsewhere.
'permissions': {
id: 'permissions',
title: formatMessage(messages.edit_permissions),
handler: null
},
'member_delete': {
id: 'member_delete',
title: formatMessage(messages.member_delete),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
return topic.delSubscription(params.user).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
'member_mute': {
id: 'member_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'member_unmute': {
id: 'member_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'member_block': {
id: 'member_block',
title: formatMessage(messages.block),
handler: this.topicPermissionSetter.bind(this, '-JP')
},
'member_unblock': {
id: 'member_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+JP')
},
};
}
componentDidMount() {
document.addEventListener('mousedown', this.handlePageClick, false);
document.addEventListener('keyup', this.handleEscapeKey, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handlePageClick, false);
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
handlePageClick(e) {
if (this.selfRef.current.contains(e.target)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.props.hide();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
this.props.hide();
}
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.hide();
let item = this.props.items[e.currentTarget.dataset.id];
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (!item) {
console.error("Invalid menu item ID", e.currentTarget.dataset.id);
} else {
this.props.onAction(
item.id,
item.handler(this.props.params, this.props.onError),
this.props.params);
}
}
// Menu Actions
deleteMessages(all, hard, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
// We don't know if the message is still pending (e.g. attachment is being uploaded),
// so try cancelling first. No harm if we can't cancel.
// The message can be cancelled if transmission to the server has not
// started yet or if the message send has failed.
if (!all && topic.cancelSend(params.seq)) {
return;
}
// Can't cancel. Delete instead.
const promise = all ?
topic.delMessagesAll(hard) :
topic.delMessagesList([params.seq], hard);
return promise.catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Retries sending failed message.
retryMessage(params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
// Remove the existing message entry.
if (!topic || !topic.flushMessage(params.seq)) {
return;
}
const msg = topic.createMessage(params.content, false);
return topic.publishDraft(msg).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Function is used by context menu to set permissions.
topicPermissionSetter(mode, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found", params.topicName);
return;
}
let result = topic.updateMode(params.user, mode);
if (errorHandler) {
result = result.catch((err) => {
errorHandler(err.message, 'err');
});
}
return result;
}
replyToMessage(params, errorHandler) {
params.pickReply(params.seq, params.content, params.forwarded, params.userFrom, params.userName, errorHandler);
}
render() {
const menu = [];
let count = 0;
this.props.items.map((item) => {
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (item && item.title) {
menu.push(
item.title == '-' ?
<li className="separator" key={count} />
:
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
);
}
count++;
});
// Ensure that menu is inside the app-container.
const hSize = 12 * REM_SIZE;
const vSize = REM_SIZE * (0.7 + menu.length * 2.5);
const left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
(this.props.clickAt.x - this.props.bounds.left - hSize) :
(this.props.clickAt.x - this.props.bounds.left);
const top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
(this.props.clickAt.y - this.props.bounds.top - vSize) :
(this.props.clickAt.y - this.props.bounds.top);
const position = {
left: left + 'px',
top: top + 'px'
};
return (
<ul className="menu" style={position} ref={this.selfRef}>
{menu}
</ul>
);
}
}
export default injectIntl(ContextMenu);
+279
View File
@@ -0,0 +1,279 @@
// Image zoom and crop widget.
import React from 'react';
const DEFAULT_MAX_ZOOM = 2.5;
export default class Cropper extends React.Component {
constructor(props) {
super(props);
this.state = {
// Coordinates of the top left corner of the image in container coordinates.
panX: 0,
panY: 0,
// Origin of the zoom in image coordinates.
originX: 0,
originY: 0,
// Zoom scale.
zoom: 1,
// Zoom min and max.
minZoom: 0,
maxZoom: DEFAULT_MAX_ZOOM,
};
// References to DOM elements for hooking up events and getting screen dimensions.
// The transparent element which captures mouse moves (moves with the image).
this.overlay = React.createRef();
// The image cutout window or view port (static).
this.cutout = React.createRef();
// Scaled and translated image preview.
this.preview = React.createRef();
// The main container (static).
this.boundingBox = React.createRef();
// The original image width and height before any scaling.
this.imageWidth = 0;
this.imageHeight = 0;
// Mouse position when dragging.
this.mouseX = 0
this.mouseY = 0;
// Length of the previous mouse move when dragging.
this.prevDistance = 0;
// Bounding rectangles of static elements.
this.cutoutRect = {};
this.bBoxRect = {};
// Center of the bounding box.
this.originX = 0;
this.originY = 0;
this.initScaling = this.initScaling.bind(this);
this.onZoom = this.onZoom.bind(this);
this.handleZoom = this.handleZoom.bind(this);
this.mouseDown = this.mouseDown.bind(this);
this.mouseUp = this.mouseUp.bind(this);
this.mouseMove = this.mouseMove.bind(this);
this.mouseTouch = this.mouseTouch.bind(this);
this.positionAll = this.positionAll.bind(this);
this.translate = this.translate.bind(this);
}
componentDidMount() {
this.overlay.current.addEventListener('mousedown', this.mouseDown, { passive: true });
this.overlay.current.addEventListener('touchstart', this.mouseDown, { passive: true });
// The rectangle position is in viewport coordinates.
this.bBoxRect = this.boundingBox.current.getBoundingClientRect();
// Center of the bounding box in container coordinates.
this.originX = this.bBoxRect.width / 2;
this.originY = this.bBoxRect.height / 2;
// The rectangle position is in viewport coordinates.
this.cutoutRect = this.cutout.current.getBoundingClientRect();
}
componentWillUnmount() {
this.overlay.current.removeEventListener('mousedown', this.mouseDown);
this.overlay.current.removeEventListener('touchstart', this.mouseDown);
}
// Position all elements.
positionAll(panX, panY, zoom) {
// Zoom origin in image preview coordinates.
this.setState({
panX: panX,
panY: panY,
zoom: zoom,
originX: this.originX - panX,
originY: this.originY - panY,
});
// Pass cut out coordinates to caller.
const left = (this.originX - panX) * zoom - this.originX;
const top = (this.originY - panY) * zoom - this.originY;
this.props.onChange(
(left + this.cutoutRect.left - this.bBoxRect.left) / zoom,
(top + this.cutoutRect.top - this.bBoxRect.top) / zoom,
this.cutoutRect.width / zoom,
this.cutoutRect.height / zoom,
zoom);
}
// Check if new location is within the limits.
static checkBound(currPan, img, cutout, delta) {
let nextDiff = Math.min(0, cutout[0] - img[0] - delta, img[1] - cutout[1] + delta);
if (nextDiff == 0) {
// Cutout is completely within the image.
currPan += delta;
} else if (Math.min(0, cutout[0] - img[0], img[1] - cutout[1]) < nextDiff) {
// Cutout is outside of the range but closer to the bound.
currPan += delta;
}
return currPan;
}
initScaling() {
// Initialize components.
const imgRect = this.preview.current.getBoundingClientRect();
this.imageWidth = imgRect.width;
this.imageHeight = imgRect.height;
// Set zoom slider's min and max values. Make sure the scaled image cannot be smaller than the cutout.
const minZoom = Math.max(this.cutoutRect.width / imgRect.width, this.cutoutRect.height / imgRect.height);
this.setState({
minZoom: minZoom,
maxZoom: Math.max(DEFAULT_MAX_ZOOM, minZoom + 1)
});
// Initial zoom level fills the bounding box at the smallest image dimension and overflows the largest, i.e. "fill" not "fit".
const zoom = Math.max(this.bBoxRect.width / imgRect.width, this.bBoxRect.height / imgRect.height);
// Converting from viewport coordinates to container, then panning.
const panX = this.cutoutRect.left - this.bBoxRect.left -
(imgRect.width - this.cutoutRect.width) / 2;
const panY = this.cutoutRect.top - this.bBoxRect.top -
(imgRect.height - this.cutoutRect.height) / 2;
this.positionAll(panX, panY, zoom);
}
onZoom(e) {
this.handleZoom(e.target.value);
}
handleZoom(zoom) {
let panX = this.state.panX;
let panY = this.state.panY;
// Ensure that the image at the new zoom is not outside of the cutout boundaries.
// Calculate image coordinates at the new zoom.
const imgLeft = this.originX - (this.originX - panX) * zoom;
const imgRight = imgLeft + this.imageWidth * zoom;
// Cutout in the same coordinates as the image.
const coLeft = this.cutoutRect.left - this.bBoxRect.left;
const coRight = coLeft + this.cutoutRect.width;
if (coLeft < imgLeft) {
panX -= imgLeft - coLeft;
} else if (coRight > imgRight) {
panX += coRight - imgRight;
}
const imgTop = this.originY - (this.originY - panY) * zoom;
const imgBottom = imgTop + this.imageHeight * zoom;
const coTop = this.cutoutRect.top - this.bBoxRect.top;
const coBottom = coTop + this.cutoutRect.height;
if (coTop < imgTop) {
panY -= imgTop - coTop;
} else if (coBottom > imgBottom) {
panY += coBottom - imgBottom;
}
this.positionAll(panX, panY, zoom);
}
mouseDown(e) {
if (e.touches) {
this.mouseX = e.touches[0].pageX;
this.mouseY = e.touches[0].pageY;
} else {
this.mouseX = e.pageX;
this.mouseY = e.pageY;
}
window.addEventListener('mousemove', this.mouseMove, { passive: false });
window.addEventListener('touchmove', this.mouseTouch, { passive: false });
window.addEventListener('mouseup', this.mouseUp, { passive: true });
window.addEventListener('touchend', this.mouseUp, { passive: true });
// Disable text selection in the entire document.
document.body.style['userSelect'] = 'none';
}
// Perform image panning.
translate(pageX, pageY) {
const dX = pageX - this.mouseX;
const dY = pageY - this.mouseY;
this.mouseX = pageX;
this.mouseY = pageY;
// Make sure the image is within the cutout window.
const imgRect = this.preview.current.getBoundingClientRect();
// Check if the new position is within the boundaries, and if not if it's closer to them.
let panX = Cropper.checkBound(this.state.panX, [imgRect.left, imgRect.right],
[this.cutoutRect.left, this.cutoutRect.right], dX);
let panY = Cropper.checkBound(this.state.panY, [imgRect.top, imgRect.bottom],
[this.cutoutRect.top, this.cutoutRect.bottom], dY);
this.positionAll(panX, panY, this.state.zoom);
}
// Image panning.
mouseMove(e) {
e.preventDefault();
this.translate(e.pageX, e.pageY);
}
mouseTouch(e) {
e.preventDefault();
if (e.touches.length == 1) {
this.translate(e.touches[0].pageX, e.touches[0].pageY);
return;
}
// Image zooming by pinching.
const [touch0, touch1] = e.touches;
const distance = Math.sqrt((touch0.pageX - touch1.pageX) * (touch0.pageX - touch1.pageX) +
(touch0.pageY - touch1.pageY) * (touch0.pageY - touch1.pageY));
if (!this.prevDistance) {
this.prevDistance = distance / this.state.zoom;
}
let scale = (distance / this.prevDistance);
this.handleZoom(Math.max(this.minZoom, Math.min(this.maxZoom, scale)));
}
mouseUp(e) {
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('touchmove', this.mouseTouch);
window.removeEventListener('mouseup', this.mouseUp);
window.removeEventListener('touchend', this.mouseUp);
// Re-enable text selection.
document.body.style['userSelect'] = '';
this.positionAll(this.state.panX, this.state.panY, this.state.zoom);
}
render() {
// transform3d: position and scale.
const t3d = `translate3d(${this.state.panX}px, ${this.state.panY}px, 0) scale(${this.state.zoom})`;
// transformOrigin: zoom origin.
const orig = `${this.state.originX}px ${this.state.originY}px`;
// Overlay position and size are exactly equal to the position and size of the transformed image
// except it uses different coordinates.
const overlay = {
top: `${this.originY - this.state.originY * this.state.zoom}px`,
left: `${this.originX - this.state.originX * this.state.zoom}px`,
width: `${this.imageWidth * this.state.zoom}px`,
height: `${this.imageHeight * this.state.zoom}px`
};
return (
<div className="cropper">
<div className="bounding-box" ref={this.boundingBox}>
<img src={this.props.source} className="preview" alt=""
style={{transform: t3d, transformOrigin: orig}} ref={this.preview} onLoad={this.initScaling} />
<div className="cutout circle" ref={this.cutout} />
<div className="overlay" style={overlay} ref={this.overlay} />
</div>
<div className="zoom-wrapper">
<input type="range" className="zoomer"
step="0.001" min={this.state.minZoom} max={this.state.maxZoom} value={this.state.zoom} onChange={this.onZoom} />
</div>
</div>
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx';
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
// Get material icon name from mime type.
function iconFromMime(mime) {
// If more icons become available in material icons, add them to this mime-to-icon mapping.
const mimeToIcon = {default: 'insert_drive_file', image: 'image', text: 'description', video: 'theaters'};
return mimeToIcon[mime] || mimeToIcon[(mime || '').split('/')[0]] || mimeToIcon['default'];
}
export default class DocPreview extends React.PureComponent {
constructor(props) {
super(props);
this.handleSendDoc = this.handleSendDoc.bind(this);
}
handleSendDoc(caption) {
this.props.onClose();
this.props.onSendMessage(this.props.content.file);
}
render() {
if (!this.props.content) {
return null;
}
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
<span>{this.props.content.name}</span>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container">
<div className="flex-column narrow">
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b> {shortenFileName(this.props.content.name, 24) || '-'}</div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
description="Label for file size" /></b> {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>
<SendMessage
noInput={true}
tinode={this.props.tinode}
reply={this.props.reply}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendDoc}
onError={this.props.onError} />
</div>
);
}
};
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import MenuCancel from './menu-cancel.jsx';
export default class ErrorPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
show: false,
};
this.hide = this.hide.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.level !== this.props.level) {
this.setState({
show: !(!this.props.level)
});
}
}
hide() {
this.setState({show: false});
if (this.props.onClearError) {
this.props.onClearError();
}
}
render() {
const icons = {err: 'error', warn: 'warning', info: 'info'}
const level = icons[this.props.level] || '';
const className = 'info-box ' + level;
return (
<div className={className}>
<div className="icon"><i className="material-icons">{level}</i></div>
<span>
{this.props.text}
{this.props.action ?
<>
&#32;<a href="#"
style={{ whiteSpace: 'nowrap' }}
onClick={(e) => {e.preventDefault(); this.props.action();}}>
{this.props.actionText}
</a>
</>
: null}
</span>
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
</div>
);
}
};
+22
View File
@@ -0,0 +1,22 @@
// File uload/download progress indicator with a cancel inside.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class FileProgress extends React.PureComponent {
render() {
return (
<div className="uploader">
<div><span style={{width: (this.props.progress * 100) + "%"}}></span></div>
{this.props.progress < 0.999 ?
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
<i className="material-icons">close</i> <FormattedMessage id="action_cancel"
defaultMessage="cancel" description="Call to action [cancel]" />
</a>
:
<FormattedMessage id="upload_finishing" defaultMessage="finishing..."
description="Notification that upload is finishing" />
}
</div>
);
}
}
+88
View File
@@ -0,0 +1,88 @@
// Forward Menu: message forwarding popup/dropdown menu.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import ContactList from './contact-list.jsx';
import SearchContacts from './search-contacts.jsx';
export default class ForwardDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
query: null
};
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
// Not forwarding the message.
this.props.hide(false);
}
}
handleClose(e) {
e.preventDefault();
this.props.hide(false);
}
handleSearchContacts(query) {
this.setState({ query: Tinode.isNullValue(query) ? null : query });
this.props.onSearchContacts(query);
}
handleContactSelected(uid) {
this.props.onTopicSelected(uid);
this.props.hide(true);
}
render() {
let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts;
// Filter out contacts without a 'W' or 'J' permissions as well as the current topic.
contacts = contacts.filter((c) => {
return (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter();
});
return (
<div className="alert-container">
<div className="forward-dialog">
<div className="title with-control">
<div><FormattedMessage id="forward_to" defaultMessage="Forward to"
desription="Title of the contact selector dialog when forwarding a message" /></div>
<div><a href="#" onClick={this.handleClose}><i className="material-icons">close</i></a></div>
</div>
<FormattedMessage id="forward_to_search_placeholder" defaultMessage="Search contacts"
description="Contact search prompt when forwarding a message.">{
(search_placeholder) => <SearchContacts
placeholder={search_placeholder}
onSearchContacts={this.handleSearchContacts} />
}</FormattedMessage>
<FormattedMessage id="search_no_results" defaultMessage="Search returned no results"
description="Text shown in contacts view when query returned no results.">{
(not_found_placeholder) => <ContactList
tinode={this.props.tinode}
contacts={contacts}
myUserId={this.props.myUserId}
emptyListMessage={not_found_placeholder}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
}</FormattedMessage>
</div>
</div>
);
}
}
+205
View File
@@ -0,0 +1,205 @@
// GroupMembers: control for managing a list of group members.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import ChipInput from './chip-input.jsx';
import ContactList from './contact-list.jsx';
const messages = defineMessages({
no_contacts: {
id: 'no_contacts',
defaultMessage: 'You have no contacts :-(',
description: 'Shown in ContactsView when the user has no contacts'
},
contacts_not_found_short: {
id: 'contacts_not_found_short',
defaultMessage: "No contacts match ''{query}''",
description: 'Shown in ContactsView when search returned no results'
}
});
class GroupManager extends React.Component {
constructor(props) {
super(props);
this.state = {
// Array of topic members
members: props.members,
index: GroupManager.indexMembers(props.members),
staticMembers: GroupManager.staticMembers(props.members, props.keepInitialMembers, props.requiredMember),
contactFilter: '',
noContactsMessage: props.intl.formatMessage(messages.no_contacts),
selectedContacts: GroupManager.selectedContacts(props.members)
};
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
this.handleContactFilter = this.handleContactFilter.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static indexMembers(members) {
let index = {};
members.map((m) => {
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
});
return index;
}
static staticMembers(members, keepInitial, requiredMember) {
let stat = [];
members.map((m) => {
if (keepInitial || m.user == requiredMember) {
stat.push(m.user);
}
});
return stat;
}
static selectedContacts(members) {
let sel = [];
members.map((m) => {
sel.push(m.user);
});
return sel;
}
handleContactSelected(userId, index) {
let status = this.state.index[userId];
if (status) {
if (status.present) {
// Prevent duplicate members
return;
}
status.delta += 1;
status.present = true;
} else {
status = {delta: 1, present: true};
}
let m = this.state.members.slice();
m.push(this.props.contacts[index]);
const sel = GroupManager.selectedContacts(m);
const i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleMemberRemoved(userId, index) {
const status = this.state.index[userId];
if (!status || !status.present) {
return;
}
status.present = false;
status.delta -= 1;
let m = this.state.members.slice();
m.splice(index, 1);
const sel = GroupManager.selectedContacts(m);
const i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleContactFilter(val) {
const {formatMessage} = this.props.intl;
const msg = !val ?
formatMessage(messages.no_contacts) :
formatMessage(messages.contacts_not_found_short, {query: val});
this.setState({contactFilter: val, noContactsMessage: msg});
}
static doContactFiltering(filter, values) {
if (filter) {
for (let i=0; i<values.length; i++) {
if (values[i].indexOf(filter) >= 0) {
return true;
}
}
return false;
}
return true;
}
handleSubmit() {
var instance = this;
var members = [];
var added = [];
var removed = [];
var keys = Object.keys(this.state.index);
keys.map(function(k) {
if (instance.state.index[k].present) {
members.push(k);
}
if (instance.state.index[k].delta > 0) {
added.push(k);
} else if (instance.state.index[k].delta < 0) {
removed.push(k);
}
});
this.props.onSubmit(members, added, removed);
}
handleCancel() {
this.props.onCancel();
}
render() {
const {formatMessage} = this.props.intl;
return (
<div id="group-manager">
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_group_members" defaultMessage="Group Members" description="Section title" />
</label>
</div>
<div className="panel-form-row">
<ChipInput
tinode={this.props.tinode}
chips={this.state.members}
staticMembers={this.state.staticMembers}
prompt="add members"
filterFunc={this.handleContactFilter}
onChipRemoved={this.handleMemberRemoved} />
</div>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_all_contacts" defaultMessage="All Contacts"
description="Section title [All Contacts]" />
</label>
</div>
<ContactList
tinode={this.props.tinode}
contacts={this.props.contacts}
myUserId={this.props.myUserId}
topicSelected={this.state.selectedContacts}
filter={this.state.contactFilter}
filterFunc={GroupManager.doContactFiltering}
emptyListMessage={this.state.noContactsMessage}
showOnline={false}
showUnread={false}
onTopicSelected={this.handleContactSelected} />
<div id="group-manager-buttons" className="panel-form-row">
<button className="secondary" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
<button className="primary" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(GroupManager);
+44
View File
@@ -0,0 +1,44 @@
// GroupSubs: a list of group subscribers currently online.
// Show in the topic title bar
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
import LetterTile from './letter-tile.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class GroupSubs extends React.Component {
constructor(props) {
super(props);
}
render() {
const usersOnline = [];
const totalCount = (this.props.subscribers || []).length;
const countToShow = Math.min(MAX_ONLINE_IN_TOPIC, totalCount);
(this.props.subscribers || []).some((sub) => {
usersOnline.push(
<div className="avatar-box" key={sub.user}>
<LetterTile
tinode={this.props.tinode}
topic={sub.user}
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null} />
</div>
);
return usersOnline.length == countToShow;
});
return (
<div id="topic-users">{usersOnline} {totalCount > countToShow ?
<span>
<FormattedMessage id="more_online_members" defaultMessage="+{overflow} more"
description="Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
values={{ overflow: (totalCount - countToShow) }} />
</span> : null}
</div>
);
}
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { KNOWN_HOSTS } from '../config.js';
/* Combobox for selecting host name */
export default class HostSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hostName: props.serverAddress,
changed: false
};
this.handleHostNameChange = this.handleHostNameChange.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handleHostNameChange(e) {
this.setState({hostName: e.target.value, changed: true});
}
handleEditingFinished() {
if (this.state.changed) {
this.setState({changed: false});
this.props.onServerAddressChange(this.state.hostName.trim());
}
}
render() {
var hostOptions = [];
for (let key in KNOWN_HOSTS) {
let item = KNOWN_HOSTS[key];
hostOptions.push(
<option key={item} value={item} />
);
}
return (
<div className="panel-form-row">
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
className="quoted" value={this.state.hostName} onChange={this.handleHostNameChange}
onBlur={this.handleEditingFinished} required />
<datalist id="known-hosts">
{hostOptions}
</datalist>
</div>
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx';
import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
export default class ImagePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
width: 0,
height: 0
};
this.handleSendImage = this.handleSendImage.bind(this);
}
assignWidth(node) {
if (node && !this.state.width) {
const bounds = node.getBoundingClientRect();
this.setState({
width: bounds.width | 0,
height: bounds.height | 0
});
}
}
handleSendImage(caption) {
this.props.onClose();
this.props.onSendMessage(caption, this.props.content.blob);
}
render() {
if (!this.props.content) {
return null;
}
const dim = fitImageSize(this.props.content.width, this.props.content.height,
this.state.width, this.state.height, false);
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
size.maxWidth = '100%';
size.maxHeight = '100%';
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
const fname = shortenFileName(this.props.content.name, maxlength) || '-';
const width = this.props.content.width || '-';
const height = this.props.content.height || '-';
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
{!this.props.onSendMessage ?
<a href={this.props.content.url} download={this.props.content.name}>
<i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" />
</a>
:
<span>{fname}</span>
}
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.name} />
</div>
{this.props.onSendMessage ?
<SendMessage
messagePrompt="add_image_caption"
acceptBlank={true}
tinode={this.props.tinode}
reply={this.props.reply}
onCancelReply={this.props.onCancelReply}
onSendMessage={this.handleSendImage}
onError={this.props.onError} />
:
<div id="image-preview-footer">
<div>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b></div>
<div><span title={this.props.content.name}>{fname}</span></div>
</div>
<div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b></div>
<div>{this.props.content.type}</div>
</div>
<div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
description="Label for file size" /></b></div>
<div>{width} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>}
</div>
);
}
};
+125
View File
@@ -0,0 +1,125 @@
// In-place text editor. Shows text with an icon which toggles it into an input field.
import React from 'react';
import VisiblePassword from './visible-password.jsx';
export default class InPlaceEdit extends React.Component {
constructor(props) {
super(props);
this.selfRef = React.createRef();
this.state = {
active: props.active,
initialValue: props.value || '',
value: props.value || ''
};
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEditing = this.handleStartEditing.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
}
componentDidUpdate(prevProps, prevState) {
// If text has changed while in read mode, update text and discard changes.
// Ignore update if in edit mode.
const newValue = this.props.value || '';
if (prevState.initialValue != newValue && !prevState.active) {
this.setState({
initialValue: newValue,
value: newValue
});
}
}
handeTextChange(e) {
this.setState({value: e.target.value || ''});
}
handleKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({value: this.props.value, active: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleEditingFinished(e);
}
}
handleStartEditing() {
if (!this.props.readOnly) {
this.setState({active: true}, () => {
if (this.selfRef.current) {
this.selfRef.current.focus();
}
});
}
}
handleEditingFinished(event) {
const value = this.state.value.trim();
if (this.props.required && (!event.target.checkValidity() || !value)) {
// Empty input
this.setState({value: this.props.value, active: false});
return;
}
this.setState({active: false});
if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
handlePasswordFinished(value) {
this.setState({active: false});
if (value && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
render() {
if (!this.state.active) {
let spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
let spanClass = 'in-place-edit' + (this.props.readOnly ? ' disabled' : '');
if (!spanText) {
spanText = this.props.placeholder;
spanClass += ' placeholder';
}
if (!this.props.multiline || this.props.multiline == 1) {
spanClass += ' short';
}
return (<span className={spanClass} onClick={this.handleStartEditing}>
<span>{spanText}</span>
</span>);
}
let element;
const attr = {};
if (this.props.type == 'password') {
element = VisiblePassword;
attr.onFinished = this.handlePasswordFinished;
} else {
if (this.props.multiline > 1) {
element = 'textarea';
attr.rows = this.props.multiline;
attr.className = 'inplace-edit';
} else {
element = 'input';
attr.type = this.props.type || 'text';
}
attr.value = this.state.value;
attr.onChange = this.handeTextChange;
attr.onKeyDown = this.handleKeyDown;
attr.onBlur = this.handleEditingFinished;
}
attr.placeholder = this.props.placeholder;
attr.required = this.props.required ? 'required' : '';
attr.autoComplete = this.props.autoComplete;
attr.autoFocus = true;
attr.ref = this.selfRef;
return React.createElement(element, attr, null);
}
};
+42
View File
@@ -0,0 +1,42 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class Invitation extends React.PureComponent {
constructor(props) {
super(props);
this.handleButtonAction = this.handleButtonAction.bind(this);
}
handleButtonAction(evt, data) {
evt.preventDefault();
this.props.onAction(data);
}
render() {
return (
<div className="accept-invite-panel">
<div className="title">
<FormattedMessage id="chat_invitation"
defaultMessage="You are invited to start a new chat. What would you like to do?"
description="New chat invitation message: [Accept] [Ignore] [Block]." />
</div>
<div className="footer">
<button className="primary" onClick={event => { this.handleButtonAction(event, "accept"); }}>
<FormattedMessage id="chat_invitation_accept"
defaultMessage="Accept" description="Action [Accept] for chat invitation." />
</button>
<button className="secondary" onClick={event => { this.handleButtonAction(event, "delete"); }}>
<FormattedMessage id="chat_invitation_ignore"
defaultMessage="Ignore" description="Action [Ignore] for chat invitation." />
</button>
<button className="secondary" onClick={event => { this.handleButtonAction(event, "block"); }}>
<FormattedMessage id="chat_invitation_block"
defaultMessage="Block" description="Action [Block] for chat invitation." />
</button>
</div>
</div>
);
}
};

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