Merge branch 'rework-user-settings' into js-sdk-v34

This commit is contained in:
Ajay Bura 2025-01-17 15:06:26 +11:00 committed by GitHub
commit 6bfd6e39bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 6682 additions and 677 deletions
package-lock.jsonpackage.json
src/app
components
features
hooks
pages
plugins
state
styles
utils

8
package-lock.json generated
View file

@ -33,7 +33,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.0.0",
"folds": "2.1.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
@ -6854,9 +6854,9 @@
}
},
"node_modules/folds": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.0.0.tgz",
"integrity": "sha512-lKv31vij4GEpEzGKWk5c3ar78fMZ9Di5n1XFR14Z2wnnpqhiiM5JTIzr127Gk5dOfy4mJkjnv/ZfMZvM2k+OQg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",

View file

@ -44,7 +44,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.0.0",
"folds": "2.1.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",

View file

@ -16,14 +16,14 @@ import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = {
imagePackRooms: Room[];
@ -52,21 +52,21 @@ export function EmoticonAutocomplete({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = [];
return list
.concat(
imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
emojis
)
return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis
);
}, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
const autoCompleteEmoticon = (result ? result.items : recentEmoji)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode));
const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) =>
a.shortcode.localeCompare(b.shortcode)
);
useEffect(() => {
if (query.text) search(query.text);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
type RenderViewerProps = {
src: string;
@ -121,7 +122,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>

View file

@ -30,8 +30,3 @@ export const AbsoluteFooter = style([
right: config.space.S100,
},
]);
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,26 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload';
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
type UploadCardRendererProps = {
file: TUploadContent;
isEncrypted?: boolean;
uploadAtom: TUploadAtom;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
};
export function UploadCardRenderer({
file,
isEncrypted,
uploadAtom,
onRemove,
onComplete,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const { upload, startUpload, cancelUpload } = useBindUploadAtom(
mx,
file,
uploadAtom,
isEncrypted
);
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
if (upload.status === UploadStatus.Idle) startUpload();
@ -33,6 +29,12 @@ export function UploadCardRenderer({
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return (
<UploadCard
radii="300"

View file

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

View file

@ -56,7 +56,13 @@ import {
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
@ -413,7 +419,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo}
uploadAtom={roomUploadAtomFamily(fileItem.file)}
onRemove={handleRemoveUpload}

View file

@ -85,7 +85,7 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { openProfileViewer } from '../../../client/action/navigation';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
@ -1032,7 +1032,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
</Message>
@ -1128,7 +1128,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
);
}
@ -1213,7 +1213,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1246,7 +1248,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1280,7 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1314,7 +1320,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1350,7 +1358,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1391,7 +1401,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1546,7 +1558,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === 1 ? config.space.S400 : toRem(64)
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
>
@ -1554,7 +1566,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === 1 ? (
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
@ -1589,7 +1601,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === 1 ? (
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />

View file

@ -716,7 +716,7 @@ export const Message = as<'div', MessageProps>(
const headerJSX = !collapse && (
<Box
gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
@ -728,12 +728,12 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
<Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
<b>{senderDisplayName}</b>
</Text>
</Username>
<Box shrink="No" gap="100">
{messageLayout === 0 && hover && (
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
@ -743,12 +743,12 @@ export const Message = as<'div', MessageProps>(
</Text>
</>
)}
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
</Box>
</Box>
);
const avatarJSX = !collapse && messageLayout !== 1 && (
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase>
<Avatar
className={css.MessageAvatar}
@ -1043,18 +1043,18 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
{messageLayout === 1 && (
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</CompactLayout>
)}
{messageLayout === 2 && (
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== 1 && messageLayout !== 2 && (
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}

View file

@ -0,0 +1,183 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
import { General } from './general';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications';
import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
SessionPage,
EncryptionPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
type SettingsMenuItem = {
page: SettingsPages;
name: string;
icon: IconSrc;
};
const useSettingsMenuItems = (): SettingsMenuItem[] =>
useMemo(
() => [
{
page: SettingsPages.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: SettingsPages.AccountPage,
name: 'Account',
icon: Icons.User,
},
{
page: SettingsPages.NotificationPage,
name: 'Notifications',
icon: Icons.Bell,
},
{
page: SettingsPages.SessionPage,
name: 'Sessions',
icon: Icons.Category,
},
{
page: SettingsPages.EncryptionPage,
name: 'Encryption',
icon: Icons.ShieldLock,
},
{
page: SettingsPages.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
{
page: SettingsPages.AboutPage,
name: 'About',
icon: Icons.Info,
},
],
[]
);
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
};
export function Settings({ initialPage, requestClose }: SettingsProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
});
const menuItems = useSettingsMenuItems();
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
setActivePage(undefined);
return;
}
requestClose();
};
return (
<PageRoot
nav={
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
<PageNav size="300">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="200">
<Avatar size="200" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
/>
</Avatar>
<Text size="H4" truncate>
Settings
</Text>
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<PageNavContent>
<div>
{menuItems.map((item) => (
<MenuItem
key={item.name}
variant="Background"
radii="400"
aria-pressed={activePage === item.page}
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
onClick={() => setActivePage(item.page)}
>
<Text
style={{
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
}}
size="T300"
truncate
>
{item.name}
</Text>
</MenuItem>
))}
</div>
</PageNavContent>
</PageNav>
)
}
>
{activePage === SettingsPages.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,211 @@
import React, {
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
as,
Box,
Header,
Text,
Icon,
Icons,
IconButton,
Input,
Button,
TextArea as TextAreaComponent,
color,
Spinner,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { MatrixError } from 'matrix-js-sdk';
import * as css from './styles.css';
import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
import { GetTarget } from '../../../plugins/text-area/type';
import { syntaxErrorPosition } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataEditorProps = {
type?: string;
content?: object;
requestClose: () => void;
};
export const AccountDataEditor = as<'div', AccountDataEditorProps>(
({ type, content, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const defaultContent = useMemo(
() => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
[content]
);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [jsonError, setJSONError] = useState<SyntaxError>();
const getTarget: GetTarget = useCallback(() => {
const target = textAreaRef.current;
if (!target) throw new Error('TextArea element not found!');
return target;
}, []);
const { textArea, operations, intent } = useMemo(() => {
const ta = new TextArea(getTarget);
const op = new TextAreaOperations(getTarget);
return {
textArea: ta,
operations: op,
intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
};
}, [getTarget]);
const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
intentHandler(evt);
if (isKeyHotkey('escape', evt)) {
const cursor = Cursor.fromTextAreaElement(getTarget());
operations.deselect(cursor);
}
};
const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (submitting) return;
const target = evt.target as HTMLFormElement | undefined;
const typeInput = target?.typeInput as HTMLInputElement | undefined;
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
if (!typeInput || !contentTextArea) return;
const typeStr = typeInput.value.trim();
const contentStr = contentTextArea.value.trim();
let parsedContent: object;
try {
parsedContent = JSON.parse(contentStr);
} catch (e) {
setJSONError(e as SyntaxError);
return;
}
setJSONError(undefined);
if (
!typeStr ||
parsedContent === null ||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
) {
return;
}
submit(typeStr, parsedContent);
};
useEffect(() => {
if (jsonError) {
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
const cursor = new Cursor(errorPosition, errorPosition, 'none');
operations.select(cursor);
getTarget()?.focus();
}
}, [jsonError, operations, getTarget]);
useEffect(() => {
if (submitState.status === AsyncStatus.Success) {
requestClose();
}
}, [submitState, requestClose]);
return (
<Box grow="Yes" direction="Column" {...props} ref={ref}>
<Header className={css.EditorHeader} size="600">
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Account Data
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
grow="Yes"
className={css.EditorContent}
direction="Column"
gap="400"
aria-disabled={submitting}
>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Type</Text>
<Box gap="300">
<Box grow="Yes" direction="Column">
<Input
name="typeInput"
size="400"
readOnly={!!type || submitting}
defaultValue={type}
required
/>
</Box>
<Button
variant="Primary"
size="400"
type="submit"
disabled={submitting}
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
>
<Text size="B400">Save</Text>
</Button>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{submitState.error.message}</b>
</Text>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
ref={textAreaRef}
name="contentTextArea"
className={css.EditorTextArea}
onKeyDown={handleKeyDown}
defaultValue={defaultContent}
resize="None"
spellCheck="false"
required
readOnly={submitting}
/>
{jsonError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>
{jsonError.name}: {jsonError.message}
</b>
</Text>
)}
</Box>
</Box>
</Box>
);
}
);

View file

@ -0,0 +1,273 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Switch,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Chip,
Button,
PopOut,
RectCords,
Menu,
config,
MenuItem,
} from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { TextViewer } from '../../../components/text-viewer';
import { stopPropagation } from '../../../utils/keyboard';
import { AccountDataEditor } from './AccountDataEditor';
function AccountData() {
const mx = useMatrixClient();
const [view, setView] = useState(false);
const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
const [selectedEvent, selectEvent] = useState<MatrixEvent>();
const [menuCords, setMenuCords] = useState<RectCords>();
const [selectedOption, selectOption] = useState<'edit' | 'inspect'>();
useAccountDataCallback(
mx,
useCallback(
() => setAccountData(Array.from(mx.store.accountData.values())),
[mx, setAccountData]
)
);
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const eventType = target.getAttribute('data-event-type');
if (eventType) {
const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType);
setMenuCords(evt.currentTarget.getBoundingClientRect());
selectEvent(mEvent);
}
};
const handleMenuClose = () => setMenuCords(undefined);
const handleEdit = () => {
selectOption('edit');
setMenuCords(undefined);
};
const handleInspect = () => {
selectOption('inspect');
setMenuCords(undefined);
};
const handleClose = useCallback(() => {
selectEvent(undefined);
selectOption(undefined);
}, []);
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Global"
description="Data stored in your global account data."
after={
<Button
onClick={() => setView(!view)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={view ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{view ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{view && (
<SettingTile>
<Box direction="Column" gap="200">
<Text size="L400">Types</Text>
<Box gap="200" wrap="Wrap">
<Chip
variant="Secondary"
fill="Soft"
radii="Pill"
onClick={handleEdit}
before={<Icon size="50" src={Icons.Plus} />}
>
<Text size="T200" truncate>
Add New
</Text>
</Chip>
{accountData.map((mEvent) => (
<Chip
key={mEvent.getType()}
variant="Secondary"
fill="Soft"
radii="Pill"
aria-pressed={menuCords && selectedEvent?.getType() === mEvent.getType()}
onClick={handleMenu}
data-event-type={mEvent.getType()}
>
<Text size="T200" truncate>
{mEvent.getType()}
</Text>
</Chip>
))}
</Box>
</Box>
</SettingTile>
)}
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleMenuClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleInspect}>
<Text size="T300">Inspect</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleEdit}>
<Text size="T300">Edit</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</SequenceCard>
{selectedEvent && selectedOption === 'inspect' && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<TextViewer
name={selectedEvent.getType() ?? 'Source Code'}
langName="json"
text={JSON.stringify(selectedEvent.getContent(), null, 2)}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{selectedOption === 'edit' && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<AccountDataEditor
type={selectedEvent?.getType()}
content={selectedEvent?.getContent()}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Box>
);
}
type DeveloperToolsProps = {
requestClose: () => void;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
</Box>
{developerTools && <AccountData />}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

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

View file

@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
export const EditorHeader = style([
DefaultReset,
{
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const EditorContent = style([
DefaultReset,
{
padding: config.space.S400,
},
]);
export const EditorTextArea = style({
fontFamily: 'monospace',
});

View file

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { GlobalPacks } from './GlobalPacks';
import { UserPack } from './UserPack';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
type EmojisStickersProps = {
requestClose: () => void;
};
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
const [imagePack, setImagePack] = useState<ImagePack>();
const handleImagePackViewClose = () => {
setImagePack(undefined);
};
if (imagePack) {
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Emojis & Stickers
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<UserPack onViewPack={setImagePack} />
<GlobalPacks onViewPack={setImagePack} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,488 @@
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Text,
Button,
Icon,
Icons,
IconButton,
Avatar,
AvatarImage,
AvatarFallback,
config,
Spinner,
Menu,
RectCords,
PopOut,
Checkbox,
toRem,
Scroll,
Header,
Line,
Chip,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
import { SequenceCardStyle } from '../styles.css';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
EmoteRoomsContent,
ImagePack,
ImageUsage,
PackAddress,
packAddressEqual,
} from '../../../plugins/custom-emoji';
import { LineClamp2 } from '../../../styles/Text.css';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
function GlobalPackSelector({
packs,
useAuthentication,
onSelect,
}: {
packs: ImagePack[];
useAuthentication: boolean;
onSelect: (addresses: PackAddress[]) => void;
}) {
const mx = useMatrixClient();
const roomToPacks = useMemo(() => {
const rToP = new Map<string, ImagePack[]>();
packs
.filter((pack) => !pack.deleted)
.forEach((pack) => {
if (!pack.address) return;
const pks = rToP.get(pack.address.roomId) ?? [];
pks.push(pack);
rToP.set(pack.address.roomId, pks);
});
return rToP;
}, [packs]);
const [selected, setSelected] = useState<PackAddress[]>([]);
const toggleSelect = (address: PackAddress) => {
setSelected((addresses) => {
const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address));
if (newAddresses.length !== addresses.length) {
return newAddresses;
}
newAddresses.push(address);
return newAddresses;
});
};
const hasSelected = selected.length > 0;
return (
<Box grow="Yes" direction="Column">
<Header size="400" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
<Box grow="Yes">
<Text size="L400" truncate>
Room Packs
</Text>
</Box>
<Box shrink="No">
<Chip
radii="Pill"
variant={hasSelected ? 'Success' : 'SurfaceVariant'}
outlined={hasSelected}
onClick={() => onSelect(selected)}
>
<Text size="B300">{hasSelected ? 'Save' : 'Close'}</Text>
</Chip>
</Box>
</Header>
<Line variant="Surface" size="300" />
<Box grow="Yes">
<Scroll size="300" hideTrack visibility="Hover">
<Box
direction="Column"
gap="400"
style={{
paddingLeft: config.space.S300,
paddingTop: config.space.S300,
paddingBottom: config.space.S300,
paddingRight: config.space.S100,
}}
>
{Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<Box key={roomId} direction="Column" gap="100">
<Text size="L400">{room.name}</Text>
{roomPacks.map((pack) => {
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication)
: undefined;
const { address } = pack;
if (!address) return null;
const added = selected.find((addr) => packAddressEqual(addr, address));
return (
<SequenceCard
key={pack.id}
className={SequenceCardStyle}
variant={added ? 'Success' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<SettingTile
title={pack.meta.name ?? 'Unknown'}
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
before={
<Box alignItems="Center" gap="300">
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
</Box>
}
after={
<Checkbox variant="Success" onClick={() => toggleSelect(address)} />
}
/>
</SequenceCard>
);
})}
</Box>
);
})}
{roomToPacks.size === 0 && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Box
justifyContent="Center"
direction="Column"
gap="200"
style={{
padding: `${config.space.S700} ${config.space.S400}`,
maxWidth: toRem(300),
margin: 'auto',
}}
>
<Text size="H5" align="Center">
No Packs
</Text>
<Text size="T200" align="Center">
Pack from rooms will appear here. You do not have any room with packs yet.
</Text>
</Box>
</SequenceCard>
)}
</Box>
</Scroll>
</Box>
</Box>
);
}
type GlobalPacksProps = {
onViewPack: (imagePack: ImagePack) => void;
};
export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const globalPacks = useGlobalImagePacks();
const [menuCords, setMenuCords] = useState<RectCords>();
const roomIds = useAtomValue(allRoomsAtom);
const rooms = useMemo(() => {
const rs: Room[] = [];
roomIds.forEach((rId) => {
const r = mx.getRoom(rId);
if (r) rs.push(r);
});
return rs;
}, [mx, roomIds]);
const roomsImagePack = useRoomsImagePacks(rooms);
const nonGlobalPacks = useMemo(
() =>
roomsImagePack.filter(
(pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address))
),
[roomsImagePack, globalPacks]
);
const [selectedPacks, setSelectedPacks] = useState<PackAddress[]>([]);
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
const unselectedGlobalPacks = useMemo(
() =>
nonGlobalPacks.filter(
(pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr))
),
[selectedPacks, nonGlobalPacks]
);
const handleRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => [...addresses, address]);
};
const handleUndoRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
};
const handleSelected = (addresses: PackAddress[]) => {
setMenuCords(undefined);
if (addresses.length > 0) {
setSelectedPacks((a) => [...addresses, ...a]);
}
};
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const content =
mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent<EmoteRoomsContent>() ?? {};
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
selectedPacks.forEach((addr) => {
const roomsToState = updatedContent.rooms ?? {};
const stateKeyToObj = roomsToState[addr.roomId] ?? {};
stateKeyToObj[addr.stateKey] = {};
roomsToState[addr.roomId] = stateKeyToObj;
updatedContent.rooms = roomsToState;
});
removedPacks.forEach((addr) => {
if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) {
delete updatedContent.rooms?.[addr.roomId][addr.stateKey];
}
});
await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
}, [mx, selectedPacks, removedPacks])
);
const resetChanges = useCallback(() => {
setSelectedPacks([]);
setRemovedPacks([]);
}, []);
useEffect(() => {
if (applyState.status === AsyncStatus.Success) {
resetChanges();
}
}, [applyState, resetChanges]);
const handleSelectMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const applyingChanges = applyState.status === AsyncStatus.Loading;
const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0;
const renderPack = (pack: ImagePack) => {
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const { address } = pack;
if (!address) return null;
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
return (
<SequenceCard
key={pack.id}
className={SequenceCardStyle}
variant={removed ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<SettingTile
title={
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
{pack.meta.name ?? 'Unknown'}
</span>
}
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
before={
<Box alignItems="Center" gap="300">
{removed ? (
<IconButton
size="300"
radii="Pill"
variant="Critical"
onClick={() => handleUndoRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Plus} size="100" />
</IconButton>
) : (
<IconButton
size="300"
radii="Pill"
variant="Secondary"
onClick={() => handleRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)}
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
</Box>
}
after={
!removed && (
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={() => onViewPack(pack)}
>
<Text size="B300">View</Text>
</Button>
)
}
/>
</SequenceCard>
);
};
return (
<>
<Box direction="Column" gap="100">
<Text size="L400">Favorite Packs</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Select Pack"
description="Pick emojis and stickers pack from rooms to use in all rooms."
after={
<>
<Button
onClick={handleSelectMenu}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Select</Text>
</Button>
<PopOut
anchor={menuCords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
display: 'flex',
maxWidth: toRem(400),
width: '100vw',
maxHeight: toRem(500),
}}
>
<GlobalPackSelector
packs={unselectedGlobalPacks}
useAuthentication={useAuthentication}
onSelect={handleSelected}
/>
</Menu>
</FocusTrap>
}
/>
</>
}
/>
</SequenceCard>
{globalPacks.map(renderPack)}
{nonGlobalPacks
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
.map(renderPack)}
</Box>
{hasChanges && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Success"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={resetChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={applyingChanges}
before={applyingChanges && <Spinner variant="Success" fill="Soft" size="100" />}
onClick={applyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</>
);
}

View file

@ -0,0 +1,71 @@
import React from 'react';
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds';
import { useUserImagePack } from '../../../hooks/useImagePacks';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
type UserPackProps = {
onViewPack: (imagePack: ImagePack) => void;
};
export function UserPack({ onViewPack }: UserPackProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userPack = useUserImagePack();
const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const handleView = () => {
if (userPack) {
onViewPack(userPack);
} else {
const defaultPack = new ImagePack(mx.getUserId() ?? '', {}, undefined);
onViewPack(defaultPack);
}
};
return (
<Box direction="Column" gap="100">
<Text size="L400">Default Pack</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userPack?.meta.name ?? 'Unknown'}
description={userPack?.meta.attribution}
before={
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
}
after={
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={handleView}
>
<Text size="B300">View</Text>
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View file

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

View file

@ -0,0 +1,618 @@
import React, {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useState,
} from 'react';
import {
as,
Box,
Button,
Chip,
config,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Switch,
Text,
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
import {
DarkTheme,
LightTheme,
Theme,
ThemeKind,
useSystemThemeKind,
useThemeNames,
useThemes,
} from '../../../hooks/useTheme';
import { stopPropagation } from '../../../utils/keyboard';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
selected: Theme;
onSelect: (theme: Theme) => void;
};
const ThemeSelector = as<'div', ThemeSelectorProps>(
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
<Menu {...props} ref={ref}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{themes.map((theme) => (
<MenuItem
key={theme.id}
size="300"
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
radii="300"
onClick={() => onSelect(theme)}
>
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
</MenuItem>
))}
</Box>
</Menu>
)
);
function SelectTheme({ disabled }: { disabled?: boolean }) {
const themes = useThemes();
const themeNames = useThemeNames();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleThemeSelect = (theme: Theme) => {
setThemeId(theme.id);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={disabled ? undefined : handleThemeMenu}
aria-disabled={disabled}
>
<Text size="T300">{themeNames[selectedTheme.id] ?? selectedTheme.id}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<ThemeSelector
themeNames={themeNames}
themes={themes}
selected={selectedTheme}
onSelect={handleThemeSelect}
/>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
const themes = useThemes();
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
const [ltCords, setLTCords] = useState<RectCords>();
const [dtCords, setDTCords] = useState<RectCords>();
const handleLightThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setLTCords(evt.currentTarget.getBoundingClientRect());
};
const handleDarkThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setDTCords(evt.currentTarget.getBoundingClientRect());
};
const handleLightThemeSelect = (theme: Theme) => {
setLightThemeId(theme.id);
setLTCords(undefined);
};
const handleDarkThemeSelect = (theme: Theme) => {
setDarkThemeId(theme.id);
setDTCords(undefined);
};
return (
<Box wrap="Wrap" gap="400">
<SettingTile
title="Light Theme:"
after={
<Chip
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
outlined={themeKind === ThemeKind.Light}
radii="Pill"
after={<Icon size="200" src={Icons.ChevronBottom} />}
onClick={handleLightThemeMenu}
>
<Text size="B300">{themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}</Text>
</Chip>
}
/>
<PopOut
anchor={ltCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setLTCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<ThemeSelector
themeNames={themeNames}
themes={lightThemes}
selected={selectedLightTheme}
onSelect={handleLightThemeSelect}
/>
</FocusTrap>
}
/>
<SettingTile
title="Dark Theme:"
after={
<Chip
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
outlined={themeKind === ThemeKind.Dark}
radii="Pill"
after={<Icon size="200" src={Icons.ChevronBottom} />}
onClick={handleDarkThemeMenu}
>
<Text size="B300">{themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}</Text>
</Chip>
}
/>
<PopOut
anchor={dtCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDTCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<ThemeSelector
themeNames={themeNames}
themes={darkThemes}
selected={selectedDarkTheme}
onSelect={handleDarkThemeSelect}
/>
</FocusTrap>
}
/>
</Box>
);
}
function PageZoomInput() {
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setCurrentZoom(evt.target.value);
};
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setCurrentZoom(pageZoom.toString());
}
if (
isKeyHotkey('enter', evt) &&
'value' in evt.target &&
typeof evt.target.value === 'string'
) {
const newZoom = parseInt(evt.target.value, 10);
if (Number.isNaN(newZoom)) return;
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
setPageZoom(safeZoom);
setCurrentZoom(safeZoom.toString());
}
};
return (
<Input
style={{ width: toRem(100) }}
variant={pageZoom === parseInt(currentZoom, 10) ? 'Secondary' : 'Success'}
size="300"
radii="300"
type="number"
min="75"
max="150"
value={currentZoom}
onChange={handleZoomChange}
onKeyDown={handleZoomEnter}
after={<Text size="T300">%</Text>}
outlined
/>
);
}
function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
return (
<Box direction="Column" gap="100">
<Text size="L400">Appearance</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="System Theme"
description="Choose between light and dark theme based on system preference."
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
/>
{systemTheme && <SystemThemePreferences />}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Theme"
description="Theme to use when system theme is not enabled."
after={<SelectTheme disabled={systemTheme} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
</Box>
);
}
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return (
<Box direction="Column" gap="100">
<Text size="L400">Editor</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="ENTER for Newline"
description={`Use ${
isMacOS() ? KeySymbol.Command : 'Ctrl'
} + ENTER to send message and ENTER for newline.`}
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Markdown Formatting"
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
</Box>
);
}
function SelectMessageLayout() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
const messageLayoutItems = useMessageLayoutItems();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (layout: MessageLayout) => {
setMessageLayout(layout);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{messageLayoutItems.map((item) => (
<MenuItem
key={item.layout}
size="300"
variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.layout)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SelectMessageSpacing() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const messageSpacingItems = useMessageSpacingItems();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (layout: MessageSpacing) => {
setMessageSpacing(layout);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{messageSpacingItems.map((item) => (
<MenuItem
key={item.spacing}
size="300"
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.spacing)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function Messages() {
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
);
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
settingsAtom,
'hideNickAvatarEvents'
);
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
return (
<Box direction="Column" gap="100">
<Text size="L400">Messages</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Membership Change"
after={
<Switch
variant="Primary"
value={hideMembershipEvents}
onChange={setHideMembershipEvents}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Profile Change"
after={
<Switch
variant="Primary"
value={hideNickAvatarEvents}
onChange={setHideNickAvatarEvents}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Disable Media Auto Load"
after={<Switch variant="Primary" value={mediaAutoLoad} onChange={setMediaAutoLoad} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Url Preview"
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Url Preview in Encrypted Room"
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
/>
</SequenceCard>
</Box>
);
}
type GeneralProps = {
requestClose: () => void;
};
export function General({ requestClose }: GeneralProps) {
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
General
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Appearance />
<Editor />
<Messages />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

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

View file

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

View file

@ -0,0 +1,152 @@
import React, { useCallback, useMemo } from 'react';
import { Badge, Box, Text } from 'folds';
import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule';
import {
getNotificationModeActions,
NotificationMode,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
const getAllMessageDefaultRule = (
ruleId: RuleId,
encrypted: boolean,
oneToOne: boolean
): PushRuleData => {
const conditions: PushRuleCondition[] = [];
if (oneToOne)
conditions.push({
kind: ConditionKind.RoomMemberCount,
is: '2',
});
conditions.push({
kind: ConditionKind.EventMatch,
key: 'type',
pattern: encrypted ? 'm.room.encrypted' : 'm.room.message',
});
return {
kind: PushRuleKind.Underride,
pushRule: {
rule_id: ruleId,
default: true,
enabled: true,
conditions,
actions: getNotificationModeActions(NotificationMode.NotifyLoud),
},
};
};
type PushRulesProps = {
ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
pushRules: IPushRules;
encrypted?: boolean;
oneToOne?: boolean;
};
function AllMessagesModeSwitcher({
ruleId,
pushRules,
encrypted = false,
oneToOne = false,
}: PushRulesProps) {
const mx = useMatrixClient();
const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
const getModeActions = useNotificationModeActions();
const handleChange = useCallback(
async (mode: NotificationMode) => {
const actions = getModeActions(mode);
await mx.setPushRuleActions('global', kind, ruleId, actions);
},
[mx, getModeActions, kind, ruleId]
);
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
}
export function AllMessagesNotifications() {
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
const pushRules = useMemo(
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
[pushRulesEvt]
);
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">All Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Secondary" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="1-to-1 Chats"
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="1-to-1 Chats (Encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
ruleId={RuleId.EncryptedDM}
encrypted
oneToOne
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Rooms"
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Rooms (Encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
ruleId={RuleId.EncryptedMessage}
encrypted
/>
}
/>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,171 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix';
type IgnoredUserListContent = {
ignored_users?: Record<string, object>;
};
function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient();
const [userId, setUserId] = useState<string>('');
const [ignoreState, ignore] = useAsyncCallback(
useCallback(
async (uId: string) => {
mx.setIgnoredUsers([...userList, uId]);
setUserId('');
},
[mx, userList]
)
);
const ignoring = ignoreState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const uId = evt.currentTarget.value;
setUserId(uId);
};
const handleReset = () => {
setUserId('');
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (ignoring) return;
const target = evt.target as HTMLFormElement | undefined;
const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
const uId = userIdInput?.value.trim();
if (!uId) return;
if (!isUserId(uId)) return;
ignore(uId);
};
return (
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={ignoring}>
<Box grow="Yes" direction="Column">
<Input
required
name="userIdInput"
value={userId}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={ignoring}
after={
userId &&
!ignoring && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant="Secondary"
fill="Soft"
outlined
radii="300"
type="submit"
disabled={ignoring}
>
{ignoring && <Spinner variant="Secondary" size="300" />}
<Text size="B400">Block</Text>
</Button>
</Box>
);
}
function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) {
const mx = useMatrixClient();
const [unignoreState, unignore] = useAsyncCallback(
useCallback(
() => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)),
[mx, userId, userList]
)
);
const handleUnignore = () => unignore();
const unIgnoring = unignoreState.status === AsyncStatus.Loading;
return (
<Chip
variant="Secondary"
radii="Pill"
after={
unIgnoring ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.Cross} size="100" />
)
}
onClick={handleUnignore}
disabled={unIgnoring}
>
<Text size="T200" truncate>
{userId}
</Text>
</Chip>
);
}
export function IgnoredUserList() {
const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList);
const ignoredUsers = useMemo(() => {
const ignoredUsersRecord =
ignoredUserListEvt?.getContent<IgnoredUserListContent>().ignored_users ?? {};
return Object.keys(ignoredUsersRecord);
}, [ignoredUserListEvt]);
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Block Messages</Text>
</Box>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Select User"
description="Prevent receiving message by adding userId into blocklist."
>
<Box direction="Column" gap="300">
<IgnoreUserInput userList={ignoredUsers} />
{ignoredUsers.length > 0 && (
<Box direction="Inherit" gap="100">
<Text size="L400">Blocklist</Text>
<Box wrap="Wrap" gap="200">
{ignoredUsers.map((userId) => (
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
))}
</Box>
</Box>
)}
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,203 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Badge, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getNotificationModeActions,
NotificationMode,
NotificationModeOptions,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
};
function KeywordInput() {
const mx = useMatrixClient();
const [keyword, setKeyword] = useState<string>('');
const [keywordState, addKeyword] = useAsyncCallback(
useCallback(
async (k: string) => {
mx.addPushRule('global', PushRuleKind.ContentSpecific, k, {
actions: getNotificationModeActions(NotificationMode.Notify, NOTIFY_MODE_OPS),
pattern: k,
});
setKeyword('');
},
[mx]
)
);
const addingKeyword = keywordState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const k = evt.currentTarget.value;
setKeyword(k);
};
const handleReset = () => {
setKeyword('');
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (addingKeyword) return;
const target = evt.target as HTMLFormElement | undefined;
const keywordInput = target?.keywordInput as HTMLInputElement | undefined;
const k = keywordInput?.value.trim();
if (!k) return;
addKeyword(k);
};
return (
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={addingKeyword}>
<Box grow="Yes" direction="Column">
<Input
required
name="keywordInput"
value={keyword}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={addingKeyword}
after={
keyword &&
!addingKeyword && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant="Secondary"
fill="Soft"
outlined
radii="300"
type="submit"
disabled={addingKeyword}
>
{addingKeyword && <Spinner variant="Secondary" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
);
}
type PushRulesProps = {
pushRule: IPushRule;
};
function KeywordCross({ pushRule }: PushRulesProps) {
const mx = useMatrixClient();
const [removeState, remove] = useAsyncCallback(
useCallback(
() => mx.deletePushRule('global', PushRuleKind.ContentSpecific, pushRule.rule_id),
[mx, pushRule]
)
);
const removing = removeState.status === AsyncStatus.Loading;
return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}>
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton>
);
}
function KeywordModeSwitcher({ pushRule }: PushRulesProps) {
const mx = useMatrixClient();
const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
const handleChange = useCallback(
async (mode: NotificationMode) => {
const actions = getModeActions(mode);
await mx.setPushRuleActions(
'global',
PushRuleKind.ContentSpecific,
pushRule.rule_id,
actions
);
},
[mx, getModeActions, pushRule]
);
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
}
export function KeywordMessagesNotifications() {
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
const pushRules = useMemo(
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
[pushRulesEvt]
);
const keywordPushRules = useMemo(() => {
const content = pushRules.global.content ?? [];
return content.filter(
(pushRule) => pushRule.default === false && typeof pushRule.pattern === 'string'
);
}, [pushRules]);
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Keyword Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Select Keyword"
description="Set a notification preference for message containing given keyword."
>
<KeywordInput />
</SettingTile>
</SequenceCard>
{keywordPushRules.map((pushRule) => (
<SequenceCard
key={pushRule.rule_id}
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={`"${pushRule.pattern}"`}
before={<KeywordCross pushRule={pushRule} />}
after={<KeywordModeSwitcher pushRule={pushRule} />}
/>
</SequenceCard>
))}
</Box>
);
}

View file

@ -0,0 +1,117 @@
import {
Box,
Button,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
} from 'folds';
import { IPushRule } from 'matrix-js-sdk';
import React, { MouseEventHandler, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode';
import { stopPropagation } from '../../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export const useNotificationModes = (): NotificationMode[] =>
useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []);
const useNotificationModeStr = (): Record<NotificationMode, string> =>
useMemo(
() => ({
[NotificationMode.OFF]: 'Disable',
[NotificationMode.Notify]: 'Notify Silent',
[NotificationMode.NotifyLoud]: 'Notify Loud',
}),
[]
);
type NotificationModeSwitcherProps = {
pushRule: IPushRule;
onChange: (mode: NotificationMode) => Promise<void>;
};
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
const modes = useNotificationModes();
const modeToStr = useNotificationModeStr();
const selectedMode = useNotificationActionsMode(pushRule.actions);
const [changeState, change] = useAsyncCallback(onChange);
const changing = changeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (mode: NotificationMode) => {
setMenuCords(undefined);
change(mode);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={
changing ? (
<Spinner variant="Secondary" size="300" />
) : (
<Icon size="300" src={Icons.ChevronBottom} />
)
}
onClick={handleMenu}
disabled={changing}
>
<Text size="T300">{modeToStr[selectedMode]}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{modes.map((mode) => (
<MenuItem
key={mode}
size="300"
variant="Surface"
aria-selected={mode === selectedMode}
radii="300"
onClick={() => handleSelect(mode)}
>
<Box grow="Yes">
<Text size="T300">{modeToStr[mode]}</Text>
</Box>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}

View file

@ -0,0 +1,117 @@
import React from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, color } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { usePermissionState } from '../../../hooks/usePermission';
import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages';
import { IgnoredUserList } from './IgnoredUserList';
function SystemNotification() {
const notifPermission = usePermissionState(
'notifications',
window.Notification.permission === 'default' ? 'prompt' : window.Notification.permission
);
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom,
'isNotificationSounds'
);
const requestNotificationPermission = () => {
window.Notification.requestPermission();
};
return (
<Box direction="Column" gap="100">
<Text size="L400">System</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Desktop Notifications"
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Notification permission is blocked. Please allow notification permission from
browser address bar.
</Text>
) : (
<span>Show desktop notifications when message arrive.</span>
)
}
after={
notifPermission === 'prompt' ? (
<Button size="300" radii="300" onClick={requestNotificationPermission}>
<Text size="B300">Enable</Text>
</Button>
) : (
<Switch
disabled={notifPermission !== 'granted'}
value={showNotifications}
onChange={setShowNotifications}
/>
)
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Notification Sound"
description="Play sound when new message arrive."
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
</Box>
);
}
type NotificationsProps = {
requestClose: () => void;
};
export function Notifications({ requestClose }: NotificationsProps) {
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Notifications
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<SystemNotification />
<AllMessagesNotifications />
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
<IgnoredUserList />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,222 @@
import React, { useCallback, useMemo } from 'react';
import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk';
import { Box, Text, Badge } from 'folds';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
import {
getNotificationModeActions,
NotificationMode,
NotificationModeOptions,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
};
const getDefaultIsUserMention = (userId: string): PushRuleData =>
makePushRuleData(
PushRuleKind.Override,
RuleId.IsUserMention,
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
[
{
kind: ConditionKind.EventPropertyContains,
key: 'content.m\\.mentions.user_ids',
value: userId,
},
]
);
const DefaultContainsDisplayName = makePushRuleData(
PushRuleKind.Override,
RuleId.ContainsDisplayName,
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
[
{
kind: ConditionKind.ContainsDisplayName,
},
]
);
const getDefaultContainsUsername = (username: string) =>
makePushRuleData(
PushRuleKind.ContentSpecific,
RuleId.ContainsUserName,
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
undefined,
username
);
const DefaultIsRoomMention = makePushRuleData(
PushRuleKind.Override,
RuleId.IsRoomMention,
getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
[
{
kind: ConditionKind.EventPropertyIs,
key: 'content.m\\.mentions.room',
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: 'room',
},
]
);
const DefaultAtRoomNotification = makePushRuleData(
PushRuleKind.Override,
RuleId.AtRoomNotification,
getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
[
{
kind: ConditionKind.EventMatch,
key: 'content.body',
pattern: '@room',
},
{
kind: ConditionKind.SenderNotificationPermission,
key: 'room',
},
]
);
type PushRulesProps = {
ruleId: RuleId;
pushRules: IPushRules;
defaultPushRuleData: PushRuleData;
};
function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRulesProps) {
const mx = useMatrixClient();
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
const handleChange = useCallback(
async (mode: NotificationMode) => {
const actions = getModeActions(mode);
await mx.setPushRuleActions('global', kind, ruleId, actions);
},
[mx, getModeActions, kind, ruleId]
);
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
}
export function SpecialMessagesNotifications() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const { displayName } = useUserProfile(userId);
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
const pushRules = useMemo(
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
[pushRulesEvt]
);
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Special Messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={`Mention User ID ("${userId}")`}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.IsUserMention}
defaultPushRuleData={getDefaultIsUserMention(userId)}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={`Contains Displayname ${displayName ? `("${displayName}")` : ''}`}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.ContainsDisplayName}
defaultPushRuleData={DefaultContainsDisplayName}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={`Contains Username ("${getMxIdLocalPart(userId)}")`}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.ContainsUserName}
defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Mention @room"
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.IsRoomMention}
defaultPushRuleData={DefaultIsRoomMention}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Contains @room"
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.AtRoomNotification}
defaultPushRuleData={DefaultAtRoomNotification}
/>
}
/>
</SequenceCard>
</Box>
);
}

