Compare commits

..

1 Commits

Author SHA1 Message Date
Ajay Bura
c38151c237 update ask access token login in service worker 2024-09-11 18:32:12 +05:30
279 changed files with 9755 additions and 17436 deletions

View File

@@ -2,14 +2,14 @@
version: 2 version: 2
updates: updates:
# - package-ecosystem: npm - package-ecosystem: npm
# directory: / directory: /
# schedule: schedule:
# interval: weekly interval: weekly
# day: "tuesday" day: "tuesday"
# time: "01:00" time: "01:00"
# timezone: "Asia/Kolkata" timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15 open-pull-requests-limit: 15
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /

View File

@@ -12,9 +12,9 @@ jobs:
PR_NUMBER: ${{github.event.number}} PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.0.3
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.3.6
with: with:
name: preview name: preview
path: dist path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.3.6
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt

View File

@@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant' - name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release # Beta Release
uses: cla-assistant/github-action@v2.6.1 uses: cla-assistant/github-action@v2.5.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret

View File

@@ -15,7 +15,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download pr number - name: Download pr number
uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +24,7 @@ jobs:
id: pr id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}

View File

@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.7.0
with: with:
context: . context: .
push: false push: false

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: NPM Lockfile Changes - name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with: with:

View File

@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.0.3
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.0.3
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -66,11 +66,11 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.1.7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0 uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.9.0 uses: docker/setup-buildx-action@v3.6.1
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.3.0
with: with:
@@ -84,13 +84,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5.6.1 uses: docker/metadata-action@v5.5.1
with: with:
images: | images: |
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.7.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -11,7 +11,7 @@ RUN npm run build
## App ## App
FROM nginx:1.27.4-alpine FROM nginx:1.27.0-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,35 +1,35 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
alias /var/lib/letsencrypt/.well-known/acme-challenge/; alias /var/lib/letsencrypt/.well-known/acme-challenge/;
} }
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
root /opt/cinny/dist/; root /opt/cinny/dist/;
rewrite ^/config.json$ /config.json break; rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break; rewrite ^/manifest.json$ /manifest.json break;
rewrite ^.*/olm.wasm$ /olm.wasm break; rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break; rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break; rewrite ^(.+)$ /index.html break;
} }
} }

7371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.4.0", "version": "4.1.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -39,12 +39,12 @@
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
"domhandler": "5.0.3", "domhandler": "5.0.3",
"emojibase": "15.3.1", "emojibase": "6.1.0",
"emojibase-data": "15.3.2", "emojibase-data": "7.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.1.0", "folds": "2.0.0",
"formik": "2.4.6", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
@@ -56,7 +56,7 @@
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "35.0.0", "matrix-js-sdk": "34.5.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.29.0", "prismjs": "1.29.0",
@@ -108,6 +108,6 @@
"vite": "5.0.13", "vite": "5.0.13",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.1"
} }
} }

Binary file not shown.

Binary file not shown.

View File

@@ -1,73 +0,0 @@
import React, { ReactNode } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
import { UIAFlowOverlay } from './UIAFlowOverlay';
import { PasswordStage, SSOStage } from './uia-stages';
import { useMatrixClient } from '../hooks/useMatrixClient';
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
if (passwordFlow) return passwordFlow;
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
};
type ActionUIAProps = {
authData: IAuthData;
ongoingFlow: UIAFlow;
action: (authDict: AuthDict) => void;
onCancel: () => void;
};
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete();
if (!stageToComplete) return null;
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={ongoingFlow.stages.length}
onCancel={onCancel}
>
{stageToComplete.type === AuthType.Password && (
<PasswordStage
userId={mx.getUserId()!}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
{stageToComplete.type === AuthType.Sso && stageToComplete.session && (
<SSOStage
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
</UIAFlowOverlay>
);
}
type ActionUIAFlowsLoaderProps = {
authData: IAuthData;
unsupported: () => ReactNode;
children: (ongoingFlow: UIAFlow) => ReactNode;
};
export function ActionUIAFlowsLoader({
authData,
unsupported,
children,
}: ActionUIAFlowsLoaderProps) {
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined;
if (!ongoingFlow) return unsupported();
return children(ongoingFlow);
}

View File

@@ -1,281 +0,0 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
import {
Badge,
Box,
Button,
color,
config,
Icon,
IconButton,
Icons,
Menu,
percent,
PopOut,
ProgressBar,
RectCords,
Spinner,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
import { InfoCard } from './info-card';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import {
useKeyBackupInfo,
useKeyBackupStatus,
useKeyBackupSync,
useKeyBackupTrust,
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
type BackupStatusProps = {
enabled: boolean;
};
function BackupStatus({ enabled }: BackupStatusProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
<Text
as="span"
size="L400"
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
>
{enabled ? 'Connected' : 'Disconnected'}
</Text>
</Box>
);
}
type BackupSyncingProps = {
count: number;
};
function BackupSyncing({ count }: BackupSyncingProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Spinner size="50" variant="Primary" fill="Soft" />
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
Syncing ({count})
</Text>
</Box>
);
}
function BackupProgressFetching() {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: 0%</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
</Box>
<Spinner size="50" variant="Secondary" fill="Soft" />
</Box>
);
}
type BackupProgressProps = {
total: number;
downloaded: number;
};
function BackupProgress({ total, downloaded }: BackupProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{downloaded} / {total}
</Text>
</Badge>
</Box>
);
}
type BackupTrustInfoProps = {
crypto: CryptoApi;
backupInfo: KeyBackupInfo;
};
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
const trust = useKeyBackupTrust(crypto, backupInfo);
if (!trust) return null;
return (
<Box direction="Column">
{trust.matchesDecryptionKey ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted decryption key.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted decryption key!</b>
</Text>
)}
{trust.trusted ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted by signature.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted signature!</b>
</Text>
)}
</Box>
);
}
type BackupRestoreTileProps = {
crypto: CryptoApi;
};
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
const restoring =
restoreProgress.status === BackupProgressStatus.Fetching ||
restoreProgress.status === BackupProgressStatus.Loading;
const backupEnabled = useKeyBackupStatus(crypto);
const backupInfo = useKeyBackupInfo(crypto);
const [remainingSession, syncFailure] = useKeyBackupSync();
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await crypto.restoreKeyBackup({
progressCallback(progress) {
setRestoreProgress(progress);
},
});
}, [crypto, setRestoreProgress])
);
const handleRestore = () => {
setMenuCords(undefined);
restoreBackup();
};
return (
<InfoCard
variant="Surface"
title="Encryption Backup"
after={
<Box alignItems="Center" gap="200">
{remainingSession === 0 ? (
<BackupStatus enabled={backupEnabled} />
) : (
<BackupSyncing count={remainingSession} />
)}
<IconButton
aria-pressed={!!menuCords}
size="300"
variant="Surface"
radii="300"
onClick={handleMenu}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
padding: config.space.S100,
}}
>
<Box direction="Column" gap="100">
<Box direction="Column" gap="200">
<InfoCard
variant="SurfaceVariant"
title="Backup Details"
description={
<>
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
<br />
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
</>
}
/>
</Box>
<Button
size="300"
variant="Success"
radii="300"
aria-disabled={restoreState.status === AsyncStatus.Loading || restoring}
onClick={
restoreState.status === AsyncStatus.Loading || restoring
? undefined
: handleRestore
}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</Box>
}
>
{syncFailure && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{syncFailure}</b>
</Text>
)}
{!backupEnabled && backupInfo === null && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>No backup present on server!</b>
</Text>
)}
{!syncFailure && !backupEnabled && backupInfo && (
<BackupTrustInfo crypto={crypto} backupInfo={backupInfo} />
)}
{restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Loading && (
<BackupProgress
total={restoreProgress.data.total}
downloaded={restoreProgress.data.downloaded}
/>
)}
{restoreState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{restoreState.error.message}</b>
</Text>
)}
</InfoCard>
);
}
export function AutoRestoreBackupOnVerification() {
useRestoreBackupOnVerification();
return null;
}

View File

@@ -19,7 +19,7 @@ export function CapabilitiesAndMediaConfigLoader({
[] []
>( >(
useCallback(async () => { useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]); const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]); const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]); const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig]; return [capabilities, mediaConfig];

View File

@@ -9,7 +9,7 @@ type CapabilitiesLoaderProps = {
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
useEffect(() => { useEffect(() => {
load(); load();

View File

@@ -1,318 +0,0 @@
import {
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
};
type WaitingMessageProps = {
message: string;
};
function WaitingMessage({ message }: WaitingMessageProps) {
return (
<Box alignItems="Center" gap="200">
<Spinner variant="Secondary" size="200" />
<Text size="T300">{message}</Text>
</Box>
);
}
type VerificationUnexpectedProps = { message: string; onClose: () => void };
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
return (
<Box direction="Column" gap="400">
<Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
</Button>
</Box>
);
}
function VerificationWaitAccept() {
return (
<Box direction="Column" gap="400">
<Text>Please accept the request from other device.</Text>
<WaitingMessage message="Waiting for request to be accepted..." />
</Box>
);
}
type VerificationAcceptProps = {
onAccept: () => Promise<void>;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="400">
<Text>Click accept to start the verification process.</Text>
<Button
variant="Primary"
fill="Solid"
onClick={accept}
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting}
>
<Text size="B400">Accept</Text>
</Button>
</Box>
);
}
function VerificationWaitStart() {
return (
<Box direction="Column" gap="400">
<Text>Verification request has been accepted.</Text>
<WaitingMessage message="Waiting for the response from other device..." />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
return (
<Box direction="Column" gap="400">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
borderRadius: config.radii.R400,
padding: config.space.S500,
}}
gap="700"
wrap="Wrap"
justifyContent="Center"
>
{sasData.sas.emoji?.map(([emoji, name], index) => (
<Box
// eslint-disable-next-line react/no-array-index-key
key={`${emoji}${name}${index}`}
direction="Column"
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Text size="H1">{emoji}</Text>
<Text size="T200">{name}</Text>
</Box>
))}
</Box>
<Box direction="Column" gap="200">
<Button
variant="Primary"
fill="Soft"
onClick={confirm}
disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />}
>
<Text size="B400">They Match</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
</Button>
</Box>
</Box>
);
}
type SasVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
useEffect(() => {
verifier.verify();
}, [verifier]);
if (sasData) {
return <CompareEmoji sasData={sasData} />;
}
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
</Box>
);
}
type VerificationDoneProps = {
onExit: () => void;
};
function VerificationDone({ onExit }: VerificationDoneProps) {
return (
<Box direction="Column" gap="400">
<div>
<Text>Your device is verified.</Text>
</div>
<Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">Okay</Text>
</Button>
</Box>
);
}
type VerificationCanceledProps = {
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
return (
<Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
</Button>
</Box>
);
}
type DeviceVerificationProps = {
request: VerificationRequest;
onExit: () => void;
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request);
const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
request.cancel();
}
onExit();
}, [request, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
)}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<VerificationRequest>();
useVerificationRequestReceived(setRequest);
const handleExit = useCallback(() => {
setRequest(undefined);
}, []);
if (!request) return null;
if (!request.isSelfVerification) {
return null;
}
return <DeviceVerification request={request} onExit={handleExit} />;
}

View File

@@ -1,375 +0,0 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import {
Dialog,
Header,
Box,
Text,
IconButton,
Icon,
Icons,
config,
Button,
Chip,
color,
Spinner,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/state/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
type UIACallback<T> = (
authDict: AuthDict | null
) => Promise<[IAuthData, undefined] | [undefined, T]>;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
type UIAAction<T> = {
authData: IAuthData;
callback: UIACallback<T>;
cancelCallback: () => void;
};
function makeUIAAction<T>(
authData: IAuthData,
performAction: PerformAction<T>,
resolve: (data: T) => void,
reject: (error?: any) => void
): UIAAction<T> {
const action: UIAAction<T> = {
authData,
callback: async (authDict) => {
const [error, data] = await to<T, MatrixError | Error>(performAction(authDict));
if (error instanceof MatrixError && error.httpStatus === 401) {
return [error.data as IAuthData, undefined];
}
if (error) {
reject(error);
throw error;
}
resolve(data);
return [undefined, data];
},
cancelCallback: reject,
};
return action;
}
type SetupVerificationProps = {
onComplete: (recoveryKey: string) => void;
};
function SetupVerification({ onComplete }: SetupVerificationProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [uiaAction, setUIAAction] = useState<UIAAction<void>>();
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
const handleAction = useCallback(
async (authDict: AuthDict) => {
if (!uiaAction) {
throw new Error('Unexpected Error! UIA action is perform without data.');
}
if (alive()) {
setNextAuthData(null);
}
const [authData] = await uiaAction.callback(authDict);
if (alive() && authData) {
setNextAuthData(authData);
}
},
[uiaAction, alive]
);
const resetUIA = useCallback(() => {
if (!alive()) return;
setUIAAction(undefined);
setNextAuthData(undefined);
}, [alive]);
const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
(makeRequest) =>
new Promise<void>((resolve, reject) => {
makeRequest(null)
.then(() => {
resolve();
resetUIA();
})
.catch((error) => {
if (error instanceof MatrixError && error.httpStatus === 401) {
const authData = error.data as IAuthData;
const action = makeUIAAction(
authData,
makeRequest as PerformAction<void>,
resolve,
(err) => {
resetUIA();
reject(err);
}
);
if (alive()) {
setUIAAction(action);
} else {
reject(new Error('Authentication failed! Failed to setup device verification.'));
}
return;
}
reject(error);
});
}),
[alive, resetUIA]
);
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
useCallback(
async (passphrase) => {
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.');
}
clearSecretStorageKeys();
await crypto.bootstrapSecretStorage({
createSecretStorageKey: async () => recoveryKeyData,
setupNewSecretStorage: true,
});
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning: true,
});
await crypto.resetKeyBackup();
onComplete(recoveryKeyData.encodedPrivateKey);
},
[mx, onComplete, authUploadDeviceSigningKeys]
)
);
const loading = setupState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (loading) return;
const target = evt.target as HTMLFormElement | undefined;
const passphraseInput = target?.passphraseInput as HTMLInputElement | undefined;
let passphrase: string | undefined;
if (passphraseInput && passphraseInput.value.length > 0) {
passphrase = passphraseInput.value;
}
setup(passphrase);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
{setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
</Text>
)}
{nextAuthData !== null && uiaAction && (
<ActionUIAFlowsLoader
authData={nextAuthData ?? uiaAction.authData}
unsupported={() => (
<Text size="T200">
Authentication steps to perform this action are not supported by client.
</Text>
)}
>
{(ongoingFlow) => (
<ActionUIA
authData={nextAuthData ?? uiaAction.authData}
ongoingFlow={ongoingFlow}
action={handleAction}
onCancel={uiaAction.cancelCallback}
/>
)}
</ActionUIAFlowsLoader>
)}
</Box>
);
}
type RecoveryKeyDisplayProps = {
recoveryKey: string;
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const handleCopy = () => {
copyToClipboard(recoveryKey);
};
const handleDownload = () => {
const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
};
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
return (
<Box direction="Column" gap="400">
<Text size="T300">
Store the Recovery Key in a safe place for future use, as you will need it to verify your
identity if you do not have access to other devices.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Recovery Key</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
alignItems="Center"
justifyContent="Center"
gap="400"
>
<Text style={{ fontFamily: 'monospace' }} size="T200" priority="300">
{safeToDisplayKey}
</Text>
<Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
<Text size="B300">{show ? 'Hide' : 'Show'}</Text>
</Chip>
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationSetupProps = {
onCancel: () => void;
};
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
({ onCancel }, ref) => {
const [recoveryKey, setRecoveryKey] = useState<string>();
return (
<Dialog ref={ref}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Setup Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{recoveryKey ? (
<RecoveryKeyDisplay recoveryKey={recoveryKey} />
) : (
<SetupVerification onComplete={setRecoveryKey} />
)}
</Box>
</Dialog>
);
}
);
type DeviceVerificationResetProps = {
onCancel: () => void;
};
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
({ onCancel }, ref) => {
const [reset, setReset] = useState(false);
return (
<Dialog ref={ref}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Reset Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{reset ? (
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<UseStateProvider initial={undefined}>
{(recoveryKey: string | undefined, setRecoveryKey) =>
recoveryKey ? (
<RecoveryKeyDisplay recoveryKey={recoveryKey} />
) : (
<SetupVerification onComplete={setRecoveryKey} />
)
}
</UseStateProvider>
</Box>
) : (
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="H1">🧑🚒🤚</Text>
<Text size="T300">Resetting device verification is permanent.</Text>
<Text size="T300">
Anyone you have verified with will see security alerts and your encryption backup
will be lost. You almost certainly do not want to do this, unless you have lost{' '}
<b>Recovery Key</b> or <b>Recovery Passphrase</b> and every device you can verify
from.
</Text>
</Box>
<Button variant="Critical" onClick={() => setReset(true)}>
<Text size="B400">Reset</Text>
</Button>
</Box>
)}
</Dialog>
);
}
);

View File

@@ -1,24 +0,0 @@
import { ReactNode } from 'react';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../hooks/useDeviceVerificationStatus';
type DeviceVerificationStatusProps = {
crypto?: CryptoApi;
userId: string;
deviceId: string;
children: (verificationStatus: VerificationStatus) => ReactNode;
};
export function DeviceVerificationStatus({
crypto,
userId,
deviceId,
children,
}: DeviceVerificationStatusProps) {
const status = useDeviceVerificationStatus(crypto, userId, deviceId);
return children(status);
}

View File

@@ -1,89 +0,0 @@
import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../hooks/useDeviceVerificationStatus';
type LogoutDialogProps = {
handleClose: () => void;
};
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
({ handleClose }, ref) => {
const mx = useMatrixClient();
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
const crossSigningActive = useCrossSigningActive();
const verificationStatus = useDeviceVerificationStatus(
mx.getCrypto(),
mx.getSafeUserId(),
mx.getDeviceId() ?? undefined
);
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await logoutClient(mx);
}, [mx])
);
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
return (
<Dialog variant="Surface" ref={ref}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Logout</Text>
</Box>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{hasEncryptedRoom &&
(crossSigningActive ? (
verificationStatus === VerificationStatus.Unverified && (
<InfoCard
variant="Critical"
title="Unverified Device"
description="Verify your device before logging out to save your encrypted messages."
/>
)
) : (
<InfoCard
variant="Critical"
title="Alert"
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/>
))}
<Text priority="400">Youre about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to logout! {logoutState.error.message}
</Text>
)}
<Box direction="Column" gap="200">
<Button
variant="Critical"
onClick={logout}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
>
<Text size="B400">Logout</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}
);

View File

