Merge branch 'cinnyapp:dev' into dev

This commit is contained in:
tezlm 2022-08-12 02:00:58 +00:00 committed by GitHub
commit 991724dad2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 4185 additions and 1879 deletions

View file

@ -1,4 +1,4 @@
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request --> <!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description ### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

View file

@ -13,7 +13,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.0.2 uses: actions/checkout@v3.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.3.0 uses: actions/setup-node@v3.4.1
with: with:
node-version: 17.9.0 node-version: 17.9.0
- name: Build app - name: Build app

View file

@ -15,7 +15,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.0.2 uses: actions/checkout@v3.0.2
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.1.1
with: with:
context: . context: .
push: false push: false

View file

@ -15,7 +15,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.0.2 uses: actions/checkout@v3.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.3.0 uses: actions/setup-node@v3.4.1
with: with:
node-version: 17.9.0 node-version: 17.9.0
- name: Build and deploy to Netlify - name: Build and deploy to Netlify

View file

@ -12,7 +12,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.0.2 uses: actions/checkout@v3.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.3.0 uses: actions/setup-node@v3.4.1
with: with:
node-version: 17.9.0 node-version: 17.9.0
- name: Build - name: Build
@ -50,7 +50,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.0.2 uses: actions/checkout@v3.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.3.0 uses: actions/setup-node@v3.4.1
with: with:
node-version: 17.9.0 node-version: 17.9.0
- name: Build and deploy to Netlify - name: Build and deploy to Netlify
@ -86,7 +86,7 @@ jobs:
with: with:
images: ajbura/cinny images: ajbura/cinny
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.1.1
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View file

@ -10,7 +10,7 @@ RUN npm run build
## App ## App
FROM nginx:1.21.6-alpine FROM nginx:1.23.1-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app

View file

@ -1,7 +1,6 @@
{ {
"defaultHomeserver": 4, "defaultHomeserver": 3,
"homeserverList": [ "homeserverList": [
"converser.eu",
"envs.net", "envs.net",
"halogen.city", "halogen.city",
"kde.org", "kde.org",

BIN
olm.wasm Normal file → Executable file

Binary file not shown.

3279
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": "2.0.4", "version": "2.1.2",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"engines": { "engines": {
@ -15,21 +15,23 @@
"author": "Ajay Bura", "author": "Ajay Bura",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource/inter": "^4.5.11", "@fontsource/inter": "^4.5.12",
"@fontsource/roboto": "^4.5.7", "@fontsource/roboto": "^4.5.8",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"blurhash": "^1.1.5",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"dateformat": "^5.0.3", "dateformat": "^5.0.3",
"emojibase-data": "^7.0.1", "emojibase-data": "^7.0.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"flux": "^4.0.3", "flux": "^4.0.3",
"formik": "^2.2.9", "formik": "^2.2.9",
"html-react-parser": "^2.0.0", "html-react-parser": "^3.0.1",
"katex": "^0.15.6", "katex": "^0.16.0",
"linkifyjs": "^2.1.9", "linkify-html": "^4.0.0-beta.5",
"matrix-js-sdk": "^18.1.0", "linkifyjs": "^4.0.0-beta.5",
"matrix-js-sdk": "^19.2.0",
"micromark": "^3.0.10", "micromark": "^3.0.10",
"micromark-extension-gfm": "^2.0.1", "micromark-extension-gfm": "^2.0.1",
"micromark-extension-math": "^2.0.2", "micromark-extension-math": "^2.0.2",
@ -39,19 +41,20 @@
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-autosize-textarea": "^7.1.0", "react-autosize-textarea": "^7.1.0",
"react-blurhash": "^0.1.3",
"react-dnd": "^15.1.2", "react-dnd": "^15.1.2",
"react-dnd-html5-backend": "^15.1.3", "react-dnd-html5-backend": "^15.1.3",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-google-recaptcha": "^2.1.0", "react-google-recaptcha": "^2.1.0",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"sanitize-html": "^2.7.0", "sanitize-html": "^2.7.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"twemoji": "^14.0.2" "twemoji": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.18.5", "@babel/core": "^7.18.10",
"@babel/preset-env": "^7.18.2", "@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.17.12", "@babel/preset-react": "^7.18.6",
"assert": "^2.0.0", "assert": "^2.0.0",
"babel-loader": "^8.2.5", "babel-loader": "^8.2.5",
"browserify-fs": "^1.0.0", "browserify-fs": "^1.0.0",
@ -61,27 +64,27 @@
"crypto-browserify": "^3.12.0", "crypto-browserify": "^3.12.0",
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.0.0", "css-minimizer-webpack-plugin": "^4.0.0",
"eslint": "^8.18.0", "eslint": "^8.21.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.30.0", "eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^6.2.2", "favicons": "^6.2.2",
"favicons-webpack-plugin": "^5.0.2", "favicons-webpack-plugin": "^5.0.2",
"html-loader": "^3.1.2", "html-loader": "^4.1.0",
"html-webpack-plugin": "^5.3.1", "html-webpack-plugin": "^5.3.1",
"mini-css-extract-plugin": "^2.6.1", "mini-css-extract-plugin": "^2.6.1",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"sass": "^1.52.3", "sass": "^1.54.3",
"sass-loader": "^13.0.0", "sass-loader": "^13.0.2",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"url": "^0.11.0", "url": "^0.11.0",
"util": "^0.12.4", "util": "^0.12.4",
"webpack": "^5.73.0", "webpack": "^5.74.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.2", "webpack-dev-server": "^4.9.3",
"webpack-merge": "^5.7.3" "webpack-merge": "^5.7.3"
} }
} }

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.92896 3.51471L3.51474 4.92892L5.97515 7.38933C4.46742 8.5776 3.32116 9.93994 2.7 10.8C2.1 11.5 2.1 12.5 2.7 13.2C4 15 7.6 19 12 19C13.5709 19 15.0398 18.4902 16.3384 17.7526L19.0711 20.4853L20.4853 19.0711L4.92896 3.51471ZM4.2 12C4.68291 11.3561 5.85678 9.9637 7.39721 8.81139L9.29238 10.7066C9.10496 11.0982 9 11.5368 9 12C9 13.6569 10.3431 15 12 15C12.4632 15 12.9018 14.895 13.2934 14.7076L14.8573 16.2715C13.9566 16.7128 12.9896 17 12 17C8.4 17 5.1 13.2 4.2 12Z" fill="black"/>
<path d="M9.6226 5.37995L11.2906 7.04797C11.5254 7.01661 11.762 7 12 7C15.6 7 18.9 10.8 19.8 12C19.493 12.4094 18.9066 13.1213 18.1244 13.8817L19.5194 15.2768C20.2973 14.4974 20.9049 13.7471 21.3 13.2C21.9 12.5 21.9 11.5 21.3 10.8C20 9 16.4 5 12 5C11.1762 5 10.3805 5.14021 9.6226 5.37995Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View file

@ -1,13 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <path d="M12 19C7.6 19 4 15 2.7 13.2C2.1 12.5 2.1 11.5 2.7 10.8C4 9 7.6 5 12 5C16.4 5 20 9 21.3 10.8C21.9 11.5 21.9 12.5 21.3 13.2C20 15 16.4 19 12 19ZM12 7C8.4 7 5.1 10.8 4.2 12C5.1 13.2 8.4 17 12 17C15.6 17 18.9 13.2 19.8 12C18.9 10.8 15.6 7 12 7Z" fill="black"/>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" fill="black"/>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g>
<g>
<path d="M12,19c-4.4,0-8-4-9.3-5.8c-0.6-0.7-0.6-1.7,0-2.4C4,9,7.6,5,12,5s8,4,9.3,5.8c0.6,0.7,0.6,1.7,0,2.4C20,15,16.4,19,12,19
z M12,7c-3.6,0-6.9,3.8-7.8,5c0.9,1.2,4.2,5,7.8,5s6.9-3.8,7.8-5C18.9,10.8,15.6,7,12,7z"/>
</g>
<circle cx="12" cy="12" r="3"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 508 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3L21 8V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16ZM19 9H17C15.8954 9 15 8.10457 15 7V5H5V19H19V9Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12H9Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View file