View file

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

View file

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});

View file

@ -1,21 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import { useMatrixClient } from './useMatrixClient';
export function useAccountData(eventType) {
const mx = useMatrixClient();
const [event, setEvent] = useState(mx.getAccountData(eventType));
useEffect(() => {
const handleChange = (mEvent) => {
if (mEvent.getType() !== eventType) return;
setEvent(mEvent);
};
mx.on('accountData', handleChange);
return () => {
mx.removeListener('accountData', handleChange);
};
}, [mx, eventType]);
return event;
}

View file

@ -0,0 +1,22 @@
import { useState, useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export function useAccountData(eventType: string) {
const mx = useMatrixClient();
const [event, setEvent] = useState(() => mx.getAccountData(eventType));
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === eventType) {
setEvent(evt);
}
},
[eventType, setEvent]
)
);
return event;
}

View file

@ -1,48 +1,161 @@
import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useEffect, useMemo } from 'react';
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import { useForceUpdate } from './useForceUpdate';
import {
getGlobalImagePacks,
getRoomImagePack,
getRoomImagePacks,
getUserImagePack,
ImagePack,
ImageUsage,
} from '../plugins/custom-emoji';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
export const useRelevantImagePacks = (
mx: MatrixClient,
usage: PackUsage,
rooms: Room[]
): ImagePack[] => {
const [forceCount, forceUpdate] = useForceUpdate();
export const useUserImagePack = (): ImagePack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserImagePack(mx));
const relevantPacks = useMemo(
() => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[mx, usage, rooms, forceCount]
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) {
setUserPack(getUserImagePack(mx));
}
},
[mx]
)
);
useEffect(() => {
const handleUpdate = (event: MatrixEvent) => {
if (
event.getType() === AccountDataEvent.PoniesEmoteRooms ||
event.getType() === AccountDataEvent.PoniesUserEmotes
) {
forceUpdate();
}
const eventRoomId = event.getRoomId();
if (
eventRoomId &&
event.getType() === StateEvent.PoniesRoomEmotes &&
rooms.find((room) => room.roomId === eventRoomId)
) {
forceUpdate();
}
};
return userPack;
};
mx.on(ClientEvent.AccountData, handleUpdate);
mx.on(RoomStateEvent.Events, handleUpdate);
return () => {
mx.removeListener(ClientEvent.AccountData, handleUpdate);
mx.removeListener(RoomStateEvent.Events, handleUpdate);
};
}, [mx, rooms, forceUpdate]);
export const useGlobalImagePacks = (): ImagePack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalImagePacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.PoniesEmoteRooms) {
setGlobalPacks(getGlobalImagePacks(mx));
}
},
[mx]
)
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const eventType = mEvent.getType();
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (eventType === StateEvent.PoniesRoomEmotes && roomId && typeof stateKey === 'string') {
const global = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey
);
if (global) {
setGlobalPacks(getGlobalImagePacks(mx));
}
}
},
[mx, globalPacks]
)
);
return globalPacks;
};
export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomImagePack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.PoniesRoomEmotes &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomImagePack(room, stateKey));
}
},
[room, stateKey]
)
);
return roomPack;
};
export const useRoomImagePacks = (room: Room): ImagePack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomImagePacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.PoniesRoomEmotes
) {
setRoomPacks(getRoomImagePacks(room));
}
},
[room]
)
);
return roomPacks;
};
export const useRoomsImagePacks = (rooms: Room[]) => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomImagePacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.PoniesRoomEmotes
) {
setRoomPacks(rooms.flatMap(getRoomImagePacks));
}
},
[rooms]
)
);
return roomPacks;
};
export const useRelevantImagePacks = (usage: ImageUsage, rooms: Room[]): ImagePack[] => {
const userPack = useUserImagePack();
const globalPacks = useGlobalImagePacks();
const roomsPacks = useRoomsImagePacks(rooms);
const relevantPacks = useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id))
);
return relPacks.filter((pack) => pack.getImages(usage).length > 0);
}, [userPack, globalPacks, roomsPacks, usage]);
return relevantPacks;
};

