Compare commits

..

22 Commits

Author SHA1 Message Date
Krishan
fb0ab76b9a Release v4.10.3 2026-02-12 10:26:26 +00:00
renovate[bot]
83e5125b37 fix(deps): update dependency folds to v2.5.0 (#2606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 16:56:47 +11:00
Gimle Larpes
ca82aa283a Hide "Delete Message" if it is forbidden (#2602)
hide "Delete Message" if it is forbidden
2026-02-12 16:27:17 +11:00
Zach
8ce33ee6ff Replace envs.net with unredacted.org in config (#2601)
* Replace 'envs.net' with 'unredacted.org' in config

https://envs.net/ is shutting down their Matrix server

* Update defaultHomeserver and reorder servers list

* Remove 'monero.social' from homeserver list
2026-02-12 10:39:58 +11:00
Santhoshkumar044
073a9f5786 Fix room alias mention triggering room-wide notifications (#2562)
* fix: prevent room alias mentions from triggering @room notifications

* fix: Simplify room mention to exact match on @room
2026-01-12 23:21:00 +11:00
dependabot[bot]
655c1c9aff Bump docker/login-action from 3.5.0 to 3.6.0 (#2496)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.5.0 to 3.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.5.0...v3.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:30:39 +11:00
dependabot[bot]
17d4bceb42 Bump nginx from 1.29.1-alpine to 1.29.3-alpine (#2525)
Bumps nginx from 1.29.1-alpine to 1.29.3-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.3-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:16:20 +11:00
willow
0f61f2f328 Fix typo: change "Advance Options" to "Advanced Options" (#2537) 2025-11-27 16:01:40 +11:00
Krishan
c88cb4bca9 Release v4.10.2 (#2528) 2025-11-05 17:49:56 +11:00
Ajay Bura
46c02b89de Update folds to fix broken scrollbar color (#2505) 2025-10-15 17:30:03 +11:00
Ajay Bura
e13d97aa98 Fix member are not sorted correctly after last js-sdk update (#2504) 2025-10-15 17:27:11 +11:00
Krishan
958ae8945d Release v4.10.1 (#2495) 2025-09-29 14:34:38 +10:00
renovate[bot]
f55a3764d5 fix(deps): update dependency matrix-js-sdk to v38 [security] (#2493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-27 10:00:04 +05:30
dependabot[bot]
3bdcf37bf0 Bump softprops/action-gh-release from 2.3.2 to 2.3.3 (#2478)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.2 to 2.3.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](72f2c25fcb...6cbd405e2c)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:24:59 +10:00
dependabot[bot]
9d7808ec46 Bump nginx from 1.29.0-alpine to 1.29.1-alpine (#2450)
Bumps nginx from 1.29.0-alpine to 1.29.1-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:21:32 +10:00
dependabot[bot]
20d30903fd Bump docker/setup-buildx-action from 3.10.0 to 3.11.1 (#2373)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.11.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.10.0...v3.11.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:19:28 +10:00
Ginger
b78f6f23b5 Add support to mark videos as spoilers (#2255)
* Add support for MSC4193: Spoilers on Media

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

* Use `aria-pressed` to indicate attachment spoiler state

* Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors

* Make it possible to mark videos as spoilers

* Allow videos to be marked as spoilers when uploaded

* Apply requested changes

* Show a loading spinner on spoiled media when unblurred

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-09-25 13:41:35 +10:00
Mari
867a47218a fix: Prevent IME-exiting Enter press from sending message on Safari (#2175)
On most browsers, pressing Enter to end IME composition produces this
sequence of events:
* keydown (keycode 229, key Processing/Unidentified, isComposing true)
* compositionend
* keyup (keycode 13, key Enter, isComposing false)

On Safari, the sequence is different:
* compositionend
* keydown (keycode 229, key Enter, isComposing false)
* keyup (keycode 13, key Enter, isComposing false)

This causes Safari users to mistakenly send their messages when they
press Enter to confirm their choice in an IME.

The workaround is to treat the next keydown with keycode 229 as if it
were part of the IME composition period if it occurs within a short time
of the compositionend event.

Fixes #2103, but needs confirmation from a Safari user.
2025-09-25 09:05:42 +05:30
Ajay Bura
afc251aa7c Add arrow to message bubbles and improve spacing (#2474)
* Add arrow to message bubbles and improve spacing

* make bubble message avatar smaller

* add bubble layout for event content

* adjust bubble arrow

* fix missing return statement for event content

* hide bubble for event content

* add new arrow to bubble message

* fix avatar username relative alignment

* fix types

* fix code block header background

* revert avatar size and make arrow less sharp

* show event messages timestamp to right when bubble is hidden

* fix avatar base css

* move message header outside bubble

* fix event time appears on left in hidden bubles
2025-09-19 21:06:05 +10:00
Ajay Bura
31efbf73b7 Make emojiboard lightweight on low end devices (#2484)
* extract emoji search component

* extract emoji board tabs component

* extract sidebar component

* extract no stickers component

* create emoji/sticker preview atom

* extract component from emoji/sticker item and sidebar buttons

* fix image group icon not loading

* separate emojis and sticker groups logic

* extract layout and emoji group components

* add virtualization in emoji board groups

* fix scroll to alignment
2025-09-18 11:14:08 +10:00
Ajay Bura
31c6d13fdf fix ctrl + k hotkey not working for browser with some extensions (#2481) 2025-09-12 21:52:51 +10:00
Ajay Bura
b3497d9ed6 fix room address checkbox prop (#2480) 2025-09-12 21:51:13 +10:00
51 changed files with 1347 additions and 970 deletions

View File

@@ -52,7 +52,7 @@ jobs:
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
- name: Upload tagged release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
with:
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -70,14 +70,14 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v3.11.1
- name: Login to Docker Hub
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

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

View File

@@ -1,11 +1,10 @@
{
"defaultHomeserver": 2,
"defaultHomeserver": 1,
"homeserverList": [
"converser.eu",
"envs.net",
"matrix.org",
"monero.social",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"allowCustomHomeservers": true,
@@ -15,7 +14,7 @@
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
@@ -28,7 +27,7 @@
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
},
"hashRouter": {

View File

@@ -90,6 +90,7 @@
window.global ||= window;
</script>
<div id="root"></div>
<div id="portalContainer"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

37
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.10.0",
"version": "4.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.10.0",
"version": "4.10.3",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -32,7 +32,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.2.0",
"folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
@@ -43,7 +43,7 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "37.5.0",
"matrix-js-sdk": "38.2.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@@ -2256,20 +2256,14 @@
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
"integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
"node_modules/@matrix-org/olm": {
"version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7163,9 +7157,9 @@
}
},
"node_modules/folds": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
"integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
"license": "Apache-2.0",
"peerDependencies": {
"@vanilla-extract/css": "1.9.2",
@@ -8631,14 +8625,13 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "37.5.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
"version": "38.2.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-38.2.0.tgz",
"integrity": "sha512-R3jzK8rDGi/3OXOax8jFFyxblCG9KTT5yuXAbvnZCGcpTm8lZ4mHQAn5UydVD8qiyUMNMpaaMd6/k7N+5I/yaQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -8653,7 +8646,7 @@
"uuid": "11"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/matrix-js-sdk/node_modules/uuid": {

View File

@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.10.0",
"version": "4.10.3",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@@ -43,7 +43,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.2.0",
"folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
@@ -54,7 +54,7 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "37.5.0",
"matrix-js-sdk": "38.2.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",

View File

@@ -209,13 +209,11 @@ export function RenderMessageContent({
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (

View File

@@ -212,9 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
if (node.type === BlockType.CodeBlock) return;
if (node.type === BlockType.Mention) {
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
if (node.name === '@room') {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import { as, Box, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
export const EmojiGroup = as<
'div',
{
id: string;
label: string;
children: ReactNode;
}
>(({ className, id, label, children, ...props }, ref) => (
<Box
id={getDOMGroupId(id)}
data-group-id={id}
className={classNames(css.EmojiGroup, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
{label}
</Text>
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
</div>
</Box>
));

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Box } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { EmojiItemInfo, EmojiType } from '../types';
import * as css from './styles.css';
import { PackImageReader } from '../../../plugins/custom-emoji';
import { IEmoji } from '../../../plugins/emoji';
import { mxcUrlToHttp } from '../../../utils/matrix';
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
const label = element.getAttribute('title');
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
const data = element.getAttribute('data-emoji-data');
const shortcode = element.getAttribute('data-emoji-shortcode');
if (type && data && shortcode && label)
return {
type,
data,
shortcode,
label,
};
return undefined;
};
type EmojiItemProps = {
emoji: IEmoji;
};
export function EmojiItem({ emoji }: EmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={emoji.label}
aria-label={`${emoji.label} emoji`}
data-emoji-type={EmojiType.Emoji}
data-emoji-data={emoji.unicode}
data-emoji-shortcode={emoji.shortcode}
>
{emoji.unicode}
</Box>
);
}
type CustomEmojiItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.CustomEmoji}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</Box>
);
}
type StickerItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.StickerItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.Sticker}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</Box>
);
}

View File

@@ -0,0 +1,30 @@
import { as, Box, Line } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const EmojiBoardLayout = as<
'div',
{
header: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
>(({ className, header, sidebar, children, ...props }, ref) => (
<Box
display="InlineFlex"
className={classNames(css.Base, className)}
direction="Row"
{...props}
ref={ref}
>
<Box direction="Column" grow="Yes">
<Box className={css.Header} direction="Column" shrink="No">
{header}
</Box>
{children}
</Box>
<Line size="300" direction="Vertical" />
{sidebar}
</Box>
));

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Box, toRem, config, Icons, Icon, Text } from 'folds';
export function NoStickerPacks() {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
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>
);
}

View File

@@ -0,0 +1,53 @@
import { Box, Text } from 'folds';
import React from 'react';
import { Atom, atom, useAtomValue } from 'jotai';
import * as css from './styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
export type PreviewData = {
key: string;
shortcode: string;
};
export const createPreviewDataAtom = (initial?: PreviewData) =>
atom<PreviewData | undefined>(initial);
type PreviewProps = {
previewAtom: Atom<PreviewData | undefined>;
};
export function Preview({ previewAtom }: PreviewProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
if (!shortcode) return null;
return (
<Box shrink="No" className={css.Preview} gap="300" alignItems="Center">
{key && (
<Box
display="InlineFlex"
className={css.PreviewEmoji}
alignItems="Center"
justifyContent="Center"
>
{key.startsWith('mxc://') ? (
<img
className={css.PreviewImg}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
alt={shortcode}
/>
) : (
key
)}
</Box>
)}
<Text size="H5" truncate>
:{shortcode}:
</Text>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import React, { ChangeEventHandler, useRef } from 'react';
import { Input, Chip, Icon, Icons, Text } from 'folds';
import { mobileOrTablet } from '../../../utils/user-agent';
type SearchInputProps = {
query?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
allowTextCustomEmoji?: boolean;
onTextCustomEmojiSelect?: (text: string) => void;
};
export function SearchInput({
query,
onChange,
allowTextCustomEmoji,
onTextCustomEmojiSelect,
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleReact = () => {
const textEmoji = inputRef.current?.value.trim();
if (!textEmoji) return;
onTextCustomEmojiSelect?.(textEmoji);
};
return (
<Input
ref={inputRef}
variant="SurfaceVariant"
size="400"
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
maxLength={50}
after={
allowTextCustomEmoji && query ? (
<Chip
variant="Primary"
radii="Pill"
after={<Icon src={Icons.ArrowRight} size="50" />}
outlined
onClick={handleReact}
>
<Text size="L400">React</Text>
</Chip>
) : (
<Icon src={Icons.Search} size="50" />
)
}
onChange={onChange}
autoFocus={!mobileOrTablet()}
/>
);
}

View File

@@ -0,0 +1,130 @@
import React, { ReactNode } from 'react';
import {
Box,
Scroll,
Line,
as,
TooltipProvider,
Tooltip,
Text,
IconButton,
Icon,
IconSrc,
Icons,
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
<Box className={css.Sidebar} shrink="No">
<Scroll size="0">
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
{children}
</Box>
</Scroll>
</Box>
);
}
export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.SidebarStack, className)}
direction="Column"
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
{children}
</Box>
));
export function SidebarDivider() {
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
}
function SidebarBtn<T extends string>({
active,
label,
id,
onClick,
children,
}: {
active?: boolean;
label: string;
id: T;
onClick: (id: T) => void;
children: ReactNode;
}) {
return (
<TooltipProvider
delay={500}
position="Left"
tooltip={
<Tooltip id={`SidebarStackItem-${id}-label`}>
<Text size="T300">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}
onClick={() => onClick(id)}
size="400"
radii="300"
variant="Surface"
>
{children}
</IconButton>
)}
</TooltipProvider>
);
}
type GroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
icon: IconSrc;
onClick: (id: T) => void;
};
export function GroupIcon<T extends string>({
active,
id,
label,
icon,
onClick,
}: GroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
<Icon src={icon} filled={active} />
</SidebarBtn>
);
}
type ImageGroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
url?: string;
onClick: (id: T) => void;
};
export function ImageGroupIcon<T extends string>({
active,
id,
label,
url,
onClick,
}: ImageGroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
{url ? (
<img className={css.SidebarBtnImg} src={url} alt={label} />
) : (
<Icon src={Icons.Photo} filled={active} />
)}
</SidebarBtn>
);
}

View File

@@ -0,0 +1,44 @@
import React, { CSSProperties } from 'react';
import { Badge, Box, Text } from 'folds';
import { EmojiBoardTab } from '../types';
const styles: CSSProperties = {
cursor: 'pointer',
};
export function EmojiBoardTabs({
tab,
onTabChange,
}: {
tab: EmojiBoardTab;
onTabChange: (tab: EmojiBoardTab) => void;
}) {
return (
<Box gap="100">
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
>
<Text as="span" size="L400">
Sticker
</Text>
</Badge>
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
>
<Text as="span" size="L400">
Emoji
</Text>
</Badge>
</Box>
);
}

View File

@@ -0,0 +1,8 @@
export * from './SearchInput';
export * from './Tabs';
export * from './Sidebar';
export * from './NoStickerPacks';
export * from './Preview';
export * from './Item';
export * from './Group';
export * from './Layout';

View File

@@ -1,5 +1,9 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
/**
* Layout
*/
export const Base = style({
maxWidth: toRem(432),
@@ -13,6 +17,15 @@ export const Base = style({
overflow: 'hidden',
});
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
});
/**
* Sidebar
*/
export const Sidebar = style({
width: toRem(54),
backgroundColor: color.Surface.Container,
@@ -29,26 +42,21 @@ export const SidebarStack = style({
backgroundColor: color.Surface.Container,
});
export const NativeEmojiSidebarStack = style({
position: 'sticky',
bottom: '-67%',
zIndex: 1,
});
export const SidebarDivider = style({
width: toRem(18),
});
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
export const SidebarBtnImg = style({
width: toRem(24),
height: toRem(24),
objectFit: 'contain',
});
export const EmojiBoardTab = style({
cursor: 'pointer',
});
/**
* Preview
*/
export const Footer = style({
export const Preview = style({
padding: config.space.S200,
margin: config.space.S300,
marginTop: 0,
@@ -59,7 +67,30 @@ export const Footer = style({
color: color.SurfaceVariant.OnContainer,
});
export const PreviewEmoji = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
export const PreviewImg = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
objectFit: 'contain',
},
]);
/**
* Group
*/
export const EmojiGroup = style({
position: 'relative',
padding: `${config.space.S300} 0`,
});
@@ -82,15 +113,9 @@ export const EmojiGroupContent = style([
},
]);
export const EmojiPreview = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
/**
* Item
*/
export const EmojiItem = style([
DefaultReset,

View File

@@ -1 +1,2 @@
export * from './EmojiBoard';
export * from './types';

View File

@@ -0,0 +1,17 @@
export enum EmojiBoardTab {
Emoji = 'Emoji',
Sticker = 'Sticker',
}
export enum EmojiType {
Emoji = 'emoji',
CustomEmoji = 'customEmoji',
Sticker = 'sticker',
}
export type EmojiItemInfo = {
type: EmojiType;
data: string;
shortcode: string;
label: string;
};

View File

@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MVideoProps = {
content: IVideoContent;
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>

View File

@@ -1,6 +1,6 @@
import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..';
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = {
@@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
</Box>
);
return messageLayout === MessageLayout.Compact ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
);
if (messageLayout === MessageLayout.Compact) {
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
}
if (messageLayout === MessageLayout.Bubble) {
return (
<BubbleLayout hideBubble before={beforeJSX}>
{msgContentJSX}
</BubbleLayout>
);
}
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
}

View File

@@ -214,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
!markedAsSpoiler && (
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -3,6 +3,7 @@ import {
Badge,
Box,
Button,
Chip,
Icon,
Icons,
Spinner,
@@ -47,6 +48,8 @@ type VideoContentProps = {
info: IVideoInfo & IThumbnailContent;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
info,
encInfo,
autoPlay,
markedAsSpoiler,
spoilerReason,
renderThumbnail,
renderVideo,
...props
@@ -72,6 +77,7 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
@@ -114,11 +120,15 @@ export const VideoContent = as<'div', VideoContentProps>(
/>
)}
{renderThumbnail && !load && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
alignItems="Center"
justifyContent="Center"
>
{renderThumbnail()}
</Box>
)}
{!autoPlay && srcState.status === AsyncStatus.Idle && (
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
@@ -133,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}>
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({
title: body,
src: srcState.data,
@@ -144,8 +154,39 @@ export const VideoContent = as<'div', VideoContentProps>(
})}
</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);
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && (
!load &&
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -1,18 +1,63 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import { Box, ContainerColor, as, color } from 'folds';
import * as css from './layout.css';
type BubbleArrowProps = {
variant: ContainerColor;
};
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
return (
<svg
className={css.BubbleLeftArrow}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
fill={color[variant].Container}
/>
</svg>
);
}
type BubbleLayoutProps = {
hideBubble?: boolean;
before?: ReactNode;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, before, header, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{children}
</Box>
</Box>
)}
</Box>
</Box>
<Box className={css.BubbleContent} direction="Column">
{children}
</Box>
</Box>
));
)
);

View File

@@ -120,6 +120,7 @@ export const CompactHeader = style([
export const AvatarBase = style({
paddingTop: toRem(4),
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
display: 'flex',
alignSelf: 'start',
selectors: {
@@ -133,14 +134,31 @@ export const ModernBefore = style({
minWidth: toRem(36),
});
export const BubbleBefore = style([ModernBefore]);
export const BubbleBefore = style({
minWidth: toRem(36),
});
export const BubbleContent = style({
maxWidth: toRem(800),
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
borderRadius: config.radii.R500,
position: 'relative',
});
export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),
position: 'absolute',
top: 0,
left: toRem(-8),
zIndex: 1,
});
export const Username = style({

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
@@ -13,8 +13,54 @@ import {
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
type PreviewImageProps = {
fileItem: TUploadItem;
};
function PreviewImage({ fileItem }: PreviewImageProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
alt={originalFile.name}
src={fileUrl}
/>
);
}
type PreviewVideoProps = {
fileItem: TUploadItem;
};
function PreviewVideo({ fileItem }: PreviewVideoProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
/>
);
}
type MediaPreviewProps = {
fileItem: TUploadItem;
onSpoiler: (marked: boolean) => void;
children: ReactNode;
};
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
position: 'relative',
}}
>
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
alt={originalFile.name}
/>
{children}
<Box
justifyContent="End"
style={{
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
bottom={
<>
{fileItem.originalFile.type.startsWith('image') && (
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewImage fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.originalFile.type.startsWith('video') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />

View File

@@ -329,7 +329,7 @@ function LocalAddressesList({
<Box shrink="No">
<Checkbox
checked={selected}
onChange={() => toggleSelect(alias)}
onClick={() => toggleSelect(alias)}
size="50"
variant="Primary"
disabled={loading}

View File

@@ -27,7 +27,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useGetMemberPowerLevel, usePowerLevels } from '../../../hooks/usePowerLevels';
import { VirtualTile } from '../../../components/virtualizer';
import { MemberTile } from '../../../components/member-tile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -87,12 +87,13 @@ export function Members({ requestClose }: MembersProps) {
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
const memberPowerSort = useMemberPowerSort(creators);
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);

View File

@@ -183,7 +183,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
onClick={() => setAdvance(!advance)}
type="button"
>
<Text size="T200">Advance Options</Text>
<Text size="T200">Advanced Options</Text>
</Chip>
</Box>
</Box>

View File

@@ -184,7 +184,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
onClick={() => setAdvance(!advance)}
type="button"
>
<Text size="T200">Advance Options</Text>
<Text size="T200">Advanced Options</Text>
</Chip>
</Box>
</Box>

View File

@@ -59,7 +59,7 @@ export function General({ requestClose }: GeneralProps) {
<RoomLocalAddresses permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text>
<Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box>
</Box>

View File

@@ -51,7 +51,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu';
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
@@ -185,6 +185,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
const fetchingMembers = members.length < room.getJoinedMemberCount();
const openUserRoomProfile = useOpenUserRoomProfile();
@@ -198,7 +199,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
const memberPowerSort = useMemberPowerSort(creators);
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
const typingMembers = useRoomTypingMember(room.roomId);

View File

@@ -116,6 +116,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
interface RoomInputProps {
editor: Editor;
@@ -217,6 +218,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const isComposing = useComposingCheck();
useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), [])
@@ -380,7 +383,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
(evt) => {
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!evt.nativeEvent.isComposing
!isComposing(evt)
) {
evt.preventDefault();
submit();
@@ -394,7 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft(undefined);
}
},
[submit, setReplyDraft, enterForNewline, autocompleteQuery]
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
);
const handleKeyUp: KeyboardEventHandler = useCallback(

View File

@@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>();
@@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canDelete={canRedact || canDeleteOwn}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
@@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canDelete={canRedact || canDeleteOwn}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
@@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canDelete={canRedact || canDeleteOwn}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}

View File

@@ -79,9 +79,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
useCallback(
(evt) => {
if (editableActiveElement()) return;
// means some menu or modal window is open
const lastNode = document.body.lastElementChild;
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {

View File

@@ -723,6 +723,7 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@@ -790,7 +791,9 @@ export const Message = as<'div', MessageProps>(
);
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase>
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={css.MessageAvatar}
as="button"
@@ -875,7 +878,9 @@ export const Message = as<'div', MessageProps>(
return (
<MessageBase
className={classNames(css.MessageBase, className)}
className={classNames(css.MessageBase, className, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
@@ -1132,8 +1137,7 @@ export const Message = as<'div', MessageProps>(
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</BubbleLayout>
)}

View File

@@ -53,6 +53,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
type MessageEditorProps = {
roomId: string;
@@ -69,6 +70,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [toolbar, setToolbar] = useState(globalToolbar);
const isComposing = useComposingCheck();
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
@@ -163,7 +165,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if ((isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing) {
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
) {
evt.preventDefault();
handleSave();
}
@@ -172,7 +177,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCancel();
}
},
[onCancel, handleSave, enterForNewline]
[onCancel, handleSave, enterForNewline, isComposing]
);
const handleKeyUp: KeyboardEventHandler = useCallback(

View File

@@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds';
export const MessageBase = style({
position: 'relative',
});
export const MessageBaseBubbleCollapsed = style({
paddingTop: 0,
});
export const MessageOptionsBase = style([
DefaultReset,
@@ -21,6 +24,10 @@ export const MessageOptionsBar = style([
},
]);
export const BubbleAvatarBase = style({
paddingTop: 0,
});
export const MessageAvatar = style({
cursor: 'pointer',
});

View File

@@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
item: TUploadItem,
mxc: string
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError);
@@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
msgtype: MsgType.Video,
filename: file.name,
body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (videoEl) {
const [thumbError, thumbContent] = await to(

View File

@@ -434,9 +434,8 @@ export function SearchModalRenderer() {
return;
}
// means some menu or modal window is open
const lastNode = document.body.lastElementChild;
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
setOpen(true);

View File

@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v4.10.0</Text>
<Text size="T200">v4.10.3</Text>
</Box>
<Text>Yet another matrix client.</Text>
</Box>

View File

@@ -55,7 +55,7 @@ export function General({ requestClose }: GeneralProps) {
<RoomLocalAddresses permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text>
<Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box>
</Box>

View File

@@ -0,0 +1,47 @@
import { useCallback, useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { lastCompositionEndAtom } from '../state/lastCompositionEnd';
interface TimeStamped {
readonly timeStamp: number;
}
export function useCompositionEndTracking(): void {
const setLastCompositionEnd = useSetAtom(lastCompositionEndAtom);
const recordCompositionEnd = useCallback(
(evt: TimeStamped) => {
setLastCompositionEnd(evt.timeStamp);
},
[setLastCompositionEnd]
);
useEffect(() => {
window.addEventListener('compositionend', recordCompositionEnd, { capture: true });
return () => {
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
};
});
}
interface IsComposingLike {
readonly timeStamp: number;
readonly keyCode: number;
readonly nativeEvent: {
readonly isComposing?: boolean;
};
}
export function useComposingCheck({
compositionEndThreshold = 500,
}: { compositionEndThreshold?: number } = {}): (evt: IsComposingLike) => boolean {
const compositionEnd = useAtomValue(lastCompositionEndAtom);
return useCallback(
(evt: IsComposingLike): boolean =>
evt.nativeEvent.isComposing ||
(evt.keyCode === 229 &&
typeof compositionEnd !== 'undefined' &&
evt.timeStamp - compositionEnd < compositionEndThreshold),
[compositionEndThreshold, compositionEnd]
);
}

View File

@@ -47,7 +47,10 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb
return item;
};
export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
export const useMemberPowerSort = (
creators: Set<string>,
getPowerLevel: (userId: string) => number
): MemberSortFn => {
const sort: MemberSortFn = useCallback(
(a, b) => {
if (creators.has(a.userId) && creators.has(b.userId)) {
@@ -56,7 +59,7 @@ export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
if (creators.has(a.userId)) return -1;
if (creators.has(b.userId)) return 1;
return b.powerLevel - a.powerLevel;
return getPowerLevel(b.userId) - getPowerLevel(a.userId);
},
[creators]
);

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -10,44 +11,44 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
const queryClient = new QueryClient();
const useLastNodeToDetectReactPortalEntry = () => {
useEffect(() => {
const lastDiv = document.createElement('div');
lastDiv.setAttribute('data-last-node', 'true');
document.body.appendChild(lastDiv);
}, []);
};
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
useLastNodeToDetectReactPortalEntry();
const portalContainer = document.getElementById('portalContainer') ?? undefined;
return (
<ScreenSizeProvider value={screenSize}>
<FeatureCheck>
<ClientConfigLoader
fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => (
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
)}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
<TooltipContainerProvider value={portalContainer}>
<PopOutContainerProvider value={portalContainer}>
<OverlayContainerProvider value={portalContainer}>
<ScreenSizeProvider value={screenSize}>
<FeatureCheck>
<ClientConfigLoader
fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => (
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
)}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
);
}

View File

@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
v4.10.0
v4.10.3
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter

View File

@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
v4.10.0
v4.10.3
</a>
</span>
}

View File

@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const lastCompositionEndAtom = atom<number | undefined>(undefined);

View File

@@ -1,6 +1,7 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds';
import { ContainerColor } from './ContainerColor.css';
export const MarginSpaced = style({
marginBottom: config.space.S200,
@@ -92,11 +93,14 @@ export const CodeBlock = style([
overflow: 'hidden',
},
]);
export const CodeBlockHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
});
export const CodeBlockHeader = style([
ContainerColor({ variant: 'Surface' }),
{
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
},
]);
export const CodeBlockInternal = style([
CodeFont,
{