@@ -1,199 +0,0 @@
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
Box,
Text,
Chip,
Icon,
Icons,
RectCords,
PopOut,
Menu,
config,
MenuItem,
color,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { SettingTile } from './setting-tile';
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { storePrivateKey } from '../../client/state/secretStorageKeys';
export enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase',
RecoveryKey = 'key',
}
type ManualVerificationMethodSwitcherProps = {
value: ManualVerificationMethod;
onChange: (value: ManualVerificationMethod) => void;
};
export function ManualVerificationMethodSwitcher({
value,
onChange,
}: ManualVerificationMethodSwitcherProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (method: ManualVerificationMethod) => {
setMenuCords(undefined);
onChange(method);
};
return (
<>
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text as="span" size="B300">
{value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'}
{value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'}
</Text>
</Chip>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
<Box grow="Yes">
<Text size="T300">Recovery Passphrase</Text>
</Box>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
<Box grow="Yes">
<Text size="T300">Recovery Key</Text>
</Box>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ManualVerificationTileProps = {
secretStorageKeyId: string;
secretStorageKeyContent: SecretStorageKeyContent;
options?: ReactNode;
};
export function ManualVerificationTile({
secretStorageKeyId,
secretStorageKeyContent,
options,
}: ManualVerificationTileProps) {
const mx = useMatrixClient();
const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
hasPassphrase
? ManualVerificationMethod.RecoveryPassphrase
: ManualVerificationMethod.RecoveryKey
);
const verifyAndRestoreBackup = useCallback(
async (recoveryKey: Uint8Array) => {
const crypto = mx.getCrypto();
if (!crypto) {
throw new Error('Unexpected Error! Crypto object not found.');
}
storePrivateKey(secretStorageKeyId, recoveryKey);
await crypto.bootstrapCrossSigning({});
await crypto.bootstrapSecretStorage({});
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
},
[mx, secretStorageKeyId]
);
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
verifyAndRestoreBackup
);
const verifying = verifyState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="200">
<SettingTile
title="Verify Manually"
description={hasPassphrase ? 'Select a verification method.' : 'Provide recovery key.'}
after={
<Box alignItems="Center" gap="200">
{hasPassphrase && (
<ManualVerificationMethodSwitcher value={method} onChange={setMethod} />
)}
{options}
</Box>
}
/>
{verifyState.status === AsyncStatus.Success ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Device verified!</b>
</Text>
) : (
<Box direction="Column" gap="100">
{method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey
processing={verifying}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
{method === ManualVerificationMethod.RecoveryPassphrase &&
secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase
processing={verifying}
keyContent={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
{verifyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{verifyState.error.message}</b>
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -1,29 +0,0 @@
import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { stopPropagation } from '../utils/keyboard';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
{children}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -29,7 +29,6 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to'; import { testMatrixTo } from '../plugins/matrix-to';
import {IImageContent} from "../../types/matrix/common";
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -68,63 +67,38 @@ export function RenderMessageContent({
</UrlPreviewHolder> </UrlPreviewHolder>
); );
}; };
const renderCaption = () => {
const content: IImageContent = getContent();
if(content.filename && content.filename !== content.body) {
return (
<MText
edited={edited}
content={content}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
)
}
return null;
}
const renderFile = () => ( const renderFile = () => (
<> <MFile
<MFile content={getContent()}
content={getContent()} renderFileContent={({ body, mimeType, info, encInfo, url }) => (
renderFileContent={({ body, mimeType, info, encInfo, url }) => ( <FileContent
<FileContent body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body} body={body}
mimeType={mimeType} mimeType={mimeType}
renderAsPdfFile={() => ( url={url}
<ReadPdfFile encInfo={encInfo}
body={body} renderViewer={(p) => <PdfViewer {...p} />}
mimeType={mimeType} />
url={url} )}
encInfo={encInfo} renderAsTextFile={() => (
renderViewer={(p) => <PdfViewer {...p} />} <ReadTextFile
/> body={body}
)} mimeType={mimeType}
renderAsTextFile={() => ( url={url}
<ReadTextFile encInfo={encInfo}
body={body} renderViewer={(p) => <TextViewer {...p} />}
mimeType={mimeType} />
url={url} )}
encInfo={encInfo} >
renderViewer={(p) => <TextViewer {...p} />} <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
/> </FileContent>
)} )}
> outlined={outlineAttachment}
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} /> />
</FileContent>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
if (msgType === MsgType.Text) { if (msgType === MsgType.Text) {
@@ -184,40 +158,36 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) { if (msgType === MsgType.Image) {
return ( return (
<> <MImage
<MImage content={getContent()}
content={getContent()} renderImageContent={(props) => (
renderImageContent={(props) => ( <ImageContent
<ImageContent {...props}
{...props} autoPlay={mediaAutoLoad}
autoPlay={mediaAutoLoad} renderImage={(p) => <Image {...p} loading="lazy" />}
renderImage={(p) => <Image {...p} loading="lazy" />} renderViewer={(p) => <ImageViewer {...p} />}
renderViewer={(p) => <ImageViewer {...p} />} />
/> )}
)} outlined={outlineAttachment}
outlined={outlineAttachment} />
/>
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Video) { if (msgType === MsgType.Video) {
return ( return (
<> <MVideo
<MVideo content={getContent()}
content={getContent()} renderAsFile={renderFile}
renderAsFile={renderFile} renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
renderVideoContent={({ body, info, mimeType, url, encInfo }) => ( <VideoContent
<VideoContent body={body}
body={body} info={info}
info={info} mimeType={mimeType}
mimeType={mimeType} url={url}
url={url} encInfo={encInfo}
encInfo={encInfo} renderThumbnail={
renderThumbnail={ mediaAutoLoad
mediaAutoLoad ? () => (
? () => (
<ThumbnailContent <ThumbnailContent
info={info} info={info}
renderImage={(src) => ( renderImage={(src) => (
@@ -225,33 +195,26 @@ export function RenderMessageContent({
)} )}
/> />
) )
: undefined : undefined
} }
renderVideo={(p) => <Video {...p} />} renderVideo={(p) => <Video {...p} />}
/> />
)} )}
outlined={outlineAttachment} outlined={outlineAttachment}
/> />
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Audio) { if (msgType === MsgType.Audio) {
return ( return (
<> <MAudio
<MAudio content={getContent()}
content={getContent()} renderAsFile={renderFile}
renderAsFile={renderFile} renderAudioContent={(props) => (
renderAudioContent={(props) => ( <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} /> )}
)} outlined={outlineAttachment}
outlined={outlineAttachment} />
/>
{renderCaption()}
</>
); );
} }

View File

@@ -1,200 +0,0 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
SecretStoragePassphraseContent,
} from '../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
type SecretStorageRecoveryPassphraseProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
passphraseContent: SecretStoragePassphraseContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryPassphrase({
processing,
keyContent,
passphraseContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryPassphraseProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array,
Error,
Parameters<typeof deriveKey>
>(
useCallback(
async (passphrase, salt, iterations, bits) => {
const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
throw new Error('Invalid recovery passphrase.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
)
);
const drivingKey = driveKeyState.status === AsyncStatus.Loading;
const loading = drivingKey || processing;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
if (loading) return;
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const recoveryPassphraseInput = target?.recoveryPassphraseInput as HTMLInputElement | undefined;
if (!recoveryPassphraseInput) return;
const recoveryPassphrase = recoveryPassphraseInput.value.trim();
if (!recoveryPassphrase) return;
const { salt, iterations, bits } = passphraseContent;
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
if (alive()) {
recoveryPassphraseInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Recovery Passphrase</Text>
<PasswordInput
name="recoveryPassphraseInput"
size="400"
variant="Secondary"
radii="300"
autoFocus
required
outlined
readOnly={loading}
/>
</Box>
<Box shrink="No" gap="200">
<Button
type="submit"
variant="Success"
size="400"
radii="300"
disabled={loading}
before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
>
<Text as="span" size="B400">
Verify
</Text>
</Button>
</Box>
</Box>
{driveKeyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{driveKeyState.error.message}</b>
</Text>
)}
</Box>
);
}
type SecretStorageRecoveryKeyProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryKey({
processing,
keyContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryKeyProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
useCallback(
async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
throw new Error('Invalid recovery key.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
)
);
const drivingKey = driveKeyState.status === AsyncStatus.Loading;
const loading = drivingKey || processing;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const recoveryKeyInput = target?.recoveryKeyInput as HTMLInputElement | undefined;
if (!recoveryKeyInput) return;
const recoveryKey = recoveryKeyInput.value.trim();
if (!recoveryKey) return;
submitRecoveryKey(recoveryKey).then((decodedRecoveryKey) => {
if (alive()) {
recoveryKeyInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Recovery Key</Text>
<PasswordInput
name="recoveryKeyInput"
size="400"
variant="Secondary"
radii="300"
autoFocus
required
outlined
readOnly={loading}
/>
</Box>
<Box shrink="No" gap="200">
<Button
type="submit"
variant="Success"
size="400"
radii="300"
disabled={loading}
before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
>
<Text as="span" size="B400">
Verify
</Text>
</Button>
</Box>
</Box>
{driveKeyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{driveKeyState.error.message}</b>
</Text>
)}
</Box>
);
}

View File

@@ -13,6 +13,7 @@ import {
IconButton, IconButton,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type UIAFlowOverlayProps = { export type UIAFlowOverlayProps = {
currentStep: number; currentStep: number;
@@ -28,7 +29,7 @@ export function UIAFlowOverlay({
}: UIAFlowOverlayProps) { }: UIAFlowOverlayProps) {
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}> <FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: stopPropagation }}>
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400"> <Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center"> <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children} {children}

View File

@@ -257,9 +257,7 @@ export function Toolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const disableInline = isBlockActive(editor, BlockType.CodeBlock); const disableInline = isBlockActive(editor, BlockType.CodeBlock);
const canEscape = isBlockActive(editor, BlockType.Paragraph) const canEscape = isAnyMarkActive(editor) || !isBlockActive(editor, BlockType.Paragraph);
? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return ( return (

View File

@@ -5,7 +5,6 @@ import { Header, Menu, Scroll, config } from 'folds';
import * as css from './AutocompleteMenu.css'; import * as css from './AutocompleteMenu.css';
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
type AutocompleteMenuProps = { type AutocompleteMenuProps = {
requestClose: () => void; requestClose: () => void;
@@ -13,22 +12,13 @@ type AutocompleteMenuProps = {
children: ReactNode; children: ReactNode;
}; };
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
const alive = useAlive();
const handleDeactivate = () => {
if (alive()) {
// The component is unmounted so we will not call for `requestClose`
requestClose();
}
};
return ( return (
<div className={css.AutocompleteMenuBase}> <div className={css.AutocompleteMenuBase}>
<div className={css.AutocompleteMenuContainer}> <div className={css.AutocompleteMenuContainer}>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onPostDeactivate: handleDeactivate, onDeactivate: () => requestClose(),
returnFocusOnDeactivate: false, returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
allowOutsideClick: true, allowOutsideClick: true,

View File

@@ -6,21 +6,24 @@ import { Room } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji'; import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../../plugins/utils';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void; type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = PackImageReader | IEmoji; type EmoticonSearchItem = ExtendedPackImage | IEmoji;
type EmoticonAutocompleteProps = { type EmoticonAutocompleteProps = {
imagePackRooms: Room[]; imagePackRooms: Room[];
@@ -30,11 +33,16 @@ type EmoticonAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
}; };
const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (emoticon) => [
`:${emoticon.shortcode}:`,
];
export function EmoticonAutocomplete({ export function EmoticonAutocomplete({
imagePackRooms, imagePackRooms,
editor, editor,
@@ -44,23 +52,19 @@ export function EmoticonAutocomplete({
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms); const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => { const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = []; const list: Array<EmoticonSearchItem> = [];
return list.concat( return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)), imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
emojis emojis
); );
}, [imagePacks]); }, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
searchList, const autoCompleteEmoticon = result ? result.items : recentEmoji;
getEmoticonSearchStr,
SEARCH_OPTIONS
);
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -65,6 +65,7 @@ type RoomMentionAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -96,7 +97,7 @@ export function RoomMentionAutocomplete({
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20); const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -19,7 +19,6 @@ import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matri
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar'; import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -68,13 +67,8 @@ type UserMentionAutocompleteProps = {
requestClose: () => void; requestClose: () => void;
}; };
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -97,9 +91,7 @@ export function UserMentionAutocomplete({
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( const autoCompleteMembers = result ? result.items : members.slice(0, 20);
withAllowedMembership
);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -26,75 +26,48 @@ import {
testMatrixTo, testMatrixTo,
} from '../../plugins/matrix-to'; } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import {
escapeMarkdownInlineSequences,
escapeMarkdownBlockSequences,
} from '../../plugins/markdown';
type ProcessTextCallback = (text: string) => string; const markNodeToType: Record<string, MarkType> = {
b: MarkType.Bold,
strong: MarkType.Bold,
i: MarkType.Italic,
em: MarkType.Italic,
u: MarkType.Underline,
s: MarkType.StrikeThrough,
del: MarkType.StrikeThrough,
code: MarkType.Code,
span: MarkType.Spoiler,
};
const getText = (node: ChildNode): string => { const elementToTextMark = (node: Element): MarkType | undefined => {
const markType = markNodeToType[node.name];
if (!markType) return undefined;
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
return undefined;
}
if (
markType === MarkType.Code &&
node.parent &&
'name' in node.parent &&
node.parent.name === 'pre'
) {
return undefined;
}
return markType;
};
const parseNodeText = (node: ChildNode): string => {
if (isText(node)) { if (isText(node)) {
return node.data; return node.data;
} }
if (isTag(node)) { if (isTag(node)) {
return node.children.map((child) => getText(child)).join(''); return node.children.map((child) => parseNodeText(child)).join('');
} }
return ''; return '';
}; };
const getInlineNodeMarkType = (node: Element): MarkType | undefined => { const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'b' || node.name === 'strong') {
return MarkType.Bold;
}
if (node.name === 'i' || node.name === 'em') {
return MarkType.Italic;
}
if (node.name === 'u') {
return MarkType.Underline;
}
if (node.name === 's' || node.name === 'del') {
return MarkType.StrikeThrough;
}
if (node.name === 'code') {
if (node.parent && 'name' in node.parent && node.parent.name === 'pre') {
return undefined; // Don't apply `Code` mark inside a <pre> tag
}
return MarkType.Code;
}
if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
return MarkType.Spoiler;
}
return undefined;
};
const getInlineMarkElement = (
markType: MarkType,
node: Element,
getChild: (child: ChildNode) => InlineElement[]
): InlineElement[] => {
const children = node.children.flatMap(getChild);
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
children.unshift({ text: mdSequence });
children.push({ text: mdSequence });
return children;
}
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
return children;
};
const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) { if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
const { src, alt } = node.attribs; const { src, alt } = node.attribs;
if (!src) return undefined; if (!src) return undefined;
@@ -106,13 +79,13 @@ const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElemen
if (testMatrixTo(href)) { if (testMatrixTo(href)) {
const userMention = parseMatrixToUser(href); const userMention = parseMatrixToUser(href);
if (userMention) { if (userMention) {
return createMentionElement(userMention, getText(node) || userMention, false); return createMentionElement(userMention, parseNodeText(node) || userMention, false);
} }
const roomMention = parseMatrixToRoom(href); const roomMention = parseMatrixToRoom(href);
if (roomMention) { if (roomMention) {
return createMentionElement( return createMentionElement(
roomMention.roomIdOrAlias, roomMention.roomIdOrAlias,
getText(node) || roomMention.roomIdOrAlias, parseNodeText(node) || roomMention.roomIdOrAlias,
false, false,
undefined, undefined,
roomMention.viaServers roomMention.viaServers
@@ -122,7 +95,7 @@ const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElemen
if (eventMention) { if (eventMention) {
return createMentionElement( return createMentionElement(
eventMention.roomIdOrAlias, eventMention.roomIdOrAlias,
getText(node) || eventMention.roomIdOrAlias, parseNodeText(node) || eventMention.roomIdOrAlias,
false, false,
eventMention.eventId, eventMention.eventId,
eventMention.viaServers eventMention.viaServers
@@ -133,40 +106,44 @@ const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElemen
return undefined; return undefined;
}; };
const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => { const parseInlineNodes = (node: ChildNode): InlineElement[] => {
if (isText(node)) { if (isText(node)) {
return [{ text: processText(node.data) }]; return [{ text: node.data }];
} }
if (isTag(node)) { if (isTag(node)) {
const markType = getInlineNodeMarkType(node); const markType = elementToTextMark(node);
if (markType) { if (markType) {
return getInlineMarkElement(markType, node, (child) => { const children = node.children.flatMap(parseInlineNodes);
if (markType === MarkType.Code) return [{ text: getText(child) }]; if (node.attribs['data-md'] !== undefined) {
return getInlineElement(child, processText); children.unshift({ text: node.attribs['data-md'] });
}); children.push({ text: node.attribs['data-md'] });
} else {
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
}
return children;
} }
const inlineNode = getInlineNonMarkElement(node); const inlineNode = elementToInlineNode(node);
if (inlineNode) return [inlineNode]; if (inlineNode) return [inlineNode];
if (node.name === 'a') { if (node.name === 'a') {
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText)); const children = node.childNodes.flatMap(parseInlineNodes);
children.unshift({ text: '[' }); children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` }); children.push({ text: `](${node.attribs.href})` });
return children; return children;
} }
return node.childNodes.flatMap((child) => getInlineElement(child, processText)); return node.childNodes.flatMap(parseInlineNodes);
} }
return []; return [];
}; };
const parseBlockquoteNode = ( const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
node: Element,
processText: ProcessTextCallback
): BlockQuoteElement[] | ParagraphElement[] => {
const quoteLines: Array<InlineElement[]> = []; const quoteLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -179,7 +156,7 @@ const parseBlockquoteNode = (
node.children.forEach((child) => { node.children.forEach((child) => {
if (isText(child)) { if (isText(child)) {
lineHolder.push({ text: processText(child.data) }); lineHolder.push({ text: child.data });
return; return;
} }
if (isTag(child)) { if (isTag(child)) {
@@ -191,20 +168,19 @@ const parseBlockquoteNode = (
if (child.name === 'p') { if (child.name === 'p') {
appendLine(); appendLine();
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText))); quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
return; return;
} }
lineHolder.push(...getInlineElement(child, processText)); parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
} }
}); });
appendLine(); appendLine();
const mdSequence = node.attribs['data-md']; if (node.attribs['data-md'] !== undefined) {
if (mdSequence !== undefined) {
return quoteLines.map((lineChildren) => ({ return quoteLines.map((lineChildren) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [{ text: `${mdSequence} ` }, ...lineChildren], children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
})); }));
} }
@@ -219,19 +195,22 @@ const parseBlockquoteNode = (
]; ];
}; };
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => { const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
const codeLines = getText(node).trim().split('\n'); const codeLines = parseNodeText(node).trim().split('\n');
const mdSequence = node.attribs['data-md']; if (node.attribs['data-md'] !== undefined) {
if (mdSequence !== undefined) { const pLines = codeLines.map<ParagraphElement>((lineText) => ({
const pLines = codeLines.map<ParagraphElement>((text) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [{ text }], children: [
{
text: lineText,
},
],
})); }));
const childCode = node.children[0]; const childCode = node.children[0];
const className = const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : ''; isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` }; const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
const suffix = { text: mdSequence }; const suffix = { text: node.attribs['data-md'] };
return [ return [
{ type: BlockType.Paragraph, children: [prefix] }, { type: BlockType.Paragraph, children: [prefix] },
...pLines, ...pLines,
@@ -242,16 +221,19 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
return [ return [
{ {
type: BlockType.CodeBlock, type: BlockType.CodeBlock,
children: codeLines.map<CodeLineElement>((text) => ({ children: codeLines.map<CodeLineElement>((lineTxt) => ({
type: BlockType.CodeLine, type: BlockType.CodeLine,
children: [{ text }], children: [
{
text: lineTxt,
},
],
})), })),
}, },
]; ];
}; };
const parseListNode = ( const parseListNode = (
node: Element, node: Element
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => { ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = []; const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -265,7 +247,7 @@ const parseListNode = (
node.children.forEach((child) => { node.children.forEach((child) => {
if (isText(child)) { if (isText(child)) {
lineHolder.push({ text: processText(child.data) }); lineHolder.push({ text: child.data });
return; return;
} }
if (isTag(child)) { if (isTag(child)) {
@@ -277,18 +259,17 @@ const parseListNode = (
if (child.name === 'li') { if (child.name === 'li') {
appendLine(); appendLine();
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText))); listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
return; return;
} }
lineHolder.push(...getInlineElement(child, processText)); parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
} }
}); });
appendLine(); appendLine();
const mdSequence = node.attribs['data-md']; if (node.attribs['data-md'] !== undefined) {
if (mdSequence !== undefined) { const prefix = node.attribs['data-md'] || '-';
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? []; const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({ return listLines.map((lineChildren) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
@@ -321,21 +302,17 @@ const parseListNode = (
}, },
]; ];
}; };
const parseHeadingNode = ( const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
node: Element, const children = node.children.flatMap((child) => parseInlineNodes(child));
processText: ProcessTextCallback
): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => getInlineElement(child, processText));
const headingMatch = node.name.match(/^h([123456])$/); const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3']; const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
const level = parseInt(g1AsLevel, 10); const level = parseInt(g1AsLevel, 10);
const mdSequence = node.attribs['data-md']; if (node.attribs['data-md'] !== undefined) {
if (mdSequence !== undefined) {
return { return {
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [{ text: `${mdSequence} ` }, ...children], children: [{ text: `${node.attribs['data-md']} ` }, ...children],
}; };
} }
@@ -346,11 +323,7 @@ const parseHeadingNode = (
}; };
}; };
export const domToEditorInput = ( export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
domNodes: ChildNode[],
processText: ProcessTextCallback,
processLineStartText: ProcessTextCallback
): Descendant[] => {
const children: Descendant[] = []; const children: Descendant[] = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -367,14 +340,7 @@ export const domToEditorInput = (
domNodes.forEach((node) => { domNodes.forEach((node) => {
if (isText(node)) { if (isText(node)) {
if (lineHolder.length === 0) { lineHolder.push({ text: node.data });
// we are inserting first part of line
// it may contain block markdown starting data
// that we may need to escape.
lineHolder.push({ text: processLineStartText(node.data) });
return;
}
lineHolder.push({ text: processText(node.data) });
return; return;
} }
if (isTag(node)) { if (isTag(node)) {
@@ -388,14 +354,14 @@ export const domToEditorInput = (
appendLine(); appendLine();
children.push({ children.push({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: node.children.flatMap((child) => getInlineElement(child, processText)), children: node.children.flatMap((child) => parseInlineNodes(child)),
}); });
return; return;
} }
if (node.name === 'blockquote') { if (node.name === 'blockquote') {
appendLine(); appendLine();
children.push(...parseBlockquoteNode(node, processText)); children.push(...parseBlockquoteNode(node));
return; return;
} }
if (node.name === 'pre') { if (node.name === 'pre') {
@@ -405,17 +371,17 @@ export const domToEditorInput = (
} }
if (node.name === 'ol' || node.name === 'ul') { if (node.name === 'ol' || node.name === 'ul') {
appendLine(); appendLine();
children.push(...parseListNode(node, processText)); children.push(...parseListNode(node));
return; return;
} }
if (node.name.match(/^h[123456]$/)) { if (node.name.match(/^h[123456]$/)) {
appendLine(); appendLine();
children.push(parseHeadingNode(node, processText)); children.push(parseHeadingNode(node));
return; return;
} }
lineHolder.push(...getInlineElement(node, processText)); parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
} }
}); });
appendLine(); appendLine();
@@ -423,31 +389,21 @@ export const domToEditorInput = (
return children; return children;
}; };
export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => { export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml); const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
const processText = (partText: string) => {
if (!markdown) return partText;
return escapeMarkdownInlineSequences(partText);
};
const domNodes = parse(sanitizedHtml); const domNodes = parse(sanitizedHtml);
const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => { const editorNodes = domToEditorInput(domNodes);
if (!markdown) return lineStartText;
return escapeMarkdownBlockSequences(lineStartText, processText);
});
return editorNodes; return editorNodes;
}; };
export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => { export const plainToEditorInput = (text: string): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => { const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = { const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [ children: [
{ {
text: markdown text: lineText,
? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
: lineText,
}, },
], ],
}; };

View File

@@ -1,17 +1,10 @@
import { Descendant, Editor, Text } from 'slate'; import { Descendant, Text } from 'slate';
import { MatrixClient } from 'matrix-js-sdk';
import { sanitizeText } from '../../utils/sanitize'; import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types'; import { BlockType } from './types';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import { import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
parseBlockMD,
parseInlineMD,
unescapeMarkdownBlockSequences,
unescapeMarkdownInlineSequences,
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace'; import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
@@ -25,7 +18,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.bold) string = `<strong>${string}</strong>`; if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`; if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`; if (node.underline) string = `<u>${string}</u>`;
if (node.strikeThrough) string = `<s>${string}</s>`; if (node.strikeThrough) string = `<del>${string}</del>`;
if (node.code) string = `<code>${string}</code>`; if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`; if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
} }
@@ -108,8 +101,7 @@ export const toMatrixCustomHTML = (
allowBlockMarkdown: false, allowBlockMarkdown: false,
}) })
.replace(/<br\/>$/, '\n') .replace(/<br\/>$/, '\n')
.replace(/^(\\*)&gt;/, '$1>'); .replace(/^&gt;/, '>');
markdownLines += line; markdownLines += line;
if (index === targetNodes.length - 1) { if (index === targetNodes.length - 1) {
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -164,14 +156,11 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
} }
}; };
export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => { export const toPlainText = (node: Descendant | Descendant[]): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join(''); if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
if (Text.isText(node)) if (Text.isText(node)) return node.text;
return isMarkdown
? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
: node.text;
const children = node.children.map((n) => toPlainText(n, isMarkdown)).join(''); const children = node.children.map((n) => toPlainText(n)).join('');
return elementToPlainText(node, children); return elementToPlainText(node, children);
}; };
@@ -190,42 +179,9 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim(); export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
export const trimCommand = (cmdName: string, str: string) => { export const trimCommand = (cmdName: string, str: string) => {
const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`); const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`);
const match = str.match(cmdRegX); const match = str.match(cmdRegX);
if (!match) return str; if (!match) return str;
return str.slice(match[0].length); return str.slice(match[0].length);
}; };
export type MentionsData = {
room: boolean;
users: Set<string>;
};
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
const mentionData: MentionsData = {
room: false,
users: new Set(),
};
const parseMentions = (node: Descendant): void => {
if (Text.isText(node)) return;
if (node.type === BlockType.CodeBlock) return;
if (node.type === BlockType.Mention) {
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}
return;
}
node.children.forEach(parseMentions);
};
editor.children.forEach(parseMentions);
return mentionData;
};

View File

@@ -41,6 +41,7 @@ import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@@ -49,8 +50,6 @@ import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji'; import { addRecentEmoji } from '../../plugins/recent-emoji';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
@@ -360,16 +359,16 @@ function ImagePackSidebarStack({
}: { }: {
mx: MatrixClient; mx: MatrixClient;
packs: ImagePack[]; packs: ImagePack[];
usage: ImageUsage; usage: PackUsage;
onItemClick: (id: string) => void; onItemClick: (id: string) => void;
useAuthentication?: boolean; useAuthentication?: boolean;
}) { }) {
const activeGroupId = useAtomValue(activeGroupIdAtom); const activeGroupId = useAtomValue(activeGroupIdAtom);
return ( return (
<SidebarStack> <SidebarStack>
{usage === ImageUsage.Emoticon && <SidebarDivider />} {usage === PackUsage.Emoticon && <SidebarDivider />}
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.meta.name; let label = pack.displayName;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
return ( return (
<SidebarBtn <SidebarBtn
@@ -385,10 +384,7 @@ function ImagePackSidebarStack({
height: toRem(24), height: toRem(24),
objectFit: 'contain', objectFit: 'contain',
}} }}
src={ src={mxcUrlToHttp(mx, pack.getPackAvatarUrl(usage) ?? '', useAuthentication) || pack.avatarUrl}
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
pack.meta.avatar
}
alt={label || 'Unknown Pack'} alt={label || 'Unknown Pack'}
/> />
</SidebarBtn> </SidebarBtn>
@@ -466,154 +462,130 @@ export function SearchEmojiGroup({
tab: EmojiBoardTab; tab: EmojiBoardTab;
label: string; label: string;
id: string; id: string;
emojis: Array<PackImageReader | IEmoji>; emojis: Array<ExtendedPackImage | IEmoji>;
useAuthentication?: boolean; useAuthentication?: boolean;
}) { }) {
return ( return (
<EmojiGroup key={id} id={id} label={label}> <EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji {tab === EmojiBoardTab.Emoji
? searchResult.map((emoji) => ? searchResult.map((emoji) =>
'unicode' in emoji ? ( 'unicode' in emoji ? (
<EmojiItem <EmojiItem
key={emoji.unicode} key={emoji.unicode}
label={emoji.label} label={emoji.label}
type={EmojiType.Emoji} type={EmojiType.Emoji}
data={emoji.unicode} data={emoji.unicode}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
{emoji.unicode} {emoji.unicode}
</EmojiItem> </EmojiItem>
) : ( ) : (
<EmojiItem <EmojiItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji} type={EmojiType.CustomEmoji}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/> />
</EmojiItem> </EmojiItem>
)
) )
)
: searchResult.map((emoji) => : searchResult.map((emoji) =>
'unicode' in emoji ? null : ( 'unicode' in emoji ? null : (
<StickerItem <StickerItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker} type={EmojiType.Sticker}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/> />
</StickerItem> </StickerItem>
) )
)} )}
</EmojiGroup> </EmojiGroup>
); );
} }
export const CustomEmojiGroups = memo( export const CustomEmojiGroups = memo(
({ ({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
mx,
groups,
useAuthentication,
}: {
mx: MatrixClient;
groups: ImagePack[];
useAuthentication?: boolean;
}) => (
<> <>
{groups.map((pack) => ( {groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}> <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack {pack.getEmojis().map((image) => (
.getImages(ImageUsage.Emoticon) <EmojiItem
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)) key={image.shortcode}
.map((image) => ( label={image.body || image.shortcode}
<EmojiItem type={EmojiType.CustomEmoji}
key={image.shortcode} data={image.url}
label={image.body || image.shortcode} shortcode={image.shortcode}
type={EmojiType.CustomEmoji} >
data={image.url} <img
shortcode={image.shortcode} loading="lazy"
> className={css.CustomEmojiImg}
<img alt={image.body || image.shortcode}
loading="lazy" src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
className={css.CustomEmojiImg} />
alt={image.body || image.shortcode} </EmojiItem>
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} ))}
/>
</EmojiItem>
))}
</EmojiGroup> </EmojiGroup>
))} ))}
</> </>
) )
); );
export const StickerGroups = memo( export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
({ <>
mx, {groups.length === 0 && (
groups, <Box
useAuthentication, style={{ padding: `${toRem(60)} ${config.space.S500}` }}
}: { alignItems="Center"
mx: MatrixClient; justifyContent="Center"
groups: ImagePack[]; direction="Column"
useAuthentication?: boolean; gap="300"
}) => ( >
<> <Icon size="600" src={Icons.Sticker} />
{groups.length === 0 && ( <Box direction="Inherit">
<Box <Text align="Center">No Sticker Packs!</Text>
style={{ padding: `${toRem(60)} ${config.space.S500}` }} <Text priority="300" align="Center" size="T200">
alignItems="Center" Add stickers from user, room or space settings.
justifyContent="Center" </Text>
direction="Column"
gap="300"
>
<Icon size="600" src={Icons.Sticker} />
<Box direction="Inherit">
<Text align="Center">No Sticker Packs!</Text>
<Text priority="300" align="Center" size="T200">
Add stickers from user, room or space settings.
</Text>
</Box>
</Box> </Box>
)} </Box>
{groups.map((pack) => ( )}
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}> {groups.map((pack) => (
{pack <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
.getImages(ImageUsage.Sticker) {pack.getStickers().map((image) => (
.sort((a, b) => a.shortcode.localeCompare(b.shortcode)) <StickerItem
.map((image) => ( key={image.shortcode}
<StickerItem label={image.body || image.shortcode}
key={image.shortcode} type={EmojiType.Sticker}
label={image.body || image.shortcode} data={image.url}
type={EmojiType.Sticker} shortcode={image.shortcode}
data={image.url} >
shortcode={image.shortcode} <img
> loading="lazy"
<img className={css.StickerImg}
loading="lazy" alt={image.body || image.shortcode}
className={css.StickerImg} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
alt={image.body || image.shortcode} />
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} </StickerItem>
/> ))}
</StickerItem> </EmojiGroup>
))} ))}
</EmojiGroup> </>
))} ));
</>
)
);
export const NativeEmojiGroups = memo( export const NativeEmojiGroups = memo(
({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => ( ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
@@ -637,8 +609,15 @@ export const NativeEmojiGroups = memo(
) )
); );
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => {
const shortcode = `:${item.shortcode}:`;
if ('body' in item) {
return [shortcode, item.body ?? ''];
}
return shortcode;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 26,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -667,14 +646,14 @@ export function EmojiBoard({
}) { }) {
const emojiTab = tab === EmojiBoardTab.Emoji; const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker; const stickerTab = tab === EmojiBoardTab.Sticker;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
const setActiveGroupId = useSetAtom(activeGroupIdAtom); const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const emojiGroupLabels = useEmojiGroupLabels(); const emojiGroupLabels = useEmojiGroupLabels();
const emojiGroupIcons = useEmojiGroupIcons(); const emojiGroupIcons = useEmojiGroupIcons();
const imagePacks = useRelevantImagePacks(usage, imagePackRooms); const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
const recentEmojis = useRecentEmoji(mx, 21); const recentEmojis = useRecentEmoji(mx, 21);
const contentScrollRef = useRef<HTMLDivElement>(null); const contentScrollRef = useRef<HTMLDivElement>(null);
@@ -682,20 +661,18 @@ export function EmojiBoard({
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null); const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
const searchList = useMemo(() => { const searchList = useMemo(() => {
let list: Array<PackImageReader | IEmoji> = []; let list: Array<ExtendedPackImage | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage))); list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
if (emojiTab) list = list.concat(emojis); if (emojiTab) list = list.concat(emojis);
return list; return list;
}, [emojiTab, usage, imagePacks]); }, [emojiTab, usage, imagePacks]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
getEmoticonSearchStr, getSearchListItemStr,
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const searchedItems = result?.items.slice(0, 100);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce( const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback( useCallback(
(evt) => { (evt) => {
@@ -711,7 +688,7 @@ export function EmojiBoard({
const syncActiveGroupId = useCallback(() => { const syncActiveGroupId = useCallback(() => {
const targetEl = contentScrollRef.current; const targetEl = contentScrollRef.current;
if (!targetEl) return; if (!targetEl) return;
const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[]; const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el)); const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined; const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
setActiveGroupId(groupId); setActiveGroupId(groupId);
@@ -758,10 +735,7 @@ export function EmojiBoard({
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = css.CustomEmojiImg; img.className = css.CustomEmojiImg;
img.setAttribute( img.setAttribute('src', mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data);
'src',
mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data
);
img.setAttribute('alt', emojiInfo.shortcode); img.setAttribute('alt', emojiInfo.shortcode);
emojiPreviewRef.current.textContent = ''; emojiPreviewRef.current.textContent = '';
emojiPreviewRef.current.appendChild(img); emojiPreviewRef.current.appendChild(img);
@@ -916,29 +890,21 @@ export function EmojiBoard({
direction="Column" direction="Column"
gap="200" gap="200"
> >
{searchedItems && ( {result && (
<SearchEmojiGroup <SearchEmojiGroup
mx={mx} mx={mx}
tab={tab} tab={tab}
id={SEARCH_GROUP_ID} id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'} label={result.items.length ? 'Search Results' : 'No Results found'}
emojis={searchedItems} emojis={result.items}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}
/> />
)} )}
{emojiTab && recentEmojis.length > 0 && ( {emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} /> <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)} )}
{emojiTab && ( {emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
<CustomEmojiGroups {stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{stickerTab && (
<StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
)}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />} {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box> </Box>
</Scroll> </Scroll>

View File

@@ -1,35 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const ImageEditor = style([
DefaultReset,
{
height: '100%',
},
]);
export const ImageEditorHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const ImageEditorContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const Image = style({
width: '100%',
height: '100%',
objectFit: 'contain',
});

View File

@@ -1,51 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css';
export type ImageEditorProps = {
name: string;
url: string;
requestClose: () => void;
};
export const ImageEditor = as<'div', ImageEditorProps>(
({ className, name, url, requestClose, ...props }, ref) => {
const handleApply = () => {
//
};
return (
<Box
className={classNames(css.ImageEditor, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageEditorHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
Image Editor
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip variant="Primary" radii="300" onClick={handleApply}>
<Text size="B300">Save</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageEditorContent}
justifyContent="Center"
alignItems="Center"
>
<img className={css.Image} src={url} alt={name} />
</Box>
</Box>
);
}
);

View File

@@ -1 +0,0 @@
export * from './ImageEditor';

View File

@@ -1,388 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { as, Box, Text, config, Button, Menu, Spinner } from 'folds';
import {
ImagePack,
ImageUsage,
PackContent,
PackImage,
PackImageReader,
packMetaEqual,
PackMetaReader,
} from '../../plugins/custom-emoji';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { SequenceCard } from '../sequence-card';
import { ImageTile, ImageTileEdit, ImageTileUpload } from './ImageTile';
import { SettingTile } from '../setting-tile';
import { UsageSwitcher } from './UsageSwitcher';
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
export type ImagePackContentProps = {
imagePack: ImagePack;
canEdit?: boolean;
onUpdate?: (packContent: PackContent) => Promise<void>;
};
export const ImagePackContent = as<'div', ImagePackContentProps>(
({ imagePack, canEdit, onUpdate, ...props }, ref) => {
const useAuthentication = useMediaAuthentication();
const [metaEditing, setMetaEditing] = useState(false);
const [savedMeta, setSavedMeta] = useState<PackMetaReader>();
const currentMeta = savedMeta ?? imagePack.meta;
const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]);
const [files, setFiles] = useState<File[]>([]);
const [uploadedImages, setUploadedImages] = useState<PackImageReader[]>([]);
const [imagesEditing, setImagesEditing] = useState<Set<string>>(new Set());
const [savedImages, setSavedImages] = useState<Map<string, PackImageReader>>(new Map());
const [deleteImages, setDeleteImages] = useState<Set<string>>(new Set());
const hasImageWithShortcode = useCallback(
(shortcode: string): boolean => {
const hasInPack = imagePack.images.collection.has(shortcode);
if (hasInPack) return true;
const hasInUploaded =
uploadedImages.find((img) => img.shortcode === shortcode) !== undefined;
if (hasInUploaded) return true;
const hasInSaved =
Array.from(savedImages).find(([, img]) => img.shortcode === shortcode) !== undefined;
return hasInSaved;
},
[imagePack, savedImages, uploadedImages]
);
const pickFiles = useFilePicker(
useCallback(
(pickedFiles: File[]) => {
const uniqueFiles = pickedFiles.map((file) => {
const fileName = replaceSpaceWithDash(file.name);
if (hasImageWithShortcode(fileName)) {
const uniqueName = suffixRename(fileName, hasImageWithShortcode);
return renameFile(file, uniqueName);
}
return fileName !== file.name ? renameFile(file, fileName) : file;
});
setFiles((f) => [...f, ...uniqueFiles]);
},
[hasImageWithShortcode]
),
true
);
const handleMetaSave = useCallback(
(editedMeta: PackMetaReader) => {
setMetaEditing(false);
setSavedMeta(
(m) =>
new PackMetaReader({
...imagePack.meta.content,
...m?.content,
...editedMeta.content,
})
);
},
[imagePack.meta]
);
const handleMetaCancel = () => setMetaEditing(false);
const handlePackUsageChange = useCallback(
(usg: ImageUsage[]) => {
setSavedMeta(
(m) =>
new PackMetaReader({
...imagePack.meta.content,
...m?.content,
usage: usg,
})
);
},
[imagePack.meta]
);
const handleUploadRemove = useCallback((file: TUploadContent) => {
setFiles((fs) => fs.filter((f) => f !== file));
}, []);
const handleUploadComplete = useCallback(
async (data: UploadSuccess) => {
const imgEl = await loadImageElement(getImageFileUrl(data.file));
const packImage: PackImage = {
url: data.mxc,
info: getImageInfo(imgEl, data.file),
};
const image = PackImageReader.fromPackImage(
getFileNameWithoutExt(data.file.name),
packImage
);
if (!image) return;
handleUploadRemove(data.file);
setUploadedImages((imgs) => [image, ...imgs]);
},
[handleUploadRemove]
);
const handleImageEdit = (shortcode: string) => {
setImagesEditing((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
shortcodeSet.add(shortcode);
return shortcodeSet;
});
};
const handleDeleteToggle = (shortcode: string) => {
setDeleteImages((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
if (shortcodeSet.has(shortcode)) shortcodeSet.delete(shortcode);
else shortcodeSet.add(shortcode);
return shortcodeSet;
});
};
const handleImageEditCancel = (shortcode: string) => {
setImagesEditing((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
shortcodeSet.delete(shortcode);
return shortcodeSet;
});
};
const handleImageEditSave = (shortcode: string, image: PackImageReader) => {
handleImageEditCancel(shortcode);
const saveImage =
shortcode !== image.shortcode && hasImageWithShortcode(image.shortcode)
? new PackImageReader(
suffixRename(image.shortcode, hasImageWithShortcode),
image.url,
image.content
)
: image;
setSavedImages((sImgs) => {
const imgs = new Map(sImgs);
imgs.set(shortcode, saveImage);
return imgs;
});
};
const handleResetSavedChanges = () => {
setSavedMeta(undefined);
setFiles([]);
setUploadedImages([]);
setSavedImages(new Map());
setDeleteImages(new Set());
};
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const pack: PackContent = {
pack: savedMeta?.content ?? imagePack.meta.content,
images: {},
};
const pushImage = (img: PackImageReader) => {
if (deleteImages.has(img.shortcode)) return;
if (!pack.images) return;
const imgToPush = savedImages.get(img.shortcode) ?? img;
pack.images[imgToPush.shortcode] = imgToPush.content;
};
uploadedImages.forEach((img) => pushImage(img));
images.forEach((img) => pushImage(img));
return onUpdate?.(pack);
}, [imagePack, images, savedMeta, uploadedImages, savedImages, deleteImages, onUpdate])
);
useEffect(() => {
if (applyState.status === AsyncStatus.Success) {
handleResetSavedChanges();
}
}, [applyState]);
const savedChanges =
(savedMeta && !packMetaEqual(imagePack.meta, savedMeta)) ||
uploadedImages.length > 0 ||
savedImages.size > 0 ||
deleteImages.size > 0;
const canApplyChanges = !metaEditing && imagesEditing.size === 0 && files.length === 0;
const applying = applyState.status === AsyncStatus.Loading;
const renderImage = (image: PackImageReader) => (
<SequenceCard
key={image.shortcode}
style={{ padding: config.space.S300 }}
variant={deleteImages.has(image.shortcode) ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
{imagesEditing.has(image.shortcode) ? (
<ImageTileEdit
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
onCancel={handleImageEditCancel}
onSave={handleImageEditSave}
/>
) : (
<ImageTile
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
canEdit={canEdit}
onEdit={handleImageEdit}
deleted={deleteImages.has(image.shortcode)}
onDeleteToggle={handleDeleteToggle}
/>
)}
</SequenceCard>
);
return (
<Box grow="Yes" direction="Column" gap="700" {...props} ref={ref}>
{savedChanges && (
<Menu className={css.UnsavedMenu} variant="Success">
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={!canApplyChanges || applying}
onClick={handleResetSavedChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={!canApplyChanges || applying}
before={applying && <Spinner variant="Success" fill="Solid" size="100" />}
onClick={applyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
<Box direction="Column" gap="100">
<Text size="L400">Pack</Text>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{metaEditing ? (
<ImagePackProfileEdit
meta={currentMeta}
onCancel={handleMetaCancel}
onSave={handleMetaSave}
/>
) : (
<ImagePackProfile
meta={currentMeta}
canEdit={canEdit}
onEdit={() => setMetaEditing(true)}
/>
)}
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Images Usage"
description="Select how the images are being used: as emojis, as stickers, or as both."
after={
<UsageSwitcher
usage={currentMeta.usage}
canEdit={canEdit}
onChange={handlePackUsageChange}
/>
}
/>
</SequenceCard>
</Box>
{images.length === 0 && !canEdit ? null : (
<Box direction="Column" gap="100">
<Text size="L400">Images</Text>
{canEdit && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Upload Images"
description="Select images from your storage to upload them in pack."
after={
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
type="button"
outlined
onClick={() => pickFiles('image/*')}
>
<Text size="B300">Select</Text>
</Button>
}
/>
</SequenceCard>
)}
{files.map((file) => (
<SequenceCard
key={file.name}
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ImageTileUpload file={file}>
{(uploadAtom) => (
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleUploadRemove}
onComplete={handleUploadComplete}
/>
)}
</ImageTileUpload>
</SequenceCard>
))}
{uploadedImages.map(renderImage)}
{images.map(renderImage)}
</Box>
)}
</Box>
);
}
);

View File

@@ -1,51 +0,0 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
import { UserImagePack } from './UserImagePack';
type ImagePackViewProps = {
address: PackAddress | undefined;
requestClose: () => void;
};
export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
const mx = useMatrixClient();
const room = address && mx.getRoom(address.roomId);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Emojis & Stickers</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
{room && address ? (
<RoomImagePack room={room} stateKey={address.stateKey} />
) : (
<UserImagePack />
)}
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -1,214 +0,0 @@
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './style.css';
import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SettingTile } from '../setting-tile';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, TUploadAtom } from '../../state/upload';
import { replaceSpaceWithDash } from '../../utils/common';
type ImageTileProps = {
defaultShortcode: string;
useAuthentication: boolean;
packUsage: ImageUsage[];
image: PackImageReader;
canEdit?: boolean;
onEdit?: (defaultShortcode: string, image: PackImageReader) => void;
deleted?: boolean;
onDeleteToggle?: (defaultShortcode: string) => void;
};
export function ImageTile({
defaultShortcode,
image,
packUsage,
useAuthentication,
canEdit,
onEdit,
onDeleteToggle,
deleted,
}: ImageTileProps) {
const mx = useMatrixClient();
const getUsageStr = useUsageStr();
return (
<SettingTile
before={
<img
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>
}
title={
deleted ? (
<span className={css.DeleteImageShortcode}>{image.shortcode}</span>
) : (
image.shortcode
)
}
description={
<Box as="span" gap="200">
{image.usage && getUsageStr(image.usage) !== getUsageStr(packUsage) && (
<Badge as="span" variant="Secondary" size="400" radii="300" outlined>
<Text as="span" size="L400">
{getUsageStr(image.usage)}
</Text>
</Badge>
)}
{image.body}
</Box>
}
after={
canEdit ? (
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant={deleted ? 'Critical' : 'Secondary'}
fill="None"
radii="Pill"
onClick={() => onDeleteToggle?.(defaultShortcode)}
>
{deleted ? <Text size="B300">Undo</Text> : <Icon size="50" src={Icons.Delete} />}
</Chip>
{!deleted && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => onEdit?.(defaultShortcode, image)}
>
<Text size="B300">Edit</Text>
</Chip>
)}
</Box>
) : undefined
}
/>
);
}
type ImageTileUploadProps = {
file: File;
children: (uploadAtom: TUploadAtom) => ReactNode;
};
export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
const url = useObjectURL(file);
const uploadAtom = useMemo(() => createUploadAtom(file), [file]);
return (
<SettingTile before={<img className={css.ImagePackImage} src={url} alt={file.name} />}>
{children(uploadAtom)}
</SettingTile>
);
}
type ImageTileEditProps = {
defaultShortcode: string;
useAuthentication: boolean;
packUsage: ImageUsage[];
image: PackImageReader;
onCancel: (shortcode: string) => void;
onSave: (shortcode: string, image: PackImageReader) => void;
};
export function ImageTileEdit({
defaultShortcode,
useAuthentication,
packUsage,
image,
onCancel,
onSave,
}: ImageTileEditProps) {
const mx = useMatrixClient();
const defaultUsage = image.usage ?? packUsage;
const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const shortcodeInput = target?.shortcodeInput as HTMLInputElement | undefined;
const bodyInput = target?.bodyInput as HTMLTextAreaElement | undefined;
if (!shortcodeInput || !bodyInput) return;
const shortcode = replaceSpaceWithDash(shortcodeInput.value.trim());
const body = bodyInput.value.trim() || undefined;
const usage = unsavedUsage;
if (!shortcode) return;
if (
shortcode === image.shortcode &&
body === image.body &&
imageUsageEqual(usage, defaultUsage)
) {
onCancel(defaultShortcode);
return;
}
const imageReader = new PackImageReader(shortcode, image.url, {
info: image.info,
body,
usage: imageUsageEqual(usage, packUsage) ? undefined : usage,
});
onSave(defaultShortcode, imageReader);
};
return (
<SettingTile
before={
<img
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>
}
>
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Box direction="Column" className={css.ImagePackImageInputs}>
<Input
before={<Text size="L400">Shortcode:</Text>}
defaultValue={image.shortcode}
name="shortcodeInput"
variant="Secondary"
size="300"
radii="0"
required
autoFocus
/>
<Input
before={<Text size="L400">Body:</Text>}
defaultValue={image.body}
name="bodyInput"
variant="Secondary"
size="300"
radii="0"
/>
</Box>
<Box gap="200">
<Box shrink="No" direction="Column">
<UsageSwitcher usage={unsavedUsage} onChange={setUnsavedUsages} canEdit />
</Box>
<Box grow="Yes" />
<Button type="submit" variant="Success" size="300" radii="300">
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onCancel(defaultShortcode)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}

View File

@@ -1,232 +0,0 @@
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
Avatar,
AvatarImage,
AvatarFallback,
Button,
Icon,
Icons,
Input,
TextArea,
Chip,
} from 'folds';
import Linkify from 'linkify-react';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PackMetaReader } from '../../plugins/custom-emoji';
type ImagePackAvatarProps = {
url?: string;
name?: string;
};
function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
return (
<Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
{url ? (
<AvatarImage src={url} alt={name ?? 'Unknown'} />
) : (
<AvatarFallback>
<Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>
</AvatarFallback>
)}
</Avatar>
);
}
type ImagePackProfileProps = {
meta: PackMetaReader;
canEdit?: boolean;
onEdit?: () => void;
};
export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = meta.avatar
? mxcUrlToHttp(mx, meta.avatar, useAuthentication) ?? undefined
: undefined;
return (
<Box gap="400">
<Box grow="Yes" direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text className={BreakWord} size="H5">
{meta.name ?? 'Unknown'}
</Text>
{meta.attribution && (
<Text className={BreakWord} size="T200">
<Linkify options={LINKIFY_OPTS}>{meta.attribution}</Linkify>
</Text>
)}
</Box>
{canEdit && (
<Box gap="200">
<Chip
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="50" src={Icons.Pencil} />}
onClick={onEdit}
outlined
>
<Text size="B300">Edit</Text>
</Chip>
</Box>
)}
</Box>
<Box shrink="No">
<ImagePackAvatar url={avatarUrl} name={meta.name} />
</Box>
</Box>
);
}
type ImagePackProfileEditProps = {
meta: PackMetaReader;
onCancel: () => void;
onSave: (meta: PackMetaReader) => void;
};
export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [avatar, setAvatar] = useState(meta.avatar);
const avatarUrl = avatar ? mxcUrlToHttp(mx, avatar, useAuthentication) ?? undefined : undefined;
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);
const uploadingAvatar = avatarFileUrl ? avatar === meta.avatar : false;
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
setAvatar(meta.avatar);
}, [meta.avatar]);
const handleUploaded = useCallback((upload: UploadSuccess) => {
setAvatar(upload.mxc);
}, []);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (uploadingAvatar) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
const attributionTextArea = target?.attributionTextArea as HTMLTextAreaElement | undefined;
if (!nameInput || !attributionTextArea) return;
const name = nameInput.value.trim();
const attribution = attributionTextArea.value.trim();
if (!name) return;
const metaReader = new PackMetaReader({
avatar_url: avatar,
display_name: name,
attribution,
});
onSave(metaReader);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Box gap="400">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Pack Avatar</Text>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => pickFile('image/*')}
>
<Text size="B300">Upload</Text>
</Button>
{!avatar && meta.avatar && (
<Button
type="button"
size="300"
variant="Success"
fill="None"
radii="300"
onClick={() => setAvatar(meta.avatar)}
>
<Text size="B300">Reset</Text>
</Button>
)}
{avatar && (
<Button
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
onClick={() => setAvatar(undefined)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
</Box>
<Box shrink="No">
<ImagePackAvatar url={avatarFileUrl ?? avatarUrl} name={meta.name} />
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text>
<TextArea
name="attributionTextArea"
defaultValue={meta.attribution}
variant="Secondary"
radii="300"
/>
</Box>
<Box gap="300">
<Button type="submit" variant="Success" size="300" radii="300" disabled={uploadingAvatar}>
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
onClick={onCancel}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
);
}

View File

@@ -1,55 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEvent } from '../../../types/matrix/room';
import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common';
type RoomImagePackProps = {
room: Room;
stateKey: string;
};
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId));
const fallbackPack = useMemo(() => {
const fakePackId = randomStr(4);
return new ImagePack(
fakePackId,
{},
{
roomId: room.roomId,
stateKey,
}
);
}, [room.roomId, stateKey]);
const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack;
const handleUpdate = useCallback(
async (packContent: PackContent) => {
const { address } = imagePack;
if (!address) return;
await mx.sendStateEvent(
address.roomId,
StateEvent.PoniesRoomEmotes,
packContent,
address.stateKey
);
},
[mx, imagePack]
);
return (
<ImagePackContent imagePack={imagePack} canEdit={canEditImagePack} onUpdate={handleUpdate} />
);
}

View File

@@ -1,116 +0,0 @@
import React, { MouseEventHandler, useMemo, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { ImageUsage } from '../../plugins/custom-emoji';
import { stopPropagation } from '../../utils/keyboard';
export const useUsageStr = (): ((usage: ImageUsage[]) => string) => {
const getUsageStr = (usage: ImageUsage[]): string => {
const sticker = usage.includes(ImageUsage.Sticker);
const emoticon = usage.includes(ImageUsage.Emoticon);
if (sticker && emoticon) return 'Both';
if (sticker) return 'Sticker';
if (emoticon) return 'Emoji';
return 'Both';
};
return getUsageStr;
};
type UsageSelectorProps = {
selected: ImageUsage[];
onChange: (usage: ImageUsage[]) => void;
};
export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
const getUsageStr = useUsageStr();
const selectedUsageStr = getUsageStr(selected);
const isSelected = (usage: ImageUsage[]) => getUsageStr(usage) === selectedUsageStr;
const allUsages: ImageUsage[][] = useMemo(
() => [[ImageUsage.Emoticon], [ImageUsage.Sticker], [ImageUsage.Sticker, ImageUsage.Emoticon]],
[]
);
return (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{allUsages.map((usage) => (
<MenuItem
key={getUsageStr(usage)}
size="300"
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
aria-selected={isSelected(usage)}
radii="300"
onClick={() => onChange(usage)}
>
<Box grow="Yes">
<Text size="T300">{getUsageStr(usage)}</Text>
</Box>
</MenuItem>
))}
</Box>
);
}
type UsageSwitcherProps = {
usage: ImageUsage[];
canEdit?: boolean;
onChange: (usage: ImageUsage[]) => void;
};
export function UsageSwitcher({ usage, onChange, canEdit }: UsageSwitcherProps) {
const getUsageStr = useUsageStr();
const [menuCords, setMenuCords] = useState<RectCords>();
const handleSelectUsage: MouseEventHandler<HTMLButtonElement> = (event) => {
setMenuCords(event.currentTarget.getBoundingClientRect());
};
return (
<>
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
type="button"
outlined
aria-disabled={!canEdit}
after={canEdit && <Icon src={Icons.ChevronBottom} size="100" />}
onClick={canEdit ? handleSelectUsage : undefined}
>
<Text size="B300">{getUsageStr(usage)}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<UsageSelector
selected={usage}
onChange={(usg) => {
setMenuCords(undefined);
onChange(usg);
}}
/>
</Menu>
</FocusTrap>
}
/>
</>
);
}

View File

@@ -1,22 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserImagePack } from '../../hooks/useImagePacks';
export function UserImagePack() {
const mx = useMatrixClient();
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
const imagePack = useUserImagePack();
const handleUpdate = useCallback(
async (packContent: PackContent) => {
await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent);
},
[mx]
);
return <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />;
}

View File

@@ -1 +0,0 @@
export * from './ImagePackView';

View File

@@ -1,37 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const ImagePackImage = style([
DefaultReset,
{
width: toRem(36),
height: toRem(36),
objectFit: 'contain',
},
]);
export const DeleteImageShortcode = style([
DefaultReset,
{
color: color.Critical.Main,
textDecoration: 'line-through',
},
]);
export const ImagePackImageInputs = style([
DefaultReset,
{
overflow: 'hidden',
borderRadius: config.radii.R300,
},
]);
export const UnsavedMenu = style({
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
top: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
});

View File

@@ -1,53 +0,0 @@
import { Box, ContainerColor, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { BreakWord } from '../../styles/Text.css';
import { ContainerColor as ContainerClr } from '../../styles/ContainerColor.css';
import * as css from './styles.css';
type InfoCardProps = {
variant?: ContainerColor;
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
after?: ReactNode;
children?: ReactNode;
};
export function InfoCard({
variant = 'Primary',
title,
description,
before,
after,
children,
}: InfoCardProps) {
return (
<Box
direction="Column"
className={classNames(css.InfoCard, ContainerClr({ variant }))}
gap="300"
>
<Box gap="200" alignItems="Center">
{before && (
<Box shrink="No" alignSelf="Start">
{before}
</Box>
)}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text size="L400" className={BreakWord}>
{title}
</Text>
)}
{description && (
<Text size="T200" className={BreakWord}>
{description}
</Text>
)}
</Box>
{after && <Box shrink="No">{after}</Box>}
</Box>
{children}
</Box>
);
}

View File

@@ -1 +0,0 @@
export * from './InfoCard';

View File

@@ -1,10 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const InfoCard = style([
{
padding: config.space.S200,
borderRadius: config.radii.R300,
borderWidth: config.borderWidth.B300,
},
]);

View File

@@ -22,8 +22,6 @@ import {
IThumbnailContent, IThumbnailContent,
IVideoContent, IVideoContent,
IVideoInfo, IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
@@ -174,13 +172,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
type RenderImageContentProps = { type RenderImageContentProps = {
body: string; body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent; info?: IImageInfo & IThumbnailContent;
mimeType?: string; mimeType?: string;
url: string; url: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
}; };
type MImageProps = { type MImageProps = {
content: IImageContent; content: IImageContent;
@@ -208,8 +203,6 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
mimeType: imgInfo?.mimetype, mimeType: imgInfo?.mimetype,
url: mxcUrl, url: mxcUrl,
encInfo: content.file, encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
@@ -289,7 +282,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader body={content.filename ?? content.body ?? 'Audio'} mimeType={safeMimeType} /> <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
@@ -329,14 +322,14 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={content.filename ?? content.body ?? 'Unnamed File'} body={content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE} mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
{renderFileContent({ {renderFileContent({
body: content.filename ?? content.body ?? 'File', body: content.body ?? 'File',
info: fileInfo ?? {}, info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl, url: mxcUrl,

View File

@@ -1,6 +1,8 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk'; import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
@@ -10,7 +12,6 @@ import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css'; import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
@@ -21,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
alignItems="Center" alignItems="Center"
alignSelf="Start"
gap="100" gap="100"
{...props} {...props}
ref={ref} ref={ref}
@@ -37,13 +39,14 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}> <Box className={css.ThreadIndicator} alignItems="Center" alignSelf="Start" {...props} ref={ref}>
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} /> <Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
<Text size="T200">Threaded reply</Text> <Text size="T200">Threaded reply</Text>
</Box> </Box>
)); ));
type ReplyProps = { type ReplyProps = {
mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet | undefined; timelineSet?: EventTimelineSet | undefined;
replyEventId: string; replyEventId: string;
@@ -51,60 +54,78 @@ type ReplyProps = {
onClick?: MouseEventHandler | undefined; onClick?: MouseEventHandler | undefined;
}; };
export const Reply = as<'div', ReplyProps>( export const Reply = as<'div', ReplyProps>((_, ref) => {
({ room, timelineSet, replyEventId, threadRootId, onClick, ...props }, ref) => { const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _;
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
const getFromLocalTimeline = useCallback( timelineSet?.findEventById(replyEventId)
() => timelineSet?.findEventById(replyEventId), );
[timelineSet, replyEventId] const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const fallbackBody = replyEvent?.isRedacted() ? ( const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent /> <MessageDeletedContent />
) : ( ) : (
<MessageFailedContent /> <MessageFailedContent />
); );
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; useEffect(() => {
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; let disposed = false;
const loadEvent = async () => {
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId));
const mEvent = new MatrixEvent(evt);
if (disposed) return;
if (err) {
setReplyEvent(null);
return;
}
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
setReplyEvent(mEvent);
};
if (replyEvent === undefined) loadEvent();
return () => {
disposed = true;
};
}, [replyEvent, mx, room, replyEventId]);
return ( const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
<Box direction="Column" alignItems="Start" {...props} ref={ref}> const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} /> return (
)} <Box direction="Column" {...props} ref={ref}>
<ReplyLayout {threadRootId && (
as="button" <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
userColor={sender ? colorMXID(sender) : undefined} )}
username={ <ReplyLayout
sender && ( as="button"
<Text size="T300" truncate> userColor={sender ? colorMXID(sender) : undefined}
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b> username={
</Text> sender && (
)
}
data-event-id={replyEventId}
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate> <Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX} <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</Text> </Text>
) : ( )
<LinePlaceholder }
style={{ data-event-id={replyEventId}
backgroundColor: color.SurfaceVariant.ContainerActive, onClick={onClick}
width: toRem(placeholderWidth), >
maxWidth: '100%', {replyEvent !== undefined ? (
}} <Text size="T300" truncate>
/> {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
)} </Text>
</ReplyLayout> ) : (
</Box> <LinePlaceholder
); style={{
} backgroundColor: color.SurfaceVariant.ContainerActive,
); maxWidth: toRem(placeholderWidth),
width: '100%',
}}
/>
)}
</ReplyLayout>
</Box>
);
});

View File

@@ -1,7 +1,6 @@
import { Box, Icon, IconSrc } from 'folds'; import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..'; import { CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = { export type EventContentProps = {
messageLayout: number; messageLayout: number;
@@ -12,9 +11,9 @@ export type EventContentProps = {
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) { export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
const beforeJSX = ( const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === MessageLayout.Compact && time} {messageLayout === 1 && time}
<Box <Box
grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'} grow={messageLayout === 1 ? undefined : 'Yes'}
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
> >
@@ -26,11 +25,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
const msgContentJSX = ( const msgContentJSX = (
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200"> <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
{content} {content}
{messageLayout !== MessageLayout.Compact && time} {messageLayout !== 1 && time}
</Box> </Box>
); );
return messageLayout === MessageLayout.Compact ? ( return messageLayout === 1 ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout> <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : ( ) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout> <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>

View File

@@ -27,6 +27,7 @@ import {
getFileNameExt, getFileNameExt,
mimeTypeToExt, mimeTypeToExt,
} from '../../../utils/mimeTypes'; } from '../../../utils/mimeTypes';
import * as css from './style.css';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { import {
decryptFile, decryptFile,
@@ -35,7 +36,6 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => ( const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider <TooltipProvider
@@ -111,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
}} }}
> >
<Modal <Modal
className={ModalWide} className={css.ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
@@ -199,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
}} }}
> >
<Modal <Modal
className={ModalWide} className={css.ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >

View File

@@ -3,7 +3,6 @@ import {
Badge, Badge,
Box, Box,
Button, Button,
Chip,
Icon, Icon,
Icons, Icons,
Modal, Modal,
@@ -29,7 +28,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
type RenderViewerProps = { type RenderViewerProps = {
src: string; src: string;
@@ -52,8 +50,6 @@ export type ImageContentProps = {
info?: IImageInfo; info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo; encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean; autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode; renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode;
}; };
@@ -67,8 +63,6 @@ export const ImageContent = as<'div', ImageContentProps>(
info, info,
encInfo, encInfo,
autoPlay, autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer, renderViewer,
renderImage, renderImage,
...props ...props
@@ -82,7 +76,6 @@ export const ImageContent = as<'div', ImageContentProps>(
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -128,7 +121,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}} }}
> >
<Modal <Modal
className={ModalWide} className={css.ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
@@ -151,7 +144,7 @@ export const ImageContent = as<'div', ImageContentProps>(
punch={1} punch={1}
/> />
)} )}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && ( {!autoPlay && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button <Button
variant="Secondary" variant="Secondary"
@@ -166,7 +159,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box className={css.AbsoluteContainer}>
{renderImage({ {renderImage({
alt: body, alt: body,
title: body, title: body,
@@ -178,42 +171,8 @@ export const ImageContent = as<'div', ImageContentProps>(
})} })}
</Box> </Box>
)} )}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
if (srcState.status === AsyncStatus.Idle) {
loadSrc();
}
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load && (
!markedAsSpoiler && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -31,9 +31,7 @@ export const AbsoluteFooter = style([
}, },
]); ]);
export const Blur = style([ export const ModalWide = style({
DefaultReset, minWidth: '85vw',
{ minHeight: '90vh',
filter: 'blur(44px)', });
},
]);

View File

@@ -1,27 +1,22 @@
import React, { useMemo } from 'react'; import React from 'react';
import { as, ContainerColor, toRem } from 'folds'; import { as, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common'; import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder'; import { LinePlaceholder } from './LinePlaceholder';
import { CompactLayout } from '../layout'; import { CompactLayout, MessageBase } from '../layout';
export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>( export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
({ variant, ...props }, ref) => { <MessageBase>
const nameSize = useMemo(() => randomNumberBetween(40, 100), []); <CompactLayout
const msgSize = useMemo(() => randomNumberBetween(120, 500), []); {...props}
ref={ref}
return ( before={
<CompactLayout <>
{...props} <LinePlaceholder style={{ maxWidth: toRem(50) }} />
ref={ref} <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
before={ </>
<> }
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} /> >
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} /> <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
</> </CompactLayout>
} </MessageBase>
> ));
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
</CompactLayout>
);
}
);

View File

@@ -1,39 +1,25 @@
import React, { CSSProperties, useMemo } from 'react'; import React, { CSSProperties } from 'react';
import { Avatar, Box, ContainerColor, as, color, toRem } from 'folds'; import { Avatar, Box, as, color, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common'; import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder'; import { LinePlaceholder } from './LinePlaceholder';
import { ModernLayout } from '../layout'; import { MessageBase, ModernLayout } from '../layout';
const contentMargin: CSSProperties = { marginTop: toRem(3) }; const contentMargin: CSSProperties = { marginTop: toRem(3) };
const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
export const DefaultPlaceholder = as<'div', { variant?: ContainerColor }>( export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
({ variant, ...props }, ref) => { <MessageBase>
const nameSize = useMemo(() => randomNumberBetween(40, 100), []); <ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
const msgSize = useMemo(() => randomNumberBetween(80, 200), []); <Box style={contentMargin} grow="Yes" direction="Column" gap="200">
const msg2Size = useMemo(() => randomNumberBetween(80, 200), []); <Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
return ( <LinePlaceholder style={{ maxWidth: toRem(50) }} />
<ModernLayout
{...props}
ref={ref}
before={
<Avatar
style={{ backgroundColor: color[variant ?? 'SurfaceVariant'].Container }}
size="300"
/>
}
>
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
</Box>
<Box grow="Yes" gap="200" wrap="Wrap">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msg2Size) }} />
</Box>
</Box> </Box>
</ModernLayout> <Box grow="Yes" gap="200" wrap="Wrap">
); <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
} <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
); </Box>
</Box>
</ModernLayout>
</MessageBase>
));

View File

@@ -1,35 +1,12 @@
import { ComplexStyleRule } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds';
import { ContainerColor, DefaultReset, color, config, toRem } from 'folds';
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ export const LinePlaceholder = style([
backgroundColor: color[variant].Container, DefaultReset,
}); {
width: '100%',
export const LinePlaceholder = recipe({ height: toRem(16),
base: [ borderRadius: config.radii.R300,
DefaultReset, backgroundColor: color.SurfaceVariant.Container,
{
width: '100%',
height: toRem(16),
borderRadius: config.radii.R300,
},
],
variants: {
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
}, },
defaultVariants: { ]);
variant: 'SurfaceVariant',
},
});
export type LinePlaceholderVariants = RecipeVariants<typeof LinePlaceholder>;

View File

@@ -3,13 +3,6 @@ import { Box, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './LinePlaceholder.css'; import * as css from './LinePlaceholder.css';
export const LinePlaceholder = as<'div', css.LinePlaceholderVariants>( export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
({ className, variant, ...props }, ref) => ( <Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
<Box ));
className={classNames(css.LinePlaceholder({ variant }), className)}
shrink="No"
{...props}
ref={ref}
/>
)
);

View File

@@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = { type ClientDrawerLayoutProps = {
children: ReactNode; children: ReactNode;
}; };
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) { export function PageNav({ children }: ClientDrawerLayoutProps) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
return ( return (
<Box <Box
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
className={css.PageNav({ size })} className={css.PageNav}
shrink={isMobile ? 'Yes' : 'No'} shrink={isMobile ? 'Yes' : 'No'}
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@@ -44,17 +44,15 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa
); );
} }
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>( export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
({ className, outlined, ...props }, ref) => ( <Header
<Header className={classNames(css.PageNavHeader, className)}
className={classNames(css.PageNavHeader({ outlined }), className)} variant="Background"
variant="Background" size="600"
size="600" {...props}
{...props} ref={ref}
ref={ref} />
/> ));
)
);
export function PageNavContent({ export function PageNavContent({
scrollRef, scrollRef,
@@ -90,11 +88,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
)); ));
export const PageHeader = as<'div', css.PageHeaderVariants>( export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => ( ({ className, balance, ...props }, ref) => (
<Header <Header
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)} className={classNames(css.PageHeader({ balance }), className)}
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -2,55 +2,30 @@ import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = recipe({ export const PageNav = style({
variants: { width: toRem(256),
size: { });
'400': {
width: toRem(256), export const PageNavHeader = style({
}, padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
'300': { flexShrink: 0,
width: toRem(222), borderBottomWidth: 1,
},
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
}, },
},
defaultVariants: {
size: '400',
}, },
}); });
export type PageNavVariants = RecipeVariants<typeof PageNav>;
export const PageNavHeader = recipe({
base: {
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
},
},
},
variants: {
outlined: {
true: {
borderBottomWidth: 1,
},
},
},
defaultVariants: {
outlined: true,
},
});
export type PageNavHeaderVariants = RecipeVariants<typeof PageNavHeader>;
export const PageNavContent = style({ export const PageNavContent = style({
minHeight: '100%', minHeight: '100%',
@@ -63,6 +38,7 @@ export const PageHeader = recipe({
base: { base: {
paddingLeft: config.space.S400, paddingLeft: config.space.S400,
paddingRight: config.space.S200, paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
}, },
variants: { variants: {
balance: { balance: {
@@ -70,14 +46,6 @@ export const PageHeader = recipe({
paddingLeft: config.space.S200, paddingLeft: config.space.S200,
}, },
}, },
outlined: {
true: {
borderBottomWidth: config.borderWidth.B300,
},
},
},
defaultVariants: {
outlined: true,
}, },
}); });
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>; export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;

View File

@@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
size: '400' | '500'; size: '400' | '500';
}; };
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>( export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ variant = 'Background', size, style, after, ...props }, ref) => { ({ variant, size, style, after, ...props }, ref) => {
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200; const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
return ( return (

View File

@@ -1 +0,0 @@
export * from './PasswordInput';

View File

@@ -1,32 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, Text } from 'folds';
import { BreakWord } from '../../styles/Text.css';
type SettingTileProps = {
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
after?: ReactNode;
children?: ReactNode;
};
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
return (
<Box alignItems="Center" gap="300">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text className={BreakWord} size="T300">
{title}
</Text>
)}
{description && (
<Text className={BreakWord} size="T200" priority="300">
{description}
</Text>
)}
{children}
</Box>
{after && <Box shrink="No">{after}</Box>}
</Box>
);
}

View File

@@ -1 +0,0 @@
export * from './SettingTile';

View File

@@ -33,6 +33,5 @@ export const TextViewerPre = style([
{ {
padding: config.space.S600, padding: config.space.S600,
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}, },
]); ]);

View File

@@ -1,89 +0,0 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
export function PasswordStage({
stageData,
submitAuthDict,
onCancel,
userId,
}: StageComponentProps & {
userId: string;
}) {
const { errorCode, error, session } = stageData;
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { passwordInput } = evt.target as HTMLFormElement & {
passwordInput: HTMLInputElement;
};
const password = passwordInput.value;
if (!password) return;
submitAuthDict({
type: AuthType.Password,
identifier: {
type: 'm.id.user',
user: userId,
},
password,
session,
});
};
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Account Password</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleFormSubmit}
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="400">
<Text size="T200">
To perform this action you need to authenticate yourself by entering you account
password.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Password</Text>
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
<Icon size="50" src={Icons.Warning} filled />
<Text size="T200">
<b>
{errorCode === ErrorCode.M_FORBIDDEN
? 'Invalid Password!'
: `${errorCode}: ${error}`}
</b>
</Text>
</Box>
)}
</Box>
</Box>
<Button variant="Primary" type="submit">
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
);
}

View File

@@ -1,91 +0,0 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types';
export function SSOStage({
ssoRedirectURL,
stageData,
submitAuthDict,
onCancel,
}: StageComponentProps & {
ssoRedirectURL: string;
}) {
const { errorCode, error, session } = stageData;
const [ssoWindow, setSSOWindow] = useState<Window>();
const handleSubmit = useCallback(() => {
submitAuthDict({
session,
});
}, [submitAuthDict, session]);
const handleContinue = () => {
const w = window.open(ssoRedirectURL, '_blank');
setSSOWindow(w ?? undefined);
};
useEffect(() => {
const handleMessage = (evt: MessageEvent) => {
if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) {
ssoWindow.close();
setSSOWindow(undefined);
handleSubmit();
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [ssoWindow, handleSubmit]);
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">SSO Login</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Text size="T200">
To perform this action you need to authenticate yourself by SSO login.
</Text>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
<Icon size="50" src={Icons.Warning} filled />
<Text size="T200">
<b>{`${errorCode}: ${error}`}</b>
</Text>
</Box>
)}
{ssoWindow ? (
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
) : (
<Button variant="Primary" onClick={handleContinue}>
<Text as="span" size="B400">
Continue with SSO
</Text>
</Button>
)}
</Box>
</Dialog>
);
}

View File

@@ -1,8 +1,6 @@
export * from './types'; export * from './types';
export * from './DummyStage'; export * from './DummyStage';
export * from './EmailStage'; export * from './EmailStage';
export * from './PasswordStage';
export * from './ReCaptchaStage'; export * from './ReCaptchaStage';
export * from './RegistrationTokenStage'; export * from './RegistrationTokenStage';
export * from './SSOStage';
export * from './TermsStage'; export * from './TermsStage';

View File

@@ -1,94 +0,0 @@
import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
uploadAtom: TUploadAtom;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
};
export function CompactUploadCardRenderer({
isEncrypted,
uploadAtom,
onRemove,
onComplete,
}: CompactUploadCardRendererProps) {
const mx = useMatrixClient();
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
if (upload.status === UploadStatus.Idle) startUpload();
const removeUpload = () => {
cancelUpload();
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return (
<UploadCard
compact
outlined
radii="300"
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
after={
<>
{upload.status === UploadStatus.Error && (
<Chip
as="button"
onClick={startUpload}
aria-label="Retry Upload"
variant="Critical"
radii="Pill"
outlined
>
<Text size="B300">Retry</Text>
</Chip>
)}
<IconButton
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
radii="Pill"
size="300"
>
<Icon src={Icons.Cross} size="200" />
</IconButton>
</>
}
>
{upload.status === UploadStatus.Success ? (
<>
<Text size="H6" truncate>
{file.name}
</Text>
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
</>
) : (
<>
{upload.status === UploadStatus.Idle && (
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
<CompactUploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
</>
)}
</UploadCard>
);
}

View File

@@ -7,21 +7,9 @@ export const UploadCard = recipe({
padding: config.space.S300, padding: config.space.S300,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderColor: color.SurfaceVariant.ContainerLine,
}, },
variants: { variants: {
radii: RadiiVariant, radii: RadiiVariant,
outlined: {
true: {
borderStyle: 'solid',
borderWidth: config.borderWidth.B300,
},
},
compact: {
true: {
padding: config.space.S100,
},
},
}, },
defaultVariants: { defaultVariants: {
radii: '400', radii: '400',

View File

@@ -12,13 +12,8 @@ type UploadCardProps = {
}; };
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>( export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
({ before, after, children, bottom, radii, outlined, compact }, ref) => ( ({ before, after, children, bottom, radii }, ref) => (
<Box <Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
className={css.UploadCard({ radii, outlined, compact })}
direction="Column"
gap="200"
ref={ref}
>
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
{before} {before}
<Box alignItems="Center" grow="Yes" gap="200"> <Box alignItems="Center" grow="Yes" gap="200">
@@ -38,7 +33,7 @@ type UploadCardProgressProps = {
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) { export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return ( return (
<Box grow="Yes" direction="Column" gap="200"> <Box direction="Column" gap="200">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} /> <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
<Box alignItems="Center" justifyContent="SpaceBetween"> <Box alignItems="Center" justifyContent="SpaceBetween">
<Badge variant="Secondary" fill="Solid" radii="Pill"> <Badge variant="Secondary" fill="Solid" radii="Pill">
@@ -54,24 +49,6 @@ export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgress
); );
} }
export function CompactUploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="Pill">
<Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
</Text>
</Badge>
</Box>
);
}
type UploadCardErrorProps = { type UploadCardErrorProps = {
children: ReactNode; children: ReactNode;
}; };

View File

@@ -1,53 +1,38 @@
import React, { useCallback, useEffect } from 'react'; import React from 'react';
import { Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds'; import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix'; import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common'; import { getFileTypeIcon } from '../../utils/common';
import {
roomUploadAtomFamily,
TUploadItem,
TUploadMetadata,
} from '../../state/room/roomInputDrafts';
type UploadCardRendererProps = { type UploadCardRendererProps = {
file: TUploadContent;
isEncrypted?: boolean; isEncrypted?: boolean;
fileItem: TUploadItem; uploadAtom: TUploadAtom;
setMetadata: (metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void; onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
}; };
export function UploadCardRenderer({ export function UploadCardRenderer({
file,
isEncrypted, isEncrypted,
fileItem, uploadAtom,
setMetadata,
onRemove, onRemove,
onComplete,
}: UploadCardRendererProps) { }: UploadCardRendererProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const uploadAtom = roomUploadAtomFamily(fileItem.file); const { upload, startUpload, cancelUpload } = useBindUploadAtom(
const { metadata } = fileItem; mx,
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted); file,
const { file } = upload; uploadAtom,
isEncrypted
);
if (upload.status === UploadStatus.Idle) startUpload(); if (upload.status === UploadStatus.Idle) startUpload();
const toggleSpoiler = useCallback(() => {
setMetadata({ ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler });
}, [setMetadata, metadata]);
const removeUpload = () => { const removeUpload = () => {
cancelUpload(); cancelUpload();
onRemove(file); onRemove(file);
}; };
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return ( return (
<UploadCard <UploadCard
radii="300" radii="300"
@@ -66,31 +51,6 @@ export function UploadCardRenderer({
<Text size="B300">Retry</Text> <Text size="B300">Retry</Text>
</Chip> </Chip>
)} )}
{file.type.startsWith('image') && (
<TooltipProvider
tooltip={
<Tooltip variant="SurfaceVariant">
<Text>Mark as Spoiler</Text>
</Tooltip>
}
position="Top"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={toggleSpoiler}
aria-label="Mark as Spoiler"
variant="SurfaceVariant"
radii="Pill"
size="300"
aria-pressed={metadata.markedAsSpoiler}
>
<Icon src={Icons.EyeBlind} size="200" />
</IconButton>
)}
</TooltipProvider>
)}
<IconButton <IconButton
onClick={removeUpload} onClick={removeUpload}
aria-label="Cancel Upload" aria-label="Cancel Upload"

View File

@@ -1,3 +1,2 @@
export * from './UploadCard'; export * from './UploadCard';
export * from './UploadCardRenderer'; export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';

View File

@@ -20,7 +20,8 @@ export const UrlPreviewImg = style([
width: toRem(100), width: toRem(100),
height: toRem(100), height: toRem(100),
objectFit: 'cover', objectFit: 'cover',
objectPosition: 'center', objectPosition: 'left',
backgroundPosition: 'start',
flexShrink: 0, flexShrink: 0,
overflow: 'hidden', overflow: 'hidden',
}, },

View File

@@ -155,7 +155,7 @@ function SettingsMenuItem({
disabled?: boolean; disabled?: boolean;
}) { }) {
const handleSettings = () => { const handleSettings = () => {
if ('space' in item) { if (item.space) {
openSpaceSettings(item.roomId); openSpaceSettings(item.roomId);
} else { } else {
toggleRoomSettings(item.roomId); toggleRoomSettings(item.roomId);
@@ -271,7 +271,7 @@ export function HierarchyItemMenu({
</Text> </Text>
</MenuItem> </MenuItem>
{promptLeave && {promptLeave &&
('space' in item ? ( (item.space ? (
<LeaveSpacePrompt <LeaveSpacePrompt
roomId={item.roomId} roomId={item.roomId}
onDone={handleRequestClose} onDone={handleRequestClose}

View File

@@ -3,17 +3,10 @@ import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import produce from 'immer';
import { useSpace } from '../../hooks/useSpace'; import { useSpace } from '../../hooks/useSpace';
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
import { import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
HierarchyItem,
HierarchyItemSpace,
useSpaceHierarchy,
} from '../../hooks/useSpaceHierarchy';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { spaceRoomsAtom } from '../../state/spaceRooms'; import { spaceRoomsAtom } from '../../state/spaceRooms';
import { MembersDrawer } from '../room/MembersDrawer'; import { MembersDrawer } from '../room/MembersDrawer';
@@ -31,15 +24,18 @@ import {
usePowerLevels, usePowerLevels,
useRoomsPowerLevels, useRoomsPowerLevels,
} from '../../hooks/usePowerLevels'; } from '../../hooks/usePowerLevels';
import { RoomItemCard } from './RoomItem';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { SpaceItemCard } from './SpaceItem';
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils'; import { getSpaceRoomPath } from '../../pages/pathUtils';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD'; import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
@@ -52,7 +48,6 @@ import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
export function Lobby() { export function Lobby() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -85,8 +80,6 @@ export function Lobby() {
return new Set(sideSpaces); return new Set(sideSpaces);
}, [sidebarItems]); }, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver( useElementSizeObserver(
useCallback(() => heroSectionRef.current, []), useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), []) useCallback((w, height) => setHeroSectionHeight(height), [])
@@ -113,20 +106,19 @@ export function Lobby() {
); );
const [draggingItem, setDraggingItem] = useState<HierarchyItem>(); const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
const hierarchy = useSpaceHierarchy( const flattenHierarchy = useSpaceHierarchy(
space.roomId, space.roomId,
spaceRooms, spaceRooms,
getRoom, getRoom,
useCallback( useCallback(
(childId) => (childId) =>
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
(draggingItem ? 'space' in draggingItem : false),
[closedCategories, space.roomId, draggingItem] [closedCategories, space.roomId, draggingItem]
) )
); );
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: hierarchy.length, count: flattenHierarchy.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 1, estimateSize: () => 1,
overscan: 2, overscan: 2,
@@ -136,17 +128,8 @@ export function Lobby() {
const roomsPowerLevels = useRoomsPowerLevels( const roomsPowerLevels = useRoomsPowerLevels(
useMemo( useMemo(
() => () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
hierarchy [mx, flattenHierarchy]
.flatMap((i) => {
const childRooms = Array.isArray(i.rooms)
? i.rooms.map((r) => mx.getRoom(r.roomId))
: [];
return [mx.getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
[mx, hierarchy]
) )
); );
@@ -158,8 +141,8 @@ export function Lobby() {
return false; return false;
} }
if ('space' in item) { if (item.space) {
if (!('space' in container.item)) return false; if (!container.item.space) return false;
const containerSpaceId = space.roomId; const containerSpaceId = space.roomId;
if ( if (
@@ -172,8 +155,9 @@ export function Lobby() {
return true; return true;
} }
const containerSpaceId = const containerSpaceId = container.item.space
'space' in container.item ? container.item.roomId : container.item.parentId; ? container.item.roomId
: container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId; const dropOutsideSpace = item.parentId !== containerSpaceId;
@@ -207,22 +191,22 @@ export function Lobby() {
); );
const reorderSpace = useCallback( const reorderSpace = useCallback(
(item: HierarchyItemSpace, containerItem: HierarchyItem) => { (item: HierarchyItem, containerItem: HierarchyItem) => {
if (!item.parentId) return; if (!item.parentId) return;
const itemSpaces: HierarchyItemSpace[] = hierarchy const childItems = flattenHierarchy
.map((i) => i.space) .filter((i) => i.parentId && i.space)
.filter((i) => i.roomId !== item.roomId); .filter((i) => i.roomId !== item.roomId);
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, { childItems.splice(insertIndex, 0, {
...item, ...item,
content: { ...item.content, order: undefined }, content: { ...item.content, order: undefined },
}); });
const currentOrders = itemSpaces.map((i) => { const currentOrders = childItems.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@@ -232,21 +216,21 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = itemSpaces[index]; const itm = childItems[index];
if (!itm || !itm.parentId) return; if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId); const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL); const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) { if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
itm.parentId, itm.parentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild] [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
); );
const reorderRoom = useCallback( const reorderRoom = useCallback(
@@ -255,12 +239,13 @@ export function Lobby() {
if (!item.parentId) { if (!item.parentId) {
return; return;
} }
const containerParentId: string = const containerParentId: string = containerItem.space
'space' in containerItem ? containerItem.roomId : containerItem.parentId; ? containerItem.roomId
: containerItem.parentId;
const itemContent = item.content; const itemContent = item.content;
if (item.parentId !== containerParentId) { if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
} }
if ( if (
@@ -273,35 +258,34 @@ export function Lobby() {
const joinRuleContent = getStateEvent( const joinRuleContent = getStateEvent(
itemRoom, itemRoom,
StateEvent.RoomJoinRules StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>(); )?.getContent<IJoinRuleEventContent>();
if (joinRuleContent) { if (joinRuleContent) {
const allow = const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
...joinRuleContent, ...joinRuleContent,
allow, allow,
}); });
} }
} }
const itemSpaces = Array.from( const childItems = flattenHierarchy
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? [] .filter((i) => i.parentId === containerParentId && !i.space)
); .filter((i) => i.roomId !== item.roomId);
const beforeItem: HierarchyItem | undefined = const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
'space' in containerItem ? undefined : containerItem; const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, { childItems.splice(insertIndex, 0, {
...item, ...item,
parentId: containerParentId, parentId: containerParentId,
content: { ...itemContent, order: undefined }, content: { ...itemContent, order: undefined },
}); });
const currentOrders = itemSpaces.map((i) => { const currentOrders = childItems.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@@ -311,18 +295,18 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = itemSpaces[index]; const itm = childItems[index];
if (itm && orderKey !== currentOrders[index]) { if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
containerParentId, containerParentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, hierarchy, lex] [mx, flattenHierarchy, lex]
); );
useDnDMonitor( useDnDMonitor(
@@ -333,7 +317,7 @@ export function Lobby() {
if (!canDrop(item, container)) { if (!canDrop(item, container)) {
return; return;
} }
if ('space' in item) { if (item.space) {
reorderSpace(item, container.item); reorderSpace(item, container.item);
} else { } else {
reorderRoom(item, container.item); reorderRoom(item, container.item);
@@ -343,16 +327,8 @@ export function Lobby() {
) )
); );
const handleSpacesFound = useCallback( const addSpaceRoom = useCallback(
(sItems: IHierarchyRoom[]) => { (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) });
setSpacesItem((current) => {
const newItems = produce(current, (draft) => {
sItems.forEach((item) => draft.set(item.room_id, item));
});
return current.size === newItems.size ? current : newItems;
});
},
[setSpaceRooms] [setSpaceRooms]
); );
@@ -417,44 +393,121 @@ export function Lobby() {
<LobbyHero /> <LobbyHero />
</PageHeroSection> </PageHeroSection>
{vItems.map((vItem) => { {vItems.map((vItem) => {
const item = hierarchy[vItem.index]; const item = flattenHierarchy[vItem.index];
if (!item) return null; if (!item) return null;
const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId; const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canInvite = powerLevelAPI.canDoAction(
itemPowerLevel,
'invite',
userPLInItem
);
const isJoined = allJoinedRooms.has(item.roomId);
const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId); const nextRoomId: string | undefined =
flattenHierarchy[vItem.index + 1]?.roomId;
const dragging =
draggingItem?.roomId === item.roomId &&
draggingItem.parentId === item.parentId;
if (item.space) {
const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
const { parentId } = item;
const parentPowerLevels = parentId
? roomsPowerLevels.get(parentId) ?? {}
: undefined;
return (
<VirtualTile
virtualItem={vItem}
style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SpaceItemCard
item={item}
joined={allJoinedRooms.has(item.roomId)}
categoryId={categoryId}
closed={closedCategories.has(categoryId) || !!draggingItem?.space}
handleClose={handleCategoryClick}
getRoom={getRoom}
canEditChild={canEditSpaceChild(
roomsPowerLevels.get(item.roomId) ?? {}
)}
canReorder={
parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...item, parentId }}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={sidebarSpaces.has(item.roomId)}
onTogglePin={togglePinToSidebar}
/>
)
}
before={item.parentId ? undefined : undefined}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={setDraggingItem}
data-dragging={dragging}
/>
</VirtualTile>
);
}
const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
style={{ style={{ paddingTop: config.space.S100 }}
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
key={vItem.index} key={vItem.index}
> >
<SpaceHierarchy <RoomItemCard
spaceItem={item.space} item={item}
summary={spacesItems.get(item.space.roomId)} onSpaceFound={addSpaceRoom}
roomItems={item.rooms} dm={mDirects.has(item.roomId)}
allJoinedRooms={allJoinedRooms} firstChild={!prevItem || prevItem.space === true}
mDirects={mDirects} lastChild={!nextItem || nextItem.space === true}
roomsPowerLevels={roomsPowerLevels} onOpen={handleOpenRoom}
canEditSpaceChild={canEditSpaceChild}
categoryId={categoryId}
closed={
closedCategories.has(categoryId) ||
(draggingItem ? 'space' in draggingItem : false)
}
handleClose={handleCategoryClick}
draggingItem={draggingItem}
onDragging={setDraggingItem}
canDrop={canDrop}
nextSpaceId={nextSpaceId}
getRoom={getRoom} getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)} canReorder={canEditSpaceChild(parentPowerLevels)}
togglePinToSidebar={togglePinToSidebar} options={
onSpacesFound={handleSpacesFound} <HierarchyItemMenu
onOpenRoom={handleOpenRoom} item={item}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
/>
}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={dragging}
onDragging={setDraggingItem}
/> />
</VirtualTile> </VirtualTile>
); );

View File

@@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
import { import {
Avatar, Avatar,
Badge, Badge,
@@ -20,20 +20,23 @@ import {
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { millify } from '../../plugins/millify'; import { millify } from '../../plugins/millify';
import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; import {
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { Membership } from '../../../types/matrix/room'; import { Membership, RoomType } from '../../../types/matrix/room';
import * as css from './RoomItem.css'; import * as css from './RoomItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
@@ -122,11 +125,13 @@ function RoomProfileLoading() {
type RoomProfileErrorProps = { type RoomProfileErrorProps = {
roomId: string; roomId: string;
inaccessibleRoom: boolean; error: Error;
suggested?: boolean; suggested?: boolean;
via?: string[]; via?: string[];
}; };
function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) { function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
return ( return (
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
<Avatar> <Avatar>
@@ -137,7 +142,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
size="300" size="300"
joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted} joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
filled filled
/> />
)} )}
@@ -157,18 +162,25 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
)} )}
</Box> </Box>
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
{inaccessibleRoom ? ( {privateRoom && (
<Badge variant="Secondary" fill="Soft" radii="300" size="500"> <>
<Text size="L400">Inaccessible</Text> <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
</Badge> <Text size="L400">Private Room</Text>
) : ( </Badge>
<Text size="T200" truncate> <Line
{roomId} variant="SurfaceVariant"
</Text> style={{ height: toRem(12) }}
direction="Vertical"
size="400"
/>
</>
)} )}
<Text size="T200" truncate>
{roomId}
</Text>
</Box> </Box>
</Box> </Box>
{!inaccessibleRoom && <RoomJoinButton roomId={roomId} via={via} />} {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
</Box> </Box>
); );
} }
@@ -276,11 +288,23 @@ function RoomProfile({
); );
} }
function CallbackOnFoundSpace({
roomId,
onSpaceFound,
}: {
roomId: string;
onSpaceFound: (roomId: string) => void;
}) {
useEffect(() => {
onSpaceFound(roomId);
}, [roomId, onSpaceFound]);
return null;
}
type RoomItemCardProps = { type RoomItemCardProps = {
item: HierarchyItem; item: HierarchyItem;
loading: boolean; onSpaceFound: (roomId: string) => void;
error: Error | null;
summary: IHierarchyRoom | undefined;
dm?: boolean; dm?: boolean;
firstChild?: boolean; firstChild?: boolean;
lastChild?: boolean; lastChild?: boolean;
@@ -296,10 +320,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
( (
{ {
item, item,
loading, onSpaceFound,
error,
summary,
dm, dm,
firstChild,
lastChild,
onOpen, onOpen,
options, options,
before, before,
@@ -324,6 +348,8 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
return ( return (
<SequenceCard <SequenceCard
className={css.RoomItemCard} className={css.RoomItemCard}
firstChild={firstChild}
lastChild={lastChild}
variant="SurfaceVariant" variant="SurfaceVariant"
gap="300" gap="300"
alignItems="Center" alignItems="Center"
@@ -341,9 +367,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
name={localSummary.name} name={localSummary.name}
topic={localSummary.topic} topic={localSummary.topic}
avatarUrl={ avatarUrl={
dm dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
memberCount={localSummary.memberCount} memberCount={localSummary.memberCount}
suggested={content.suggested} suggested={content.suggested}
@@ -371,46 +395,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
)} )}
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<> <HierarchyRoomSummaryLoader roomId={roomId}>
{!summary && {(summaryState) => (
(error ? ( <>
<RoomProfileError {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
roomId={roomId} {summaryState.status === AsyncStatus.Error && (
inaccessibleRoom={false} <RoomProfileError
suggested={content.suggested} roomId={roomId}
via={content.via} error={summaryState.error}
/> suggested={content.suggested}
) : ( via={content.via}
<> />
{loading && <RoomProfileLoading />} )}
{!loading && ( {summaryState.status === AsyncStatus.Success && (
<RoomProfileError <>
{summaryState.data.room_type === RoomType.Space && (
<CallbackOnFoundSpace
roomId={summaryState.data.room_id}
onSpaceFound={onSpaceFound}
/>
)}
<RoomProfile
roomId={roomId} roomId={roomId}
inaccessibleRoom name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
topic={summaryState.data.topic}
avatarUrl={
summaryState.data?.avatar_url
? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summaryState.data.num_joined_members}
suggested={content.suggested} suggested={content.suggested}
via={content.via} joinRule={summaryState.data.join_rule}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/> />
)} </>
</> )}
))} </>
{summary && (
<RoomProfile
roomId={roomId}
name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic}
avatarUrl={
summary?.avatar_url
? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summary.num_joined_members}
suggested={content.suggested}
joinRule={summary.join_rule}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/>
)} )}
</> </HierarchyRoomSummaryLoader>
)} )}
</Box> </Box>
{options} {options}

View File

@@ -1,225 +0,0 @@
import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { Box, config, Text } from 'folds';
import {
HierarchyItem,
HierarchyItemRoom,
HierarchyItemSpace,
useFetchSpaceHierarchyLevel,
} from '../../hooks/useSpaceHierarchy';
import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SpaceItemCard } from './SpaceItem';
import { AfterItemDropTarget, CanDropCallback } from './DnD';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { RoomItemCard } from './RoomItem';
import { RoomType } from '../../../types/matrix/room';
import { SequenceCard } from '../../components/sequence-card';
type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined;
spaceItem: HierarchyItemSpace;
roomItems?: HierarchyItemRoom[];
allJoinedRooms: Set<string>;
mDirects: Set<string>;
roomsPowerLevels: Map<string, IPowerLevels>;
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler<HTMLButtonElement>;
draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
};
export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
(
{
summary,
spaceItem,
roomItems,
allJoinedRooms,
mDirects,
roomsPowerLevels,
canEditSpaceChild,
categoryId,
closed,
handleClose,
draggingItem,
onDragging,
canDrop,
nextSpaceId,
getRoom,
pinned,
togglePinToSidebar,
onOpenRoom,
onSpacesFound,
},
ref
) => {
const mx = useMatrixClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map();
rooms.forEach((r) => {
if (r.room_type === RoomType.Space) {
s.set(r.room_id, r);
}
});
return s;
}, [rooms]);
const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
const userPLInSpace = powerLevelAPI.getPowerLevel(
spacePowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
const draggingSpace =
draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
const { parentId } = spaceItem;
const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
useEffect(() => {
onSpacesFound(Array.from(subspaces.values()));
}, [subspaces, onSpacesFound]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
if (!canEditSpaceChild(spacePowerLevels)) {
// hide unknown rooms for normal user
childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true);
return !inaccessibleRoom;
});
}
return (
<Box direction="Column" gap="100" ref={ref}>
<SpaceItemCard
summary={rooms.get(spaceItem.roomId) ?? summary}
loading={fetching}
item={spaceItem}
joined={allJoinedRooms.has(spaceItem.roomId)}
categoryId={categoryId}
closed={closed}
handleClose={handleClose}
getRoom={getRoom}
canEditChild={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...spaceItem, parentId }}
canInvite={canInviteInSpace}
joined={allJoinedRooms.has(spaceItem.roomId)}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
)
}
after={
<AfterItemDropTarget
item={spaceItem}
nextRoomId={closed ? nextSpaceId : childItems?.[0]?.roomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={onDragging}
data-dragging={draggingSpace}
/>
{childItems && childItems.length > 0 ? (
<Box direction="Column" gap="100">
{childItems.map((roomItem, index) => {
const roomSummary = rooms.get(roomItem.roomId);
const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
const userPLInRoom = powerLevelAPI.getPowerLevel(
roomPowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInRoom = powerLevelAPI.canDoAction(
roomPowerLevels,
'invite',
userPLInRoom
);
const lastItem = index === childItems.length;
const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
const roomDragging =
draggingItem?.roomId === roomItem.roomId &&
draggingItem.parentId === roomItem.parentId;
return (
<RoomItemCard
key={roomItem.roomId}
item={roomItem}
loading={fetching}
error={error}
summary={roomSummary}
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)}
options={
<HierarchyItemMenu
item={roomItem}
canInvite={canInviteInRoom}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={canEditSpaceChild(spacePowerLevels)}
/>
}
after={
<AfterItemDropTarget
item={roomItem}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={roomDragging}
onDragging={onDragging}
/>
);
})}
</Box>
) : (
childItems && (
<SequenceCard variant="SurfaceVariant" gap="300" alignItems="Center">
<Box
grow="Yes"
style={{
padding: config.space.S700,
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
>
<Text size="H5" align="Center">
No Rooms
</Text>
<Text align="Center" size="T300" priority="300">
This space does not contains rooms yet.
</Text>
</Box>
</SequenceCard>
)
)}
</Box>
);
}
);

View File

@@ -19,16 +19,19 @@ import {
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import classNames from 'classnames'; import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader'; import {
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { getRoomAvatarUrl } from '../../utils/room'; import { getRoomAvatarUrl } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css'; import * as css from './SpaceItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { ErrorCode } from '../../cs-errorcode';
import { useDraggableItem } from './DnD'; import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation'; import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@@ -50,11 +53,18 @@ function SpaceProfileLoading() {
); );
} }
type InaccessibleSpaceProfileProps = { type UnknownPrivateSpaceProfileProps = {
roomId: string; roomId: string;
name?: string;
avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) { function UnknownPrivateSpaceProfile({
roomId,
name,
avatarUrl,
suggested,
}: UnknownPrivateSpaceProfileProps) {
return ( return (
<Chip <Chip
as="span" as="span"
@@ -65,9 +75,11 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
<Avatar size="200" radii="300"> <Avatar size="200" radii="300">
<RoomAvatar <RoomAvatar
roomId={roomId} roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H6"> <Text as="span" size="H6">
U {nameInitials(name)}
</Text> </Text>
)} )}
/> />
@@ -76,11 +88,11 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
> >
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Text size="H4" truncate> <Text size="H4" truncate>
Unknown {name || 'Unknown'}
</Text> </Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined> <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Inaccessible</Text> <Text size="L400">Private Space</Text>
</Badge> </Badge>
{suggested && ( {suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined> <Badge variant="Success" fill="Soft" radii="Pill" outlined>
@@ -92,20 +104,20 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
); );
} }
type UnjoinedSpaceProfileProps = { type UnknownSpaceProfileProps = {
roomId: string; roomId: string;
via?: string[]; via?: string[];
name?: string; name?: string;
avatarUrl?: string; avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function UnjoinedSpaceProfile({ function UnknownSpaceProfile({
roomId, roomId,
via, via,
name, name,
avatarUrl, avatarUrl,
suggested, suggested,
}: UnjoinedSpaceProfileProps) { }: UnknownSpaceProfileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>( const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
@@ -364,8 +376,6 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
} }
type SpaceItemCardProps = { type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
item: HierarchyItem; item: HierarchyItem;
joined?: boolean; joined?: boolean;
categoryId: string; categoryId: string;
@@ -383,8 +393,6 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
( (
{ {
className, className,
summary,
loading,
joined, joined,
closed, closed,
categoryId, categoryId,
@@ -443,31 +451,37 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
} }
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<> <HierarchyRoomSummaryLoader roomId={roomId}>
{!summary && {(summaryState) => (
(loading ? ( <>
<SpaceProfileLoading /> {summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
) : ( {summaryState.status === AsyncStatus.Error &&
<InaccessibleSpaceProfile (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
roomId={item.roomId} <UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
suggested={item.content.suggested} ) : (
/> <UnknownSpaceProfile
))} roomId={roomId}
{summary && ( via={item.content.via}
<UnjoinedSpaceProfile suggested={content.suggested}
roomId={roomId} />
via={item.content.via} ))}
name={summary.name || summary.canonical_alias || roomId} {summaryState.status === AsyncStatus.Success && (
avatarUrl={ <UnknownSpaceProfile
summary?.avatar_url roomId={roomId}
? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ?? via={item.content.via}
undefined name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
: undefined avatarUrl={
} summaryState.data?.avatar_url
suggested={content.suggested} ? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
/> undefined
: undefined
}
suggested={content.suggested}
/>
)}
</>
)} )}
</> </HierarchyRoomSummaryLoader>
)} )}
</Box> </Box>
{canEditChild && ( {canEditChild && (

View File

@@ -182,9 +182,7 @@ export function RoomNavItem({
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId);
(receipt) => receipt.userId !== mx.getUserId()
);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
@@ -221,9 +219,7 @@ export function RoomNavItem({
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={room.roomId}
src={ src={
direct direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
alt={room.name} alt={room.name}
renderFallback={() => ( renderFallback={() => (

View File

@@ -156,7 +156,7 @@ export type MembersFilterOptions = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 100,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -428,9 +428,8 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
}} }}
after={<Icon size="50" src={Icons.Cross} />} after={<Icon size="50" src={Icons.Cross} />}
> >
<Text size="B300">{`${result.items.length || 'No'} ${ <Text size="B300">{`${result.items.length || 'No'} ${result.items.length === 1 ? 'Result' : 'Results'
result.items.length === 1 ? 'Result' : 'Results' }`}</Text>
}`}</Text>
</Chip> </Chip>
) )
} }
@@ -486,17 +485,15 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const member = tagOrMember; const member = tagOrMember;
const name = getName(member); const name = getName(member);
const avatarMxcUrl = member.getMxcAvatarUrl(); const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(
? mx.mxcUrlToHttp( avatarMxcUrl,
avatarMxcUrl, 100,
100, 100,
100, 'crop',
'crop', undefined,
undefined, false,
false, useAuthentication
useAuthentication ) : undefined;
)
: undefined;
return ( return (
<MenuItem <MenuItem

View File

@@ -53,17 +53,10 @@ import {
isEmptyEditor, isEmptyEditor,
getBeginCommand, getBeginCommand,
trimCommand, trimCommand,
getMentions,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
@@ -103,11 +96,14 @@ import colorMXID from '../../../util/colorMXID';
import { import {
getAllParents, getAllParents,
getMemberDisplayName, getMemberDisplayName,
getMentionContent, parseReplyBody,
parseReplyFormattedBody,
trimReplyFromBody, trimReplyFromBody,
trimReplyFromFormattedBody,
} from '../../utils/room'; } from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -161,28 +157,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const safeFiles = files.map(safeFile); const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = []; const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) { if (mx.isRoomEncrypted(roomId)) {
const encryptFiles = fulfilledPromiseSettledResult( const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
); );
encryptFiles.forEach((ef) => encryptFiles.forEach((ef) => fileItems.push(ef));
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else { } else {
safeFiles.forEach((f) => safeFiles.forEach((f) =>
fileItems.push({ fileItems.push({ file: f, originalFile: f, encInfo: undefined })
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
); );
} }
setSelectedFiles({ setSelectedFiles({
@@ -190,7 +172,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
item: fileItems, item: fileItems,
}); });
}, },
[setSelectedFiles, room] [setSelectedFiles, roomId, mx]
); );
const pickFile = useFilePicker(handleFiles, true); const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles); const handlePaste = useFilePasteHandler(handleFiles);
@@ -266,7 +248,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor); const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let plainText = toPlainText(editor.children).trim();
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
@@ -287,12 +270,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else if (commandName === Command.Shrug) { } else if (commandName === Command.Shrug) {
plainText = `${SHRUG} ${plainText}`; plainText = `${SHRUG} ${plainText}`;
customHtml = `${SHRUG} ${customHtml}`; customHtml = `${SHRUG} ${customHtml}`;
} else if (commandName === Command.TableFlip) {
plainText = `${TABLEFLIP} ${plainText}`;
customHtml = `${TABLEFLIP} ${customHtml}`;
} else if (commandName === Command.UnFlip) {
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) { } else if (commandName) {
const commandContent = commands[commandName as Command]; const commandContent = commands[commandName as Command];
if (commandContent) { if (commandContent) {
@@ -306,22 +283,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (plainText === '') return; if (plainText === '') return;
const body = plainText; let body = plainText;
const formattedBody = customHtml; let formattedBody = customHtml;
const mentionData = getMentions(mx, roomId, editor); if (replyDraft) {
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
replyDraft.formattedBody
? trimReplyFromFormattedBody(replyDraft.formattedBody)
: sanitizeText(replyDraft.body)
) + formattedBody;
}
const content: IContent = { const content: IContent = {
msgtype: msgType, msgtype: msgType,
body, body,
}; };
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
mentionData.users.add(replyDraft.userId);
}
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
content['m.mentions'] = mMentions;
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody; content.formatted_body = formattedBody;
@@ -427,15 +407,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<UploadCardRenderer <UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
key={index} key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo} isEncrypted={!!fileItem.encInfo}
fileItem={fileItem} uploadAtom={roomUploadAtomFamily(fileItem.file)}
setMetadata={(metadata) =>
setSelectedFiles({
type: 'REPLACE',
item: fileItem,
replacement: { ...fileItem, metadata },
})
}
onRemove={handleRemoveUpload} onRemove={handleRemoveUpload}
/> />
))} ))}

View File

@@ -85,7 +85,7 @@ import {
reactionOrEditEvent, reactionOrEditEvent,
} from '../../utils/room'; } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { openProfileViewer } from '../../../client/action/navigation'; import { openProfileViewer } from '../../../client/action/navigation';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message'; import { Reactions, Message, Event, EncryptedContent } from './message';
@@ -336,10 +336,7 @@ const useTimelinePagination = (
backwards ? Direction.Backward : Direction.Forward backwards ? Direction.Backward : Direction.Forward
) ?? timelineToPaginate; ) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle // Decrypt all event ahead of render cycle
const roomId = fetchedTimeline.getRoomId(); if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
await to(decryptAllTimelineEvent(mx, fetchedTimeline)); await to(decryptAllTimelineEvent(mx, fetchedTimeline));
} }
@@ -424,6 +421,7 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
@@ -431,16 +429,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
usePowerLevelsAPI(powerLevels);
const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
const canRedact = canDoAction('redact', myPowerLevel); const canRedact = canDoAction('redact', myPowerLevel);
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -586,19 +582,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// so timeline can be updated with evt like: edits, reactions etc // so timeline can be updated with evt like: edits, reactions etc
if (atBottomRef.current) { if (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
// Check if the document is in focus (user is actively viewing the app),
// 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.
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!)); requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!));
} }
if (!document.hasFocus() && !unreadInfo) { if (document.hasFocus()) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
} else if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
} }
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({ setTimeline((ct) => ({
...ct, ...ct,
range: { range: {
@@ -617,36 +609,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
); );
const handleOpenEvent = useCallback(
async (
evtId: string,
highlight = true,
onScroll: ((scrolled: boolean) => void) | undefined = undefined
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
if (onScroll) onScroll(scrolled);
setFocusItem({
index: absoluteIndex,
scrollTo: false,
highlight,
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(evtId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
);
useLiveTimelineRefresh( useLiveTimelineRefresh(
room, room,
useCallback(() => { useCallback(() => {
@@ -680,17 +642,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
const tryAutoMarkAsRead = useCallback(() => { const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current; if (!unreadInfo) {
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId)); requestAnimationFrame(() => markAsRead(mx, room.roomId));
return; return;
} }
const evtTimeline = getEventTimeline(room, readUptoEventId); const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) { if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId)); requestAnimationFrame(() => markAsRead(mx, room.roomId));
} }
}, [mx, room]); }, [mx, room, unreadInfo]);
const debounceSetAtBottom = useDebounce( const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
@@ -707,9 +668,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) { if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); setAtBottom(true);
if (document.hasFocus()) { tryAutoMarkAsRead();
tryAutoMarkAsRead();
}
} }
}, },
[debounceSetAtBottom, tryAutoMarkAsRead] [debounceSetAtBottom, tryAutoMarkAsRead]
@@ -728,20 +687,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback( useCallback(
(inFocus) => { (inFocus) => {
if (inFocus && atBottomRef.current) { if (inFocus && atBottomRef.current) {
if (unreadInfo?.inLiveTimeline) {
handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
// the unread event is already in view
// so, try mark as read;
if (!scrolled) {
tryAutoMarkAsRead();
}
});
return;
}
tryAutoMarkAsRead(); tryAutoMarkAsRead();
} }
}, },
[tryAutoMarkAsRead, unreadInfo, handleOpenEvent] [tryAutoMarkAsRead]
) )
); );
@@ -879,9 +828,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
async (evt) => { async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id'); const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return; if (!targetId) return;
handleOpenEvent(targetId); const replyTimeline = getEventTimeline(room, targetId);
const absoluteIndex =
replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
setFocusItem({
index: absoluteIndex,
scrollTo: false,
highlight: true,
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(targetId);
}
}, },
[handleOpenEvent] [room, timeline, scrollToItem, loadEventTimeline]
); );
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
@@ -931,7 +898,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content; const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getWireContent(); const { 'm.relates_to': relation } = replyEvt.getOriginalContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@@ -1016,7 +983,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -1027,6 +993,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
replyEventId={replyEventId} replyEventId={replyEventId}
@@ -1061,7 +1028,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === 2}
/> />
)} )}
</Message> </Message>
@@ -1088,7 +1055,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -1099,6 +1065,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
replyEventId={replyEventId} replyEventId={replyEventId}
@@ -1157,7 +1124,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === 2}
/> />
); );
} }
@@ -1196,7 +1163,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -1242,9 +1208,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent); const parsed = parseMemberEvent(mEvent);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1277,9 +1241,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1313,9 +1275,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1349,9 +1309,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1387,9 +1345,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1430,9 +1386,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1587,7 +1541,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<div <div
style={{ style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${ padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64) messageLayout === 1 ? config.space.S400 : toRem(64)
}`, }`,
}} }}
> >
@@ -1595,70 +1549,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</div> </div>
)} )}
{(canPaginateBack || !rangeAtStart) && {(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? ( (messageLayout === 1 ? (
<> <>
<MessageBase> <CompactPlaceholder />
<CompactPlaceholder key={getItems().length} /> <CompactPlaceholder />
</MessageBase> <CompactPlaceholder />
<MessageBase> <CompactPlaceholder />
<CompactPlaceholder key={getItems().length} /> <CompactPlaceholder ref={observeBackAnchor} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</> </>
) : ( ) : (
<> <>
<MessageBase> <DefaultPlaceholder />
<DefaultPlaceholder key={getItems().length} /> <DefaultPlaceholder />
</MessageBase> <DefaultPlaceholder ref={observeBackAnchor} />
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</> </>
))} ))}
{getItems().map(eventRenderer)} {getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) && {(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? ( (messageLayout === 1 ? (
<> <>
<MessageBase ref={observeFrontAnchor}> <CompactPlaceholder ref={observeFrontAnchor} />
<CompactPlaceholder key={getItems().length} /> <CompactPlaceholder />
</MessageBase> <CompactPlaceholder />
<MessageBase> <CompactPlaceholder />
<CompactPlaceholder key={getItems().length} /> <CompactPlaceholder />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</> </>
) : ( ) : (
<> <>
<MessageBase ref={observeFrontAnchor}> <DefaultPlaceholder ref={observeFrontAnchor} />
<DefaultPlaceholder key={getItems().length} /> <DefaultPlaceholder />
</MessageBase> <DefaultPlaceholder />
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</> </>
))} ))}
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />

View File

@@ -19,7 +19,6 @@ import {
Line, Line,
PopOut, PopOut,
RectCords, RectCords,
Badge,
} from 'folds'; } from 'folds';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk'; import { JoinRule, Room } from 'matrix-js-sdk';
@@ -55,8 +54,6 @@ import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@@ -183,18 +180,14 @@ export function RoomViewHeader() {
const room = useRoom(); const room = useRoom();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent; const ecryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room); const name = useRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
@@ -212,10 +205,6 @@ export function RoomViewHeader() {
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PageHeader balance={screenSize === ScreenSize.Mobile}> <PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
@@ -308,62 +297,6 @@ export function RoomViewHeader() {
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View File

@@ -1,6 +1,5 @@
import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk'; import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
import React, { ReactNode, useEffect, useState } from 'react'; import React, { ReactNode, useEffect, useState } from 'react';
import { MessageEvent } from '../../../../types/matrix/room';
type EncryptedContentProps = { type EncryptedContentProps = {
mEvent: MatrixEvent; mEvent: MatrixEvent;
@@ -8,12 +7,11 @@ type EncryptedContentProps = {
}; };
export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
const [, toggleEncrypted] = useState(mEvent.getType() === MessageEvent.RoomMessageEncrypted); const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
useEffect(() => { useEffect(() => {
toggleEncrypted(mEvent.getType() === MessageEvent.RoomMessageEncrypted); const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => { toggleDecrypted((s) => !s);
toggleEncrypted(event.getType() === MessageEvent.RoomMessageEncrypted);
}; };
mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
return () => { return () => {

View File

@@ -35,7 +35,6 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations'; import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames'; import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { import {
AvatarBase, AvatarBase,
BubbleLayout, BubbleLayout,
@@ -52,12 +51,7 @@ import {
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberDisplayName,
} from '../../../utils/room'; } from '../../../utils/room';
import { import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias, mxcUrlToHttp } from '../../../utils/matrix';
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
isRoomAlias,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -74,8 +68,6 @@ import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { StateEvent } from '../../../../types/matrix/room';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -243,9 +235,9 @@ export const MessageSourceCodeItem = as<
const getContent = (evt: MatrixEvent) => const getContent = (evt: MatrixEvent) =>
evt.isEncrypted() evt.isEncrypted()
? { ? {
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
[`<== ORIGINAL_EVENT ==>`]: evt.event, [`<== ORIGINAL_EVENT ==>`]: evt.event,
} }
: evt.event; : evt.event;
const getText = (): string => { const getText = (): string => {
@@ -348,46 +340,6 @@ export const MessageCopyLinkItem = as<
); );
}); });
export const MessagePinItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const pinnedEvents = useRoomPinnedEvents(room);
const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
const handlePin = () => {
const eventId = mEvent.getId();
const pinContent: RoomPinnedEventsEventContent = {
pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
};
if (!isPinned && eventId) {
pinContent.pinned.push(eventId);
}
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pin} />}
radii="300"
onClick={handlePin}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{isPinned ? 'Unpin Message' : 'Pin Message'}
</Text>
</MenuItem>
);
});
export const MessageDeleteItem = as< export const MessageDeleteItem = as<
'button', 'button',
{ {
@@ -659,7 +611,6 @@ export type MessageProps = {
edit?: boolean; edit?: boolean;
canDelete?: boolean; canDelete?: boolean;
canSendReaction?: boolean; canSendReaction?: boolean;
canPinEvent?: boolean;
imagePackRooms?: Room[]; imagePackRooms?: Room[];
relations?: Relations; relations?: Relations;
messageLayout: MessageLayout; messageLayout: MessageLayout;
@@ -683,7 +634,6 @@ export const Message = as<'div', MessageProps>(
edit, edit,
canDelete, canDelete,
canSendReaction, canSendReaction,
canPinEvent,
imagePackRooms, imagePackRooms,
relations, relations,
messageLayout, messageLayout,
@@ -716,7 +666,7 @@ export const Message = as<'div', MessageProps>(
const headerJSX = !collapse && ( const headerJSX = !collapse && (
<Box <Box
gap="300" gap="300"
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'} direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
alignItems="Baseline" alignItems="Baseline"
grow="Yes" grow="Yes"
@@ -728,12 +678,12 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick} onContextMenu={onUserClick}
onClick={onUsernameClick} onClick={onUsernameClick}
> >
<Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate> <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
<b>{senderDisplayName}</b> <b>{senderDisplayName}</b>
</Text> </Text>
</Username> </Username>
<Box shrink="No" gap="100"> <Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && ( {messageLayout === 0 && hover && (
<> <>
<Text as="span" size="T200" priority="300"> <Text as="span" size="T200" priority="300">
{senderId} {senderId}
@@ -743,12 +693,12 @@ export const Message = as<'div', MessageProps>(
</Text> </Text>
</> </>
)} )}
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
</Box> </Box>
</Box> </Box>
); );
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( const avatarJSX = !collapse && messageLayout !== 1 && (
<AvatarBase> <AvatarBase>
<Avatar <Avatar
className={css.MessageAvatar} className={css.MessageAvatar}
@@ -999,32 +949,29 @@ export const Message = as<'div', MessageProps>(
/> />
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box> </Box>
{((!mEvent.isRedacted() && canDelete) || {((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && ( mEvent.getSender() !== mx.getUserId()) && (
<> <>
<Line size="300" /> <Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && ( {!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem <MessageDeleteItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
{mEvent.getSender() !== mx.getUserId() && ( {mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem <MessageReportItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
</Box> </Box>
</> </>
)} )}
</Menu> </Menu>
</FocusTrap> </FocusTrap>
} }
@@ -1043,18 +990,18 @@ export const Message = as<'div', MessageProps>(
</Menu> </Menu>
</div> </div>
)} )}
{messageLayout === MessageLayout.Compact && ( {messageLayout === 1 && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}> <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX} {msgContentJSX}
</CompactLayout> </CompactLayout>
)} )}
{messageLayout === MessageLayout.Bubble && ( {messageLayout === 2 && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}> <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}
</BubbleLayout> </BubbleLayout>
)} )}
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( {messageLayout !== 1 && messageLayout !== 2 && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}> <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}
@@ -1148,26 +1095,26 @@ export const Event = as<'div', EventProps>(
</Box> </Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) || {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
<> <>
<Line size="300" /> <Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && ( {!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem <MessageDeleteItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
{mEvent.getSender() !== mx.getUserId() && ( {mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem <MessageReportItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
</Box> </Box>
</> </>
)} )}
</Menu> </Menu>
</FocusTrap> </FocusTrap>
} }

View File

@@ -21,7 +21,7 @@ import {
} from 'folds'; } from 'folds';
import { Editor, Transforms } from 'slate'; import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { import {
AUTOCOMPLETE_PREFIXES, AUTOCOMPLETE_PREFIXES,
@@ -43,7 +43,6 @@ import {
toPlainText, toPlainText,
trimCustomHtml, trimCustomHtml,
useEditor, useEditor,
getMentions,
} from '../../../components/editor'; } from '../../../components/editor';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
@@ -51,7 +50,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board'; import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
type MessageEditorProps = { type MessageEditorProps = {
@@ -75,29 +74,25 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const getPrevBodyAndFormattedBody = useCallback((): [ const getPrevBodyAndFormattedBody = useCallback((): [
string | undefined, string | undefined,
string | undefined, string | undefined
IMentions | undefined
] => { ] => {
const evtId = mEvent.getId()!; const evtId = mEvent.getId()!;
const evtTimeline = room.getTimelineForEvent(evtId); const evtTimeline = room.getTimelineForEvent(evtId);
const editedEvent = const editedEvent =
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); const { body, formatted_body: customHtml }: Record<string, unknown> =
const { body, formatted_body: customHtml }: Record<string, unknown> = content; editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
const mMentions: IMentions | undefined = content['m.mentions'];
return [ return [
typeof body === 'string' ? body : undefined, typeof body === 'string' ? body : undefined,
typeof customHtml === 'string' ? customHtml : undefined, typeof customHtml === 'string' ? customHtml : undefined,
mMentions,
]; ];
}, [room, mEvent]); }, [room, mEvent]);
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const plainText = toPlainText(editor.children, isMarkdown).trim(); const plainText = toPlainText(editor.children).trim();
const customHtml = trimCustomHtml( const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
@@ -106,7 +101,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}) })
); );
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined; if (plainText === '') return undefined;
if (prevBody) { if (prevBody) {
@@ -127,15 +122,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
body: plainText, body: plainText,
}; };
const mentionData = getMentions(mx, roomId, editor);
prevMentions?.user_ids?.forEach((prevMentionId) => {
mentionData.users.add(prevMentionId);
});
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
newContent['m.mentions'] = mMentions;
if (!customHtmlEqualsPlainText(customHtml, plainText)) { if (!customHtmlEqualsPlainText(customHtml, plainText)) {
newContent.format = 'org.matrix.custom.html'; newContent.format = 'org.matrix.custom.html';
newContent.formatted_body = customHtml; newContent.formatted_body = customHtml;
@@ -206,8 +192,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const initialValue = const initialValue =
typeof customHtml === 'string' typeof customHtml === 'string'
? htmlToEditorInput(customHtml, isMarkdown) ? htmlToEditorInput(customHtml)
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); : plainToEditorInput(typeof body === 'string' ? body : '');
Transforms.select(editor, { Transforms.select(editor, {
anchor: Editor.start(editor, []), anchor: Editor.start(editor, []),
@@ -216,7 +202,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
editor.insertFragment(initialValue); editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]); }, [editor, getPrevBodyAndFormattedBody]);
useEffect(() => { useEffect(() => {
if (saveState.status === AsyncStatus.Success) { if (saveState.status === AsyncStatus.Success) {

View File

@@ -1,10 +1,6 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js'; import to from 'await-to-js';
import { import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
MATRIX_SPOILER_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { import {
getImageFileUrl, getImageFileUrl,
getThumbnail, getThumbnail,
@@ -48,15 +44,13 @@ export const getImageMsgContent = async (
item: TUploadItem, item: TUploadItem,
mxc: string mxc: string
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError); if (imgError) console.warn(imgError);
const content: IContent = { const content: IContent = {
msgtype: MsgType.Image, msgtype: MsgType.Image,
filename: file.name,
body: file.name, body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
}; };
if (imgEl) { if (imgEl) {
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height)); const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
@@ -89,7 +83,6 @@ export const getVideoMsgContent = async (
const content: IContent = { const content: IContent = {
msgtype: MsgType.Video, msgtype: MsgType.Video,
filename: file.name,
body: file.name, body: file.name,
}; };
if (videoEl) { if (videoEl) {
@@ -129,7 +122,6 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent =>
const { file, encInfo } = item; const { file, encInfo } = item;
const content: IContent = { const content: IContent = {
msgtype: MsgType.Audio, msgtype: MsgType.Audio,
filename: file.name,
body: file.name, body: file.name,
info: { info: {
mimetype: file.type, mimetype: file.type,

View File

@@ -1,18 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const PinMenu = style({
display: 'flex',
maxWidth: toRem(548),
width: '100vw',
maxHeight: '90vh',
});
export const PinMenuHeader = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
});
export const PinMenuContent = style({
paddingLeft: config.space.S200,
});

View File

@@ -1,469 +0,0 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
Avatar,
Box,
Chip,
color,
config,
Header,
Icon,
IconButton,
Icons,
Menu,
Scroll,
Spinner,
Text,
toRem,
} from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import * as css from './RoomPinMenu.css';
import { SequenceCard } from '../../../components/sequence-card';
import { useRoomEvent } from '../../../hooks/useRoomEvent';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import {
AvatarBase,
DefaultPlaceholder,
ImageContent,
MessageNotDecryptedContent,
MessageUnsupportedContent,
ModernLayout,
MSticker,
RedactedContent,
Reply,
Time,
Username,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getEditedEvent,
getMemberAvatarMxc,
getMemberDisplayName,
getStateEvent,
} from '../../../utils/room';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import colorMXID from '../../../../util/colorMXID';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
LINKIFY_OPTS,
makeMentionCustomProps,
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { EncryptedContent } from '../message';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { VirtualTile } from '../../../components/virtualizer';
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { ContainerColor } from '../../../styles/ContainerColor.css';
type PinnedMessageProps = {
room: Room;
eventId: string;
renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
canPinEvent: boolean;
};
function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: PinnedMessageProps) {
const pinnedEvent = useRoomEvent(room, eventId);
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
const [unpinState, unpin] = useAsyncCallback(
useCallback(() => {
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
const content = pinEvent?.getContent<RoomPinnedEventsEventContent>() ?? { pinned: [] };
const newContent: RoomPinnedEventsEventContent = {
pinned: content.pinned.filter((id) => id !== eventId),
};
return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, newContent);
}, [room, eventId, mx])
);
const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id');
if (!evtId) return;
onOpen(room.roomId, evtId);
};
const handleUnpinClick: MouseEventHandler = (evt) => {
evt.stopPropagation();
unpin();
};
const renderOptions = () => (
<Box shrink="No" gap="200" alignItems="Center">
<Chip data-event-id={eventId} onClick={handleOpenClick} variant="Secondary" radii="Pill">
<Text size="T200">Open</Text>
</Chip>
{canPinEvent && (
<IconButton
data-event-id={eventId}
variant="Secondary"
size="300"
radii="Pill"
onClick={unpinState.status === AsyncStatus.Loading ? undefined : handleUnpinClick}
aria-disabled={unpinState.status === AsyncStatus.Loading}
>
{unpinState.status === AsyncStatus.Loading ? (
<Spinner size="100" />
) : (
<Icon src={Icons.Cross} size="100" />
)}
</IconButton>
)}
</Box>
);
if (pinnedEvent === undefined) return <DefaultPlaceholder variant="Secondary" />;
if (pinnedEvent === null)
return (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center">
<Box>
<Text style={{ color: color.Critical.Main }}>Failed to load message!</Text>
</Box>
{renderOptions()}
</Box>
);
const sender = pinnedEvent.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
return (
<ModernLayout
before={
<AvatarBase>
<Avatar size="300">
<UserAvatar
userId={sender}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</AvatarBase>
}
>
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline">
<Username style={{ color: colorMXID(sender) }}>
<Text as="span" truncate>
<b>{displayName}</b>
</Text>
</Username>
<Time ts={pinnedEvent.getTs()} />
</Box>
{renderOptions()}
</Box>
{pinnedEvent.replyEventId && (
<Reply
room={room}
replyEventId={pinnedEvent.replyEventId}
threadRootId={pinnedEvent.threadRootId}
onClick={handleOpenClick}
/>
)}
{renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}
</ModernLayout>
);
}
type RoomPinMenuProps = {
room: Room;
requestClose: () => void;
};
export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevelsContext();
const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, getPowerLevel(userId));
const pinnedEvents = useRoomPinnedEvents(room);
const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]);
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const { navigateRoom } = useRoomNavigate();
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: sortedPinnedEvent.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 75,
overscan: 4,
});
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room, mentionClickHandler]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
);
const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
{
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
if (event.isRedacted()) {
return (
<RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />
);
}
return (
<RenderMessageContent
displayName={displayName}
msgType={event.getContent().msgtype ?? ''}
ts={event.getTs()}
getContent={getContent}
edited={!!event.replacingEvent()}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
);
},
[MessageEvent.RoomMessageEncrypted]: (event, displayName) => {
const eventId = event.getId()!;
const evtTimeline = room.getTimelineForEvent(eventId);
const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId);
if (!mEvent || !evtTimeline) {
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{event.getType()}</code>
{' event'}
</Text>
</Box>
);
}
return (
<EncryptedContent mEvent={mEvent}>
{() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessage) {
const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet());
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
return (
<RenderMessageContent
displayName={displayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent || !!mEvent.replacingEvent()}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}}
</EncryptedContent>
);
},
[MessageEvent.Sticker]: (event, displayName, getContent) => {
if (event.isRedacted()) {
return (
<RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />
);
}
return (
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
},
},
undefined,
(event) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{event.getType()}</code>
{' event'}
</Text>
</Box>
);
}
);
const handleOpen = (roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
requestClose();
};
return (
<Menu ref={ref} className={css.PinMenu}>
<Box grow="Yes" direction="Column">
<Header className={css.PinMenuHeader} size="500">
<Box grow="Yes">
<Text size="H5">Pinned Messages</Text>
</Box>
<Box shrink="No">
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</Box>
</Header>
<Box grow="Yes">
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
<Box className={css.PinMenuContent} direction="Column" gap="100">
{sortedPinnedEvent.length > 0 ? (
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const eventId = sortedPinnedEvent[vItem.index];
if (!eventId) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SequenceCard
style={{ padding: config.space.S400, borderRadius: config.radii.R300 }}
variant="SurfaceVariant"
direction="Column"
>
<PinnedMessage
room={room}
eventId={eventId}
renderContent={renderMatrixEvent}
onOpen={handleOpen}
canPinEvent={canPinEvent}
/>
</SequenceCard>
</VirtualTile>
);
})}
</div>
) : (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: `${config.space.S700} ${config.space.S400} ${toRem(60)}`,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Icon src={Icons.Pin} size="600" />
<Box
style={{ maxWidth: toRem(300) }}
direction="Column"
gap="200"
alignItems="Center"
>
<Text size="H4" align="Center">
No Pinned Messages
</Text>
<Text size="T400" align="Center">
Users with sufficient power level can pin a messages from its context menu.
</Text>
</Box>
</Box>
)}
</Box>
</Scroll>
</Box>
</Box>
</Menu>
);
}
);

View File

@@ -1 +0,0 @@
export * from './RoomPinMenu';

View File

@@ -1,234 +0,0 @@
import React, { useMemo, useState } from 'react';
import {
Avatar,
Box,
Button,
config,
Icon,
IconButton,
Icons,
IconSrc,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { General } from './general';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications';
import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
type SettingsMenuItem = {
page: SettingsPages;
name: string;
icon: IconSrc;
};
const useSettingsMenuItems = (): SettingsMenuItem[] =>
useMemo(
() => [
{
page: SettingsPages.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: SettingsPages.AccountPage,
name: 'Account',
icon: Icons.User,
},
{
page: SettingsPages.NotificationPage,
name: 'Notifications',
icon: Icons.Bell,
},
{
page: SettingsPages.DevicesPage,
name: 'Devices',
icon: Icons.Category,
},
{
page: SettingsPages.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
{
page: SettingsPages.AboutPage,
name: 'About',
icon: Icons.Info,
},
],
[]
);
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
};
export function Settings({ initialPage, requestClose }: SettingsProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
});
const menuItems = useSettingsMenuItems();
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
setActivePage(undefined);
return;
}
requestClose();
};
return (
<PageRoot
nav={
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
<PageNav size="300">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="200">
<Avatar size="200" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
/>
</Avatar>
<Text size="H4" truncate>
Settings
</Text>
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{menuItems.map((item) => (
<MenuItem
key={item.name}
variant="Background"
radii="400"
aria-pressed={activePage === item.page}
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
onClick={() => setActivePage(item.page)}
>
<Text
style={{
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
}}
size="T300"
truncate
>
{item.name}
</Text>
</MenuItem>
))}
</div>
</PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
<UseStateProvider initial={false}>
{(logout, setLogout) => (
<>
<Button
size="300"
variant="Critical"
fill="None"
radii="Pill"
before={<Icon src={Icons.Power} size="100" />}
onClick={() => setLogout(true)}
>
<Text size="B400">Logout</Text>
</Button>
{logout && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setLogout(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={() => setLogout(false)} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</>
)}
</UseStateProvider>
</Box>
</Box>
</PageNav>
)
}
>
{activePage === SettingsPages.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
);
}

View File

@@ -1,245 +0,0 @@
import React from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
import cons from '../../../../client/state/cons';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
type AboutProps = {
requestClose: () => void;
};
export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient();
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
About
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box gap="400">
<Box shrink="No">
<img
style={{ width: toRem(60), height: toRem(60) }}
src={CinnySVG}
alt="Cinny logo"
/>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v{cons.version}</Text>
</Box>
<Text>Yet another matrix client.</Text>
</Box>
<Box gap="200" wrap="Wrap">
<Button
as="a"
href="https://github.com/cinnyapp/cinny"
rel="noreferrer noopener"
target="_blank"
variant="Secondary"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Code} size="100" filled />}
>
<Text size="B300">Source Code</Text>
</Button>
<Button
as="a"
href="https://cinny.in/#sponsor"
rel="noreferrer noopener"
target="_blank"
variant="Critical"
fill="Soft"
size="300"
radii="300"
before={<Icon src={Icons.Heart} size="100" filled />}
>
<Text size="B300">Support</Text>
</Button>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Clear Cache & Reload"
description="Clear all your locally stored data and reload from server."
after={
<Button
onClick={() => clearCacheAndReload(mx)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Clear Cache</Text>
</Button>
}
/>
</SequenceCard>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Credits</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box
as="ul"
direction="Column"
gap="200"
style={{
margin: 0,
paddingLeft: config.space.S400,
}}
>
<li>
<Text size="T300">
The{' '}
<a
href="https://github.com/matrix-org/matrix-js-sdk"
rel="noreferrer noopener"
target="_blank"
>
matrix-js-sdk
</a>{' '}
is ©{' '}
<a
href="https://matrix.org/foundation"
rel="noreferrer noopener"
target="_blank"
>
The Matrix.org Foundation C.I.C
</a>{' '}
used under the terms of{' '}
<a
href="http://www.apache.org/licenses/LICENSE-2.0"
rel="noreferrer noopener"
target="_blank"
>
Apache 2.0
</a>
.
</Text>
</li>
<li>
<Text size="T300">
The{' '}
<a
href="https://github.com/mozilla/twemoji-colr"
target="_blank"
rel="noreferrer noopener"
>
twemoji-colr
</a>{' '}
font is ©{' '}
<a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">
Mozilla Foundation
</a>{' '}
used under the terms of{' '}
<a
href="http://www.apache.org/licenses/LICENSE-2.0"
target="_blank"
rel="noreferrer noopener"
>
Apache 2.0
</a>
.
</Text>
</li>
<li>
<Text size="T300">
The{' '}
<a
href="https://twemoji.twitter.com"
target="_blank"
rel="noreferrer noopener"
>
Twemoji
</a>{' '}
emoji art is ©{' '}
<a
href="https://twemoji.twitter.com"
target="_blank"
rel="noreferrer noopener"
>
Twitter, Inc and other contributors
</a>{' '}
used under the terms of{' '}
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noreferrer noopener"
>
CC-BY 4.0
</a>
.
</Text>
</li>
<li>
<Text size="T300">
The{' '}
<a
href="https://material.io/design/sound/sound-resources.html"
target="_blank"
rel="noreferrer noopener"
>
Material sound resources
</a>{' '}
are ©{' '}
<a href="https://google.com" target="_blank" rel="noreferrer noopener">
Google
</a>{' '}
used under the terms of{' '}
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noreferrer noopener"
>
CC-BY 4.0
</a>
.
</Text>
</li>
</Box>
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -1 +0,0 @@
export * from './About';

View File

@@ -1,428 +0,0 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Input,
Avatar,
Button,
Chip,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { copyToClipboard } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
function MatrixId() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
return (
<Box direction="Column" gap="100">
<Text size="L400">Matrix ID</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userId}
after={
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
<Text size="T200">Copy</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>();
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
);
}
function ContactInformation() {
const mx = useMatrixClient();
const [threePIdsState, loadThreePIds] = useAsyncCallback(
useCallback(() => mx.getThreePids(), [mx])
);
const threePIds =
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
const emailIds = threePIds?.filter((id) => id.medium === 'email');
useEffect(() => {
loadThreePIds();
}, [loadThreePIds]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
<Text size="T200">{email.address}</Text>
</Chip>
))}
</Box>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
);
}
type AccountProps = {
requestClose: () => void;
};
export function Account({ requestClose }: AccountProps) {
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Account
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Profile />
<MatrixId />
<ContactInformation />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -1 +0,0 @@
export * from './Account';

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