View file

@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { MessageLayout } from '../state/settings';
export type MessageLayoutItem = {
name: string;
layout: MessageLayout;
};
export const useMessageLayoutItems = (): MessageLayoutItem[] =>
useMemo(
() => [
{
layout: MessageLayout.Modern,
name: 'Modern',
},
{
layout: MessageLayout.Compact,
name: 'Compact',
},
{
layout: MessageLayout.Bubble,
name: 'Bubble',
},
],
[]
);

View file

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { MessageSpacing } from '../state/settings';
export type MessageSpacingItem = {
name: string;
spacing: MessageSpacing;
};
export const useMessageSpacingItems = (): MessageSpacingItem[] =>
useMemo(
() => [
{
spacing: '0',
name: 'None',
},
{
spacing: '100',
name: 'Ultra Small',
},
{
spacing: '200',
name: 'Extra Small',
},
{
spacing: '300',
name: 'Small',
},
{
spacing: '400',
name: 'Normal',
},
{
spacing: '500',
name: 'Large',
},
],
[]
);

View file

@ -0,0 +1,66 @@
import { PushRuleAction, PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
export enum NotificationMode {
OFF = 'OFF',
Notify = 'Notify',
NotifyLoud = 'NotifyLoud',
}
export type NotificationModeOptions = {
soundValue?: string;
highlight?: boolean;
};
export const getNotificationModeActions = (
mode: NotificationMode,
options?: NotificationModeOptions
): PushRuleAction[] => {
if (mode === NotificationMode.OFF) return [];
const actions: PushRuleAction[] = [PushRuleActionName.Notify];
if (mode === NotificationMode.NotifyLoud) {
actions.push({
set_tweak: TweakName.Sound,
value: options?.soundValue ?? 'default',
});
}
if (options?.highlight) {
actions.push({
set_tweak: TweakName.Highlight,
value: true,
});
}
return actions;
};
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = (
options?: NotificationModeOptions
): GetNotificationModeCallback => {
const getAction: GetNotificationModeCallback = useCallback(
(mode) => getNotificationModeActions(mode, options),
[options]
);
return getAction;
};
export const useNotificationActionsMode = (actions: PushRuleAction[]): NotificationMode => {
const mode: NotificationMode = useMemo(() => {
const soundTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound
);
const notify = actions.find(
(action) => typeof action === 'string' && action === PushRuleActionName.Notify
);
if (notify && soundTweak) return NotificationMode.NotifyLoud;
if (notify) return NotificationMode.Notify;
return NotificationMode.OFF;
}, [actions]);
return mode;
};

