chore: prettier format all files, brotli, Sentry release tagging, CI gates

Prettier: auto-formatted 103 files to fix baseline. Prettier check in CI
  is now a hard gate (removed continue-on-error).

Brotli: installed libnginx-mod-http-brotli-filter/static. Enabled in nginx
  with brotli_static on for pre-compressed assets and comp_level 6.

Sentry releases: deploy script now exports VITE_APP_VERSION=<git-short-sha>
  before building so each Sentry release maps to an exact commit.
  CI also passes github.sha as VITE_APP_VERSION.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lotus Bot
2026-05-21 20:49:33 -04:00
parent 408fc1b846
commit 42b9cc2b64
105 changed files with 2749 additions and 1850 deletions
+2 -4
View File
@@ -1,8 +1,6 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
@@ -14,4 +12,4 @@
"enabled": false,
"basename": "/"
}
}
}
+24 -30
View File
@@ -4,15 +4,15 @@ module.exports = {
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'airbnb',
'prettier',
],
parser: "@typescript-eslint/parser",
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
@@ -20,46 +20,40 @@ module.exports = {
ecmaVersion: 'latest',
sourceType: 'module',
},
"globals": {
JSX: "readonly"
globals: {
JSX: 'readonly',
},
plugins: [
'react',
'@typescript-eslint'
],
plugins: ['react', '@typescript-eslint'],
rules: {
'linebreak-style': 0,
'no-underscore-dangle': 0,
"no-shadow": "off",
'no-shadow': 'off',
"import/prefer-default-export": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": [
"error",
'import/prefer-default-export': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
'react/no-unstable-nested-components': [
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
'react/jsx-filename-extension': [
'error',
{ allowAsProps: true },
],
"react/jsx-filename-extension": [
"error",
{
extensions: [".tsx", ".jsx"],
extensions: ['.tsx', '.jsx'],
},
],
"react/require-default-props": "off",
"react/jsx-props-no-spreading": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
'react/require-default-props': 'off',
'react/jsx-props-no-spreading': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error"
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-shadow': 'error',
},
overrides: [
{
+1 -1
View File
@@ -29,6 +29,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
SENTRY_AUTH_TOKEN: ""
VITE_APP_VERSION: ${{ github.sha }}
# ── Quality checks (informational — pre-existing issues exist) ───────
- name: TypeScript
@@ -41,7 +42,6 @@ jobs:
- name: Prettier
run: npm run check:prettier
continue-on-error: true
# ── Security ─────────────────────────────────────────────────────────
- name: Audit (high/critical)
+7 -7
View File
@@ -1,4 +1,4 @@
labels: ["needs-confirmation"]
labels: ['needs-confirmation']
body:
- type: markdown #add faqs in future
attributes:
@@ -7,7 +7,7 @@ body:
> Please read through [the Discussion rules](https://github.com/cinnyapp/cinny/discussions/2653) and check for both existing [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc) prior to opening a new Discussion.
- type: markdown
attributes:
value: "# Issue Details"
value: '# Issue Details'
- type: textarea
attributes:
label: Issue Description
@@ -64,7 +64,7 @@ body:
- Browser:
- Cinny Web Version: (app.cinny.in or self hosted)
- Cinny desktop Version: (appimage or deb or flatpak)
- Matrix Homeserver:
- Matrix Homeserver:
placeholder: |
- OS: Windows 11
- Browser: Chrome 120.0.6099.109
@@ -80,12 +80,12 @@ body:
label: Relevant Logs
description: |
If applicable, add browser console logs to help explain your problem.
**To get browser console logs:**
- Chrome/Edge: Press F12 → Console tab
- Firefox: Press F12 → Console tab
- Safari: Develop → Show Web Inspector → Console
Please wrap large log outputs in code blocks with triple backticks (```).
placeholder: |
```
@@ -98,7 +98,7 @@ body:
render: shell
validations:
required: false
- type: textarea
- type: textarea
attributes:
label: Additional context
description: |
@@ -119,7 +119,7 @@ body:
> Use these links to review the existing Cinny [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc).
- type: checkboxes #add faqs in future
attributes:
label: "I acknowledge that:"
label: 'I acknowledge that:'
options:
- label: I have searched the Cinny repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
required: true
+14 -14
View File
@@ -2,29 +2,29 @@
version: 2
updates:
# - package-ecosystem: npm
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15
# - package-ecosystem: npm
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
day: 'tuesday'
time: '01:00'
timezone: 'Asia/Kolkata'
open-pull-requests-limit: 5
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
day: 'tuesday'
time: '01:00'
timezone: 'Asia/Kolkata'
open-pull-requests-limit: 5
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
+6 -6
View File
@@ -1,10 +1,10 @@
name: Deploy PR to Netlify
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
run-name: 'Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})'
on:
workflow_run:
workflows: ["Build pull request"]
types: [completed]
workflows: ['Build pull request']
types: [completed]
jobs:
deploy-pull-request:
@@ -42,7 +42,7 @@ jobs:
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
deploy-message: 'Deploy PR ${{ steps.pr.outputs.id }}'
alias: ${{ steps.pr.outputs.id }}
# These don't work because we're in workflow_run
enable-pull-request-comment: false
@@ -59,5 +59,5 @@ jobs:
pr-number: ${{ steps.pr.outputs.id }}
comment-tag: ${{ steps.pr.outputs.id }}
message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
collapsibleThreshold: 25
failOnDowngrade: false
path: package-lock.json
updateComment: true
updateComment: true
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".node-version"
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
@@ -118,4 +118,4 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+11 -11
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
+6 -3
View File
@@ -5,6 +5,7 @@ First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
>
> - Star the project
> - Tweet about it (tag @cinnyapp)
> - Refer this project in your project's readme
@@ -18,6 +19,7 @@ Bug reports and feature suggestions must use descriptive and concise titles and
## Pull requests
> ### Legal Notice
>
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. You will also be asked to [sign the CLA](https://github.com/cinnyapp/cla) upon submiting your pull request.
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.
@@ -26,9 +28,9 @@ Bug reports and feature suggestions must use descriptive and concise titles and
Example:
|Not ideal|Better|
|---|----|
|Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
| Not ideal | Better |
| ----------------------------------- | --------------------------------------------- |
| Fixed markAllAsRead in RoomTimeline | Fix read marker when paginating room timeline |
It is not always possible to phrase every change in such a manner, but it is desired.
@@ -39,6 +41,7 @@ Also, we use [ESLint](https://eslint.org/) for clean and stylistically consisten
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
## Helpful links
- [BEM methodology](http://getbem.com/introduction/)
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
+1 -3
View File
@@ -1,8 +1,6 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
+11 -8
View File
@@ -11,15 +11,15 @@
name="description"
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
/>
<meta
name="keywords"
content="lotus chat, lotus guild, matrix, matrix client"
/>
<meta name="keywords" content="lotus chat, lotus guild, matrix, matrix client" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Lotus Chat" />
<meta property="og:url" content="https://chat.lotusguild.org" />
<meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
<meta
property="og:image"
content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png"
/>
<meta
property="og:description"
content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
@@ -27,9 +27,12 @@
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap"
rel="stylesheet"
/>
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
+386 -389
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -69,7 +69,7 @@
"@fontsource/inter": "4.5.14",
"@giphy/js-fetch-api": "5.8.0",
"@giphy/js-types": "5.1.0",
"@giphy/react-components": "10.1.2",
"@giphy/react-components": "1.6.0",
"@sentry/react": "10.53.1",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
@@ -162,6 +162,6 @@
"vite": "6.4.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-static-copy": "4.1.0",
"vite-plugin-top-level-await": "1.6.0"
"vite-plugin-top-level-await": "1.2.2"
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
+1 -5
View File
@@ -56,11 +56,7 @@
"type": "image/png"
}
],
"categories": [
"social",
"communication",
"productivity"
],
"categories": ["social", "communication", "productivity"],
"shortcuts": [
{
"name": "New Message",
+2 -2
View File
@@ -7,11 +7,11 @@ const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js');
try {
let content = readFileSync(foldsPath, 'utf8');
// Defensive guard: if src is not a function, render null instead of crashing
const original = 'children: src(filled)';
const patched = 'children: typeof src === "function" ? src(filled) : null';
if (content.includes(patched)) {
console.log('folds patch already applied.');
} else if (content.includes(original)) {
+12 -12
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import { fileURLToPath } from "url";
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -9,26 +9,26 @@ const __dirname = path.dirname(__filename);
const version = process.argv[2];
if (!version) {
console.error("Version argument missing");
console.error('Version argument missing');
process.exit(1);
}
const root = path.resolve(__dirname, "..");
const root = path.resolve(__dirname, '..');
const newVersionTag = `v${version}`;
// Update package.json + package-lock.json safely
execSync(`npm version ${version} --no-git-tag-version`, {
cwd: root,
stdio: "inherit",
stdio: 'inherit',
});
console.log(`Updated package.json and package-lock.json → ${version}`);
// Update UI version references
const files = [
"src/app/features/settings/about/About.tsx",
"src/app/pages/auth/AuthFooter.tsx",
"src/app/pages/client/WelcomePage.tsx",
'src/app/features/settings/about/About.tsx',
'src/app/pages/auth/AuthFooter.tsx',
'src/app/pages/client/WelcomePage.tsx',
];
files.forEach((filePath) => {
@@ -39,10 +39,10 @@ files.forEach((filePath) => {
return;
}
const content = fs.readFileSync(absPath, "utf8");
const content = fs.readFileSync(absPath, 'utf8');
const updated = content.replace(/v\d+\.\d+\.\d+/g, newVersionTag);
fs.writeFileSync(absPath, updated);
console.log(`Updated ${filePath}${newVersionTag}`);
});
});
+238 -57
View File
@@ -137,7 +137,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
if (remaining <= 0) { onIgnore(); return; }
if (remaining <= 0) {
onIgnore();
return;
}
const id = setTimeout(onIgnore, remaining);
return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]);
@@ -157,7 +160,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender}
{getMemberDisplayName(info.room, info.sender) ??
getMxIdLocalPart(info.sender) ??
info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
@@ -294,7 +299,8 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const refEventId = relation?.event_id;
const mention =
content['m.mentions']?.room || content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
@@ -410,10 +416,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const { navigateRoom } = useRoomNavigate();
const pipDragRef = React.useRef<{
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
startX: number;
startY: number;
origLeft: number;
origTop: number;
dragged: boolean;
} | null>(null);
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => () => { activeDragCleanupRef.current?.(); }, []);
React.useEffect(
() => () => {
activeDragCleanupRef.current?.();
},
[]
);
// Track previous pipMode to only reset position when first entering pip (not on callVisible changes)
const prevPipModeRef = React.useRef(false);
@@ -422,16 +437,35 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el) return;
if (pipMode) {
if (!prevPipModeRef.current) {
el.style.top = 'auto'; el.style.left = 'auto';
el.style.bottom = '72px'; el.style.right = '16px';
el.style.width = '280px'; el.style.height = '158px';
el.style.borderRadius = '12px'; el.style.overflow = 'hidden';
el.style.zIndex = '99'; el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.top = 'auto';
el.style.left = 'auto';
el.style.bottom = '72px';
el.style.right = '16px';
el.style.width = '280px';
el.style.height = '158px';
el.style.borderRadius = '12px';
el.style.overflow = 'hidden';
el.style.zIndex = '99';
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.border = '1px solid rgba(255,255,255,0.1)';
}
el.style.visibility = 'visible';
} else {
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
[
'top',
'left',
'bottom',
'right',
'width',
'height',
'borderRadius',
'overflow',
'zIndex',
'boxShadow',
'border',
].forEach((p) => {
(el.style as any)[p] = '';
});
el.style.visibility = callVisible ? '' : 'hidden';
}
prevPipModeRef.current = pipMode;
@@ -444,30 +478,66 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el) return;
const l = parseFloat(el.style.left);
const t = parseFloat(el.style.top);
if (!isNaN(l)) el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
if (!isNaN(t)) el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
if (!isNaN(l))
el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
if (!isNaN(t))
el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
};
window.addEventListener('resize', onPipWindowResize);
return () => window.removeEventListener('resize', onPipWindowResize);
}, [pipMode, callEmbedRef]);
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current; if (!el) return;
const el = callEmbedRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
pipDragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
pipDragRef.current = {
startX: e.clientX,
startY: e.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onMove = (ev: MouseEvent) => {
if (!pipDragRef.current || !el) return;
const dx = ev.clientX - pipDragRef.current.startX, dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx)+Math.abs(dy) > 5) { pipDragRef.current.dragged = true; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }
const dx = ev.clientX - pipDragRef.current.startX,
dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
if (pipDragRef.current.dragged) {
el.style.left = `${Math.max(0, Math.min(window.innerWidth-el.offsetWidth, pipDragRef.current.origLeft+dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight-el.offsetHeight, pipDragRef.current.origTop+dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
el.style.left = `${Math.max(
0,
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx)
)}px`;
el.style.top = `${Math.max(
0,
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy)
)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; activeDragCleanupRef.current = null; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
activeDragCleanupRef.current = null;
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
activeDragCleanupRef.current = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const handlePipTouchStart = (e: React.TouchEvent) => {
@@ -475,50 +545,115 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el || e.touches.length !== 1) return;
const touch = e.touches[0];
const rect = el.getBoundingClientRect();
pipDragRef.current = { startX: touch.clientX, startY: touch.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
pipDragRef.current = {
startX: touch.clientX,
startY: touch.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onTouchMove = (ev: TouchEvent) => {
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
ev.preventDefault();
const t = ev.touches[0];
const dx = t.clientX - pipDragRef.current.startX;
const dy = t.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) pipDragRef.current.dragged = true;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5)
pipDragRef.current.dragged = true;
if (pipDragRef.current.dragged) {
el.style.left = `${Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
el.style.left = `${Math.max(
0,
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx)
)}px`;
el.style.top = `${Math.max(
0,
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy)
)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
activeDragCleanupRef.current = null;
setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0);
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
activeDragCleanupRef.current = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
activeDragCleanupRef.current = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); };
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation(); e.preventDefault();
const el = callEmbedRef.current; if (!el) return;
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
normaliseToTopLeft(el);
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
const sx = e.clientX,
sy = e.clientY,
sw = el.offsetWidth,
sh = el.offsetHeight;
const sl = parseFloat(el.style.left),
st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX-sx, dy = ev.clientY-sy;
let w = sw, h = sh, l = sl, t = st;
if (corner==='se'){w=sw+dx;h=sh+dy;} if (corner==='sw'){w=sw-dx;h=sh+dy;l=sl+sw-Math.max(PIP_MIN_W,w);}
if (corner==='ne'){w=sw+dx;h=sh-dy;t=st+sh-Math.max(PIP_MIN_H,h);} if (corner==='nw'){w=sw-dx;h=sh-dy;l=sl+sw-Math.max(PIP_MIN_W,w);t=st+sh-Math.max(PIP_MIN_H,h);}
w=Math.max(PIP_MIN_W,Math.min(w,window.innerWidth)); h=Math.max(PIP_MIN_H,Math.min(h,window.innerHeight));
l=Math.max(0,Math.min(l,window.innerWidth-PIP_MIN_W)); t=Math.max(0,Math.min(t,window.innerHeight-PIP_MIN_H));
el.style.width=`${w}px`; el.style.height=`${h}px`; el.style.left=`${l}px`; el.style.top=`${t}px`;
const dx = ev.clientX - sx,
dy = ev.clientY - sy;
let w = sw,
h = sh,
l = sl,
t = st;
if (corner === 'se') {
w = sw + dx;
h = sh + dy;
}
if (corner === 'sw') {
w = sw - dx;
h = sh + dy;
l = sl + sw - Math.max(PIP_MIN_W, w);
}
if (corner === 'ne') {
w = sw + dx;
h = sh - dy;
t = st + sh - Math.max(PIP_MIN_H, h);
}
if (corner === 'nw') {
w = sw - dx;
h = sh - dy;
l = sl + sw - Math.max(PIP_MIN_W, w);
t = st + sh - Math.max(PIP_MIN_H, h);
}
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.left = `${l}px`;
el.style.top = `${t}px`;
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; activeDragCleanupRef.current = null; };
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
activeDragCleanupRef.current = null;
};
activeDragCleanupRef.current = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
return (
@@ -548,25 +683,71 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
onClick={() => { if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId); }}
onClick={() => {
if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId);
}}
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(callEmbed.roomId)}
style={{ position:'absolute', inset:0, zIndex:1, background:'transparent', cursor:'grab', display:'flex', alignItems:'flex-start', justifyContent:'flex-end', padding:'6px' }}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
cursor: 'grab',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '6px',
}}
>
<div style={{ background:'rgba(0,0,0,0.65)', backdropFilter:'blur(4px)', borderRadius:'6px', padding:'3px 8px', color:'#fff', fontSize:'11px', fontWeight:600, pointerEvents:'none' }}>
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 8px',
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
Return to call
</div>
</div>
{(['se','sw','ne','nw'] as Corner[]).map((corner) => {
const s = corner.includes('s'); const e2 = corner.includes('e');
const dots = [[2,2],[2,7],[7,2]].map(([a,b]) => ({
position:'absolute' as const, width:4, height:4, borderRadius:'50%',
background:'rgba(255,255,255,0.45)',
[s?'bottom':'top']:a, [e2?'right':'left']:b,
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
[2, 2],
[2, 7],
[7, 2],
].map(([a, b]) => ({
position: 'absolute' as const,
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255,255,255,0.45)',
[s ? 'bottom' : 'top']: a,
[e2 ? 'right' : 'left']: b,
}));
return (
<div key={corner} onMouseDown={(ev) => handleResizeMouseDown(ev, corner)} onClick={(ev) => ev.stopPropagation()}
style={{ position:'absolute', width:'18px', height:'18px', [s?'bottom':'top']:0, [e2?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }}>
{dots.map((style, i) => <div key={i} style={style} />)}
<div
key={corner}
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
onClick={(ev) => ev.stopPropagation()}
style={{
position: 'absolute',
width: '18px',
height: '18px',
[s ? 'bottom' : 'top']: 0,
[e2 ? 'right' : 'left']: 0,
cursor: `${corner}-resize`,
zIndex: 2,
}}
>
{dots.map((style, i) => (
<div key={i} style={style} />
))}
</div>
);
})}
+9 -2
View File
@@ -259,9 +259,16 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text as="h2" size="H4">Device Verification</Text>
<Text as="h2" size="H4">
Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel} aria-label="Cancel verification">
<IconButton
size="300"
radii="300"
onClick={handleCancel}
aria-label="Cancel verification"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -299,7 +299,9 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Setup Device Verification</Text>
<Text as="h2" size="H4">
Setup Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -334,7 +336,9 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Reset Device Verification</Text>
<Text as="h2" size="H4">
Reset Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
+20 -13
View File
@@ -8,7 +8,6 @@ import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
@@ -34,23 +33,27 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}>
{lotusTerminal && (
<div style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
userSelect: 'none',
}}>
<div
style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
userSelect: 'none',
}}
>
// GIF_SEARCH
</div>
)}
<Box style={{ padding: '8px 8px 4px' }}>
<SearchBar style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }} />
</Box>
<div style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}>
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
<Grid
key={searchKey}
fetchGifs={fetchGifs}
@@ -108,7 +111,11 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
style={containerStyle}
>
<SearchContextManager apiKey={apiKey} initialTerm="">
<GifPickerInner onSelect={onSelect} requestClose={requestClose} lotusTerminal={!!lotusTerminal} />
<GifPickerInner
onSelect={onSelect}
requestClose={requestClose}
lotusTerminal={!!lotusTerminal}
/>
</SearchContextManager>
</Box>
</FocusTrap>
+3 -1
View File
@@ -43,7 +43,9 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Logout</Text>
<Text as="h2" size="H4">
Logout
</Text>
</Box>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+33 -13
View File
@@ -22,7 +22,8 @@ export function RoomSkeleton() {
`;
const shimmer = {
background: 'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
background:
'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
backgroundSize: '800px 100%',
animation: `shimmer-${id} 1.6s ease-in-out infinite`,
borderRadius: '4px',
@@ -32,16 +33,18 @@ export function RoomSkeleton() {
<>
<style>{shimmerKeyframes}</style>
<div
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
// CSS vars resolve against both light and dark themes automatically
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties}
style={
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
// CSS vars resolve against both light and dark themes automatically
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties
}
>
{/* Header — matches PageHeader size="600" (56px) */}
<div
@@ -56,7 +59,15 @@ export function RoomSkeleton() {
}}
>
{/* Avatar */}
<div style={{ ...shimmer, width: '32px', height: '32px', borderRadius: '50%', flexShrink: 0 }} />
<div
style={{
...shimmer,
width: '32px',
height: '32px',
borderRadius: '50%',
flexShrink: 0,
}}
/>
{/* Room name */}
<div style={{ ...shimmer, width: '140px', height: '16px' }} />
{/* Spacer */}
@@ -70,7 +81,16 @@ export function RoomSkeleton() {
<div style={{ flex: 1, overflowY: 'hidden', padding: '16px 0' }}>
{MESSAGES.map((msg, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i} style={{ display: 'flex', gap: '12px', padding: '4px 16px', alignItems: 'flex-start', marginBottom: msg.showAvatar ? '8px' : '2px' }}>
<div
key={i}
style={{
display: 'flex',
gap: '12px',
padding: '4px 16px',
alignItems: 'flex-start',
marginBottom: msg.showAvatar ? '8px' : '2px',
}}
>
{/* Avatar — only shown on first message in a group */}
<div style={{ width: '36px', flexShrink: 0 }}>
{msg.showAvatar && (
+11 -2
View File
@@ -23,7 +23,11 @@ export function EditorPreview() {
return (
<>
<IconButton variant="SurfaceVariant" aria-label="Open editor preview" onClick={() => setOpen(!open)}>
<IconButton
variant="SurfaceVariant"
aria-label="Open editor preview"
onClick={() => setOpen(!open)}
>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
@@ -58,7 +62,12 @@ export function EditorPreview() {
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Insert emoji">
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Insert emoji"
>
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Send">
+32 -30
View File
@@ -279,44 +279,42 @@ export function Toolbar() {
<MarkButton
format={MarkType.Bold}
icon={Icons.Bold}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`}
label="Bold"
/>}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} label="Bold" />}
/>
<MarkButton
format={MarkType.Italic}
icon={Icons.Italic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`}
label="Italic"
/>}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} label="Italic" />}
/>
<MarkButton
format={MarkType.Underline}
icon={Icons.Underline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`}
label="Underline"
/>}
tooltip={
<BtnTooltip text="Underline" shortCode={`${modKey} + U`} label="Underline" />
}
/>
<MarkButton
format={MarkType.StrikeThrough}
icon={Icons.Strike}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`}
label="Strikethrough"
/>}
tooltip={
<BtnTooltip
text="Strike Through"
shortCode={`${modKey} + S`}
label="Strikethrough"
/>
}
/>
<MarkButton
format={MarkType.Code}
icon={Icons.Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`}
label="Inline code"
/>}
tooltip={
<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} label="Inline code" />
}
/>
<MarkButton
format={MarkType.Spoiler}
icon={Icons.EyeBlind}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`}
label="Spoiler"
/>}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} label="Spoiler" />}
/>
</Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
@@ -325,30 +323,34 @@ export function Toolbar() {
<BlockButton
format={BlockType.BlockQuote}
icon={Icons.BlockQuote}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`}
label="Block quote"
/>}
tooltip={
<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} label="Block quote" />
}
/>
<BlockButton
format={BlockType.CodeBlock}
icon={Icons.BlockCode}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`}
label="Code block"
/>}
tooltip={
<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} label="Code block" />
}
/>
<BlockButton
format={BlockType.OrderedList}
icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`}
label="Ordered list"
/>}
tooltip={
<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} label="Ordered list" />
}
/>
<BlockButton
format={BlockType.UnorderedList}
icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`}
label="Unordered list"
/>}
tooltip={
<BtnTooltip
text="Unordered List"
shortCode={`${modKey} + 8`}
label="Unordered list"
/>
}
/>
<HeadingBlockButton />
</Box>
@@ -68,10 +68,30 @@ export const EventReaders = as<'div', EventReadersProps>(
className={css.Header}
variant="Surface"
size="600"
style={lotusTerminal ? { borderBottom: '1px solid rgba(0,212,255,0.30)', boxShadow: '0 2px 12px rgba(0,212,255,0.08)' } : undefined}
style={
lotusTerminal
? {
borderBottom: '1px solid rgba(0,212,255,0.30)',
boxShadow: '0 2px 12px rgba(0,212,255,0.08)',
}
: undefined
}
>
<Box grow="Yes">
<Text size="H3" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>Seen by</Text>
<Text
size="H3"
style={
lotusTerminal
? {
color: '#00D4FF',
textShadow: '0 0 6px rgba(0,212,255,0.45)',
letterSpacing: '0.05em',
}
: undefined
}
>
Seen by
</Text>
</Box>
<IconButton size="300" onClick={requestClose} aria-label="Close">
<Icon src={Icons.Cross} />
@@ -120,9 +140,15 @@ export const EventReaders = as<'div', EventReadersProps>(
{receiptTs !== undefined && (
<Text
size="T200"
style={lotusTerminal
? { color: '#FFB300', textShadow: '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)', fontSize: '0.72rem' }
: { opacity: 0.6 }}
style={
lotusTerminal
? {
color: '#FFB300',
textShadow: '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)',
fontSize: '0.72rem',
}
: { opacity: 0.6 }
}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
@@ -80,7 +80,9 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Join with Address</Text>
<Text as="h2" size="H4">
Join with Address
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -66,7 +66,9 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="leave-room-dialog-title">Leave Room</Text>
<Text as="h2" size="H4" id="leave-room-dialog-title">
Leave Room
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -66,7 +66,9 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Leave Space</Text>
<Text as="h2" size="H4">
Leave Space
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
+7 -1
View File
@@ -48,7 +48,13 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={downloading ? 'Downloading...' : hasError ? 'Download failed, click to retry' : 'Download file'}
aria-label={
downloading
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
@@ -394,7 +394,9 @@ export function MLocation({ content }: MLocationProps) {
const lat = parseFloat(location.latitude);
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${lat - 0.004},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
return (
<Box direction="Column" alignItems="Start" gap="200">
+1 -2
View File
@@ -29,8 +29,7 @@ export const Reaction = as<
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
alt={reaction}
/>
) : (
@@ -8,7 +8,9 @@ export const MessageDeletedContent = as<'div', { children?: never; reason?: stri
({ reason, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Delete} />
<i>{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}</i>
<i>
{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}
</i>
</Box>
)
);
@@ -105,9 +105,9 @@ export function PollContent({
const mx = useMatrixClient();
const isStable = !!content['m.poll'];
const poll = (
content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
) as PollData | undefined;
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
| PollData
| undefined;
const [votes, setVotes] = useState<VoteState>(() => {
if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 };
@@ -259,7 +259,9 @@ export function PollContent({
padding: '7px 12px',
borderRadius: '8px',
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
border: `1px solid ${selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'}`,
border: `1px solid ${
selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'
}`,
fontSize: '0.88rem',
lineHeight: 1.4,
textAlign: 'left',
@@ -281,23 +283,21 @@ export function PollContent({
position: 'absolute',
inset: 0,
width: `${pct}%`,
background: selected
? 'rgba(255,255,255,0.10)'
: 'rgba(255,255,255,0.05)',
background: selected ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.05)',
borderRadius: '8px',
pointerEvents: 'none',
}}
/>
)}
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
<span
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
>
<span style={{ flexGrow: 1 }}>{text}</span>
{selected && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span>
)}
{total > 0 && (
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>
{pct}%
</span>
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
)}
</span>
</button>
@@ -79,7 +79,9 @@ export function ReadReceiptAvatars({
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: lotusTerminal ? 'rgba(0,212,255,0.07)' : color.SurfaceVariant.Container,
backgroundColor: lotusTerminal
? 'rgba(0,212,255,0.07)'
: color.SurfaceVariant.Container,
border: lotusTerminal ? '1px solid rgba(0,212,255,0.30)' : '1px solid transparent',
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
borderRadius: '999px',
@@ -88,8 +90,7 @@ export function ReadReceiptAvatars({
}}
>
{displayed.map((userId) => {
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
+3 -1
View File
@@ -18,7 +18,9 @@ function DummyErrorDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
+12 -3
View File
@@ -37,9 +37,16 @@ function EmailErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
<Text as="label" htmlFor="retryEmailInput" size="L400" style={{ paddingTop: config.space.S400 }}>
<Text
as="label"
htmlFor="retryEmailInput"
size="L400"
style={{ paddingTop: config.space.S400 }}
>
Email
</Text>
<Input
@@ -141,7 +148,9 @@ export function EmailStageDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">Verification Request Sent</Text>
<Text as="h2" size="H4">
Verification Request Sent
</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
{errorCode && (
@@ -43,7 +43,9 @@ export function PasswordStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Account Password</Text>
<Text as="h2" size="H4">
Account Password
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -35,9 +35,16 @@ function RegistrationTokenErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
<Text as="label" htmlFor="retryTokenInput" size="L400" style={{ paddingTop: config.space.S400 }}>
<Text
as="label"
htmlFor="retryTokenInput"
size="L400"
style={{ paddingTop: config.space.S400 }}
>
Registration Token
</Text>
<Input
+3 -1
View File
@@ -54,7 +54,9 @@ export function SSOStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">SSO Login</Text>
<Text as="h2" size="H4">
SSO Login
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
+3 -1
View File
@@ -18,7 +18,9 @@ function TermsErrorDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
@@ -4,7 +4,13 @@ import { Box, as } from 'folds';
import * as css from './UrlPreview.css';
export const UrlPreview = as<'div'>(({ className, ...props }, ref) => (
<Box shrink="No" data-url-preview="" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
<Box
shrink="No"
data-url-preview=""
className={classNames(css.UrlPreview, className)}
{...props}
ref={ref}
/>
));
export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
@@ -68,7 +68,9 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Self Demotion</Text>
<Text as="h2" size="H4">
Self Demotion
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -118,7 +120,9 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Shared Power</Text>
<Text as="h2" size="H4">
Shared Power
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -198,7 +198,9 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">Add Existing</Text>
<Text as="h2" size="H4">
Add Existing
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
+52 -28
View File
@@ -62,7 +62,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => { microphoneRef.current = microphone; }, [microphone]);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
@@ -165,8 +167,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setPttActive(false);
}
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pttMode, pttKey, callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@@ -184,21 +186,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
lotusTerminal ? (
<Box style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em', fontFamily: 'JetBrains Mono, monospace' }}>
{pttMode &&
(lotusTerminal ? (
<Box
style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
<Text
size="T200"
style={{
color: pttActive ? '#00FF88' : '#FF6B00',
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
}}
>
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Box>
@@ -221,8 +233,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
)
)}
))}
{shareConfirm && (
<Box
style={{
@@ -242,17 +253,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button
size="300" variant="Success" fill="Solid" radii="300"
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300" variant="Secondary" fill="Soft" radii="300" outlined
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
@@ -281,9 +306,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
</Box>
+3 -1
View File
@@ -108,7 +108,9 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
onClick={() => onToggle()}
outlined
disabled={disabled}
aria-label={disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'}
aria-label={
disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'
}
aria-pressed={enabled}
style={disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
>
+4 -1
View File
@@ -75,7 +75,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
</Box>
<Box grow="Yes" direction="Column" gap="200">
{micDenied && (
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
<Text
size="T200"
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
>
Microphone access is blocked. Enable it in your browser settings to join.
</Text>
)}
@@ -121,9 +121,16 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Enable Encryption</Text>
<Text as="h2" size="H4">
Enable Encryption
</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300" aria-label="Cancel">
<IconButton
size="300"
onClick={() => setPrompt(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -103,7 +103,9 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
<Text as="h2" size="H4">
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
@@ -58,7 +58,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">New Space</Text>
<Text as="h2" size="H4">
New Space
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog} aria-label="Close">
+3 -1
View File
@@ -22,7 +22,9 @@ export function LobbyHero() {
const name = useRoomName(space);
const topic = useRoomTopic(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<PageHero
+4 -2
View File
@@ -147,7 +147,8 @@ const DARK: Record<ChatBackground, CSSProperties> = {
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
@@ -305,7 +306,8 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
hexgrid: {
backgroundColor: '#f4f8ff',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundImage:
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
+1 -1
View File
@@ -440,4 +440,4 @@ function RoomNavItem_({
</NavItem>
);
}
export const RoomNavItem = React.memo(RoomNavItem_);
export const RoomNavItem = React.memo(RoomNavItem_);
@@ -117,7 +117,11 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+6 -1
View File
@@ -41,7 +41,12 @@ export function CallChatView() {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose} aria-label="Close call chat">
<IconButton
ref={triggerRef}
variant="Surface"
onClick={handleClose}
aria-label="Close call chat"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+68 -31
View File
@@ -30,7 +30,9 @@ import {
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })));
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker }))
);
import { useClientConfig } from '../../hooks/useClientConfig';
import {
CustomEditor,
@@ -57,7 +59,9 @@ import {
getMentions,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
const EmojiBoard = React.lazy(() => import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })));
const EmojiBoard = React.lazy(() =>
import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard }))
);
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
@@ -143,7 +147,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const alive = useAlive();
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const replyUserID = replyDraft?.userId;
@@ -467,8 +471,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
async (gifUrl: string, w: number, h: number) => {
try {
// Only fetch from trusted Giphy CDN domains
const allowed = ['media.giphy.com', 'i.giphy.com', 'media0.giphy.com',
'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com'];
const allowed = [
'media.giphy.com',
'i.giphy.com',
'media0.giphy.com',
'media1.giphy.com',
'media2.giphy.com',
'media3.giphy.com',
'media4.giphy.com',
];
const { hostname } = new URL(gifUrl);
if (!allowed.includes(hostname)) return;
@@ -695,24 +706,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<React.Suspense fallback={null}><EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/></React.Suspense>
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{!hideStickerBtn && (
@@ -765,11 +778,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: undefined
}
content={
<React.Suspense fallback={null}><GifPicker
apiKey={gifApiKey}
onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)}
/></React.Suspense>
<React.Suspense fallback={null}>
<GifPicker
apiKey={gifApiKey}
onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)}
/>
</React.Suspense>
}
>
<IconButton
@@ -798,7 +813,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</UseStateProvider>
)}
{gifError && (
<Text size="T100" style={{ color: 'var(--tc-danger-normal)', padding: '2px 6px', alignSelf: 'center', whiteSpace: 'nowrap' }}>
<Text
size="T100"
style={{
color: 'var(--tc-danger-normal)',
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
@@ -811,14 +834,28 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
title="Share location"
>
{locating ? (
<Text size="T200" style={{ fontWeight: 800, fontSize: '10px', letterSpacing: '0.04em', lineHeight: 1 }}>
<Text
size="T200"
style={{
fontWeight: 800,
fontSize: '10px',
letterSpacing: '0.04em',
lineHeight: 1,
}}
>
...
</Text>
) : (
<Icon src={Icons.Pin} size="100" />
)}
</IconButton>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300" aria-label="Send message">
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
+277 -147
View File
@@ -637,7 +637,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
}
if (!document.hasFocus() && !unreadInfo) {
@@ -673,7 +673,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId);
evtTimeline &&
getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, {
@@ -1242,7 +1243,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return <PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />;
return (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
@@ -1371,7 +1378,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
)}
</Message>
);
@@ -1424,7 +1435,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
)}
</Message>
);
@@ -1608,12 +1623,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Lock}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{' enabled end-to-end encryption'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Lock}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' enabled end-to-end encryption'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1623,14 +1664,45 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const joinRule = mEvent.getContent<{ join_rule?: string }>().join_rule ?? 'unknown';
const ruleLabel: Record<string, string> = { public: 'public', invite: 'invite-only', knock: 'knock', restricted: 'restricted' };
const ruleLabel: Record<string, string> = {
public: 'public',
invite: 'invite-only',
knock: 'knock',
restricted: 'restricted',
};
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Settings}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}
</Text>
</Box>
}
/>
</Event>
);
@@ -1641,12 +1713,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const access = mEvent.getContent<{ guest_access?: string }>().guest_access ?? 'unknown';
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Settings}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1657,12 +1755,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const alias = mEvent.getContent<{ alias?: string }>().alias;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Hash}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{alias ? ` set room address to ${alias}` : ' removed room address'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{alias ? ` set room address to ${alias}` : ' removed room address'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1831,9 +1955,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) { hi = mid - 1; }
else if (item >= base + len) { lo = mid + 1; }
else { eventTimeline = timelineSegments[mid][2]; baseIndex = base; break; }
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
eventTimeline = timelineSegments[mid][2];
baseIndex = base;
break;
}
}
}
if (!eventTimeline) return null;
@@ -1935,131 +2065,131 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return (
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<Text size="L400">Jump to Unread</Text>
</Chip>
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</TimelineFloat>
)}
</Box>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</TimelineFloat>
)}
</Box>
</ReadPositionsContext.Provider>
);
}
+3 -4
View File
@@ -56,8 +56,6 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return true;
};
export function RoomView({ eventId }: { eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
@@ -98,8 +96,9 @@ export function RoomView({ eventId }: { eventId?: string }) {
)
);
const chatBgStyle = useMemo(
() => getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
const chatBgStyle = useMemo(
() =>
getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
[chatBackground, lotusTerminal, isDark]
);
+19 -7
View File
@@ -533,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick} aria-label="Search">
<IconButton
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
aria-label="Search"
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
@@ -596,11 +601,13 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission &&
(direct || (room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && (
<CallButton />
)}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
@@ -616,7 +623,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle} aria-label="Toggle member list">
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
+7 -1
View File
@@ -117,7 +117,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</>
)}
</Text>
<IconButton title="Drop Typing Status" aria-label="Drop typing status" size="300" radii="Pill" onClick={handleDropAll}>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
@@ -57,7 +57,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
>
<Modal
size="400"
style={{ maxHeight: '440px', borderRadius: config.radii.R500, display: 'flex', flexDirection: 'column' }}
style={{
maxHeight: '440px',
borderRadius: config.radii.R500,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
direction="Column"
@@ -89,11 +94,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
) : (
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll size="300" hideTrack visibility="Hover">
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200 }}
>
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{filtered.slice(0, 60).map((room) => (
<MenuItem
key={room.roomId}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -141,7 +141,11 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
@@ -185,7 +185,12 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300" aria-label="Cancel">
<IconButton
size="300"
onClick={() => setAlertRemove(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+49 -21
View File
@@ -34,7 +34,13 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import {
ChatBackground,
DateFormat,
MessageLayout,
MessageSpacing,
settingsAtom,
} from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -313,7 +319,10 @@ function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
settingsAtom,
'perMessageProfiles'
);
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
return (
@@ -359,7 +368,12 @@ function Appearance() {
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="200">
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<SettingTile
title="Chat Background"
description="Pattern applied behind the message timeline."
@@ -373,7 +387,9 @@ function Appearance() {
<SettingTile
title="Show Profile on Every Message"
description="Display avatar and name on each message instead of grouping consecutive messages."
after={<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />}
after={
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
@@ -385,7 +401,10 @@ function Appearance() {
{lotusTerminal && (
<button
type="button"
onClick={() => { resetBootSequence(); runLotusBootSequence(true); }}
onClick={() => {
resetBootSequence();
runLotusBootSequence(true);
}}
title="Replay boot sequence"
style={{
background: 'transparent',
@@ -808,10 +827,12 @@ function Editor() {
);
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
settingsAtom,
'callNoiseSuppression'
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false);
@@ -843,7 +864,8 @@ function Calls() {
window.addEventListener('keydown', onKey, true);
}, [listeningForKey, setPttKey]);
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
return (
<Box direction="Column" gap="100">
@@ -859,10 +881,21 @@ function Calls() {
<SettingTile
title="Noise Suppression"
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
after={
<Switch
variant="Primary"
value={callNoiseSuppression}
onChange={setCallNoiseSuppression}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
@@ -882,9 +915,7 @@ function Calls() {
onClick={handleKeyBind}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
</Text>
<Text size="B300">{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}</Text>
</Button>
}
/>
@@ -894,7 +925,6 @@ function Calls() {
);
}
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const theme = useTheme();
@@ -915,18 +945,16 @@ function ChatBgGrid() {
height: toRem(50),
borderRadius: toRem(8),
cursor: 'pointer',
border: chatBackground === opt.value
? '2px solid #980000'
: '2px solid rgba(128,128,128,0.25)',
border:
chatBackground === opt.value
? '2px solid #980000'
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark),
}}
/>
<Text
size="T200"
style={chatBackground === opt.value ? { color: '#980000' } : undefined}
>
<Text size="T200" style={chatBackground === opt.value ? { color: '#980000' } : undefined}>
{opt.label}
</Text>
</Box>
@@ -120,7 +120,14 @@ function KeywordCross({ pushRule }: PushRulesProps) {
const removing = removeState.status === AsyncStatus.Loading;
return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing} aria-label="Remove keyword">
<IconButton
onClick={remove}
size="300"
radii="Pill"
variant="Secondary"
disabled={removing}
aria-label="Remove keyword"
>
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton>
);
@@ -117,7 +117,11 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+22 -4
View File
@@ -53,10 +53,19 @@ export const createCallEmbed = (
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
const initialAudio = forceAudioOff ? false : pref?.microphone ?? true;
const initialVideo = pref?.video ?? false;
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
const controlState = pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
const widget = CallEmbed.getWidget(
mx,
room,
intent,
themeKind,
noiseSuppression,
initialAudio,
initialVideo
);
const controlState =
pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
const embed = new CallEmbed(mx, room, widget, container, controlState);
@@ -77,7 +86,16 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true, !!pttMode);
const callEmbed = createCallEmbed(
mx,
room,
dm,
theme.kind,
container,
pref,
callNoiseSuppression ?? true,
!!pttMode
);
setCallEmbed(callEmbed);
},
+6 -3
View File
@@ -4,7 +4,10 @@ import { useState } from 'react';
export function useForceUpdate() {
const [data, setData] = useState(null);
return [data, function forceUpdateHook() {
setData({});
}];
return [
data,
function forceUpdateHook() {
setData({});
},
];
}
+8 -5
View File
@@ -55,11 +55,14 @@ export const usePan = (active: boolean) => {
}, [active]);
// Clean up document listeners if component unmounts during an active drag
useEffect(() => () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(
() => () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[]
);
return {
pan,
+1 -3
View File
@@ -23,9 +23,7 @@ function computePositions(room: Room, myUserId: string): Map<string, string[]> {
const map = new Map<string, string[]>();
const liveEvents = room.getLiveTimeline().getEvents();
// Build O(1) index once instead of O(T) findIndex per member
const eventIndex = new Map<string, number>(
liveEvents.map((e, i) => [e.getId() ?? '', i])
);
const eventIndex = new Map<string, number>(liveEvents.map((e, i) => [e.getId() ?? '', i]));
for (const member of room.getJoinedMembers()) {
if (member.userId === myUserId) continue;
const evtId = room.getEventReadUpTo(member.userId);
+13 -2
View File
@@ -1,7 +1,13 @@
import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
import {
butterTheme,
darkTheme,
lotusTerminalLightTheme,
lotusTerminalTheme,
silverTheme,
} from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@@ -45,7 +51,12 @@ export const LotusTerminalTheme: Theme = {
export const LotusTerminalLightTheme: Theme = {
id: 'lotus-terminal-light-theme',
kind: ThemeKind.Light,
classNames: ['lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, 'prism-light'],
classNames: [
'lotus-terminal-light-theme',
lotusTerminalLightTheme,
onLightFontWeight,
'prism-light',
],
};
export const useThemes = (): Theme[] => {
+120 -33
View File
@@ -10,10 +10,12 @@ import {
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
const AuthLayout = React.lazy(() => import('./auth').then(m => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then(m => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then(m => ({ default: m.Register })));
const ResetPassword = React.lazy(() => import('./auth').then(m => ({ default: m.ResetPassword })));
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
const ResetPassword = React.lazy(() =>
import('./auth').then((m) => ({ default: m.ResetPassword }))
);
import {
DIRECT_PATH,
EXPLORE_PATH,
@@ -48,9 +50,8 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
const Room = React.lazy(() => import('../features/room').then(m => ({ default: m.Room })));
const Room = React.lazy(() => import('../features/room').then((m) => ({ default: m.Room })));
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -62,22 +63,38 @@ import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
const RoomSettingsRenderer = React.lazy(() => import('../features/room-settings').then(m => ({ default: m.RoomSettingsRenderer })));
const RoomSettingsRenderer = React.lazy(() =>
import('../features/room-settings').then((m) => ({ default: m.RoomSettingsRenderer }))
);
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
const SpaceSettingsRenderer = React.lazy(() => import('../features/space-settings').then(m => ({ default: m.SpaceSettingsRenderer })));
const SpaceSettingsRenderer = React.lazy(() =>
import('../features/space-settings').then((m) => ({ default: m.SpaceSettingsRenderer }))
);
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
const CreateRoomModalRenderer = React.lazy(() => import('../features/create-room').then(m => ({ default: m.CreateRoomModalRenderer })));
const CreateRoomModalRenderer = React.lazy(() =>
import('../features/create-room').then((m) => ({ default: m.CreateRoomModalRenderer }))
);
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
const CreateSpaceModalRenderer = React.lazy(() => import('../features/create-space').then(m => ({ default: m.CreateSpaceModalRenderer })));
const SearchModalRenderer = React.lazy(() => import('../features/search').then(m => ({ default: m.SearchModalRenderer })));
const Explore = React.lazy(() => import('./client/explore').then(m => ({ default: m.Explore })));
const FeaturedRooms = React.lazy(() => import('./client/explore').then(m => ({ default: m.FeaturedRooms })));
const PublicRooms = React.lazy(() => import('./client/explore').then(m => ({ default: m.PublicRooms })));
const Notifications = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Notifications })));
const Inbox = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Inbox })));
const Invites = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Invites })));
const Lobby = React.lazy(() => import('../features/lobby').then(m => ({ default: m.Lobby })));
const CreateSpaceModalRenderer = React.lazy(() =>
import('../features/create-space').then((m) => ({ default: m.CreateSpaceModalRenderer }))
);
const SearchModalRenderer = React.lazy(() =>
import('../features/search').then((m) => ({ default: m.SearchModalRenderer }))
);
const Explore = React.lazy(() => import('./client/explore').then((m) => ({ default: m.Explore })));
const FeaturedRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.FeaturedRooms }))
);
const PublicRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.PublicRooms }))
);
const Notifications = React.lazy(() =>
import('./client/inbox').then((m) => ({ default: m.Notifications }))
);
const Inbox = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Inbox })));
const Invites = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Invites })));
const Lobby = React.lazy(() => import('../features/lobby').then((m) => ({ default: m.Lobby })));
import { getFallbackSession } from '../state/sessions';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
@@ -112,9 +129,30 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</React.Suspense>
}
>
<Route path={LOGIN_PATH} element={<React.Suspense fallback={null}><Login /></React.Suspense>} />
<Route path={REGISTER_PATH} element={<React.Suspense fallback={null}><Register /></React.Suspense>} />
<Route path={RESET_PASSWORD_PATH} element={<React.Suspense fallback={null}><ResetPassword /></React.Suspense>} />
<Route
path={LOGIN_PATH}
element={
<React.Suspense fallback={null}>
<Login />
</React.Suspense>
}
/>
<Route
path={REGISTER_PATH}
element={
<React.Suspense fallback={null}>
<Register />
</React.Suspense>
}
/>
<Route
path={RESET_PASSWORD_PATH}
element={
<React.Suspense fallback={null}>
<ResetPassword />
</React.Suspense>
}
/>
</Route>
<Route
@@ -149,12 +187,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</ClientLayout>
<CallStatusRenderer />
</CallEmbedProvider>
<React.Suspense fallback={null}><SearchModalRenderer /></React.Suspense>
<React.Suspense fallback={null}>
<SearchModalRenderer />
</React.Suspense>
<UserRoomProfileRenderer />
<React.Suspense fallback={null}><CreateRoomModalRenderer /></React.Suspense>
<React.Suspense fallback={null}><CreateSpaceModalRenderer /></React.Suspense>
<React.Suspense fallback={null}><RoomSettingsRenderer /></React.Suspense>
<React.Suspense fallback={null}><SpaceSettingsRenderer /></React.Suspense>
<React.Suspense fallback={null}>
<CreateRoomModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<CreateSpaceModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<RoomSettingsRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<SpaceSettingsRenderer />
</React.Suspense>
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
@@ -250,7 +298,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_LOBBY_PATH} element={<React.Suspense fallback={null}><Lobby /></React.Suspense>} />
<Route
path={_LOBBY_PATH}
element={
<React.Suspense fallback={null}>
<Lobby />
</React.Suspense>
}
/>
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
@@ -269,7 +324,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}>
<React.Suspense fallback={null}><Explore /></React.Suspense>
<React.Suspense fallback={null}>
<Explore />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
@@ -284,8 +341,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_FEATURED_PATH} element={<React.Suspense fallback={null}><FeaturedRooms /></React.Suspense>} />
<Route path={_SERVER_PATH} element={<React.Suspense fallback={null}><PublicRooms /></React.Suspense>} />
<Route
path={_FEATURED_PATH}
element={
<React.Suspense fallback={null}>
<FeaturedRooms />
</React.Suspense>
}
/>
<Route
path={_SERVER_PATH}
element={
<React.Suspense fallback={null}>
<PublicRooms />
</React.Suspense>
}
/>
</Route>
<Route path={CREATE_PATH} element={<Create />} />
<Route
@@ -294,7 +365,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={INBOX_PATH}>
<React.Suspense fallback={null}><Inbox /></React.Suspense>
<React.Suspense fallback={null}>
<Inbox />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
@@ -309,8 +382,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_NOTIFICATIONS_PATH} element={<React.Suspense fallback={null}><Notifications /></React.Suspense>} />
<Route path={_INVITES_PATH} element={<React.Suspense fallback={null}><Invites /></React.Suspense>} />
<Route
path={_NOTIFICATIONS_PATH}
element={
<React.Suspense fallback={null}>
<Notifications />
</React.Suspense>
}
/>
<Route
path={_INVITES_PATH}
element={
<React.Suspense fallback={null}>
<Invites />
</React.Suspense>
}
/>
</Route>
</Route>
<Route path="/*" element={<p>Page not found</p>} />
+6 -2
View File
@@ -25,7 +25,9 @@ export function UnAuthRouteThemeManager() {
if (lotusTerminal) {
const isLight = systemThemeKind === ThemeKind.Light;
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
document.body.classList.add(...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames);
document.body.classList.add(
...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames
);
document.body.classList.add(lotusTerminalBodyClass);
} else {
document.documentElement.removeAttribute('data-theme');
@@ -47,7 +49,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const terminalIsLight = lotusTerminal && activeTheme.kind === ThemeKind.Light;
const effectiveTheme = lotusTerminal
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
? terminalIsLight
? LotusTerminalLightTheme
: LotusTerminalTheme
: activeTheme;
// Boot animation only fires when lotusTerminal is toggled on, not on every theme change
+7 -1
View File
@@ -18,7 +18,13 @@ export function AuthFooter() {
>
v{pkg.version}
</Text>
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
<Text
as="a"
size="T300"
href="https://matrix.lotusguild.org"
target="_blank"
rel="noreferrer"
>
Community
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
+3 -1
View File
@@ -135,7 +135,9 @@ export function AuthLayout() {
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
<Text as="h1" size="H3">Lotus Chat</Text>
<Text as="h1" size="H3">
Lotus Chat
</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
@@ -230,7 +230,15 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
<Text as="label" htmlFor="passwordInput" size="L400" priority="300">
Password
</Text>
<PasswordInput id="passwordInput" name="passwordInput" aria-label="Password" variant="Background" size="500" outlined required />
<PasswordInput
id="passwordInput"
name="passwordInput"
aria-label="Password"
variant="Background"
size="500"
outlined
required
/>
<Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
{loginState.status === AsyncStatus.Error && (
<>
+2 -2
View File
@@ -118,8 +118,8 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
const _redir = afterLoginRedirectUrl;
const _safePath = (_redir && /^\/(?!\/)/.test(_redir)) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
const _safePath = _redir && /^\/(?!\/)/.test(_redir) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
}
}, [data, navigate]);
};
+12 -4
View File
@@ -21,14 +21,22 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
borderRadius: '0 0 4px 0',
transition: 'top 0.1s',
}}
onFocus={(e) => { (e.currentTarget as HTMLElement).style.top = '0'; }}
onBlur={(e) => { (e.currentTarget as HTMLElement).style.top = '-40px'; }}
onFocus={(e) => {
(e.currentTarget as HTMLElement).style.top = '0';
}}
onBlur={(e) => {
(e.currentTarget as HTMLElement).style.top = '-40px';
}}
>
Skip to main content
</a>
<Box grow="Yes">
<Box shrink="No" as="nav" aria-label="Room navigation">{nav}</Box>
<Box grow="Yes" as="main" id="main-content">{children}</Box>
<Box shrink="No" as="nav" aria-label="Room navigation">
{nav}
</Box>
<Box grow="Yes" as="main" id="main-content">
{children}
</Box>
</Box>
</>
);
+2 -1
View File
@@ -22,7 +22,8 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children:
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Unable to connect to the homeserver. The homeserver or your internet connection may be down.
Unable to connect to the homeserver. The homeserver or your internet connection
may be down.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
+9 -1
View File
@@ -15,7 +15,15 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={LotusLogo} alt="Lotus Chat" style={{ objectFit: "contain" }} />}
icon={
<img
width="70"
height="70"
src={LotusLogo}
alt="Lotus Chat"
style={{ objectFit: 'contain' }}
/>
}
title="Welcome to Lotus Chat"
subTitle={
<span>
+7 -1
View File
@@ -108,7 +108,13 @@ function DirectHeader() {
</Text>
</Box>
<Box>
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Direct messages options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Direct messages options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
+16 -3
View File
@@ -94,9 +94,16 @@ export function AddServer() {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Add Server</Text>
<Text as="h2" size="H4">
Add Server
</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300" aria-label="Close">
<IconButton
size="300"
onClick={() => setDialog(false)}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -110,7 +117,13 @@ export function AddServer() {
<Text priority="400">Add server name to explore public communities.</Text>
<Box direction="Column" gap="100">
<Text size="L400">Server Name</Text>
<Input ref={serverInputRef} name="serverInput" aria-label="Server name" variant="Background" required />
<Input
ref={serverInputRef}
name="serverInput"
aria-label="Server name"
variant="Background"
required
/>
{exploreState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to load public rooms. Please try again.
+6 -2
View File
@@ -56,7 +56,9 @@ export function FeaturedRooms() {
<Box direction="Column" gap="700">
{spaces && spaces.length > 0 && (
<Box direction="Column" gap="400">
<Text as="h2" size="H4">Featured Spaces</Text>
<Text as="h2" size="H4">
Featured Spaces
</Text>
<RoomCardGrid>
{spaces.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
@@ -85,7 +87,9 @@ export function FeaturedRooms() {
)}
{rooms && rooms.length > 0 && (
<Box direction="Column" gap="400">
<Text as="h3" size="H4">Featured Rooms</Text>
<Text as="h3" size="H4">
Featured Rooms
</Text>
<RoomCardGrid>
{rooms.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+3 -1
View File
@@ -537,7 +537,9 @@ export function PublicRooms() {
{isSearch ? (
<Text as="h3" size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
) : (
<Text as="h3" size="H4">Popular Communities</Text>
<Text as="h3" size="H4">
Popular Communities
</Text>
)}
<Box gap="200">
{roomTypeFilters.map((filter) => (
+7 -1
View File
@@ -122,7 +122,13 @@ function HomeHeader() {
</Text>
</Box>
<Box>
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Home options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Home options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
+9 -3
View File
@@ -429,7 +429,9 @@ function KnownInvites({
}: KnownInvitesProps) {
return (
<Box direction="Column" gap="200">
<Text as="h3" size="H4">Primary</Text>
<Text as="h3" size="H4">
Primary
</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
@@ -488,7 +490,9 @@ function UnknownInvites({
return (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text as="h3" size="H4">Public</Text>
<Text as="h3" size="H4">
Public
</Text>
<Box>
{invites.length > 0 && (
<Chip
@@ -585,7 +589,9 @@ function SpamInvites({
return (
<Box direction="Column" gap="200">
<Text as="h3" size="H4">Spam</Text>
<Text as="h3" size="H4">
Spam
</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
+7 -1
View File
@@ -522,7 +522,13 @@ function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose} aria-label="Close folder">
<IconButton
data-id={folder.id}
size="300"
variant="Background"
onClick={onClose}
aria-label="Close folder"
>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
+10 -2
View File
@@ -274,7 +274,13 @@ function SpaceHeader() {
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
</Box>
<Box shrink="No">
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Space options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Space options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
@@ -433,7 +439,9 @@ export function Space() {
return false;
}
const showRoomAnyway =
roomsWithUnreadSet.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
roomsWithUnreadSet.has(roomId) ||
roomId === selectedRoomId ||
callEmbed?.roomId === roomId;
return !showRoomAnyway;
},
[space.roomId, closedCategories, roomsWithUnreadSet, selectedRoomId, callEmbed]
+14 -3
View File
@@ -30,7 +30,10 @@ export class CallControl extends EventEmitter implements CallControlState {
private get settingsButton(): HTMLElement | undefined {
// EC 0.19.3: settings button has data-testid="settings-bottom-center"
return (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? undefined;
return (
(this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ??
undefined
);
}
private get reactionsButton(): HTMLElement | undefined {
@@ -98,7 +101,13 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public async forceState(desired: CallControlState) {
this.state = new CallControlState(desired.microphone, desired.video, desired.sound, this.screenshare, this.spotlight);
this.state = new CallControlState(
desired.microphone,
desired.video,
desired.sound,
this.screenshare,
this.spotlight
);
await this.applyState();
}
@@ -177,7 +186,9 @@ export class CallControl extends EventEmitter implements CallControlState {
// EC auto-switches to spotlight when screenshare starts — revert to grid
if (!prevScreenshare && screenshare) {
setTimeout(() => { if (this.spotlight) this.gridButton?.click(); }, 600);
setTimeout(() => {
if (this.spotlight) this.gridButton?.click();
}, 600);
}
}
+3 -1
View File
@@ -462,7 +462,9 @@ export const getReactCustomHtmlParser = (
{...props}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any); }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any);
}}
onClick={params.handleSpoilerClick}
className={css.Spoiler()}
aria-label="Spoiler — click to reveal"
+8 -5
View File
@@ -6,9 +6,9 @@ export type ListAction<T> =
item: T | T[];
}
| {
type: 'REPLACE';
item: T;
replacement: T;
type: 'REPLACE';
item: T;
replacement: T;
}
| {
type: 'DELETE';
@@ -34,9 +34,12 @@ export const createListAtom = <T>() => {
return;
}
if (action.type === 'REPLACE') {
set(baseListAtom, items.map((item) => item === action.item ? action.replacement : item));
set(
baseListAtom,
items.map((item) => (item === action.item ? action.replacement : item))
);
}
}
);
};
export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
+1 -3
View File
@@ -21,9 +21,7 @@ export type TUploadItem = {
export type TUploadListAtom = ReturnType<typeof createListAtom<TUploadItem>>;
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(
createListAtom
);
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(createListAtom);
export const roomUploadAtomFamily = createUploadAtomFamily();
+23 -2
View File
@@ -9,7 +9,24 @@ export type DateFormat =
| 'YYYY-MM-DD'
| '';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid' | 'waves' | 'neon' | 'aurora';
export type ChatBackground =
| 'none'
| 'blueprint'
| 'carbon'
| 'stars'
| 'topographic'
| 'herringbone'
| 'crosshatch'
| 'chevron'
| 'polka'
| 'triangles'
| 'plaid'
| 'tactical'
| 'circuit'
| 'hexgrid'
| 'waves'
| 'neon'
| 'aurora';
export enum MessageLayout {
Modern = 0,
Compact = 1,
@@ -114,7 +131,11 @@ export const getSettings = (): Settings => {
};
export const setSettings = (settings: Settings) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* quota */ }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
/* quota */
}
};
const baseSettings = atom<Settings>(getSettings());
+18 -2
View File
@@ -84,7 +84,15 @@ const transformFontTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
@@ -92,7 +100,15 @@ const transformSpanTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
-1
View File
@@ -421,4 +421,3 @@ export const lotusTerminalLightTheme = createTheme(color, {
Overlay: 'rgba(237, 240, 245, 0.97)',
},
});

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