@ -26,12 +26,12 @@
&--icon { &--icon {
@include dir.side(padding, var(--sp-tight), var(--sp-loose)); @include dir.side(padding, var(--sp-tight), var(--sp-loose));
}
.ic-raw { .ic-raw {
@include dir.side(margin, 0, var(--sp-extra-tight)); @include dir.side(margin, 0, var(--sp-extra-tight));
flex-shrink: 0; flex-shrink: 0;
} }
} }
}
@mixin color($textColor, $iconColor) { @mixin color($textColor, $iconColor) {
.text { .text {

View file

@ -16,6 +16,7 @@ function Input({
{ resizable { resizable
? ( ? (
<TextareaAutosize <TextareaAutosize
dir="auto"
style={{ minHeight: `${minHeight}px` }} style={{ minHeight: `${minHeight}px` }}
name={name} name={name}
id={id} id={id}
@ -34,6 +35,7 @@ function Input({
/> />
) : ( ) : (
<input <input
dir="auto"
ref={forwardRef} ref={forwardRef}
id={id} id={id}
name={name} name={name}

View file

@ -5,7 +5,6 @@ import katex from 'katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex'; import 'katex/dist/contrib/copy-tex';
import 'katex/dist/contrib/copy-tex.css';
const Math = React.memo(({ const Math = React.memo(({
content, throwOnError, errorColor, displayMode, content, throwOnError, errorColor, displayMode,

View file

@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import dateFormat from 'dateformat';
import { isInSameDay } from '../../../util/common';
function Time({ timestamp, fullTime }) {
const date = new Date(timestamp);
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
let formattedDate = formattedFullTime;
if (!fullTime) {
const compareDate = new Date();
const isToday = isInSameDay(date, compareDate);
compareDate.setDate(compareDate.getDate() - 1);
const isYesterday = isInSameDay(date, compareDate);
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
if (isYesterday) {
formattedDate = `Yesterday, ${formattedDate}`;
}
}
return (
<time
dateTime={date.toISOString()}
title={formattedFullTime}
>
{formattedDate}
</time>
);
}
Time.defaultProps = {
fullTime: false,
};
Time.propTypes = {
timestamp: PropTypes.number.isRequired,
fullTime: PropTypes.bool,
};
export default Time;

View file

@ -0,0 +1,469 @@
import React, {
useState, useMemo, useReducer, useEffect,
} from 'react';
import PropTypes from 'prop-types';
import './ImagePack.scss';
import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
import { suffixRename } from '../../../util/common';
import Button from '../../atoms/button/Button';
import Text from '../../atoms/text/Text';
import Input from '../../atoms/input/Input';
import Checkbox from '../../atoms/button/Checkbox';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
import ImagePackProfile from './ImagePackProfile';
import ImagePackItem from './ImagePackItem';
import ImagePackUpload from './ImagePackUpload';
const renameImagePackItem = (shortcode) => new Promise((resolve) => {
let isCompleted = false;
openReusableDialog(
<Text variant="s1" weight="medium">Rename</Text>,
(requestClose) => (
<div style={{ padding: 'var(--sp-normal)' }}>
<form
onSubmit={(e) => {
e.preventDefault();
const sc = e.target.shortcode.value;
if (sc.trim() === '') return;
isCompleted = true;
resolve(sc.trim());
requestClose();
}}
>
<Input
value={shortcode}
name="shortcode"
label="Shortcode"
autoFocus
required
/>
<div style={{ height: 'var(--sp-normal)' }} />
<Button variant="primary" type="submit">Rename</Button>
</form>
</div>
),
() => {
if (!isCompleted) resolve(null);
},
);
});
function getUsage(usage) {
if (usage.includes('emoticon') && usage.includes('sticker')) return 'both';
if (usage.includes('emoticon')) return 'emoticon';
if (usage.includes('sticker')) return 'sticker';
return 'both';
}
function isGlobalPack(roomId, stateKey) {
const mx = initMatrix.matrixClient;
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
if (typeof globalContent !== 'object') return false;
const { rooms } = globalContent;
if (typeof rooms !== 'object') return false;
return rooms[roomId]?.[stateKey] !== undefined;
}
function useRoomImagePack(roomId, stateKey) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const packEvent = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = useMemo(() => (
ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent())
), [room, stateKey]);
const sendPackContent = (content) => {
mx.sendStateEvent(roomId, 'im.ponies.room_emotes', content, stateKey);
};
return {
pack,
sendPackContent,
};
}
function useUserImagePack() {
const mx = initMatrix.matrixClient;
const packEvent = mx.getAccountData('im.ponies.user_emotes');
const pack = useMemo(() => (
ImagePackBuilder.parsePack(mx.getUserId(), packEvent?.getContent() ?? {
pack: { display_name: 'Personal' },
images: {},
})
), []);
const sendPackContent = (content) => {
mx.setAccountData('im.ponies.user_emotes', content);
};
return {
pack,
sendPackContent,
};
}
function useImagePackHandles(pack, sendPackContent) {
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const getNewKey = (key) => {
if (typeof key !== 'string') return undefined;
let newKey = key?.replace(/\s/g, '_');
if (pack.getImages().get(newKey)) {
newKey = suffixRename(
newKey,
(suffixedKey) => pack.getImages().get(suffixedKey),
);
}
return newKey;
};
const handleAvatarChange = (url) => {
pack.setAvatarUrl(url);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleEditProfile = (name, attribution) => {
pack.setDisplayName(name);
pack.setAttribution(attribution);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleUsageChange = (newUsage) => {
const usage = [];
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
pack.setUsage(usage);
pack.getImages().forEach((img) => pack.setImageUsage(img.shortcode, undefined));
sendPackContent(pack.getContent());
forceUpdate();
};
const handleRenameItem = async (key) => {
const newKey = getNewKey(await renameImagePackItem(key));
if (!newKey || newKey === key) return;
pack.updateImageKey(key, newKey);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleDeleteItem = async (key) => {
const isConfirmed = await confirmDialog(
'Delete',
`Are you sure that you want to delete "${key}"?`,
'Delete',
'danger',
);
if (!isConfirmed) return;
pack.removeImage(key);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleUsageItem = (key, newUsage) => {
const usage = [];
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
pack.setImageUsage(key, usage);
sendPackContent(pack.getContent());
forceUpdate();
};
const handleAddItem = (key, url) => {
const newKey = getNewKey(key);
if (!newKey || !url) return;
pack.addImage(newKey, {
url,
});
sendPackContent(pack.getContent());
forceUpdate();
};
return {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
};
}
function addGlobalImagePack(mx, roomId, stateKey) {
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
if (!content.rooms) content.rooms = {};
if (!content.rooms[roomId]) content.rooms[roomId] = {};
content.rooms[roomId][stateKey] = {};
return mx.setAccountData('im.ponies.emote_rooms', content);
}
function removeGlobalImagePack(mx, roomId, stateKey) {
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
if (!content.rooms) return Promise.resolve();
if (!content.rooms[roomId]) return Promise.resolve();
delete content.rooms[roomId][stateKey];
if (Object.keys(content.rooms[roomId]).length === 0) {
delete content.rooms[roomId];
}
return mx.setAccountData('im.ponies.emote_rooms', content);
}
function ImagePack({ roomId, stateKey, handlePackDelete }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const [viewMore, setViewMore] = useState(false);
const [isGlobal, setIsGlobal] = useState(isGlobalPack(roomId, stateKey));
const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
const {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
} = useImagePackHandles(pack, sendPackContent);
const handleGlobalChange = (isG) => {
setIsGlobal(isG);
if (isG) addGlobalImagePack(mx, roomId, stateKey);
else removeGlobalImagePack(mx, roomId, stateKey);
};
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
const handleDeletePack = async () => {
const isConfirmed = await confirmDialog(
'Delete Pack',
`Are you sure that you want to delete "${pack.displayName}"?`,
'Delete',
'danger',
);
if (!isConfirmed) return;
handlePackDelete(stateKey);
};
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
return (
<div className="image-pack">
<ImagePackProfile
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
displayName={pack.displayName ?? 'Unknown'}
attribution={pack.attribution}
usage={getUsage(pack.usage)}
onUsageChange={canChange ? handleUsageChange : null}
onAvatarChange={canChange ? handleAvatarChange : null}
onEditProfile={canChange ? handleEditProfile : null}
/>
{ canChange && (
<ImagePackUpload onUpload={handleAddItem} />
)}
{ images.length === 0 ? null : (
<div>
<div className="image-pack__header">
<Text variant="b3">Image</Text>
<Text variant="b3">Shortcode</Text>
<Text variant="b3">Usage</Text>
</div>
{images.map(([shortcode, image]) => (
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={canChange ? handleUsageItem : undefined}
onDelete={canChange ? handleDeleteItem : undefined}
onRename={canChange ? handleRenameItem : undefined}
/>
))}
</div>
)}
{(pack.images.size > 2 || handlePackDelete) && (
<div className="image-pack__footer">
{pack.images.size > 2 && (
<Button onClick={() => setViewMore(!viewMore)}>
{
viewMore
? 'View less'
: `View ${pack.images.size - 2} more`
}
</Button>
)}
{ handlePackDelete && <Button variant="danger" onClick={handleDeletePack}>Delete Pack</Button>}
</div>
)}
<div className="image-pack__global">
<Checkbox variant="positive" onToggle={handleGlobalChange} isActive={isGlobal} />
<div>
<Text variant="b2">Use globally</Text>
<Text variant="b3">Add this pack to your account to use in all rooms.</Text>
</div>
</div>
</div>
);
}
ImagePack.defaultProps = {
handlePackDelete: null,
};
ImagePack.propTypes = {
roomId: PropTypes.string.isRequired,
stateKey: PropTypes.string.isRequired,
handlePackDelete: PropTypes.func,
};
function ImagePackUser() {
const mx = initMatrix.matrixClient;
const [viewMore, setViewMore] = useState(false);
const { pack, sendPackContent } = useUserImagePack();
const {
handleAvatarChange,
handleEditProfile,
handleUsageChange,
handleRenameItem,
handleDeleteItem,
handleUsageItem,
handleAddItem,
} = useImagePackHandles(pack, sendPackContent);
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
return (
<div className="image-pack">
<ImagePackProfile
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
displayName={pack.displayName ?? 'Personal'}
attribution={pack.attribution}
usage={getUsage(pack.usage)}
onUsageChange={handleUsageChange}
onAvatarChange={handleAvatarChange}
onEditProfile={handleEditProfile}
/>
<ImagePackUpload onUpload={handleAddItem} />
{ images.length === 0 ? null : (
<div>
<div className="image-pack__header">
<Text variant="b3">Image</Text>
<Text variant="b3">Shortcode</Text>
<Text variant="b3">Usage</Text>
</div>
{images.map(([shortcode, image]) => (
<ImagePackItem
key={shortcode}
url={mx.mxcUrlToHttp(image.mxc)}
shortcode={shortcode}
usage={getUsage(image.usage)}
onUsageChange={handleUsageItem}
onDelete={handleDeleteItem}
onRename={handleRenameItem}
/>
))}
</div>
)}
{(pack.images.size > 2) && (
<div className="image-pack__footer">
<Button onClick={() => setViewMore(!viewMore)}>
{
viewMore
? 'View less'
: `View ${pack.images.size - 2} more`
}
</Button>
</div>
)}
</div>
);
}
function useGlobalImagePack() {
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const mx = initMatrix.matrixClient;
const roomIdToStateKeys = new Map();
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? { rooms: {} };
const { rooms } = globalContent;
Object.keys(rooms).forEach((roomId) => {
if (typeof rooms[roomId] !== 'object') return;
const room = mx.getRoom(roomId);
const stateKeys = Object.keys(rooms[roomId]);
if (!room || stateKeys.length === 0) return;
roomIdToStateKeys.set(roomId, stateKeys);
});
useEffect(() => {
const handleEvent = (event) => {
if (event.getType() === 'im.ponies.emote_rooms') forceUpdate();
};
mx.addListener('accountData', handleEvent);
return () => {
mx.removeListener('accountData', handleEvent);
};
}, []);
return roomIdToStateKeys;
}
function ImagePackGlobal() {
const mx = initMatrix.matrixClient;
const roomIdToStateKeys = useGlobalImagePack();
const handleChange = (roomId, stateKey) => {
removeGlobalImagePack(mx, roomId, stateKey);
};
return (
<div className="image-pack-global">
<MenuHeader>Global packs</MenuHeader>
<div>
{
roomIdToStateKeys.size > 0
? [...roomIdToStateKeys].map(([roomId, stateKeys]) => {
const room = mx.getRoom(roomId);
return (
stateKeys.map((stateKey) => {
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = ImagePackBuilder.parsePack(data?.getId(), data?.getContent());
if (!pack) return null;
return (
<div className="image-pack__global" key={pack.id}>
<Checkbox variant="positive" onToggle={() => handleChange(roomId, stateKey)} isActive />
<div>
<Text variant="b2">{pack.displayName ?? 'Unknown'}</Text>
<Text variant="b3">{room.name}</Text>
</div>
</div>
);
})
);
})
: <div className="image-pack-global__empty"><Text>No global packs</Text></div>
}
</div>
</div>
);
}
export default ImagePack;
export { ImagePackUser, ImagePackGlobal };

View file

@ -0,0 +1,47 @@
@use '../../partials/flex';
.image-pack {
&-item {
border-top: 1px solid var(--bg-surface-border);
}
&__header {
padding: var(--sp-extra-tight) var(--sp-normal);
display: flex;
align-items: center;
gap: var(--sp-normal);
& > *:nth-child(2) {
@extend .cp-fx__item-one;
}
}
&__footer {
padding: var(--sp-normal);
display: flex;
justify-content: space-between;
gap: var(--sp-tight);
}
&__global {
padding: var(--sp-normal);
padding-top: var(--sp-tight);
display: flex;
align-items: center;
gap: var(--sp-normal);
}
}
.image-pack-global {
&__empty {
text-align: center;
padding: var(--sp-extra-loose) var(--sp-normal);
}
& .image-pack__global {
padding: 0 var(--sp-normal);
padding-bottom: var(--sp-normal);
&:first-child {
padding-top: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ImagePackItem.scss';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Avatar from '../../atoms/avatar/Avatar';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import ImagePackUsageSelector from './ImagePackUsageSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
function ImagePackItem({
url, shortcode, usage, onUsageChange, onDelete, onRename,
}) {
const handleUsageSelect = (event) => {
openReusableContextMenu(
'bottom',
getEventCords(event, '.btn-surface'),
(closeMenu) => (
<ImagePackUsageSelector
usage={usage}
onSelect={(newUsage) => {
onUsageChange(shortcode, newUsage);
closeMenu();
}}
/>
),
);
};
return (
<div className="image-pack-item">
<Avatar imageSrc={url} size="extra-small" text={shortcode} bgColor="black" />
<div className="image-pack-item__content">
<Text>{shortcode}</Text>
</div>
<div className="image-pack-item__usage">
<div className="image-pack-item__btn">
{onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={() => onRename(shortcode)} />}
{onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={() => onDelete(shortcode)} />}
</div>
<Button onClick={onUsageChange ? handleUsageSelect : undefined}>
{onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />}
<Text variant="b2">
{usage === 'emoticon' && 'Emoji'}
{usage === 'sticker' && 'Sticker'}
{usage === 'both' && 'Both'}
</Text>
</Button>
</div>
</div>
);
}
ImagePackItem.defaultProps = {
onUsageChange: null,
onDelete: null,
onRename: null,
};
ImagePackItem.propTypes = {
url: PropTypes.string.isRequired,
shortcode: PropTypes.string.isRequired,
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onUsageChange: PropTypes.func,
onDelete: PropTypes.func,
onRename: PropTypes.func,
};
export default ImagePackItem;

View file

@ -0,0 +1,43 @@
@use '../../partials/flex';
@use '../../partials/dir';
.image-pack-item {
margin: 0 var(--sp-normal);
padding: var(--sp-tight) 0;
display: flex;
align-items: center;
gap: var(--sp-normal);
& .avatar-container img {
object-fit: contain;
border-radius: 0;
}
&__content {
@extend .cp-fx__item-one;
}
&__usage {
display: flex;
gap: var(--sp-ultra-tight);
& button {
padding: 6px;
}
& > button.btn-surface {
padding: 6px var(--sp-tight);
min-width: 0;
@include dir.side(margin, var(--sp-ultra-tight), 0);
}
}
&__btn {
display: none;
}
&:hover,
&:focus-within {
.image-pack-item__btn {
display: flex;
gap: var(--sp-ultra-tight);
}
}
}

View file

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './ImagePackProfile.scss';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import ImageUpload from '../image-upload/ImageUpload';
import ImagePackUsageSelector from './ImagePackUsageSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
function ImagePackProfile({
avatarUrl, displayName, attribution, usage,
onUsageChange, onAvatarChange, onEditProfile,
}) {
const [isEdit, setIsEdit] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const { nameInput, attributionInput } = e.target;
const name = nameInput.value.trim() || undefined;
const att = attributionInput.value.trim() || undefined;
onEditProfile(name, att);
setIsEdit(false);
};
const handleUsageSelect = (event) => {
openReusableContextMenu(
'bottom',
getEventCords(event, '.btn-surface'),
(closeMenu) => (
<ImagePackUsageSelector
usage={usage}
onSelect={(newUsage) => {
onUsageChange(newUsage);
closeMenu();
}}
/>
),
);
};
return (
<div className="image-pack-profile">
{
onAvatarChange
? (
<ImageUpload
bgColor="#555"
text={displayName}
imageSrc={avatarUrl}
size="normal"
onUpload={onAvatarChange}
onRequestRemove={() => onAvatarChange(undefined)}
/>
)
: <Avatar bgColor="#555" text={displayName} imageSrc={avatarUrl} size="normal" />
}
<div className="image-pack-profile__content">
{
isEdit
? (
<form onSubmit={handleSubmit}>
<Input name="nameInput" label="Name" value={displayName} required />
<Input name="attributionInput" label="Attribution" value={attribution} resizable />
<div>
<Button variant="primary" type="submit">Save</Button>
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
</div>
</form>
) : (
<>
<div>
<Text>{displayName}</Text>
{onEditProfile && <IconButton size="extra-small" onClick={() => setIsEdit(true)} src={PencilIC} tooltip="Edit" />}
</div>
{attribution && <Text variant="b3">{attribution}</Text>}
</>
)
}
</div>
<div className="image-pack-profile__usage">
<Text variant="b3">Pack usage</Text>
<Button
onClick={onUsageChange ? handleUsageSelect : undefined}
iconSrc={onUsageChange ? ChevronBottomIC : null}
>
<Text>
{usage === 'emoticon' && 'Emoji'}
{usage === 'sticker' && 'Sticker'}
{usage === 'both' && 'Both'}
</Text>
</Button>
</div>
</div>
);
}
ImagePackProfile.defaultProps = {
avatarUrl: null,
attribution: null,
onUsageChange: null,
onAvatarChange: null,
onEditProfile: null,
};
ImagePackProfile.propTypes = {
avatarUrl: PropTypes.string,
displayName: PropTypes.string.isRequired,
attribution: PropTypes.string,
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onUsageChange: PropTypes.func,
onAvatarChange: PropTypes.func,
onEditProfile: PropTypes.func,
};
export default ImagePackProfile;

View file

@ -0,0 +1,37 @@
@use '../../partials/flex';
.image-pack-profile {
padding: var(--sp-normal);
display: flex;
align-items: flex-start;
gap: var(--sp-tight);
&__content {
@extend .cp-fx__item-one;
& > div:first-child {
display: flex;
align-items: center;
gap: var(--sp-extra-tight);
& .ic-btn {
padding: var(--sp-ultra-tight);
}
}
& > form {
display: flex;
flex-direction: column;
gap: var(--sp-extra-tight);
& > div:last-child {
margin: var(--sp-extra-tight) 0;
display: flex;
gap: var(--sp-tight);
}
}
}
&__usage {
& > *:first-child {
margin-bottom: var(--sp-ultra-tight);
}
}
}

View file

@ -0,0 +1,75 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import './ImagePackUpload.scss';
import initMatrix from '../../../client/initMatrix';
import { scaleDownImage } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import IconButton from '../../atoms/button/IconButton';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
function ImagePackUpload({ onUpload }) {
const mx = initMatrix.matrixClient;
const inputRef = useRef(null);
const shortcodeRef = useRef(null);
const [imgFile, setImgFile] = useState(null);
const [progress, setProgress] = useState(false);
const handleSubmit = async (evt) => {
evt.preventDefault();
if (!imgFile) return;
const { shortcodeInput } = evt.target;
const shortcode = shortcodeInput.value.trim();
if (shortcode === '') return;
setProgress(true);
const image = await scaleDownImage(imgFile, 512, 512);
const url = await mx.uploadContent(image, {
onlyContentUri: true,
});
onUpload(shortcode, url);
setProgress(false);
setImgFile(null);
shortcodeRef.current.value = '';
};
const handleFileChange = (evt) => {
const img = evt.target.files[0];
if (!img) return;
setImgFile(img);
shortcodeRef.current.value = img.name.slice(0, img.name.indexOf('.'));
shortcodeRef.current.focus();
};
const handleRemove = () => {
setImgFile(null);
inputRef.current.value = null;
shortcodeRef.current.value = '';
};
return (
<form onSubmit={handleSubmit} className="image-pack-upload">
<input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" accept=".png, .gif, .webp" required />
{
imgFile
? (
<div className="image-pack-upload__file">
<IconButton onClick={handleRemove} src={CirclePlusIC} tooltip="Remove file" />
<Text>{imgFile.name}</Text>
</div>
)
: <Button onClick={() => inputRef.current.click()}>Import image</Button>
}
<Input forwardRef={shortcodeRef} name="shortcodeInput" placeholder="shortcode" required />
<Button disabled={progress} variant="primary" type="submit">{progress ? 'Uploading...' : 'Upload'}</Button>
</form>
);
}
ImagePackUpload.propTypes = {
onUpload: PropTypes.func.isRequired,
};
export default ImagePackUpload;

View file

@ -0,0 +1,43 @@
@use '../../partials/dir';
@use '../../partials/text';
.image-pack-upload {
padding: var(--sp-normal);
padding-top: 0;
display: flex;
gap: var(--sp-tight);
& > .input-container {
flex-grow: 1;
input {
padding: 9px var(--sp-normal);
}
}
&__file {
display: inline-flex;
align-items: center;
background: var(--bg-surface-low);
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
& button {
--parent-height: 40px;
width: var(--parent-height);
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
& .ic-raw {
background-color: var(--bg-caution);
transform: rotate(45deg);
}
& .text {
@extend .cp-txt__ellipsis;
@include dir.side(margin, var(--sp-ultra-tight), var(--sp-normal));
max-width: 86px;
}
}
}

View file

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import CheckIC from '../../../../public/res/ic/outlined/check.svg';
function ImagePackUsageSelector({ usage, onSelect }) {
return (
<div>
<MenuHeader>Usage</MenuHeader>
<MenuItem
iconSrc={usage === 'emoticon' ? CheckIC : undefined}
variant={usage === 'emoticon' ? 'positive' : 'surface'}
onClick={() => onSelect('emoticon')}
>
Emoji
</MenuItem>
<MenuItem
iconSrc={usage === 'sticker' ? CheckIC : undefined}
variant={usage === 'sticker' ? 'positive' : 'surface'}
onClick={() => onSelect('sticker')}
>
Sticker
</MenuItem>
<MenuItem
iconSrc={usage === 'both' ? CheckIC : undefined}
variant={usage === 'both' ? 'positive' : 'surface'}
onClick={() => onSelect('both')}
>
Both
</MenuItem>
</div>
);
}
ImagePackUsageSelector.propTypes = {
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
onSelect: PropTypes.func.isRequired,
};
export default ImagePackUsageSelector;

View file

@ -7,9 +7,13 @@ import initMatrix from '../../../client/initMatrix';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import Spinner from '../../atoms/spinner/Spinner'; import Spinner from '../../atoms/spinner/Spinner';
import RawIcon from '../../atoms/system-icons/RawIcon';
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
function ImageUpload({ function ImageUpload({
text, bgColor, imageSrc, onUpload, onRequestRemove, text, bgColor, imageSrc, onUpload, onRequestRemove,
size,
}) { }) {
const [uploadPromise, setUploadPromise] = useState(null); const [uploadPromise, setUploadPromise] = useState(null);
const uploadImageRef = useRef(null); const uploadImageRef = useRef(null);
@ -50,10 +54,14 @@ function ImageUpload({
imageSrc={imageSrc} imageSrc={imageSrc}
text={text} text={text}
bgColor={bgColor} bgColor={bgColor}
size="large" size={size}
/> />
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}> <div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
{uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>} {uploadPromise === null && (
size === 'large'
? <Text variant="b3" weight="bold">Upload</Text>
: <RawIcon src={PlusIC} color="white" />
)}
{uploadPromise !== null && <Spinner size="small" />} {uploadPromise !== null && <Spinner size="small" />}
</div> </div>
</button> </button>
@ -75,6 +83,7 @@ ImageUpload.defaultProps = {
text: null, text: null,
bgColor: 'transparent', bgColor: 'transparent',
imageSrc: null, imageSrc: null,
size: 'large',
}; };
ImageUpload.propTypes = { ImageUpload.propTypes = {
@ -83,6 +92,7 @@ ImageUpload.propTypes = {
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
onUpload: PropTypes.func.isRequired, onUpload: PropTypes.func.isRequired,
onRequestRemove: PropTypes.func.isRequired, onRequestRemove: PropTypes.func.isRequired,
size: PropTypes.oneOf(['large', 'normal']),
}; };
export default ImageUpload; export default ImageUpload;

View file

@ -4,6 +4,7 @@ import './Media.scss';
import encrypt from 'browser-encrypt-attachment'; import encrypt from 'browser-encrypt-attachment';
import { BlurhashCanvas } from 'react-blurhash';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner'; import Spinner from '../../atoms/spinner/Spinner';
@ -12,15 +13,19 @@ import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg'; import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
import PlaySVG from '../../../../public/res/ic/outlined/play.svg'; import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
// https://github.com/matrix-org/matrix-react-sdk/blob/a9e28db33058d1893d964ec96cd247ecc3d92fc3/src/utils/blobs.ts#L73 // https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts#L73
const ALLOWED_BLOB_MIMETYPES = [ const ALLOWED_BLOB_MIMETYPES = [
'image/jpeg', 'image/jpeg',
'image/gif', 'image/gif',
'image/png', 'image/png',
'image/apng',
'image/webp',
'image/avif',
'video/mp4', 'video/mp4',
'video/webm', 'video/webm',
'video/ogg', 'video/ogg',
'video/quicktime',
'audio/mp4', 'audio/mp4',
'audio/webm', 'audio/webm',
@ -38,6 +43,10 @@ function getBlobSafeMimeType(mimetype) {
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
return 'application/octet-stream'; return 'application/octet-stream';
} }
// Required for Chromium browsers
if (mimetype === 'video/quicktime') {
return 'video/mp4';
}
return mimetype; return mimetype;
} }
@ -61,9 +70,8 @@ async function getUrl(link, type, decryptData) {
} }
} }
function getNativeHeight(width, height) { function getNativeHeight(width, height, maxWidth = 296) {
const MEDIA_MAX_WIDTH = 296; const scale = maxWidth / width;
const scale = MEDIA_MAX_WIDTH / width;
return scale * height; return scale * height;
} }
@ -147,9 +155,10 @@ File.propTypes = {
}; };
function Image({ function Image({
name, width, height, link, file, type, name, width, height, link, file, type, blurhash,
}) { }) {
const [url, setUrl] = useState(null); const [url, setUrl] = useState(null);
const [blur, setBlur] = useState(true);
useEffect(() => { useEffect(() => {
let unmounted = false; let unmounted = false;
@ -168,7 +177,8 @@ function Image({
<div className="file-container"> <div className="file-container">
<FileHeader name={name} link={url || link} type={type} external /> <FileHeader name={name} link={url || link} type={type} external />
<div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container"> <div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
{ url !== null && <img src={url || link} alt={name} />} { blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
{ url !== null && <img style={{ display: blur ? 'none' : 'unset' }} onLoad={() => setBlur(false)} src={url || link} alt={name} />}
</div> </div>
</div> </div>
); );
@ -178,6 +188,7 @@ Image.defaultProps = {
width: null, width: null,
height: null, height: null,
type: '', type: '',
blurhash: '',
}; };
Image.propTypes = { Image.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
@ -186,6 +197,46 @@ Image.propTypes = {
link: PropTypes.string.isRequired, link: PropTypes.string.isRequired,
file: PropTypes.shape({}), file: PropTypes.shape({}),
type: PropTypes.string, type: PropTypes.string,
blurhash: PropTypes.string,
};
function Sticker({
name, height, width, link, file, type,
}) {
const [url, setUrl] = useState(null);
useEffect(() => {
let unmounted = false;
async function fetchUrl() {
const myUrl = await getUrl(link, type, file);
if (unmounted) return;
setUrl(myUrl);
}
fetchUrl();
return () => {
unmounted = true;
};
}, []);
return (
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
{ url !== null && <img src={url || link} title={name} alt={name} />}
</div>
);
}
Sticker.defaultProps = {
file: null,
type: '',
width: null,
height: null,
};
Sticker.propTypes = {
name: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
link: PropTypes.string.isRequired,
file: PropTypes.shape({}),
type: PropTypes.string,
}; };
function Audio({ function Audio({
@ -232,12 +283,13 @@ Audio.propTypes = {
}; };
function Video({ function Video({
name, link, thumbnail, name, link, thumbnail, thumbnailFile, thumbnailType,
width, height, file, type, thumbnailFile, thumbnailType, width, height, file, type, blurhash,
}) { }) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [url, setUrl] = useState(null); const [url, setUrl] = useState(null);
const [thumbUrl, setThumbUrl] = useState(null); const [thumbUrl, setThumbUrl] = useState(null);
const [blur, setBlur] = useState(true);
useEffect(() => { useEffect(() => {
let unmounted = false; let unmounted = false;
@ -252,16 +304,16 @@ function Video({
}; };
}, []); }, []);
async function loadVideo() { const loadVideo = async () => {
const myUrl = await getUrl(link, type, file); const myUrl = await getUrl(link, type, file);
setUrl(myUrl); setUrl(myUrl);
setIsLoading(false); setIsLoading(false);
} };
function handlePlayVideo() { const handlePlayVideo = () => {
setIsLoading(true); setIsLoading(true);
loadVideo(); loadVideo();
} };
return ( return (
<div className="file-container"> <div className="file-container">
@ -269,13 +321,19 @@ function Video({
<div <div
style={{ style={{
height: width !== null ? getNativeHeight(width, height) : 'unset', height: width !== null ? getNativeHeight(width, height) : 'unset',
backgroundImage: thumbUrl === null ? 'none' : `url(${thumbUrl}`,
}} }}
className="video-container" className="video-container"
> >
{ url === null && isLoading && <Spinner size="small" /> } { url === null ? (
{ url === null && !isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />} <>
{ url !== null && ( { blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
{ thumbUrl !== null && (
<img style={{ display: blur ? 'none' : 'unset' }} src={thumbUrl} onLoad={() => setBlur(false)} alt={name} />
)}
{isLoading && <Spinner size="small" />}
{!isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
</>
) : (
/* eslint-disable-next-line jsx-a11y/media-has-caption */ /* eslint-disable-next-line jsx-a11y/media-has-caption */
<video autoPlay controls poster={thumbUrl}> <video autoPlay controls poster={thumbUrl}>
<source src={url} type={getBlobSafeMimeType(type)} /> <source src={url} type={getBlobSafeMimeType(type)} />
@ -290,22 +348,24 @@ Video.defaultProps = {
height: null, height: null,
file: null, file: null,
thumbnail: null, thumbnail: null,
type: '',
thumbnailType: null, thumbnailType: null,
thumbnailFile: null, thumbnailFile: null,
type: '',
blurhash: null,
}; };
Video.propTypes = { Video.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
link: PropTypes.string.isRequired, link: PropTypes.string.isRequired,
thumbnail: PropTypes.string, thumbnail: PropTypes.string,
thumbnailFile: PropTypes.shape({}),
thumbnailType: PropTypes.string,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
file: PropTypes.shape({}), file: PropTypes.shape({}),
type: PropTypes.string, type: PropTypes.string,
thumbnailFile: PropTypes.shape({}), blurhash: PropTypes.string,
thumbnailType: PropTypes.string,
}; };
export { export {
File, Image, Audio, Video, File, Image, Sticker, Audio, Video,
}; };

View file

@ -27,6 +27,15 @@
white-space: initial; white-space: initial;
} }
.sticker-container {
display: inline-flex;
max-width: 128px;
width: 100%;
& img {
width: 100% !important;
}
}
.image-container, .image-container,
.video-container, .video-container,
.audio-container { .audio-container {
@ -42,25 +51,33 @@
background-size: cover; background-size: cover;
} }
.image-container { .image-container,
& img { .video-container {
& img,
& canvas {
max-width: unset !important; max-width: unset !important;
width: 100% !important; width: 100% !important;
height: 100%;
border-radius: 0 !important; border-radius: 0 !important;
margin: 0 !important; margin: 0 !important;
} }
} }
.video-container { .video-container {
position: relative;
& .ic-btn-surface { & .ic-btn-surface {
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
} }
& .ic-btn-surface,
& .donut-spinner {
position: absolute;
}
video { video {
width: 100% width: 100%;
} }
} }
.audio-container { .audio-container {
audio { audio {
width: 100% width: 100%;
} }
} }

View file

@ -5,7 +5,6 @@ import React, {
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Message.scss'; import './Message.scss';
import { getShortcodeToCustomEmoji } from '../../organisms/emoji-board/custom-emoji';
import { twemojify } from '../../../util/twemojify'; import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
@ -25,6 +24,7 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
import Input from '../../atoms/input/Input'; import Input from '../../atoms/input/Input';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import Time from '../../atoms/time/Time';
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu'; import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
import * as Media from '../media/Media'; import * as Media from '../media/Media';
@ -68,7 +68,7 @@ const MessageAvatar = React.memo(({
)); ));
const MessageHeader = React.memo(({ const MessageHeader = React.memo(({
userId, username, time, userId, username, timestamp, fullTime,
}) => ( }) => (
<div className="message__header"> <div className="message__header">
<Text <Text
@ -82,14 +82,20 @@ const MessageHeader = React.memo(({
<span>{twemojify(userId)}</span> <span>{twemojify(userId)}</span>
</Text> </Text>
<div className="message__time"> <div className="message__time">
<Text variant="b3">{time}</Text> <Text variant="b3">
<Time timestamp={timestamp} fullTime={fullTime} />
</Text>
</div> </div>
</div> </div>
)); ));
MessageHeader.defaultProps = {
fullTime: false,
};
MessageHeader.propTypes = { MessageHeader.propTypes = {
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
username: PropTypes.string.isRequired, username: PropTypes.string.isRequired,
time: PropTypes.string.isRequired, timestamp: PropTypes.number.isRequired,
fullTime: PropTypes.bool,
}; };
function MessageReply({ name, color, body }) { function MessageReply({ name, color, body }) {
@ -162,8 +168,8 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
}, []); }, []);
const focusReply = (ev) => { const focusReply = (ev) => {
if (!ev.keyCode || ev.keyCode === 32 || ev.keyCode === 13) { if (!ev.key || ev.key === ' ' || ev.key === 'Enter') {
if (ev.keyCode) ev.preventDefault(); if (ev.key) ev.preventDefault();
if (reply?.event === null) return; if (reply?.event === null) return;
if (reply?.event.isRedacted()) return; if (reply?.event.isRedacted()) return;
roomTimeline.loadEventTimeline(eventId); roomTimeline.loadEventTimeline(eventId);
@ -200,7 +206,13 @@ const MessageBody = React.memo(({
let content = null; let content = null;
if (isCustomHTML) { if (isCustomHTML) {
try { try {
content = twemojify(sanitizeCustomHtml(body), undefined, true, false, true); content = twemojify(
sanitizeCustomHtml(initMatrix.matrixClient, body),
undefined,
true,
false,
true,
);
} catch { } catch {
console.error('Malformed custom html: ', body); console.error('Malformed custom html: ', body);
content = twemojify(body, undefined); content = twemojify(body, undefined);
@ -240,7 +252,7 @@ const MessageBody = React.memo(({
return ( return (
<div className="message__body"> <div className="message__body">
<div className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}> <div dir="auto" className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}>
{ msgType === 'm.emote' && ( { msgType === 'm.emote' && (
<> <>
{'* '} {'* '}
@ -277,7 +289,7 @@ function MessageEdit({ body, onSave, onCancel }) {
}, []); }, []);
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.keyCode === 13 && e.shiftKey === false) { if (e.key === 'Enter' && e.shiftKey === false) {
e.preventDefault(); e.preventDefault();
onSave(editInputRef.current.value); onSave(editInputRef.current.value);
} }
@ -322,7 +334,7 @@ function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
return rEvent; return rEvent;
} }
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) { function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline); const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
if (myAlreadyReactEvent) { if (myAlreadyReactEvent) {
const rId = myAlreadyReactEvent.getId(); const rId = myAlreadyReactEvent.getId();
@ -330,17 +342,17 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
redactEvent(roomId, rId); redactEvent(roomId, rId);
return; return;
} }
sendReaction(roomId, eventId, emojiKey); sendReaction(roomId, eventId, emojiKey, shortcode);
} }
function pickEmoji(e, roomId, eventId, roomTimeline) { function pickEmoji(e, roomId, eventId, roomTimeline) {
openEmojiBoard(getEventCords(e), (emoji) => { openEmojiBoard(getEventCords(e), (emoji) => {
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline); toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
e.target.click(); e.target.click();
}); });
} }
function genReactionMsg(userIds, reaction) { function genReactionMsg(userIds, reaction, shortcode) {
return ( return (
<> <>
{userIds.map((userId, index) => ( {userIds.map((userId, index) => (
@ -354,24 +366,22 @@ function genReactionMsg(userIds, reaction) {
</React.Fragment> </React.Fragment>
))} ))}
<span style={{ opacity: '.6' }}>{' reacted with '}</span> <span style={{ opacity: '.6' }}>{' reacted with '}</span>
{twemojify(reaction, { className: 'react-emoji' })} {twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
</> </>
); );
} }
function MessageReaction({ function MessageReaction({
shortcodeToEmoji, reaction, count, users, isActive, onClick, reaction, shortcode, count, users, isActive, onClick,
}) { }) {
const customEmojiMatch = reaction.match(/^:(\S+):$/);
let customEmojiUrl = null; let customEmojiUrl = null;
if (customEmojiMatch) { if (reaction.match(/^mxc:\/\/\S+$/)) {
const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]); customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
} }
return ( return (
<Tooltip <Tooltip
className="msg__reaction-tooltip" className="msg__reaction-tooltip"
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>} content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
> >
<button <button
onClick={onClick} onClick={onClick}
@ -380,7 +390,7 @@ function MessageReaction({
> >
{ {
customEmojiUrl customEmojiUrl
? <img className="react-emoji" draggable="false" alt={reaction} src={customEmojiUrl} /> ? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
: twemojify(reaction, { className: 'react-emoji' }) : twemojify(reaction, { className: 'react-emoji' })
} }
<Text variant="b3" className="msg__reaction-count">{count}</Text> <Text variant="b3" className="msg__reaction-count">{count}</Text>
@ -388,9 +398,12 @@ function MessageReaction({
</Tooltip> </Tooltip>
); );
} }
MessageReaction.defaultProps = {
shortcode: undefined,
};
MessageReaction.propTypes = { MessageReaction.propTypes = {
shortcodeToEmoji: PropTypes.shape({}).isRequired,
reaction: PropTypes.node.isRequired, reaction: PropTypes.node.isRequired,
shortcode: PropTypes.string,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
users: PropTypes.arrayOf(PropTypes.string).isRequired, users: PropTypes.arrayOf(PropTypes.string).isRequired,
isActive: PropTypes.bool.isRequired, isActive: PropTypes.bool.isRequired,
@ -401,11 +414,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
const { roomId, room, reactionTimeline } = roomTimeline; const { roomId, room, reactionTimeline } = roomTimeline;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const reactions = {}; const reactions = {};
const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId()); const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
const eventReactions = reactionTimeline.get(mEvent.getId()); const eventReactions = reactionTimeline.get(mEvent.getId());
const addReaction = (key, count, senderId, isActive) => { const addReaction = (key, shortcode, count, senderId, isActive) => {
let reaction = reactions[key]; let reaction = reactions[key];
if (reaction === undefined) { if (reaction === undefined) {
reaction = { reaction = {
@ -414,6 +426,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
isActive: false, isActive: false,
}; };
} }
if (shortcode) reaction.shortcode = shortcode;
if (count) { if (count) {
reaction.count = count; reaction.count = count;
} else { } else {
@ -429,9 +442,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
if (rEvent.getRelation() === null) return; if (rEvent.getRelation() === null) return;
const reaction = rEvent.getRelation(); const reaction = rEvent.getRelation();
const senderId = rEvent.getSender(); const senderId = rEvent.getSender();
const { shortcode } = rEvent.getContent();
const isActive = senderId === mx.getUserId(); const isActive = senderId === mx.getUserId();
addReaction(reaction.key, undefined, senderId, isActive); addReaction(reaction.key, shortcode, undefined, senderId, isActive);
}); });
} else { } else {
// Use aggregated reactions // Use aggregated reactions
@ -439,7 +453,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
if (!aggregatedReaction) return null; if (!aggregatedReaction) return null;
aggregatedReaction.forEach((reaction) => { aggregatedReaction.forEach((reaction) => {
if (reaction.type !== 'm.reaction') return; if (reaction.type !== 'm.reaction') return;
addReaction(reaction.key, reaction.count, undefined, false); addReaction(reaction.key, undefined, reaction.count, undefined, false);
}); });
} }
@ -449,13 +463,13 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
Object.keys(reactions).map((key) => ( Object.keys(reactions).map((key) => (
<MessageReaction <MessageReaction
key={key} key={key}
shortcodeToEmoji={shortcodeToEmoji}
reaction={key} reaction={key}
shortcode={reactions[key].shortcode}
count={reactions[key].count} count={reactions[key].count}
users={reactions[key].users} users={reactions[key].users}
isActive={reactions[key].isActive} isActive={reactions[key].isActive}
onClick={() => { onClick={() => {
toggleEmoji(roomId, mEvent.getId(), key, roomTimeline); toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
}} }}
/> />
)) ))
@ -607,7 +621,9 @@ function genMediaContent(mE) {
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>; if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
let msgType = mE.getContent()?.msgtype; let msgType = mE.getContent()?.msgtype;
if (mE.getType() === 'm.sticker') msgType = 'm.image'; if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
const blurhash = mContent?.info?.['xyz.amorgan.blurhash'];
switch (msgType) { switch (msgType) {
case 'm.file': case 'm.file':
@ -628,6 +644,18 @@ function genMediaContent(mE) {
link={mx.mxcUrlToHttp(mediaMXC)} link={mx.mxcUrlToHttp(mediaMXC)}
file={isEncryptedFile ? mContent.file : null} file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype} type={mContent.info?.mimetype}
blurhash={blurhash}
/>
);
case 'm.sticker':
return (
<Media.Sticker
name={mContent.body}
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
link={mx.mxcUrlToHttp(mediaMXC)}
file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype}
/> />
); );
case 'm.audio': case 'm.audio':
@ -654,6 +682,7 @@ function genMediaContent(mE) {
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null} height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
file={isEncryptedFile ? mContent.file : null} file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype} type={mContent.info?.mimetype}
blurhash={blurhash}
/> />
); );
default: default:
@ -674,7 +703,7 @@ function getEditedBody(editedMEvent) {
} }
function Message({ function Message({
mEvent, isBodyOnly, roomTimeline, focus, time, mEvent, isBodyOnly, roomTimeline, focus, fullTime,
}) { }) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const roomId = mEvent.getRoomId(); const roomId = mEvent.getRoomId();
@ -735,7 +764,12 @@ function Message({
} }
<div className="message__main-container"> <div className="message__main-container">
{!isBodyOnly && ( {!isBodyOnly && (
<MessageHeader userId={senderId} username={username} time={time} /> <MessageHeader
userId={senderId}
username={username}
timestamp={mEvent.getTs()}
fullTime={fullTime}
/>
)} )}
{roomTimeline && isReply && ( {roomTimeline && isReply && (
<MessageReplyWrapper <MessageReplyWrapper
@ -783,13 +817,14 @@ Message.defaultProps = {
isBodyOnly: false, isBodyOnly: false,
focus: false, focus: false,
roomTimeline: null, roomTimeline: null,
fullTime: false,
}; };
Message.propTypes = { Message.propTypes = {
mEvent: PropTypes.shape({}).isRequired, mEvent: PropTypes.shape({}).isRequired,
isBodyOnly: PropTypes.bool, isBodyOnly: PropTypes.bool,
roomTimeline: PropTypes.shape({}), roomTimeline: PropTypes.shape({}),
focus: PropTypes.bool, focus: PropTypes.bool,
time: PropTypes.string.isRequired, fullTime: PropTypes.bool,
}; };
export { Message, MessageReply, PlaceholderMessage }; export { Message, MessageReply, PlaceholderMessage };

View file

@ -250,7 +250,6 @@
cursor: pointer; cursor: pointer;
& .react-emoji { & .react-emoji {
width: 16px;
height: 16px; height: 16px;
margin: 2px; margin: 2px;
} }

View file

@ -4,6 +4,7 @@ import './TimelineChange.scss';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon'; import RawIcon from '../../atoms/system-icons/RawIcon';
import Time from '../../atoms/time/Time';
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg'; import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg'; import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
@ -12,7 +13,7 @@ import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-canc
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
function TimelineChange({ function TimelineChange({
variant, content, time, onClick, variant, content, timestamp, onClick,
}) { }) {
let iconSrc; let iconSrc;
@ -48,7 +49,9 @@ function TimelineChange({
</Text> </Text>
</div> </div>
<div className="timeline-change__time"> <div className="timeline-change__time">
<Text variant="b3">{time}</Text> <Text variant="b3">
<Time timestamp={timestamp} />
</Text>
</div> </div>
</button> </button>
); );
@ -68,7 +71,7 @@ TimelineChange.propTypes = {
PropTypes.string, PropTypes.string,
PropTypes.node, PropTypes.node,
]).isRequired, ]).isRequired,
time: PropTypes.string.isRequired, timestamp: PropTypes.number.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
}; };

View file

@ -0,0 +1,130 @@
import React, { useReducer, useEffect } from 'react';
import PropTypes from 'prop-types';
import './RoomEmojis.scss';
import initMatrix from '../../../client/initMatrix';
import { suffixRename } from '../../../util/common';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import Text from '../../atoms/text/Text';
import Input from '../../atoms/input/Input';
import Button from '../../atoms/button/Button';
import ImagePack from '../image-pack/ImagePack';
function useRoomPacks(room) {
const mx = initMatrix.matrixClient;
const [, forceUpdate] = useReducer((count) => count + 1, 0);
const packEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
const unUsablePacks = [];
const usablePacks = packEvents.filter((mEvent) => {
if (typeof mEvent.getContent()?.images !== 'object') {
unUsablePacks.push(mEvent);
return false;
}
return true;
});
useEffect(() => {
const handleEvent = (event, state, prevEvent) => {
if (event.getRoomId() !== room.roomId) return;
if (event.getType() !== 'im.ponies.room_emotes') return;
if (!prevEvent?.getContent()?.images || !event.getContent().images) {
forceUpdate();
}
};
mx.on('RoomState.events', handleEvent);
return () => {
mx.removeListener('RoomState.events', handleEvent);
};
}, [room, mx]);
const isStateKeyAvailable = (key) => !room.currentState.getStateEvents('im.ponies.room_emotes', key);
const createPack = async (name) => {
const packContent = {
pack: { display_name: name },
images: {},
};
let stateKey = '';
if (unUsablePacks.length > 0) {
const mEvent = unUsablePacks[0];
stateKey = mEvent.getStateKey();
} else {
stateKey = packContent.pack.display_name.replace(/\s/g, '-');
if (!isStateKeyAvailable(stateKey)) {
stateKey = suffixRename(
stateKey,
isStateKeyAvailable,
);
}
}
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', packContent, stateKey);
};
const deletePack = async (stateKey) => {
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', {}, stateKey);
};
return {
usablePacks,
createPack,
deletePack,
};
}
function RoomEmojis({ roomId }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const { usablePacks, createPack, deletePack } = useRoomPacks(room);
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
const handlePackCreate = (e) => {
e.preventDefault();
const { nameInput } = e.target;
const name = nameInput.value.trim();
if (name === '') return;
nameInput.value = '';
createPack(name);
};
return (
<div className="room-emojis">
{ canChange && (
<div className="room-emojis__add-pack">
<MenuHeader>Create Pack</MenuHeader>
<form onSubmit={handlePackCreate}>
<Input name="nameInput" placeholder="Pack Name" required />
<Button variant="primary" type="submit">Create pack</Button>
</form>
</div>
)}
{
usablePacks.length > 0
? usablePacks.reverse().map((mEvent) => (
<ImagePack
key={mEvent.getId()}
roomId={roomId}
stateKey={mEvent.getStateKey()}
handlePackDelete={canChange ? deletePack : undefined}
/>
)) : (
<div className="room-emojis__empty">
<Text>No emoji or sticker pack.</Text>
</div>
)
}
</div>
);
}
RoomEmojis.propTypes = {
roomId: PropTypes.string.isRequired,
};
export default RoomEmojis;

View file

@ -0,0 +1,29 @@
.room-emojis {
.image-pack,
.room-emojis__add-pack,
.room-emojis__empty {
margin: var(--sp-normal) 0;
background-color: var(--bg-surface);
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
overflow: hidden;
& > .context-menu__header:first-child {
margin-top: 2px;
}
}
&__add-pack {
& form {
margin: var(--sp-normal);
display: flex;
gap: var(--sp-normal);
& .input-container {
flex-grow: 1;
}
}
}
&__empty {
padding: var(--sp-extra-loose) var(--sp-normal);
text-align: center;
}
}

View file

@ -237,12 +237,12 @@ function RoomPermissions({ roomId }) {
? permissions[permInfo.parent]?.[permKey] ? permissions[permInfo.parent]?.[permKey]
: permissions[permKey]; : permissions[permKey];
if (!permValue) permValue = permInfo.default; if (permValue === undefined) permValue = permInfo.default;
if (typeof permValue === 'number') { if (typeof permValue === 'number') {
powerLevel = permValue; powerLevel = permValue;
} else if (permKey === 'notifications') { } else if (permKey === 'notifications') {
powerLevel = permValue.room || 50; powerLevel = permValue.room ?? 50;
} }
return ( return (
<SettingTile <SettingTile

View file

@ -132,7 +132,7 @@ function RoomProfile({ roomId }) {
const renderEditNameAndTopic = () => ( const renderEditNameAndTopic = () => (
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}> <form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" required />} {canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" />}
{canChangeTopic && <Input value={roomTopic} name="room-topic" disabled={status.type === cons.status.IN_FLIGHT} minHeight={100} resizable label="Topic" />} {canChangeTopic && <Input value={roomTopic} name="room-topic" disabled={status.type === cons.status.IN_FLIGHT} minHeight={100} resizable label="Topic" />}
{(!canChangeName || !canChangeTopic) && <Text variant="b3">{`You have permission to change ${room.isSpaceRoom() ? 'space' : 'room'} ${canChangeName ? 'name' : 'topic'} only.`}</Text>} {(!canChangeName || !canChangeTopic) && <Text variant="b3">{`You have permission to change ${room.isSpaceRoom() ? 'space' : 'room'} ${canChangeName ? 'name' : 'topic'} only.`}</Text>}
{ status.type === cons.status.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>} { status.type === cons.status.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>}

View file

@ -2,8 +2,6 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './RoomSearch.scss'; import './RoomSearch.scss';
import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { selectRoom } from '../../../client/action/navigation'; import { selectRoom } from '../../../client/action/navigation';
@ -120,14 +118,13 @@ function RoomSearch({ roomId }) {
const renderTimeline = (timeline) => ( const renderTimeline = (timeline) => (
<div className="room-search__result-item" key={timeline[0].getId()}> <div className="room-search__result-item" key={timeline[0].getId()}>
{ timeline.map((mEvent) => { { timeline.map((mEvent) => {
const time = dateFormat(mEvent.getDate(), 'dd/mm/yyyy - hh:MM TT');
const id = mEvent.getId(); const id = mEvent.getId();
return ( return (
<React.Fragment key={id}> <React.Fragment key={id}>
<Message <Message
mEvent={mEvent} mEvent={mEvent}
isBodyOnly={false} isBodyOnly={false}
time={time} fullTime
/> />
<Button onClick={() => selectRoom(roomId, id)}>View</Button> <Button onClick={() => selectRoom(roomId, id)}>View</Button>
</React.Fragment> </React.Fragment>

View file

@ -5,6 +5,7 @@ import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation'; import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
import { markAsRead } from '../../../client/action/notifications';
import { leave } from '../../../client/action/room'; import { leave } from '../../../client/action/room';
import { import {
createSpaceShortcut, createSpaceShortcut,
@ -17,6 +18,7 @@ import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import CategoryIC from '../../../../public/res/ic/outlined/category.svg'; import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg'; import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg'; import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg'; import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
@ -28,11 +30,21 @@ import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function SpaceOptions({ roomId, afterOptionSelect }) { function SpaceOptions({ roomId, afterOptionSelect }) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const { roomList } = initMatrix;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
const canInvite = room?.canInvite(mx.getUserId()); const canInvite = room?.canInvite(mx.getUserId());
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId); const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId); const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
const handleMarkAsRead = () => {
const spaceChildren = roomList.getCategorizedSpaces([roomId]);
spaceChildren?.forEach((childIds, spaceId) => {
childIds?.forEach((childId) => {
markAsRead(childId);
})
});
afterOptionSelect();
};
const handleInviteClick = () => { const handleInviteClick = () => {
openInviteUser(roomId); openInviteUser(roomId);
afterOptionSelect(); afterOptionSelect();
@ -71,6 +83,7 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
return ( return (
<div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}> <div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}>
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader> <MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
<MenuItem iconSrc={TickMarkIC} onClick={handleMarkAsRead}>Mark as read</MenuItem>
<MenuItem <MenuItem
onClick={handleCategorizeClick} onClick={handleCategorizeClick}
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC} iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}

View file

@ -70,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
unicode={`:${emoji.shortcode}:`} unicode={`:${emoji.shortcode}:`}
shortcodes={emoji.shortcode} shortcodes={emoji.shortcode}
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)} src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
data-mx-emoticon data-mx-emoticon={emoji.mxc}
/> />
) )
} }
@ -141,10 +141,13 @@ function EmojiBoard({ onSelect, searchRef }) {
function getEmojiDataFromTarget(target) { function getEmojiDataFromTarget(target) {
const unicode = target.getAttribute('unicode'); const unicode = target.getAttribute('unicode');
const hexcode = target.getAttribute('hexcode'); const hexcode = target.getAttribute('hexcode');
const mxc = target.getAttribute('data-mx-emoticon');
let shortcodes = target.getAttribute('shortcodes'); let shortcodes = target.getAttribute('shortcodes');
if (typeof shortcodes === 'undefined') shortcodes = undefined; if (typeof shortcodes === 'undefined') shortcodes = undefined;
else shortcodes = shortcodes.split(','); else shortcodes = shortcodes.split(',');
return { unicode, hexcode, shortcodes }; return {
unicode, hexcode, shortcodes, mxc,
};
} }
function selectEmoji(e) { function selectEmoji(e) {
@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) {
setAvailableEmojis([]); setAvailableEmojis([]);
return; return;
} }
// Retrieve the packs for the new room
// Remove packs that aren't marked as emoji packs const mx = initMatrix.matrixClient;
// Remove packs without emojis const room = mx.getRoom(selectedRoomId);
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
if (room) {
const packs = getRelevantPacks( const packs = getRelevantPacks(
initMatrix.matrixClient.getRoom(selectedRoomId), room.client,
) [room, ...parentRooms],
.filter((pack) => pack.usage.indexOf('emoticon') !== -1) ).filter((pack) => pack.getEmojis().length !== 0);
.filter((pack) => pack.getEmojis().length !== 0);
// Set an index for each pack so that we know where to jump when the user uses the nav // Set an index for each pack so that we know where to jump when the user uses the nav
for (let i = 0; i < packs.length; i += 1) { for (let i = 0; i < packs.length; i += 1) {
packs[i].packIndex = i; packs[i].packIndex = i;
} }
setAvailableEmojis(packs); setAvailableEmojis(packs);
}
}; };
const onOpen = () => { const onOpen = () => {
@ -247,39 +252,6 @@ function EmojiBoard({ onSelect, searchRef }) {
return ( return (
<div id="emoji-board" className="emoji-board"> <div id="emoji-board" className="emoji-board">
<div className="emoji-board__content">
<div className="emoji-board__content__search">
<RawIcon size="small" src={SearchIC} />
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
</div>
<div className="emoji-board__content__emojis">
<ScrollView ref={scrollEmojisRef} autoHide>
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
<SearchedEmoji />
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
{
availableEmojis.map((pack) => (
<EmojiGroup
name={pack.displayName}
key={pack.packIndex}
groupEmojis={pack.getEmojis()}
className="custom-emoji-group"
/>
))
}
{
emojiGroups.map((group) => (
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
))
}
</div>
</ScrollView>
</div>
<div ref={emojiInfo} className="emoji-board__content__info">
<div>{ parse(twemoji.parse('🙂')) }</div>
<Text>:slight_smile:</Text>
</div>
</div>
<ScrollView invisible> <ScrollView invisible>
<div className="emoji-board__nav"> <div className="emoji-board__nav">
{recentEmojis.length > 0 && ( {recentEmojis.length > 0 && (
@ -287,20 +259,21 @@ function EmojiBoard({ onSelect, searchRef }) {
onClick={() => openGroup(0)} onClick={() => openGroup(0)}
src={RecentClockIC} src={RecentClockIC}
tooltip="Recent" tooltip="Recent"
tooltipPlacement="right" tooltipPlacement="left"
/> />
)} )}
<div className="emoji-board__nav-custom"> <div className="emoji-board__nav-custom">
{ {
availableEmojis.map((pack) => { availableEmojis.map((pack) => {
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc); const src = initMatrix.matrixClient
.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
return ( return (
<IconButton <IconButton
onClick={() => openGroup(recentOffset + pack.packIndex)} onClick={() => openGroup(recentOffset + pack.packIndex)}
src={src} src={src}
key={pack.packIndex} key={pack.packIndex}
tooltip={pack.displayName} tooltip={pack.displayName ?? 'Unknown'}
tooltipPlacement="right" tooltipPlacement="left"
isImage isImage
/> />
); );
@ -324,13 +297,46 @@ function EmojiBoard({ onSelect, searchRef }) {
key={indx} key={indx}
src={ico} src={ico}
tooltip={name} tooltip={name}
tooltipPlacement="right" tooltipPlacement="left"
/> />
)) ))
} }
</div> </div>
</div> </div>
</ScrollView> </ScrollView>
<div className="emoji-board__content">
<div className="emoji-board__content__search">
<RawIcon size="small" src={SearchIC} />
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
</div>
<div className="emoji-board__content__emojis">
<ScrollView ref={scrollEmojisRef} autoHide>
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
<SearchedEmoji />
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
{
availableEmojis.map((pack) => (
<EmojiGroup
name={pack.displayName ?? 'Unknown'}
key={pack.packIndex}
groupEmojis={pack.getEmojis()}
className="custom-emoji-group"
/>
))
}
{
emojiGroups.map((group) => (
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
))
}
</div>
</ScrollView>
</div>
<div ref={emojiInfo} className="emoji-board__content__info">
<div>{ parse(twemoji.parse('🙂')) }</div>
<Text>:slight_smile:</Text>
</div>
</div>
</div> </div>
); );
} }

View file

@ -25,8 +25,7 @@
min-height: 100%; min-height: 100%;
padding: 4px 6px; padding: 4px 6px;
background-color: var(--bg-surface-low); @include dir.side(border, none, 1px solid var(--bg-surface-border));
@include dir.side(border, 1px solid var(--bg-surface-border), none);
position: relative; position: relative;
@ -84,6 +83,7 @@
.emoji { .emoji {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: contain;
} }
} }
& > p:last-child { & > p:last-child {
@ -121,8 +121,12 @@
@include dir.side(margin, var(--left-margin), var(--right-margin)); @include dir.side(margin, var(--left-margin), var(--right-margin));
} }
& .emoji { & .emoji {
width: 38px; max-width: 38px;
height: 38px; max-height: 38px;
width: 100%;
height: 100%;
overflow: hidden;
object-fit: contain;
padding: var(--emoji-padding); padding: var(--emoji-padding);
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View file

@ -1,135 +1,224 @@
import { emojis } from './emoji'; import { emojis } from './emoji';
// Custom emoji are stored in one of three places: // https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
// - User emojis, which are stored in account data
// - Room emojis, which are stored in state events in a room
// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
// cannonical space
//
// Emojis and packs referenced from within a user's account data should be available
// globally, while emojis and packs in rooms and spaces should only be available within
// those spaces and rooms
class ImagePack { class ImagePack {
// Convert a raw image pack into a more maliable format static parsePack(eventId, packContent) {
// if (!eventId || typeof packContent?.images !== 'object') {
// Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
// format used here, while filling in defaults.
//
// The room argument is the room the pack exists in, which is used as a fallback for
// missing properties
//
// Returns `null` if the rawPack is not a properly formatted image pack, although there
// is still a fair amount of tolerance for malformed packs.
static parsePack(rawPack, room) {
if (typeof rawPack.images === 'undefined') {
return null; return null;
} }
const pack = rawPack.pack ?? {}; return new ImagePack(eventId, packContent);
}
const displayName = pack.display_name ?? (room ? room.name : undefined); constructor(eventId, content) {
const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined); this.id = eventId;
const usage = pack.usage ?? ['emoticon', 'sticker']; this.content = JSON.parse(JSON.stringify(content));
const { attribution } = pack;
const images = Object.entries(rawPack.images).flatMap((e) => { this.applyPack(content);
const data = e[1]; this.applyImages(content);
const shortcode = e[0]; }
applyPack(content) {
const pack = content.pack ?? {};
this.displayName = pack.display_name;
this.avatarUrl = pack.avatar_url;
this.usage = pack.usage ?? ['emoticon', 'sticker'];
this.attribution = pack.attribution;
}
applyImages(content) {
this.images = new Map();
this.emoticons = [];
this.stickers = [];
Object.entries(content.images).forEach(([shortcode, data]) => {
const mxc = data.url; const mxc = data.url;
const body = data.body ?? shortcode; const body = data.body ?? shortcode;
const usage = data.usage ?? this.usage;
const { info } = data; const { info } = data;
const usage_ = data.usage ?? usage;
if (mxc) { if (!mxc) return;
return [{ const image = {
shortcode, mxc, body, info, usage: usage_, shortcode, mxc, body, usage, info,
}]; };
this.images.set(shortcode, image);
if (usage.includes('emoticon')) {
this.emoticons.push(image);
} }
return []; if (usage.includes('sticker')) {
this.stickers.push(image);
}
});
}
getImages() {
return this.images;
}
getEmojis() {
return this.emoticons;
}
getStickers() {
return this.stickers;
}
getContent() {
return this.content;
}
_updatePackProperty(property, value) {
if (this.content.pack === undefined) {
this.content.pack = {};
}
this.content.pack[property] = value;
this.applyPack(this.content);
}
setAvatarUrl(avatarUrl) {
this._updatePackProperty('avatar_url', avatarUrl);
}
setDisplayName(displayName) {
this._updatePackProperty('display_name', displayName);
}
setAttribution(attribution) {
this._updatePackProperty('attribution', attribution);
}
setUsage(usage) {
this._updatePackProperty('usage', usage);
}
addImage(key, imgContent) {
this.content.images = {
[key]: imgContent,
...this.content.images,
};
this.applyImages(this.content);
}
removeImage(key) {
if (this.content.images[key] === undefined) return;
delete this.content.images[key];
this.applyImages(this.content);
}
updateImageKey(key, newKey) {
if (this.content.images[key] === undefined) return;
const copyImages = {};
Object.keys(this.content.images).forEach((imgKey) => {
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
});
this.content.images = copyImages;
this.applyImages(this.content);
}
_updateImageProperty(key, property, value) {
if (this.content.images[key] === undefined) return;
this.content.images[key][property] = value;
this.applyImages(this.content);
}
setImageUrl(key, url) {
this._updateImageProperty(key, 'url', url);
}
setImageBody(key, body) {
this._updateImageProperty(key, 'body', body);
}
setImageInfo(key, info) {
this._updateImageProperty(key, 'info', info);
}
setImageUsage(key, usage) {
this._updateImageProperty(key, 'usage', usage);
}
}
function getGlobalImagePacks(mx) {
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
if (typeof globalContent !== 'object') return [];
const { rooms } = globalContent;
if (typeof rooms !== 'object') return [];
const roomIds = Object.keys(rooms);
const packs = roomIds.flatMap((roomId) => {
if (typeof rooms[roomId] !== 'object') return [];
const room = mx.getRoom(roomId);
if (!room) return [];
const stateKeys = Object.keys(rooms[roomId]);
return stateKeys.map((stateKey) => {
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
if (pack) {
pack.displayName ??= room.name;
pack.avatarUrl ??= room.getMxcAvatarUrl();
}
return pack;
}).filter((pack) => pack !== null);
}); });
return new ImagePack(displayName, avatar, usage, attribution, images); return packs;
} }
constructor(displayName, avatar, usage, attribution, images) {
this.displayName = displayName;
this.avatar = avatar;
this.usage = usage;
this.attribution = attribution;
this.images = images;
}
// Produce a list of emoji in this image pack
getEmojis() {
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
}
// Produce a list of stickers in this image pack
getStickers() {
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
}
}
// Retrieve a list of user emojis
//
// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
// image pack.
//
// Accepts a reference to a matrix client as the only argument
function getUserImagePack(mx) { function getUserImagePack(mx) {
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes'); const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
if (!accountDataEmoji) { if (!accountDataEmoji) {
return null; return null;
} }
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content); const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
if (userImagePack) userImagePack.displayName ??= 'Your Emoji'; userImagePack.displayName ??= 'Personal Emoji';
return userImagePack; return userImagePack;
} }
// Produces a list of all of the emoji packs in a room function getRoomImagePacks(room) {
// const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
// Returns a list of `ImagePack`s. This does not include packs in spaces that contain
// this room.
function getPacksInRoom(room) {
const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
return packs return dataEvents
.map((p) => ImagePack.parsePack(p.event.content, room)) .map((data) => {
.filter((p) => p !== null); const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
if (pack) {
pack.displayName ??= room.name;
pack.avatarUrl ??= room.getMxcAvatarUrl();
}
return pack;
})
.filter((pack) => pack !== null);
} }
// Produce a list of all image packs which should be shown for a given room /**
// * @param {MatrixClient} mx Provide if you want to include user personal/global pack
// This includes packs in that room, the user's personal images, and will eventually * @param {Room[]} rooms Provide rooms if you want to include rooms pack
// include the user's enabled global image packs and space-level packs. * @returns {ImagePack[]} packs
// */
// This differs from getPacksInRoom, as the former only returns packs that are directly in function getRelevantPacks(mx, rooms) {
// a room, whereas this function returns all packs which should be shown to the user while const userPack = mx ? getUserImagePack(mx) : [];
// they are in this room. const globalPacks = mx ? getGlobalImagePacks(mx) : [];
// const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
// Packs will be returned in the order that shortcode conflicts should be resolved, with const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
// higher priority packs coming first.
function getRelevantPacks(room) {
return [].concat( return [].concat(
getUserImagePack(room.client) ?? [], userPack ?? [],
getPacksInRoom(room), globalPacks,
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
); );
} }
// Returns all user+room emojis and all standard unicode emojis function getShortcodeToEmoji(mx, rooms) {
//
// Accepts a reference to a matrix client as the only argument
//
// Result is a map from shortcode to the corresponding emoji. If two emoji share a
// shortcode, only one will be presented, with priority given to custom emoji.
//
// Will eventually be expanded to include all emojis revelant to a room and the user
function getShortcodeToEmoji(room) {
const allEmoji = new Map(); const allEmoji = new Map();
emojis.forEach((emoji) => { emojis.forEach((emoji) => {
if (emoji.shortcodes.constructor.name === 'Array') { if (Array.isArray(emoji.shortcodes)) {
emoji.shortcodes.forEach((shortcode) => { emoji.shortcodes.forEach((shortcode) => {
allEmoji.set(shortcode, emoji); allEmoji.set(shortcode, emoji);
}); });
@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
} }
}); });
getRelevantPacks(room).reverse() getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
function getShortcodeToCustomEmoji(room) { function getShortcodeToCustomEmoji(room) {
const allEmoji = new Map(); const allEmoji = new Map();
getRelevantPacks(room).reverse() getRelevantPacks(room.client, [room])
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
return allEmoji; return allEmoji;
} }
// Produces a special list of emoji specifically for auto-completion function getEmojiForCompletion(mx, rooms) {
//
// This list contains each emoji once, with all emoji being deduplicated by shortcode.
// However, the order of the standard emoji will have been preserved, and alternate
// shortcodes for the standard emoji will not be considered.
//
// Standard emoji are guaranteed to be earlier in the list than custom emoji
function getEmojiForCompletion(room) {
const allEmoji = new Map(); const allEmoji = new Map();
getRelevantPacks(room).reverse() getRelevantPacks(mx, rooms)
.flatMap((pack) => pack.getEmojis()) .flatMap((pack) => pack.getEmojis())
.forEach((emoji) => { .forEach((emoji) => {
allEmoji.set(emoji.shortcode, emoji); allEmoji.set(emoji.shortcode, emoji);
}); });
return emojis.filter((e) => !allEmoji.has(e.shortcode)) return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
.concat(Array.from(allEmoji.values()));
} }
export { export {
getUserImagePack, ImagePack,
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
getShortcodeToEmoji, getShortcodeToCustomEmoji, getShortcodeToEmoji, getShortcodeToCustomEmoji,
getRelevantPacks, getEmojiForCompletion, getRelevantPacks, getEmojiForCompletion,
}; };

View file

@ -1,5 +1,6 @@
import emojisData from 'emojibase-data/en/compact.json'; import emojisData from 'emojibase-data/en/compact.json';
import shortcodes from 'emojibase-data/en/shortcodes/joypixels.json'; import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
const emojiGroups = [{ const emojiGroups = [{
name: 'Smileys & people', name: 'Smileys & people',
@ -52,7 +53,7 @@ function addToGroup(emoji) {
const emojis = []; const emojis = [];
emojisData.forEach((emoji) => { emojisData.forEach((emoji) => {
const myShortCodes = shortcodes[emoji.hexcode]; const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
if (!myShortCodes) return; if (!myShortCodes) return;
const em = { const em = {
...emoji, ...emoji,

View file

@ -6,7 +6,7 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import * as roomActions from '../../../client/action/room'; import * as roomActions from '../../../client/action/room';
import { selectRoom } from '../../../client/action/navigation'; import { selectRoom } from '../../../client/action/navigation';
import { hasDMWith } from '../../../util/matrixUtil'; import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
@ -103,18 +103,6 @@ function InviteUser({
updateIsSearching(false); updateIsSearching(false);
} }
async function hasDevices(userId) {
try {
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
return Object.values(usersDeviceMap).every((userDevices) =>
Object.keys(userDevices).length > 0,
);
} catch (e) {
console.error("Error determining if it's possible to encrypt to all users: ", e);
return false;
}
}
async function createDM(userId) { async function createDM(userId) {
if (mx.getUserId() === userId) return; if (mx.getUserId() === userId) return;
const dmRoomId = hasDMWith(userId); const dmRoomId = hasDMWith(userId);

View file

@ -11,7 +11,7 @@ import { selectRoom, openReusableContextMenu } from '../../../client/action/navi
import * as roomActions from '../../../client/action/room'; import * as roomActions from '../../../client/action/room';
import { import {
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices
} from '../../../util/matrixUtil'; } from '../../../util/matrixUtil';
import { getEventCords } from '../../../util/common'; import { getEventCords } from '../../../util/common';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@ -201,7 +201,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
// Create new DM // Create new DM
try { try {
setIsCreatingDM(true); setIsCreatingDM(true);
await roomActions.createDM(userId); await roomActions.createDM(userId, await hasDevices(userId));
} catch { } catch {
if (isMountedRef.current === false) return; if (isMountedRef.current === false) return;
setIsCreatingDM(false); setIsCreatingDM(false);

View file

@ -25,9 +25,11 @@ import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomH
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption'; import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions'; import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers'; import RoomMembers from '../../molecules/room-members/RoomMembers';
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg'; import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg'; import LockIC from '../../../../public/res/ic/outlined/lock.svg';
@ -42,6 +44,7 @@ const tabText = {
GENERAL: 'General', GENERAL: 'General',
SEARCH: 'Search', SEARCH: 'Search',
MEMBERS: 'Members', MEMBERS: 'Members',
EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions', PERMISSIONS: 'Permissions',
SECURITY: 'Security', SECURITY: 'Security',
}; };
@ -58,6 +61,10 @@ const tabItems = [{
iconSrc: UserIC, iconSrc: UserIC,
text: tabText.MEMBERS, text: tabText.MEMBERS,
disabled: false, disabled: false,
}, {
iconSrc: EmojiIC,
text: tabText.EMOJIS,
disabled: false,
}, { }, {
iconSrc: ShieldUserIC, iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS, text: tabText.PERMISSIONS,
@ -197,6 +204,7 @@ function RoomSettings({ roomId }) {
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />} {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />} {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />} {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />} {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />} {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
</div> </div>
@ -210,7 +218,5 @@ RoomSettings.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
}; };
export { export default RoomSettings;
RoomSettings as default, export { tabText };
tabText,
};

View file

@ -21,7 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import FollowingMembers from '../../molecules/following-members/FollowingMembers'; import FollowingMembers from '../../molecules/following-members/FollowingMembers';
import { addRecentEmoji } from '../emoji-board/recent'; import { addRecentEmoji, getRecentEmojis } from '../emoji-board/recent';
const commands = [{ const commands = [{
name: 'markdown', name: 'markdown',
@ -213,9 +213,15 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
setCmd({ prefix, suggestions: commands }); setCmd({ prefix, suggestions: commands });
}, },
':': () => { ':': () => {
const emojis = getEmojiForCompletion(mx.getRoom(roomId)); const parentIds = initMatrix.roomList.getAllParentSpaces(roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const emojis = getEmojiForCompletion(mx, [mx.getRoom(roomId), ...parentRooms]);
const recentEmoji = getRecentEmojis(20);
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
setCmd({ prefix, suggestions: emojis.slice(26, 46) }); setCmd({
prefix,
suggestions: recentEmoji.length > 0 ? recentEmoji : emojis.slice(26, 46),
});
}, },
'@': () => { '@': () => {
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({ const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
@ -247,7 +253,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
} }
if (myCmd.prefix === '@') { if (myCmd.prefix === '@') {
viewEvent.emit('cmd_fired', { viewEvent.emit('cmd_fired', {
replace: myCmd.result.name, replace: `@${myCmd.result.userId}`,
}); });
} }
deactivateCmd(); deactivateCmd();
@ -256,11 +262,11 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
function listenKeyboard(event) { function listenKeyboard(event) {
const { activeElement } = document; const { activeElement } = document;
const lastCmdItem = document.activeElement.parentNode.lastElementChild; const lastCmdItem = document.activeElement.parentNode.lastElementChild;
if (event.keyCode === 27) { if (event.key === 'Escape') {
if (activeElement.className !== 'cmd-item') return; if (activeElement.className !== 'cmd-item') return;
viewEvent.emit('focus_msg_input'); viewEvent.emit('focus_msg_input');
} }
if (event.keyCode === 9) { if (event.key === 'Tab') {
if (lastCmdItem.className !== 'cmd-item') return; if (lastCmdItem.className !== 'cmd-item') return;
if (lastCmdItem !== activeElement) return; if (lastCmdItem !== activeElement) return;
if (event.shiftKey) return; if (event.shiftKey) return;

View file

@ -125,10 +125,7 @@ function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
&& prevMEvent.getType() !== 'm.room.create' && prevMEvent.getType() !== 'm.room.create'
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
); );
const mDate = mEvent.getDate(); const timestamp = mEvent.getTs();
const isToday = isInSameDay(mDate, new Date());
const time = dateFormat(mDate, isToday ? 'hh:MM TT' : 'dd/mm/yyyy');
if (mEvent.getType() === 'm.room.member') { if (mEvent.getType() === 'm.room.member') {
const timelineChange = parseTimelineChange(mEvent); const timelineChange = parseTimelineChange(mEvent);
@ -138,7 +135,7 @@ function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
key={mEvent.getId()} key={mEvent.getId()}
variant={timelineChange.variant} variant={timelineChange.variant}
content={timelineChange.content} content={timelineChange.content}
time={time} timestamp={timestamp}
/> />
); );
} }
@ -149,7 +146,7 @@ function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
isBodyOnly={isBodyOnly} isBodyOnly={isBodyOnly}
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
focus={isFocus} focus={isFocus}
time={time} fullTime={false}
/> />
); );
} }

View file

@ -8,7 +8,7 @@ import TextareaAutosize from 'react-autosize-textarea';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings'; import settings from '../../../client/state/settings';
import { openEmojiBoard } from '../../../client/action/navigation'; import { openEmojiBoard, openReusableContextMenu } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { bytesToSize, getEventCords } from '../../../util/common'; import { bytesToSize, getEventCords } from '../../../util/common';
import { getUsername } from '../../../util/matrixUtil'; import { getUsername } from '../../../util/matrixUtil';
@ -20,9 +20,12 @@ import IconButton from '../../atoms/button/IconButton';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import { MessageReply } from '../../molecules/message/Message'; import { MessageReply } from '../../molecules/message/Message';
import StickerBoard from '../sticker-board/StickerBoard';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SendIC from '../../../../public/res/ic/outlined/send.svg'; import SendIC from '../../../../public/res/ic/outlined/send.svg';
import StickerIC from '../../../../public/res/ic/outlined/sticker.svg';
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg'; import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg'; import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg'; import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
@ -129,7 +132,9 @@ function RoomViewInput({
function firedCmd(cmdData) { function firedCmd(cmdData) {
const msg = textAreaRef.current.value; const msg = textAreaRef.current.value;
textAreaRef.current.value = replaceCmdWith( textAreaRef.current.value = replaceCmdWith(
msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '', msg,
cmdCursorPos,
typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
); );
deactivateCmd(); deactivateCmd();
} }
@ -201,6 +206,10 @@ function RoomViewInput({
if (replyTo !== null) setReplyTo(null); if (replyTo !== null) setReplyTo(null);
}; };
const handleSendSticker = async (data) => {
roomsInput.sendSticker(roomId, data);
};
function processTyping(msg) { function processTyping(msg) {
const isEmptyMsg = msg === ''; const isEmptyMsg = msg === '';
@ -254,7 +263,7 @@ function RoomViewInput({
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.keyCode === 13 && e.shiftKey === false) { if (e.key === 'Enter' && e.shiftKey === false) {
e.preventDefault(); e.preventDefault();
sendMessage(); sendMessage();
} }
@ -328,6 +337,7 @@ function RoomViewInput({
<ScrollView autoHide> <ScrollView autoHide>
<Text className="room-input__textarea-wrapper"> <Text className="room-input__textarea-wrapper">
<TextareaAutosize <TextareaAutosize
dir="auto"
id="message-textarea" id="message-textarea"
ref={textAreaRef} ref={textAreaRef}
onChange={handleMsgTyping} onChange={handleMsgTyping}
@ -340,6 +350,29 @@ function RoomViewInput({
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />} {isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
</div> </div>
<div ref={rightOptionsRef} className="room-input__option-container"> <div ref={rightOptionsRef} className="room-input__option-container">
<IconButton
onClick={(e) => {
openReusableContextMenu(
'top',
(() => {
const cords = getEventCords(e);
cords.y -= 20;
return cords;
})(),
(closeMenu) => (
<StickerBoard
roomId={roomId}
onSelect={(data) => {
handleSendSticker(data);
closeMenu();
}}
/>
),
);
}}
tooltip="Sticker"
src={StickerIC}
/>
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
const cords = getEventCords(e); const cords = getEventCords(e);

View file

@ -25,6 +25,7 @@ import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile'; import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys'; import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys'; import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
import ProfileEditor from '../profile-editor/ProfileEditor'; import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning'; import CrossSigning from './CrossSigning';
@ -32,6 +33,7 @@ import KeyBackup from './KeyBackup';
import DeviceManage from './DeviceManage'; import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg'; import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg'; import LockIC from '../../../../public/res/ic/outlined/lock.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg'; import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg'; import InfoIC from '../../../../public/res/ic/outlined/info.svg';
@ -58,23 +60,25 @@ function AppearanceSection() {
)} )}
content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>} content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>}
/> />
{!settings.useSystemTheme && (
<SettingTile <SettingTile
title="Theme" title="Theme"
content={( content={(
<SegmentedControls <SegmentedControls
selected={settings.getThemeIndex()} selected={settings.useSystemTheme ? -1 : settings.getThemeIndex()}
segments={[ segments={[
{ text: 'Light' }, { text: 'Light' },
{ text: 'Silver' }, { text: 'Silver' },
{ text: 'Dark' }, { text: 'Dark' },
{ text: 'Butter' }, { text: 'Butter' },
]} ]}
onSelect={(index) => settings.setTheme(index)} onSelect={(index) => {
if (settings.useSystemTheme) toggleSystemTheme();
settings.setTheme(index);
updateState({});
}}
/> />
)} )}
/> />
)}
</div> </div>
<div className="settings-appearance__card"> <div className="settings-appearance__card">
<MenuHeader>Room messages</MenuHeader> <MenuHeader>Room messages</MenuHeader>
@ -191,6 +195,15 @@ function NotificationsSection() {
); );
} }
function EmojiSection() {
return (
<>
<div className="settings-emoji__card"><ImagePackUser /></div>
<div className="settings-emoji__card"><ImagePackGlobal /></div>
</>
);
}
function SecuritySection() { function SecuritySection() {
return ( return (
<div className="settings-security"> <div className="settings-security">
@ -272,6 +285,7 @@ function AboutSection() {
export const tabText = { export const tabText = {
APPEARANCE: 'Appearance', APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications', NOTIFICATIONS: 'Notifications',
EMOJI: 'Emoji',
SECURITY: 'Security', SECURITY: 'Security',
ABOUT: 'About', ABOUT: 'About',
}; };
@ -285,6 +299,11 @@ const tabItems = [{
iconSrc: BellIC, iconSrc: BellIC,
disabled: false, disabled: false,
render: () => <NotificationsSection />, render: () => <NotificationsSection />,
}, {
text: tabText.EMOJI,
iconSrc: EmojiIC,
disabled: false,
render: () => <EmojiSection />,
}, { }, {
text: tabText.SECURITY, text: tabText.SECURITY,
iconSrc: LockIC, iconSrc: LockIC,

View file

@ -40,7 +40,8 @@
.settings-notifications, .settings-notifications,
.settings-security__card, .settings-security__card,
.settings-security .device-manage, .settings-security .device-manage,
.settings-about__card { .settings-about__card,
.settings-emoji__card {
@extend .settings-window__card; @extend .settings-window__card;
} }

View file

@ -25,6 +25,7 @@ import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
import RoomAliases from '../../molecules/room-aliases/RoomAliases'; import RoomAliases from '../../molecules/room-aliases/RoomAliases';
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions'; import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
import RoomMembers from '../../molecules/room-members/RoomMembers'; import RoomMembers from '../../molecules/room-members/RoomMembers';
import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
@ -35,6 +36,7 @@ import PinIC from '../../../../public/res/ic/outlined/pin.svg';
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg'; import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
import CategoryIC from '../../../../public/res/ic/outlined/category.svg'; import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg'; import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog'; import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useForceUpdate } from '../../hooks/useForceUpdate'; import { useForceUpdate } from '../../hooks/useForceUpdate';
@ -42,6 +44,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
const tabText = { const tabText = {
GENERAL: 'General', GENERAL: 'General',
MEMBERS: 'Members', MEMBERS: 'Members',
EMOJIS: 'Emojis',
PERMISSIONS: 'Permissions', PERMISSIONS: 'Permissions',
}; };
@ -53,6 +56,10 @@ const tabItems = [{
iconSrc: UserIC, iconSrc: UserIC,
text: tabText.MEMBERS, text: tabText.MEMBERS,
disabled: false, disabled: false,
}, {
iconSrc: EmojiIC,
text: tabText.EMOJIS,
disabled: false,
}, { }, {
iconSrc: ShieldUserIC, iconSrc: ShieldUserIC,
text: tabText.PERMISSIONS, text: tabText.PERMISSIONS,
@ -178,6 +185,7 @@ function SpaceSettings() {
<div className="space-settings__cards-wrapper"> <div className="space-settings__cards-wrapper">
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />} {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />} {selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />} {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
</div> </div>
</div> </div>

View file

@ -0,0 +1,115 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import './StickerBoard.scss';
import initMatrix from '../../../client/initMatrix';
import { getRelevantPacks } from '../emoji-board/custom-emoji';
import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView';
import IconButton from '../../atoms/button/IconButton';
function StickerBoard({ roomId, onSelect }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const scrollRef = useRef(null);
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const packs = getRelevantPacks(
mx,
[room, ...parentRooms],
).filter((pack) => pack.getStickers().length !== 0);
function isTargetNotSticker(target) {
return target.classList.contains('sticker-board__sticker') === false;
}
function getStickerData(target) {
const mxc = target.getAttribute('data-mx-sticker');
const body = target.getAttribute('title');
const httpUrl = target.getAttribute('src');
return { mxc, body, httpUrl };
}
const handleOnSelect = (e) => {
if (isTargetNotSticker(e.target)) return;
const stickerData = getStickerData(e.target);
onSelect(stickerData);
};
const openGroup = (groupIndex) => {
const scrollContent = scrollRef.current.firstElementChild;
scrollContent.children[groupIndex].scrollIntoView();
};
const renderPack = (pack) => (
<div className="sticker-board__pack" key={pack.id}>
<Text className="sticker-board__pack-header" variant="b2" weight="bold">{pack.displayName ?? 'Unknown'}</Text>
<div className="sticker-board__pack-items">
{pack.getStickers().map((sticker) => (
<img
key={sticker.shortcode}
className="sticker-board__sticker"
src={mx.mxcUrlToHttp(sticker.mxc)}
alt={sticker.shortcode}
title={sticker.body ?? sticker.shortcode}
data-mx-sticker={sticker.mxc}
loading="lazy"
/>
))}
</div>
</div>
);
return (
<div className="sticker-board">
{packs.length > 0 && (
<ScrollView invisible>
<div className="sticker-board__sidebar">
{packs.map((pack, index) => {
const src = mx.mxcUrlToHttp(pack.avatarUrl ?? pack.getStickers()[0].mxc);
return (
<IconButton
key={pack.id}
onClick={() => openGroup(index)}
src={src}
tooltip={pack.displayName || 'Unknown'}
tooltipPlacement="left"
isImage
/>
);
})}
</div>
</ScrollView>
)}
<div className="sticker-board__container">
<ScrollView autoHide ref={scrollRef}>
<div
onClick={handleOnSelect}
className="sticker-board__content"
>
{
packs.length > 0
? packs.map(renderPack)
: (
<div className="sticker-board__empty">
<Text>There is no sticker pack.</Text>
</div>
)
}
</div>
</ScrollView>
</div>
<div />
</div>
);
}
StickerBoard.propTypes = {
roomId: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default StickerBoard;

View file

@ -0,0 +1,74 @@
@use '../../partials/dir';
.sticker-board {
--sticker-board-height: 390px;
--sticker-board-width: 286px;
display: flex;
height: var(--sticker-board-height);
display: flex;
& > .scrollbar {
width: initial;
height: var(--sticker-board-height);
}
&__sidebar {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 4px 6px;
@include dir.side(border, none, 1px solid var(--bg-surface-border));
}
&__container {
flex-grow: 1;
min-width: 0;
width: var(--sticker-board-width);
display: flex;
}
&__content {
min-height: 100%;
}
&__pack {
margin-bottom: var(--sp-normal);
position: relative;
&-header {
position: sticky;
top: 0;
z-index: 99;
background-color: var(--bg-surface);
@include dir.side(margin, var(--sp-extra-tight), 0);
padding: var(--sp-extra-tight) var(--sp-ultra-tight);
text-transform: uppercase;
box-shadow: 0 -4px 0 0 var(--bg-surface);
border-bottom: 1px solid var(--bg-surface-border);
}
&-items {
margin: var(--sp-tight);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
display: flex;
flex-wrap: wrap;
gap: var(--sp-normal) var(--sp-tight);
img {
width: 76px;
height: 76px;
object-fit: contain;
cursor: pointer;
}
}
}
&__empty {
width: 100%;
height: var(--sticker-board-height);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
}

View file

@ -21,6 +21,8 @@ import Avatar from '../../atoms/avatar/Avatar';
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu'; import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
import EyeBlindIC from '../../../../public/res/ic/outlined/eye-blind.svg';
import CinnySvg from '../../../../public/res/svg/cinny.svg'; import CinnySvg from '../../../../public/res/svg/cinny.svg';
import SSOButtons from '../../molecules/sso-buttons/SSOButtons'; import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
@ -54,11 +56,8 @@ function Homeserver({ onChange }) {
const setupHsConfig = async (servername) => { const setupHsConfig = async (servername) => {
setProcess({ isLoading: true, message: 'Looking for homeserver...' }); setProcess({ isLoading: true, message: 'Looking for homeserver...' });
let baseUrl = null; let baseUrl = null;
try {
baseUrl = await getBaseUrl(servername); baseUrl = await getBaseUrl(servername);
} catch (e) {
baseUrl = e.message;
}
if (searchingHs !== servername) return; if (searchingHs !== servername) return;
setProcess({ isLoading: true, message: `Connecting to ${baseUrl}...` }); setProcess({ isLoading: true, message: `Connecting to ${baseUrl}...` });
const tempClient = auth.createTemporaryClient(baseUrl); const tempClient = auth.createTemporaryClient(baseUrl);
@ -97,7 +96,7 @@ function Homeserver({ onChange }) {
if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) { if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) {
throw new Error(); throw new Error();
} }
setHs({ selected: hsList[selectedHs], list: hsList, allowCustom: allowCustom }); setHs({ selected: hsList[selectedHs], list: hsList, allowCustom });
} catch { } catch {
setHs({ selected: 'matrix.org', list: ['matrix.org'], allowCustom: true }); setHs({ selected: 'matrix.org', list: ['matrix.org'], allowCustom: true });
} }
@ -114,8 +113,14 @@ function Homeserver({ onChange }) {
return ( return (
<> <>
<div className="homeserver-form"> <div className="homeserver-form">
<Input name="homeserver" onChange={handleHsInput} value={hs?.selected} forwardRef={hsRef} label="Homeserver" <Input
disabled={hs === null || !hs.allowCustom} /> name="homeserver"
onChange={handleHsInput}
value={hs?.selected}
forwardRef={hsRef}
label="Homeserver"
disabled={hs === null || !hs.allowCustom}
/>
<ContextMenu <ContextMenu
placement="right" placement="right"
content={(hideMenu) => ( content={(hideMenu) => (
@ -156,6 +161,7 @@ Homeserver.propTypes = {
function Login({ loginFlow, baseUrl }) { function Login({ loginFlow, baseUrl }) {
const [typeIndex, setTypeIndex] = useState(0); const [typeIndex, setTypeIndex] = useState(0);
const [passVisible, setPassVisible] = useState(false);
const loginTypes = ['Username', 'Email']; const loginTypes = ['Username', 'Email'];
const isPassword = loginFlow?.filter((flow) => flow.type === 'm.login.password')[0]; const isPassword = loginFlow?.filter((flow) => flow.type === 'm.login.password')[0];
const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0]; const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
@ -166,17 +172,23 @@ function Login({ loginFlow, baseUrl }) {
const validator = (values) => { const validator = (values) => {
const errors = {}; const errors = {};
if (typeIndex === 0 && values.username.length > 0 && values.username.indexOf(':') > -1) {
errors.username = 'Username must contain local-part only';
}
if (typeIndex === 1 && values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) { if (typeIndex === 1 && values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
errors.email = BAD_EMAIL_ERROR; errors.email = BAD_EMAIL_ERROR;
} }
return errors; return errors;
}; };
const submitter = (values, actions) => auth.login( const submitter = async (values, actions) => {
baseUrl, let userBaseUrl = baseUrl;
typeIndex === 0 ? normalizeUsername(values.username) : undefined, let { username } = values;
const mxIdMatch = username.match(/^@(.+):(.+\..+)$/);
if (typeIndex === 0 && mxIdMatch) {
[, username, userBaseUrl] = mxIdMatch;
userBaseUrl = await getBaseUrl(userBaseUrl);
}
return auth.login(
userBaseUrl,
typeIndex === 0 ? normalizeUsername(username) : undefined,
typeIndex === 1 ? values.email : undefined, typeIndex === 1 ? values.email : undefined,
values.password, values.password,
).then(() => { ).then(() => {
@ -191,6 +203,7 @@ function Login({ loginFlow, baseUrl }) {
}); });
actions.setSubmitting(false); actions.setSubmitting(false);
}); });
};
return ( return (
<> <>
@ -236,7 +249,10 @@ function Login({ loginFlow, baseUrl }) {
{errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>} {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
{typeIndex === 1 && <Input values={values.email} name="email" onChange={handleChange} label="Email" type="email" required />} {typeIndex === 1 && <Input values={values.email} name="email" onChange={handleChange} label="Email" type="email" required />}
{errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>} {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
<Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required /> <div className="auth-form__pass-eye-wrapper">
<Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>} {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
{errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>} {errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
<div className="auth-form__btns"> <div className="auth-form__btns">
@ -269,6 +285,8 @@ let sid;
let clientSecret; let clientSecret;
function Register({ registerInfo, loginFlow, baseUrl }) { function Register({ registerInfo, loginFlow, baseUrl }) {
const [process, setProcess] = useState({}); const [process, setProcess] = useState({});
const [passVisible, setPassVisible] = useState(false);
const [cPassVisible, setCPassVisible] = useState(false);
const formRef = useRef(); const formRef = useRef();
const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0]; const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
@ -319,6 +337,7 @@ function Register({ registerInfo, loginFlow, baseUrl }) {
if (!isAvail) { if (!isAvail) {
actions.setErrors({ username: 'Username is already taken' }); actions.setErrors({ username: 'Username is already taken' });
actions.setSubmitting(false); actions.setSubmitting(false);
return;
} }
if (isEmail && values.email.length > 0) { if (isEmail && values.email.length > 0) {
const result = await auth.verifyEmail(baseUrl, values.email, clientSecret, 1); const result = await auth.verifyEmail(baseUrl, values.email, clientSecret, 1);
@ -437,9 +456,15 @@ function Register({ registerInfo, loginFlow, baseUrl }) {
<form className="auth-form" ref={formRef} onSubmit={handleSubmit}> <form className="auth-form" ref={formRef} onSubmit={handleSubmit}>
<Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required /> <Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />
{errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>} {errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
<Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required /> <div className="auth-form__pass-eye-wrapper">
<Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>} {errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
<Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type="password" required /> <div className="auth-form__pass-eye-wrapper">
<Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type={cPassVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setCPassVisible(!cPassVisible)} src={cPassVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.confirmPassword && <Text className="auth-form__error" variant="b3">{errors.confirmPassword}</Text>} {errors.confirmPassword && <Text className="auth-form__error" variant="b3">{errors.confirmPassword}</Text>}
{isEmail && <Input values={values.email} name="email" onChange={handleChange} label={`Email${isEmailRequired ? '' : ' (optional)'}`} type="email" required={isEmailRequired} />} {isEmail && <Input values={values.email} name="email" onChange={handleChange} label={`Email${isEmailRequired ? '' : ' (optional)'}`} type="email" required={isEmailRequired} />}
{errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>} {errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}

View file

@ -97,7 +97,8 @@
} }
.auth-form { .auth-form {
& > .input-container { & > .input-container,
&__pass-eye-wrapper {
margin: var(--sp-tight) 0 var(--sp-ultra-tight); margin: var(--sp-tight) 0 var(--sp-ultra-tight);
} }
@ -107,6 +108,20 @@
margin-top: calc(var(--sp-extra-loose) + var(--sp-tight)); margin-top: calc(var(--sp-extra-loose) + var(--sp-tight));
} }
&__pass-eye-wrapper {
position: relative;
& .ic-btn {
position: absolute;
@include dir.prop(right, 6px, unset);
@include dir.prop(left, unset, 6px );
bottom: 6px;
border-radius: 4px;
}
& input {
@include dir.side(padding, var(--sp-normal), 46px);
}
}
&__btns { &__btns {
padding-top: var(--sp-loose); padding-top: var(--sp-loose);
margin-bottom: var(--sp-extra-loose); margin-bottom: var(--sp-extra-loose);

View file

@ -1,13 +1,16 @@
import initMatrix from '../initMatrix'; import initMatrix from '../initMatrix';
function logout() { async function logout() {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
mx.stopClient(); mx.stopClient();
mx.logout().then(() => { try {
await mx.logout();
} catch {
// ignore if failed to logout
}
mx.clearStores(); mx.clearStores();
window.localStorage.clear(); window.localStorage.clear();
window.location.reload(); window.location.reload();
});
} }
export default logout; export default logout;

View file

@ -11,17 +11,18 @@ async function redactEvent(roomId, eventId, reason) {
} }
} }
async function sendReaction(roomId, toEventId, reaction) { async function sendReaction(roomId, toEventId, reaction, shortcode) {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const content = {
try {
await mx.sendEvent(roomId, 'm.reaction', {
'm.relates_to': { 'm.relates_to': {
event_id: toEventId, event_id: toEventId,
key: reaction, key: reaction,
rel_type: 'm.annotation', rel_type: 'm.annotation',
}, },
}); };
if (typeof shortcode === 'string') content.shortcode = shortcode;
try {
await mx.sendEvent(roomId, 'm.reaction', content);
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
} }

View file

@ -31,14 +31,14 @@ function listenKeyboard(event) {
// Ctrl/Cmd + // Ctrl/Cmd +
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
// open search modal // open search modal
if (event.code === 'KeyK') { if (event.key === 'k') {
event.preventDefault(); event.preventDefault();
if (navigation.isRawModalVisible) return; if (navigation.isRawModalVisible) return;
openSearch(); openSearch();
} }
// focus message field on paste // focus message field on paste
if (event.code === 'KeyV') { if (event.key === 'v') {
if (navigation.isRawModalVisible) return; if (navigation.isRawModalVisible) return;
const msgTextarea = document.getElementById('message-textarea'); const msgTextarea = document.getElementById('message-textarea');
const { activeElement } = document; const { activeElement } = document;
@ -51,11 +51,8 @@ function listenKeyboard(event) {
if (!event.ctrlKey && !event.altKey && !event.metaKey) { if (!event.ctrlKey && !event.altKey && !event.metaKey) {
if (navigation.isRawModalVisible) return; if (navigation.isRawModalVisible) return;
if (['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase())) {
return;
}
if (event.code === 'Escape') { if (event.key === 'Escape') {
if (navigation.isRoomSettings) { if (navigation.isRoomSettings) {
toggleRoomSettings(); toggleRoomSettings();
return; return;
@ -66,6 +63,10 @@ function listenKeyboard(event) {
} }
} }
if (['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase())) {
return;
}
// focus the text field on most keypresses // focus the text field on most keypresses
if (shouldFocusMessageField(event.code)) { if (shouldFocusMessageField(event.code)) {
// press any key to focus and type in message field // press any key to focus and type in message field

View file

@ -33,7 +33,6 @@ class InitMatrix extends EventEmitter {
accessToken: secret.accessToken, accessToken: secret.accessToken,
userId: secret.userId, userId: secret.userId,
store: indexedDBStore, store: indexedDBStore,
sessionStore: new sdk.WebStorageSessionStore(global.localStorage),
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'), cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
deviceId: secret.deviceId, deviceId: secret.deviceId,
timelineSupport: true, timelineSupport: true,
@ -67,7 +66,7 @@ class InitMatrix extends EventEmitter {
if (prevState === null) { if (prevState === null) {
this.roomList = new RoomList(this.matrixClient); this.roomList = new RoomList(this.matrixClient);
this.accountData = new AccountData(this.roomList); this.accountData = new AccountData(this.roomList);
this.roomsInput = new RoomsInput(this.matrixClient); this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
this.notifications = new Notifications(this.roomList); this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished'); this.emit('init_loading_finished');
} }

View file

@ -3,23 +3,36 @@ import { micromark } from 'micromark';
import { gfm, gfmHtml } from 'micromark-extension-gfm'; import { gfm, gfmHtml } from 'micromark-extension-gfm';
import encrypt from 'browser-encrypt-attachment'; import encrypt from 'browser-encrypt-attachment';
import { math } from 'micromark-extension-math'; import { math } from 'micromark-extension-math';
import { encode } from 'blurhash';
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji'; import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown'; import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
import { sanitizeText } from '../../util/sanitize';
import cons from './cons'; import cons from './cons';
import settings from './settings'; import settings from './settings';
function getImageDimension(file) { const blurhashField = 'xyz.amorgan.blurhash';
return new Promise((resolve) => { const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
function encodeBlurhash(img) {
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
const context = canvas.getContext('2d');
context.drawImage(img, 0, 0, canvas.width, canvas.height);
const data = context.getImageData(0, 0, canvas.width, canvas.height);
return encode(data.data, data.width, data.height, 4, 4);
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = async () => { img.onload = () => resolve(img);
resolve({ img.onerror = (err) => reject(err);
w: img.width, img.src = url;
h: img.height,
});
};
img.src = URL.createObjectURL(file);
}); });
} }
function loadVideo(videoFile) { function loadVideo(videoFile) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const video = document.createElement('video'); const video = document.createElement('video');
@ -46,7 +59,12 @@ function loadVideo(videoFile) {
reader.onerror = (e) => { reader.onerror = (e) => {
reject(e); reject(e);
}; };
if (videoFile.type === 'video/quicktime') {
const quicktimeVideoFile = new File([videoFile], videoFile.name, { type: 'video/mp4' });
reader.readAsDataURL(quicktimeVideoFile);
} else {
reader.readAsDataURL(videoFile); reader.readAsDataURL(videoFile);
}
}); });
} }
function getVideoThumbnail(video, width, height, mimeType) { function getVideoThumbnail(video, width, height, mimeType) {
@ -115,32 +133,46 @@ function bindReplyToContent(roomId, reply, content) {
return newContent; return newContent;
} }
// Apply formatting to a plain text message function findAndReplace(text, regex, filter, replace) {
// let copyText = text;
// This includes inserting any custom emoji that might be relevant, and (only if the Array.from(copyText.matchAll(regex))
// user has enabled it in their settings) formatting the message using markdown. .filter(filter)
function formatAndEmojifyText(room, text) { .reverse() /* to replace backward to forward */
const allEmoji = getShortcodeToEmoji(room); .forEach((match) => {
const matchText = match[0];
const tag = replace(match);
// Start by applying markdown formatting (if relevant) copyText = copyText.substr(0, match.index)
let formattedText; + tag
if (settings.isMarkdown) { + copyText.substr(match.index + matchText.length);
formattedText = getFormattedBody(text); });
} else { return copyText;
formattedText = text;
} }
// Check to see if there are any :shortcode-style-tags: in the message function formatUserPill(room, text) {
Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g)) const { userIdsToDisplayNames } = room.currentState;
// Then filter to only the ones corresponding to a valid emoji return findAndReplace(
.filter((match) => allEmoji.has(match[1])) text,
// Reversing the array ensures that indices are preserved as we start replacing MXID_REGEX,
.reverse() (match) => userIdsToDisplayNames[match[0]],
// Replace each :shortcode: with an <img/> tag (match) => (
.forEach((shortcodeMatch) => { `<a href="https://matrix.to/#/${match[0]}">@${userIdsToDisplayNames[match[0]]}</a>`
const emoji = allEmoji.get(shortcodeMatch[1]); ),
);
}
function formatEmoji(mx, room, roomList, text) {
const parentIds = roomList.getAllParentSpaces(room.roomId);
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
return findAndReplace(
text,
SHORTCODE_REGEX,
(match) => allEmoji.has(match[1]),
(match) => {
const emoji = allEmoji.get(match[1]);
// Render the tag that will replace the shortcode
let tag; let tag;
if (emoji.mxc) { if (emoji.mxc) {
tag = `<img data-mx-emoticon="" src="${ tag = `<img data-mx-emoticon="" src="${
@ -153,21 +185,17 @@ function formatAndEmojifyText(room, text) {
} else { } else {
tag = emoji.unicode; tag = emoji.unicode;
} }
return tag;
// Splice the tag into the text },
formattedText = formattedText.substr(0, shortcodeMatch.index) );
+ tag
+ formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length);
});
return formattedText;
} }
class RoomsInput extends EventEmitter { class RoomsInput extends EventEmitter {
constructor(mx) { constructor(mx, roomList) {
super(); super();
this.matrixClient = mx; this.matrixClient = mx;
this.roomList = roomList;
this.roomIdToInput = new Map(); this.roomIdToInput = new Map();
} }
@ -252,6 +280,7 @@ class RoomsInput extends EventEmitter {
} }
async sendInput(roomId) { async sendInput(roomId) {
const room = this.matrixClient.getRoom(roomId);
const input = this.getInput(roomId); const input = this.getInput(roomId);
input.isSending = true; input.isSending = true;
this.roomIdToInput.set(roomId, input); this.roomIdToInput.set(roomId, input);
@ -261,17 +290,27 @@ class RoomsInput extends EventEmitter {
} }
if (this.getMessage(roomId).trim() !== '') { if (this.getMessage(roomId).trim() !== '') {
const rawMessage = input.message;
let content = { let content = {
body: input.message, body: rawMessage,
msgtype: 'm.text', msgtype: 'm.text',
}; };
// Apply formatting if relevant // Apply formatting if relevant
const formattedBody = formatAndEmojifyText( let formattedBody = settings.isMarkdown
this.matrixClient.getRoom(roomId), ? getFormattedBody(rawMessage)
input.message, : sanitizeText(rawMessage);
formattedBody = formatUserPill(room, formattedBody);
formattedBody = formatEmoji(this.matrixClient, room, this.roomList, formattedBody);
content.body = findAndReplace(
content.body,
MXID_REGEX,
(match) => room.currentState.userIdsToDisplayNames[match[0]],
(match) => `@${room.currentState.userIdsToDisplayNames[match[0]]}`,
); );
if (formattedBody !== input.message) { if (formattedBody !== sanitizeText(rawMessage)) {
// Formatting was applied, and we need to switch to custom HTML // Formatting was applied, and we need to switch to custom HTML
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody; content.formatted_body = formattedBody;
@ -287,6 +326,34 @@ class RoomsInput extends EventEmitter {
this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId); this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId);
} }
async sendSticker(roomId, data) {
const { mxc: url, body, httpUrl } = data;
const info = {};
const img = new Image();
img.src = httpUrl;
try {
const res = await fetch(httpUrl);
const blob = await res.blob();
info.w = img.width;
info.h = img.height;
info.mimetype = blob.type;
info.size = blob.size;
info.thumbnail_info = { ...info };
info.thumbnail_url = url;
} catch {
// send sticker without info
}
this.matrixClient.sendEvent(roomId, 'm.sticker', {
body,
url,
info,
});
this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId);
}
async sendFile(roomId, file) { async sendFile(roomId, file) {
const fileType = file.type.slice(0, file.type.indexOf('/')); const fileType = file.type.slice(0, file.type.indexOf('/'));
const info = { const info = {
@ -297,10 +364,11 @@ class RoomsInput extends EventEmitter {
let uploadData = null; let uploadData = null;
if (fileType === 'image') { if (fileType === 'image') {
const imgDimension = await getImageDimension(file); const img = await loadImage(URL.createObjectURL(file));
info.w = imgDimension.w; info.w = img.width;
info.h = imgDimension.h; info.h = img.height;
info[blurhashField] = encodeBlurhash(img);
content.msgtype = 'm.image'; content.msgtype = 'm.image';
content.body = file.name || 'Image'; content.body = file.name || 'Image';
@ -310,8 +378,11 @@ class RoomsInput extends EventEmitter {
try { try {
const video = await loadVideo(file); const video = await loadVideo(file);
info.w = video.videoWidth; info.w = video.videoWidth;
info.h = video.videoHeight; info.h = video.videoHeight;
info[blurhashField] = encodeBlurhash(video);
const thumbnailData = await getVideoThumbnail(video, video.videoWidth, video.videoHeight, 'image/jpeg'); const thumbnailData = await getVideoThumbnail(video, video.videoWidth, video.videoHeight, 'image/jpeg');
const thumbnailUploadData = await this.uploadFile(roomId, thumbnailData.thumbnail); const thumbnailUploadData = await this.uploadFile(roomId, thumbnailData.thumbnail);
info.thumbnail_info = thumbnailData.info; info.thumbnail_info = thumbnailData.info;
@ -390,6 +461,7 @@ class RoomsInput extends EventEmitter {
} }
async sendEditedMessage(roomId, mEvent, editedBody) { async sendEditedMessage(roomId, mEvent, editedBody) {
const room = this.matrixClient.getRoom(roomId);
const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
const content = { const content = {
@ -406,11 +478,19 @@ class RoomsInput extends EventEmitter {
}; };
// Apply formatting if relevant // Apply formatting if relevant
const formattedBody = formatAndEmojifyText( let formattedBody = settings.isMarkdown
this.matrixClient.getRoom(roomId), ? getFormattedBody(editedBody)
editedBody, : sanitizeText(editedBody);
formattedBody = formatUserPill(room, formattedBody);
formattedBody = formatEmoji(this.matrixClient, room, this.roomList, formattedBody);
content.body = findAndReplace(
content.body,
MXID_REGEX,
(match) => room.currentState.userIdsToDisplayNames[match[0]],
(match) => `@${room.currentState.userIdsToDisplayNames[match[0]]}`,
); );
if (formattedBody !== editedBody) { if (formattedBody !== sanitizeText(editedBody)) {
content.formatted_body = ` * ${formattedBody}`; content.formatted_body = ` * ${formattedBody}`;
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content['m.new_content'].formatted_body = formattedBody; content['m.new_content'].formatted_body = formattedBody;

View file

@ -1,5 +1,5 @@
const cons = { const cons = {
version: '2.0.4', version: '2.1.2',
secretKey: { secretKey: {
ACCESS_TOKEN: 'cinny_access_token', ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id', DEVICE_ID: 'cinny_device_id',

View file

@ -50,31 +50,43 @@ class Settings extends EventEmitter {
return this.themes[this.themeIndex]; return this.themes[this.themeIndex];
} }
setTheme(themeIndex) { _clearTheme() {
const appBody = document.getElementById('appBody'); document.body.classList.remove('system-theme');
appBody.classList.remove('system-theme');
this.themes.forEach((themeName) => { this.themes.forEach((themeName) => {
if (themeName === '') return; if (themeName === '') return;
appBody.classList.remove(themeName); document.body.classList.remove(themeName);
}); });
// If use system theme is enabled
// we will override current theme choice with system theme
if (this.useSystemTheme) {
appBody.classList.add('system-theme');
} else if (this.themes[themeIndex] !== '') {
appBody.classList.add(this.themes[themeIndex]);
} }
setSettings('themeIndex', themeIndex);
applyTheme() {
this._clearTheme();
if (this.useSystemTheme) {
document.body.classList.add('system-theme');
} else if (this.themes[this.themeIndex]) {
document.body.classList.add(this.themes[this.themeIndex]);
}
}
setTheme(themeIndex) {
this.themeIndex = themeIndex; this.themeIndex = themeIndex;
setSettings('themeIndex', this.themeIndex);
this.applyTheme();
}
toggleUseSystemTheme() {
this.useSystemTheme = !this.useSystemTheme;
setSettings('useSystemTheme', this.useSystemTheme);
this.applyTheme();
this.emit(cons.events.settings.SYSTEM_THEME_TOGGLED, this.useSystemTheme);
} }
getUseSystemTheme() { getUseSystemTheme() {
if (typeof this.useSystemTheme === 'boolean') return this.useSystemTheme; if (typeof this.useSystemTheme === 'boolean') return this.useSystemTheme;
const settings = getSettings(); const settings = getSettings();
if (settings === null) return false; if (settings === null) return true;
if (typeof settings.useSystemTheme === 'undefined') return false; if (typeof settings.useSystemTheme === 'undefined') return true;
return settings.useSystemTheme; return settings.useSystemTheme;
} }
@ -158,12 +170,7 @@ class Settings extends EventEmitter {
setter(action) { setter(action) {
const actions = { const actions = {
[cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => { [cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => {
this.useSystemTheme = !this.useSystemTheme; this.toggleUseSystemTheme();
setSettings('useSystemTheme', this.useSystemTheme);
this.setTheme(this.themeIndex);
this.emit(cons.events.settings.SYSTEM_THEME_TOGGLED, this.useSystemTheme);
}, },
[cons.actions.settings.TOGGLE_MARKDOWN]: () => { [cons.actions.settings.TOGGLE_MARKDOWN]: () => {
this.isMarkdown = !this.isMarkdown; this.isMarkdown = !this.isMarkdown;

View file

@ -7,7 +7,7 @@ import settings from './client/state/settings';
import App from './app/pages/App'; import App from './app/pages/App';
settings.setTheme(settings.getThemeIndex()); settings.applyTheme();
ReactDom.render( ReactDom.render(
<App />, <App />,

View file

@ -132,3 +132,65 @@ export function copyToClipboard(text) {
copyInput.remove(); copyInput.remove();
} }
} }
export function suffixRename(name, validator) {
let suffix = 2;
let newName = name;
do {
newName = name + suffix;
suffix += 1;
} while (validator(newName));
return newName;
}
export function getImageDimension(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = async () => {
resolve({
w: img.width,
h: img.height,
});
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
}
export function scaleDownImage(imageFile, width, height) {
return new Promise((resolve) => {
const imgURL = URL.createObjectURL(imageFile);
const img = new Image();
img.onload = () => {
let newWidth = img.width;
let newHeight = img.height;
if (newHeight <= height && newWidth <= width) {
resolve(imageFile);
}
if (newHeight > height) {
newWidth = Math.floor(newWidth * (height / newHeight));
newHeight = height;
}
if (newWidth > width) {
newHeight = Math.floor(newHeight * (width / newWidth));
newWidth = width;
}
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, newWidth, newHeight);
canvas.toBlob((thumbnail) => {
URL.revokeObjectURL(imgURL);
resolve(thumbnail);
}, imageFile.type);
};
img.src = imgURL;
});
}

View file

@ -20,7 +20,7 @@ export async function getBaseUrl(servername) {
if (baseUrl === undefined) throw new Error(); if (baseUrl === undefined) throw new Error();
return baseUrl; return baseUrl;
} catch (e) { } catch (e) {
throw new Error(`${protocol}${servername}`); return `${protocol}${servername}`;
} }
} }
@ -199,3 +199,15 @@ export function getSSKeyInfo(key) {
return undefined; return undefined;
} }
} }
export async function hasDevices(userId) {
const mx = initMatrix.matrixClient;
try {
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
return Object.values(usersDeviceMap)
.every((userDevices) => (Object.keys(userDevices).length > 0));
} catch (e) {
console.error("Error determining if it's possible to encrypt to all users: ", e);
return false;
}
}

View file

@ -1,7 +1,7 @@
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import initMatrix from '../client/initMatrix';
const MAX_TAG_NESTING = 100; const MAX_TAG_NESTING = 100;
let mx = null;
const permittedHtmlTags = [ const permittedHtmlTags = [
'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
@ -44,7 +44,7 @@ function transformSpanTag(tagName, attribs) {
} }
function transformATag(tagName, attribs) { function transformATag(tagName, attribs) {
const userLink = attribs.href.match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/); const userLink = decodeURIComponent(attribs.href).match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
if (userLink !== null) { if (userLink !== null) {
// convert user link to pill // convert user link to pill
const userId = userLink[1]; const userId = userLink[1];
@ -54,7 +54,7 @@ function transformATag(tagName, attribs) {
'data-mx-pill': userId, 'data-mx-pill': userId,
}, },
}; };
if (userId === initMatrix.matrixClient.getUserId()) { if (userId === mx?.getUserId()) {
pill.attribs['data-mx-ping'] = undefined; pill.attribs['data-mx-ping'] = undefined;
} }
return pill; return pill;
@ -76,17 +76,28 @@ function transformATag(tagName, attribs) {
function transformImgTag(tagName, attribs) { function transformImgTag(tagName, attribs) {
const { src } = attribs; const { src } = attribs;
const mx = initMatrix.matrixClient; if (src.startsWith('mxc://') === false) {
return {
tagName: 'a',
attribs: {
href: src,
rel: 'noopener',
target: '_blank',
},
text: attribs.alt || src,
};
}
return { return {
tagName, tagName,
attribs: { attribs: {
...attribs, ...attribs,
src: src.startsWith('mxc://') ? mx.mxcUrlToHttp(src) : src, src: mx?.mxcUrlToHttp(src),
}, },
}; };
} }
export function sanitizeCustomHtml(body) { export function sanitizeCustomHtml(matrixClient, body) {
mx = matrixClient;
return sanitizeHtml(body, { return sanitizeHtml(body, {
allowedTags: permittedHtmlTags, allowedTags: permittedHtmlTags,
allowedAttributes: permittedTagToAttributes, allowedAttributes: permittedTagToAttributes,

View file

@ -1,7 +1,7 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
import React, { lazy, Suspense } from 'react'; import React, { lazy, Suspense } from 'react';
import linkifyHtml from 'linkifyjs/html'; import linkifyHtml from 'linkify-html';
import parse from 'html-react-parser'; import parse from 'html-react-parser';
import twemoji from 'twemoji'; import twemoji from 'twemoji';
import { sanitizeText } from './sanitize'; import { sanitizeText } from './sanitize';