View file

@ -0,0 +1,17 @@
import { useEffect, useMemo } from 'react';
export const useObjectURL = (object?: Blob): string | undefined => {
const url = useMemo(() => {
if (object) return URL.createObjectURL(object);
return undefined;
}, [object]);
useEffect(
() => () => {
if (url) URL.revokeObjectURL(url);
},
[url]
);
return url;
};

View file

@ -0,0 +1,71 @@
import {
IPushRule,
IPushRules,
PushRuleAction,
PushRuleCondition,
PushRuleKind,
RuleId,
} from 'matrix-js-sdk';
import { useMemo } from 'react';
export type PushRuleData = {
kind: PushRuleKind;
pushRule: IPushRule;
};
export const makePushRuleData = (
kind: PushRuleKind,
ruleId: RuleId,
actions: PushRuleAction[],
conditions?: PushRuleCondition[],
pattern?: string,
enabled?: boolean,
_default?: boolean
): PushRuleData => ({
kind,
pushRule: {
rule_id: ruleId,
default: _default ?? true,
enabled: enabled ?? true,
pattern,
conditions,
actions,
},
});
export const orderedPushRuleKinds: PushRuleKind[] = [
PushRuleKind.Override,
PushRuleKind.ContentSpecific,
PushRuleKind.RoomSpecific,
PushRuleKind.SenderSpecific,
PushRuleKind.Underride,
];
export const getPushRule = (
pushRules: IPushRules,
ruleId: RuleId | string
): PushRuleData | undefined => {
const { global } = pushRules;
let ruleData: PushRuleData | undefined;
orderedPushRuleKinds.some((kind) => {
const rules = global[kind];
const pushRule = rules?.find((r) => r.rule_id === ruleId);
if (pushRule) {
ruleData = {
kind,
pushRule,
};
return true;
}
return false;
});
return ruleData;
};
export const usePushRule = (
pushRules: IPushRules,
ruleId: RuleId | string
): PushRuleData | undefined => useMemo(() => getPushRule(pushRules, ruleId), [pushRules, ruleId]);

View file

@ -0,0 +1,58 @@
import { isKeyHotkey } from 'is-hotkey';
import { KeyboardEventHandler, useCallback } from 'react';
import { Cursor, Intent, Operations, TextArea } from '../plugins/text-area';
export const useTextAreaIntentHandler = (
textArea: TextArea,
operations: Operations,
intent: Intent
) => {
const handler: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(evt) => {
const target = evt.currentTarget;
if (isKeyHotkey('tab', evt)) {
evt.preventDefault();
const cursor = Cursor.fromTextAreaElement(target);
if (textArea.selection(cursor)) {
operations.select(intent.moveForward(cursor));
} else {
operations.deselect(operations.insert(cursor, intent.str));
}
target.focus();
}
if (isKeyHotkey('shift+tab', evt)) {
evt.preventDefault();
const cursor = Cursor.fromTextAreaElement(target);
const intentCursor = intent.moveBackward(cursor);
if (textArea.selection(cursor)) {
operations.select(intentCursor);
} else {
operations.deselect(intentCursor);
}
target.focus();
}
if (isKeyHotkey('enter', evt) || isKeyHotkey('shift+enter', evt)) {
evt.preventDefault();
const cursor = Cursor.fromTextAreaElement(target);
operations.select(intent.addNewLine(cursor));
}
if (isKeyHotkey('mod+enter', evt)) {
evt.preventDefault();
const cursor = Cursor.fromTextAreaElement(target);
operations.select(intent.addNextLine(cursor));
}
if (isKeyHotkey('mod+shift+enter', evt)) {
evt.preventDefault();
const cursor = Cursor.fromTextAreaElement(target);
operations.select(intent.addPreviousLine(cursor));
}
},
[textArea, operations, intent]
);
return handler;
};

74
src/app/hooks/useTheme.ts Normal file
View file

@ -0,0 +1,74 @@
import { lightTheme } from 'folds';
import { useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
export enum ThemeKind {
Light = 'light',
Dark = 'dark',
}
export type Theme = {
id: string;
kind: ThemeKind;
classNames: string[];
};
export const LightTheme: Theme = {
id: 'light-theme',
kind: ThemeKind.Light,
classNames: [lightTheme, onLightFontWeight, 'prism-light'],
};
export const SilverTheme: Theme = {
id: 'silver-theme',
kind: ThemeKind.Light,
classNames: ['silver-theme', silverTheme, onLightFontWeight, 'prism-light'],
};
export const DarkTheme: Theme = {
id: 'dark-theme',
kind: ThemeKind.Dark,
classNames: ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'],
};
export const ButterTheme: Theme = {
id: 'butter-theme',
kind: ThemeKind.Dark,
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
return themes;
};
export const useThemeNames = (): Record<string, string> =>
useMemo(
() => ({
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter',
}),
[]
);
export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
const [themeKind, setThemeKind] = useState<ThemeKind>(
darkModeQueryList.matches ? ThemeKind.Dark : ThemeKind.Light
);
useEffect(() => {
const handleMediaQueryChange = () => {
setThemeKind(darkModeQueryList.matches ? ThemeKind.Dark : ThemeKind.Light);
};
darkModeQueryList.addEventListener('change', handleMediaQueryChange);
return () => {
darkModeQueryList.removeEventListener('change', handleMediaQueryChange);
};
}, [darkModeQueryList, setThemeKind]);
return themeKind;
};

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export type UserProfile = {
avatarUrl?: string;
displayName?: string;
};
export const useUserProfile = (userId: string): UserProfile => {
const mx = useMatrixClient();
const [profile, setProfile] = useState<UserProfile>(() => {
const user = mx.getUser(userId);
return {
avatarUrl: user?.avatarUrl,
displayName: user?.displayName,
};
});
useEffect(() => {
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatarUrl: myUser.avatarUrl,
}));
};
const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
setProfile((cp) => ({
...cp,
displayName: myUser.displayName,
}));
};
mx.getProfileInfo(userId).then((info) =>
setProfile({
avatarUrl: info.avatar_url,
displayName: info.displayname,
})
);
user?.on(UserEvent.AvatarUrl, onAvatarChange);
user?.on(UserEvent.DisplayName, onDisplayNameChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
};
}, [mx, userId]);
return profile;
};

View file

@ -55,6 +55,7 @@ import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@ -79,7 +80,12 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return null;
}}
element={<AuthLayout />}
element={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<Register />} />
@ -99,23 +105,26 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return null;
}}
element={
<ClientRoot>
<ClientInitStorageAtom>
<ClientBindAtoms>
<ClientNonUIFeatures>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
</ClientNonUIFeatures>
</ClientBindAtoms>
</ClientInitStorageAtom>
</ClientRoot>
<>
<ClientRoot>
<ClientInitStorageAtom>
<ClientBindAtoms>
<ClientNonUIFeatures>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
</ClientNonUIFeatures>
</ClientBindAtoms>
</ClientInitStorageAtom>
</ClientRoot>
<AuthRouteThemeManager />
</>
}
>
<Route

View file

@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { configClass, varsClass } from 'folds';
import { DarkTheme, LightTheme, ThemeKind, useSystemThemeKind, useThemes } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind();
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
if (systemThemeKind === ThemeKind.Dark) {
document.body.classList.add(...DarkTheme.classNames);
}
if (systemThemeKind === ThemeKind.Light) {
document.body.classList.add(...LightTheme.classNames);
}
}, [systemThemeKind]);
return null;
}
export function AuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind();
const themes = useThemes();
const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [themeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
// apply normal theme if system theme is disabled
useEffect(() => {
if (!systemTheme) {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
document.body.classList.add(...selectedTheme.classNames);
}
}, [systemTheme, themes, themeId]);
// apply preferred system theme if system theme is enabled
useEffect(() => {
if (systemTheme) {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
const selectedTheme =
systemThemeKind === ThemeKind.Dark
? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme
: themes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
document.body.classList.add(...selectedTheme.classNames);
}
}, [systemTheme, systemThemeKind, themes, lightThemeId, darkThemeId]);
return null;
}

View file

@ -16,7 +16,7 @@ import {
SpaceTabs,
InboxTab,
ExploreTab,
UserTab,
SettingsTab,
UnverifiedTab,
} from './sidebar';
import { openCreateRoom, openSearch } from '../../../client/action/navigation';
@ -76,7 +76,7 @@ export function SidebarNav() {
<UnverifiedTab />
<InboxTab />
<UserTab />
<SettingsTab />
</SidebarStack>
</>
}

View file

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { stopPropagation } from '../../../utils/keyboard';
import { useUserProfile } from '../../../hooks/useUserProfile';
export function SettingsTab() {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const [settings, setSettings] = useState(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false);
return (
<SidebarItem active={settings}>
<SidebarItemTooltip tooltip={displayName}>
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} onClick={openSettings}>
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
{settings && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeSettings,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
<Settings requestClose={closeSettings} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SidebarItem>
);
}

View file

@ -1,65 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Text } from 'folds';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { openSettings } from '../../../../client/action/navigation';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
type UserProfile = {
avatar_url?: string;
displayname?: string;
};
export function UserTab() {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const [profile, setProfile] = useState<UserProfile>({});
const displayName = profile.displayname ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatar_url
? mxcUrlToHttp(mx, profile.avatar_url, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
useEffect(() => {
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatar_url: myUser.avatarUrl,
}));
};
const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatar_url: myUser.displayName,
}));
};
mx.getProfileInfo(userId).then((info) => setProfile(() => ({ ...info })));
user?.on(UserEvent.AvatarUrl, onAvatarChange);
user?.on(UserEvent.DisplayName, onDisplayNameChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
};
}, [mx, userId]);
return (
<SidebarItem>
<SidebarItemTooltip tooltip="User Settings">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} onClick={() => openSettings()}>
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -3,5 +3,5 @@ export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
export * from './UserTab';
export * from './SettingsTab';
export * from './UnverifiedTab';

View file

@ -83,8 +83,6 @@ export type InboxNotificationsPathSearchParams = {
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
export const USER_SETTINGS_PATH = '/user-settings/';
export const SPACE_SETTINGS_PATH = '/space-settings/';
export const ROOM_SETTINGS_PATH = '/room-settings/';

View file

@ -1,303 +0,0 @@
import { IImageInfo, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData, getStateEvents } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
export type PackEventIdToUnknown = Record<string, unknown>;
export type EmoteRoomIdToPackEvents = Record<string, PackEventIdToUnknown>;
export type EmoteRoomsContent = {
rooms?: EmoteRoomIdToPackEvents;
};
export enum PackUsage {
Emoticon = 'emoticon',
Sticker = 'sticker',
}
export type PackImage = {
url: string;
body?: string;
usage?: PackUsage[];
info?: IImageInfo;
};
export type PackImages = Record<string, PackImage>;
export type PackMeta = {
display_name?: string;
avatar_url?: string;
attribution?: string;
usage?: PackUsage[];
};
export type ExtendedPackImage = PackImage & {
shortcode: string;
};
export type PackContent = {
pack?: PackMeta;
images?: PackImages;
};
export class ImagePack {
public id: string;
public content: PackContent;
public displayName?: string;
public avatarUrl?: string;
public usage?: PackUsage[];
public attribution?: string;
public images: Map<string, ExtendedPackImage>;
public emoticons: ExtendedPackImage[];
public stickers: ExtendedPackImage[];
static parsePack(eventId: string, packContent: PackContent) {
if (!eventId || typeof packContent?.images !== 'object') {
return undefined;
}
return new ImagePack(eventId, packContent);
}
constructor(eventId: string, content: PackContent) {
this.id = eventId;
this.content = JSON.parse(JSON.stringify(content));
this.images = new Map();
this.emoticons = [];
this.stickers = [];
this.applyPackMeta(content);
this.applyImages(content);
}
applyPackMeta(content: PackContent) {
const pack = content.pack ?? {};
this.displayName = pack.display_name;
this.avatarUrl = pack.avatar_url;
this.usage = pack.usage ?? [PackUsage.Emoticon, PackUsage.Sticker];
this.attribution = pack.attribution;
}
applyImages(content: PackContent) {
this.images = new Map();
this.emoticons = [];
this.stickers = [];
if (!content.images) return;
Object.entries(content.images).forEach(([shortcode, data]) => {
const { url } = data;
const body = data.body ?? shortcode;
const usage = data.usage ?? this.usage;
const { info } = data;
if (!url) return;
const image: ExtendedPackImage = {
shortcode,
url,
body,
usage,
info,
};
this.images.set(shortcode, image);
if (usage && usage.includes(PackUsage.Emoticon)) {
this.emoticons.push(image);
}
if (usage && usage.includes(PackUsage.Sticker)) {
this.stickers.push(image);
}
});
}
getImages() {
return this.images;
}
getEmojis() {
return this.emoticons;
}
getStickers() {
return this.stickers;
}
getImagesFor(usage: PackUsage) {
if (usage === PackUsage.Emoticon) return this.getEmojis();
if (usage === PackUsage.Sticker) return this.getStickers();
return this.getEmojis();
}
getContent() {
return this.content;
}
getPackAvatarUrl(usage: PackUsage): string | undefined {
return this.avatarUrl || this.getImagesFor(usage)[0].url;
}
private updatePackProperty<K extends keyof PackMeta>(property: K, value: PackMeta[K]) {
if (this.content.pack === undefined) {
this.content.pack = {};
}
this.content.pack[property] = value;
this.applyPackMeta(this.content);
}
setAvatarUrl(avatarUrl?: string) {
this.updatePackProperty('avatar_url', avatarUrl);
}
setDisplayName(displayName?: string) {
this.updatePackProperty('display_name', displayName);
}
setAttribution(attribution?: string) {
this.updatePackProperty('attribution', attribution);
}
setUsage(usage?: PackUsage[]) {
this.updatePackProperty('usage', usage);
}
addImage(key: string, imgContent: PackImage) {
this.content.images = {
[key]: imgContent,
...this.content.images,
};
this.applyImages(this.content);
}
removeImage(key: string) {
if (!this.content.images) return;
if (this.content.images[key] === undefined) return;
delete this.content.images[key];
this.applyImages(this.content);
}
updateImageKey(key: string, newKey: string) {
const { images } = this.content;
if (!images) return;
if (images[key] === undefined) return;
const copyImages: PackImages = {};
Object.keys(images).forEach((imgKey) => {
copyImages[imgKey === key ? newKey : imgKey] = images[imgKey];
});
this.content.images = copyImages;
this.applyImages(this.content);
}
private updateImageProperty<K extends keyof PackImage>(
key: string,
property: K,
value: PackImage[K]
) {
if (!this.content.images) return;
if (this.content.images[key] === undefined) return;
this.content.images[key][property] = value;
this.applyImages(this.content);
}
setImageUrl(key: string, url: string) {
this.updateImageProperty(key, 'url', url);
}
setImageBody(key: string, body?: string) {
this.updateImageProperty(key, 'body', body);
}
setImageInfo(key: string, info?: IImageInfo) {
this.updateImageProperty(key, 'info', info);
}
setImageUsage(key: string, usage?: PackUsage[]) {
this.updateImageProperty(key, 'usage', usage);
}
}
export function packEventsToImagePacks(packEvents: MatrixEvent[]): ImagePack[] {
return packEvents.reduce<ImagePack[]>((imagePacks, packEvent) => {
const packId = packEvent?.getId();
const content = packEvent?.getContent() as PackContent | undefined;
if (!packId || !content) return imagePacks;
const pack = ImagePack.parsePack(packId, content);
if (pack) {
imagePacks.push(pack);
}
return imagePacks;
}, []);
}
export function getRoomImagePacks(room: Room): ImagePack[] {
const dataEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes);
return packEventsToImagePacks(dataEvents);
}
export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] {
const emoteRoomsContent = getAccountData(mx, AccountDataEvent.PoniesEmoteRooms)?.getContent() as
| EmoteRoomsContent
| undefined;
if (typeof emoteRoomsContent !== 'object') return [];
const { rooms } = emoteRoomsContent;
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 packEventIdToUnknown = rooms[roomId];
const roomPacks = getStateEvents(room, StateEvent.PoniesRoomEmotes);
const globalPacks = roomPacks.filter((mE) => {
const packKey = mE.getStateKey();
if (typeof packKey === 'string') return !!packEventIdToUnknown[packKey];
return false;
});
return packEventsToImagePacks(globalPacks);
});
return packs;
}
export function getUserImagePack(mx: MatrixClient): ImagePack | undefined {
const userPackContent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes)?.getContent() as
| PackContent
| undefined;
const userId = mx.getUserId();
if (!userPackContent || !userId) {
return undefined;
}
const userImagePack = ImagePack.parsePack(userId, userPackContent);
return userImagePack;
}
/**
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
* @returns {ImagePack[]} packs
*/
export function getRelevantPacks(mx?: MatrixClient, rooms?: Room[]): ImagePack[] {
const userPack = mx && getUserImagePack(mx);
const userPacks = userPack ? [userPack] : [];
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
return userPacks.concat(
globalPacks,
roomsPack.filter((pack) => !globalPackIds.has(pack.id))
);
}

View file

@ -0,0 +1,78 @@
import { MatrixEvent } from 'matrix-js-sdk';
import { PackAddress } from './PackAddress';
import { PackImageReader } from './PackImageReader';
import { PackImagesReader } from './PackImagesReader';
import { PackMetaReader } from './PackMetaReader';
import { ImageUsage, PackContent } from './types';
export class ImagePack {
public readonly id: string;
public readonly deleted: boolean;
public readonly address: PackAddress | undefined;
public readonly meta: PackMetaReader;
public readonly images: PackImagesReader;
private emoticonMemo: PackImageReader[] | undefined;
private stickerMemo: PackImageReader[] | undefined;
constructor(id: string, content: PackContent, address: PackAddress | undefined) {
this.id = id;
this.address = address;
this.deleted = content.pack === undefined && content.images === undefined;
this.meta = new PackMetaReader(content.pack ?? {});
this.images = new PackImagesReader(content.images ?? {});
}
static fromMatrixEvent(id: string, matrixEvent: MatrixEvent) {
const roomId = matrixEvent.getRoomId();
const stateKey = matrixEvent.getStateKey();
const address =
roomId && typeof stateKey === 'string' ? new PackAddress(roomId, stateKey) : undefined;
const content = matrixEvent.getContent<PackContent>();
const imagePack: ImagePack = new ImagePack(id, content, address);
return imagePack;
}
public getImages(usage: ImageUsage): PackImageReader[] {
if (usage === ImageUsage.Emoticon && this.emoticonMemo) {
return this.emoticonMemo;
}
if (usage === ImageUsage.Sticker && this.stickerMemo) {
return this.stickerMemo;
}
const images = Array.from(this.images.collection.values()).filter((image) => {
const usg = image.usage ?? this.meta.usage;
return usg.includes(usage);
});
if (usage === ImageUsage.Emoticon) {
this.emoticonMemo = images;
}
if (usage === ImageUsage.Sticker) {
this.stickerMemo = images;
}
return images;
}
public getAvatarUrl(usage: ImageUsage): string | undefined {
if (this.meta.avatar) return this.meta.avatar;
const images = this.getImages(usage);
const firstImage = images[0];
if (firstImage) return firstImage.url;
return undefined;
}
}

View file

@ -0,0 +1,10 @@
export class PackAddress {
public readonly roomId: string;
public readonly stateKey: string;
constructor(roomId: string, stateKey: string) {
this.roomId = roomId;
this.stateKey = stateKey;
}
}

View file

@ -0,0 +1,52 @@
import { IImageInfo } from '../../../types/matrix/common';
import { ImageUsage, PackImage } from './types';
export class PackImageReader {
public readonly shortcode: string;
public readonly url: string;
private readonly image: Omit<PackImage, 'url'>;
constructor(shortcode: string, url: string, image: Omit<PackImage, 'url'>) {
this.shortcode = shortcode;
this.url = url;
this.image = image;
}
static fromPackImage(shortcode: string, image: PackImage): PackImageReader | undefined {
const { url } = image;
if (typeof url !== 'string') return undefined;
return new PackImageReader(shortcode, url, image);
}
get body(): string | undefined {
const { body } = this.image;
return typeof body === 'string' ? body : undefined;
}
get info(): IImageInfo | undefined {
return this.image.info;
}
get usage(): ImageUsage[] | undefined {
const usg = this.image.usage;
if (!Array.isArray(usg)) return undefined;
const knownUsage = usg.filter((u) => u === ImageUsage.Emoticon || u === ImageUsage.Sticker);
return knownUsage.length > 0 ? knownUsage : undefined;
}
get content(): PackImage {
return {
url: this.url,
body: this.image.body,
usage: this.image.usage,
info: this.image.info,
};
}
}

View file

@ -0,0 +1,28 @@
import { PackImageReader } from './PackImageReader';
import { PackImages } from './types';
export class PackImagesReader {
private readonly rawImages: PackImages;
private shortcodeToImages: Map<string, PackImageReader> | undefined;
constructor(images: PackImages) {
this.rawImages = images;
}
get collection(): Map<string, PackImageReader> {
if (this.shortcodeToImages) return this.shortcodeToImages;
const shortcodeToImages: Map<string, PackImageReader> = new Map();
Object.entries(this.rawImages).forEach(([shortcode, image]) => {
const imageReader = PackImageReader.fromPackImage(shortcode, image);
if (imageReader) {
shortcodeToImages.set(shortcode, imageReader);
}
});
this.shortcodeToImages = shortcodeToImages;
return this.shortcodeToImages;
}
}

View file

@ -0,0 +1,45 @@
import { PackMeta, ImageUsage } from './types';
export class PackMetaReader {
private readonly meta: PackMeta;
public readonly fallbackUsage: ImageUsage[] = [ImageUsage.Emoticon, ImageUsage.Sticker];
constructor(meta: PackMeta) {
this.meta = meta;
}
get name(): string | undefined {
const displayName = this.meta.display_name;
if (typeof displayName === 'string') return displayName;
return undefined;
}
get avatar(): string | undefined {
const avatarURL = this.meta.avatar_url;
if (typeof avatarURL === 'string') return avatarURL;
return undefined;
}
get attribution(): string | undefined {
const { attribution } = this.meta;
if (typeof this.meta.attribution === 'string') return attribution;
return undefined;
}
get usage(): ImageUsage[] {
if (!Array.isArray(this.meta.usage)) return this.fallbackUsage;
const knownUsage = this.meta.usage.filter(
(u) => u === ImageUsage.Emoticon || u === ImageUsage.Sticker
);
if (knownUsage.length === 0) return this.fallbackUsage;
return knownUsage;
}
get content(): PackMeta {
return this.meta;
}
}

View file

@ -0,0 +1,7 @@
export * from './PackAddress';
export * from './PackMetaReader';
export * from './PackImageReader';
export * from './PackImagesReader';
export * from './ImagePack';
export * from './types';
export * from './utils';

View file

@ -0,0 +1,41 @@
import { IImageInfo } from '../../../types/matrix/common';
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
/**
* im.ponies.emote_rooms content
*/
export type PackStateKeyToObject = Record<string, object>;
export type RoomIdToStateKey = Record<string, PackStateKeyToObject>;
export type EmoteRoomsContent = {
rooms?: RoomIdToStateKey;
};
/**
* Pack
*/
export enum ImageUsage {
Emoticon = 'emoticon',
Sticker = 'sticker',
}
export type PackImage = {
url: string;
body?: string;
usage?: ImageUsage[];
info?: IImageInfo;
};
export type PackImages = Record<string, PackImage>;
export type PackMeta = {
display_name?: string;
avatar_url?: string;
attribution?: string;
usage?: ImageUsage[];
};
export type PackContent = {
pack?: PackMeta;
images?: PackImages;
};

View file

@ -0,0 +1,88 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { ImagePack } from './ImagePack';
import { EmoteRoomsContent, ImageUsage } from './types';
import { StateEvent } from '../../../types/matrix/room';
import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { PackMetaReader } from './PackMetaReader';
import { PackAddress } from './PackAddress';
export function packAddressEqual(a1?: PackAddress, a2?: PackAddress): boolean {
if (!a1 && !a2) return true;
if (!a1 || !a2) return false;
return a1.roomId === a2.roomId && a1.stateKey === a2.stateKey;
}
export function imageUsageEqual(u1: ImageUsage[], u2: ImageUsage[]) {
return u1.length === u2.length && u1.every((u) => u2.includes(u));
}
export function packMetaEqual(a: PackMetaReader, b: PackMetaReader): boolean {
return (
a.name === b.name &&
a.avatar === b.avatar &&
a.attribution === b.attribution &&
imageUsageEqual(a.usage, b.usage)
);
}
export function makeImagePacks(packEvents: MatrixEvent[]): ImagePack[] {
return packEvents.reduce<ImagePack[]>((imagePacks, packEvent) => {
const packId = packEvent.getId();
if (!packId) return imagePacks;
imagePacks.push(ImagePack.fromMatrixEvent(packId, packEvent));
return imagePacks;
}, []);
}
export function getRoomImagePack(room: Room, stateKey: string): ImagePack | undefined {
const packEvent = getStateEvent(room, StateEvent.PoniesRoomEmotes, stateKey);
if (!packEvent) return undefined;
const packId = packEvent.getId();
if (!packId) return undefined;
return ImagePack.fromMatrixEvent(packId, packEvent);
}
export function getRoomImagePacks(room: Room): ImagePack[] {
const packEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes);
return makeImagePacks(packEvents);
}
export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] {
const emoteRoomsContent = getAccountData(mx, AccountDataEvent.PoniesEmoteRooms)?.getContent() as
| EmoteRoomsContent
| undefined;
if (typeof emoteRoomsContent !== 'object') return [];
const { rooms: roomIdToPackInfo } = emoteRoomsContent;
if (typeof roomIdToPackInfo !== 'object') return [];
const roomIds = Object.keys(roomIdToPackInfo);
const packs = roomIds.flatMap((roomId) => {
if (typeof roomIdToPackInfo[roomId] !== 'object') return [];
const room = mx.getRoom(roomId);
if (!room) return [];
const packStateKeyToUnknown = roomIdToPackInfo[roomId];
const packEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes);
const globalPackEvents = packEvents.filter((mE) => {
const stateKey = mE.getStateKey();
if (typeof stateKey === 'string') return !!packStateKeyToUnknown[stateKey];
return false;
});
return makeImagePacks(globalPackEvents);
});
return packs;
}
export function getUserImagePack(mx: MatrixClient): ImagePack | undefined {
const packEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes);
const userId = mx.getUserId();
if (!packEvent || !userId) {
return undefined;
}
const userImagePack = ImagePack.fromMatrixEvent(userId, packEvent);
return userImagePack;
}

View file

@ -16,7 +16,6 @@ import 'prismjs/components/prism-java';
import 'prismjs/components/prism-python';
import './ReactPrism.css';
// we apply theme in client/state/settings.js
// using classNames .prism-dark .prism-light from ReactPrism.css
export default function ReactPrism({

View file

@ -0,0 +1,27 @@
export type CursorDirection = 'forward' | 'backward' | 'none';
export class Cursor {
public readonly start: number;
public readonly end: number;
public readonly direction: CursorDirection;
constructor(start: number, end: number, direction: CursorDirection) {
this.start = start;
this.end = end;
this.direction = direction;
}
static fromTextAreaElement(element: HTMLTextAreaElement) {
return new Cursor(element.selectionStart, element.selectionEnd, element.selectionDirection);
}
public get selection() {
return this.start !== this.end;
}
public get length() {
return this.end - this.start;
}
}

View file

@ -0,0 +1,7 @@
import { Cursor } from './Cursor';
export interface Operations {
select(cursor: Cursor): void;
deselect(cursor: Cursor): void;
insert(cursor: Cursor, text: string): Cursor;
}

View file

@ -0,0 +1,58 @@
import { Cursor } from './Cursor';
import { GetTarget } from './type';
export class TextArea {
private readonly getTarget: GetTarget;
constructor(getTarget: GetTarget) {
this.getTarget = getTarget;
}
get target() {
return this.getTarget();
}
public selection(cursor: Cursor): string {
return this.target.value.substring(cursor.start, cursor.end);
}
public lineBeginIndex(cursor: Cursor): number {
const beforeValue = this.target.value.substring(0, cursor.start);
const lineEndIndex = beforeValue.lastIndexOf('\n');
return lineEndIndex + 1;
}
public lineEndIndex(cursor: Cursor): number {
const afterValue = this.target.value.substring(cursor.end);
const lineEndIndex = afterValue.indexOf('\n');
return cursor.end + (lineEndIndex === -1 ? afterValue.length : lineEndIndex);
}
public cursorLines(cursor: Cursor): Cursor {
const lineBeginIndex = this.lineBeginIndex(cursor);
const lineEndIndex = this.lineEndIndex(cursor);
const linesCursor = new Cursor(lineBeginIndex, lineEndIndex, 'none');
return linesCursor;
}
public prevLine(cursor: Cursor): Cursor | undefined {
const currentLineIndex = this.lineBeginIndex(cursor);
const prevIndex = currentLineIndex - 1;
if (prevIndex < 0) return undefined;
const lineCursor = this.cursorLines(new Cursor(prevIndex, prevIndex, 'none'));
return lineCursor;
}
public nextLine(cursor: Cursor): Cursor | undefined {
const currentLineIndex = this.lineEndIndex(cursor);
const nextIndex = currentLineIndex + 1;
if (nextIndex > this.target.value.length) return undefined;
const lineCursor = this.cursorLines(new Cursor(nextIndex, nextIndex, 'none'));
return lineCursor;
}
}

View file

@ -0,0 +1,34 @@
import { Cursor } from './Cursor';
import { Operations } from './Operations';
import { GetTarget } from './type';
export class TextAreaOperations implements Operations {
private readonly getTarget: GetTarget;
constructor(getTarget: GetTarget) {
this.getTarget = getTarget;
}
get target() {
return this.getTarget();
}
public select(cursor: Cursor) {
this.target.setSelectionRange(cursor.start, cursor.end, cursor.direction);
}
public deselect(cursor: Cursor) {
if (cursor.direction === 'backward') {
this.target.setSelectionRange(cursor.start, cursor.start, 'none');
return;
}
this.target.setSelectionRange(cursor.end, cursor.end, 'none');
}
public insert(cursor: Cursor, text: string): Cursor {
const { value } = this.target;
this.target.value = `${value.substring(0, cursor.start)}${text}${value.substring(cursor.end)}`;
return new Cursor(cursor.start, cursor.start + text.length, cursor.direction);
}
}

View file

@ -0,0 +1,5 @@
export class TextUtils {
static multiline(str: string) {
return str.indexOf('\n') !== -1;
}
}

View file

@ -0,0 +1,6 @@
export * from './Cursor';
export * from './mods';
export * from './Operations';
export * from './TextArea';
export * from './TextAreaOperations';
export * from './TextUtils';

View file

@ -0,0 +1,102 @@
import { Cursor } from '../Cursor';
import { Operations } from '../Operations';
import { TextArea } from '../TextArea';
export class Intent {
public readonly textArea: TextArea;
public readonly operations: Operations;
public readonly size: number;
public readonly str: string;
private intentReg: RegExp;
constructor(size: number, textArea: TextArea, operations: Operations) {
this.textArea = textArea;
this.operations = operations;
this.size = size;
this.intentReg = /^\s*/;
this.str = '';
for (let i = 0; i < size; i += 1) this.str += ' ';
}
private lineIntent(cursor: Cursor): string {
const lines = this.textArea.cursorLines(cursor);
const selection = this.textArea.selection(lines);
const match = selection.match(this.intentReg);
if (!match) return '';
return match[0];
}
public moveForward(cursor: Cursor): Cursor {
const linesCursor = this.textArea.cursorLines(cursor);
const selection = this.textArea.selection(linesCursor);
const lines = selection.split('\n');
const intentLines = lines.map((line) => `${this.str}${line}`);
this.operations.insert(linesCursor, intentLines.join('\n'));
const addedIntentLength = lines.length * this.str.length;
return new Cursor(
cursor.start === linesCursor.start ? cursor.start : cursor.start + this.str.length,
cursor.end + addedIntentLength,
cursor.direction
);
}
public moveBackward(cursor: Cursor): Cursor {
const linesCursor = this.textArea.cursorLines(cursor);
const selection = this.textArea.selection(linesCursor);
const lines = selection.split('\n');
const intentLines = lines.map((line) => {
if (line.startsWith(this.str)) return line.substring(this.str.length);
return line.replace(this.intentReg, '');
});
const intentCursor = this.operations.insert(linesCursor, intentLines.join('\n'));
const firstLineTrimLength = lines[0].length - intentLines[0].length;
const lastLine = this.textArea.cursorLines(
new Cursor(intentCursor.end, intentCursor.end, 'none')
);
const start = Math.max(cursor.start - firstLineTrimLength, linesCursor.start);
const trimmedContentLength = linesCursor.length - intentCursor.length;
const end = Math.max(lastLine.start, cursor.end - trimmedContentLength);
return new Cursor(start, end, cursor.direction);
}
public addNewLine(cursor: Cursor): Cursor {
const lineIntent = this.lineIntent(cursor);
const line = `\n${lineIntent}`;
const insertCursor = this.operations.insert(cursor, line);
return new Cursor(insertCursor.end, insertCursor.end, 'none');
}
public addNextLine(cursor: Cursor): Cursor {
const lineIntent = this.lineIntent(cursor);
const line = `\n${lineIntent}`;
const currentLine = this.textArea.cursorLines(cursor);
const lineCursor = new Cursor(currentLine.end, currentLine.end, 'none');
const insertCursor = this.operations.insert(lineCursor, line);
return new Cursor(insertCursor.end, insertCursor.end, 'none');
}
public addPreviousLine(cursor: Cursor): Cursor {
const lineIntent = this.lineIntent(cursor);
const line = `\n${lineIntent}`;
const prevLine = this.textArea.prevLine(cursor);
const insertIndex = prevLine?.end ?? 0;
const lineCursor = new Cursor(insertIndex, insertIndex, 'none');
const insertCursor = this.operations.insert(lineCursor, line);
return new Cursor(insertCursor.end, insertCursor.end, 'none');
}
}

View file

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

View file

@ -0,0 +1 @@
export type GetTarget = () => HTMLTextAreaElement;

View file

@ -2,11 +2,17 @@ import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export type MessageLayout = 0 | 1 | 2;
export enum MessageLayout {
Modern = 0,
Compact = 1,
Bubble = 2,
}
export interface Settings {
themeIndex: number;
themeId?: string;
useSystemTheme: boolean;
lightThemeId?: string;
darkThemeId?: string;
isMarkdown: boolean;
editorToolbar: boolean;
twitterEmoji: boolean;
@ -26,11 +32,15 @@ export interface Settings {
showNotifications: boolean;
isNotificationSounds: boolean;
developerTools: boolean;
}
const defaultSettings: Settings = {
themeIndex: 0,
themeId: undefined,
useSystemTheme: true,
lightThemeId: undefined,
darkThemeId: undefined,
isMarkdown: true,
editorToolbar: false,
twitterEmoji: false,
@ -50,6 +60,8 @@ const defaultSettings: Settings = {
showNotifications: true,
isNotificationSounds: true,
developerTools: false,
};
export const getSettings = () => {

View file

@ -99,11 +99,11 @@ export type TUploadAtom = ReturnType<typeof createUploadAtom>;
export const useBindUploadAtom = (
mx: MatrixClient,
file: TUploadContent,
uploadAtom: TUploadAtom,
hideFilename?: boolean
) => {
const [upload, setUpload] = useAtom(uploadAtom);
const { file } = upload;
const handleProgress = useThrottle(
useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]),

View file

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
});

View file

@ -0,0 +1,19 @@
import { style } from '@vanilla-extract/css';
export const BreakWord = style({
wordBreak: 'break-word',
});
export const LineClamp2 = style({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});
export const LineClamp3 = style({
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});

View file

@ -112,3 +112,16 @@ export const randomStr = (len = 12): string => {
}
return str;
};
export const suffixRename = (name: string, validator: (newName: string) => boolean): string => {
let suffix = 1;
let newName = name;
do {
newName = name + suffix;
suffix += 1;
} while (validator(newName));
return newName;
};
export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-');

View file

@ -6,10 +6,10 @@ export const targetFromEvent = (evt: Event, selector: string): Element | undefin
export const editableActiveElement = (): boolean =>
!!document.activeElement &&
(document.activeElement.nodeName.toLowerCase() === 'input' ||
document.activeElement.nodeName.toLowerCase() === 'textbox' ||
document.activeElement.nodeName.toLowerCase() === 'textarea' ||
document.activeElement.getAttribute('contenteditable') === 'true' ||
document.activeElement.getAttribute('role') === 'input' ||
document.activeElement.getAttribute('role') === 'textbox');
document.activeElement.getAttribute('role') === 'textarea');
export const isIntersectingScrollView = (
scrollElement: HTMLElement,
@ -75,6 +75,9 @@ export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undef
return files;
};
export const renameFile = (file: File, name: string): File =>
new File([file], name, { type: file.type });
export const getImageUrlBlob = async (url: string) => {
const res = await fetch(url);
const blob = await res.blob();
@ -204,3 +207,13 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
return encodedURIComponent;
}
};
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
const match = error.message.match(/position\s(\d+)\s/);
if (!match) return undefined;
const posStr = match[1];
const position = parseInt(posStr, 10);
if (Number.isNaN(position)) return undefined;
return position;
};

View file

@ -34,6 +34,15 @@ export const onEnterOrSpace =
};
export const stopPropagation = (evt: KeyboardEvent): boolean => {
const ae = document.activeElement;
const editableActiveElement = ae
? ae.nodeName.toLowerCase() === 'input' ||
ae.nodeName.toLowerCase() === 'textarea' ||
ae.getAttribute('contenteditable') === 'true'
: false;
if (editableActiveElement) return false;
evt.stopPropagation();
return true;
};

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