Merge branch 'dev' into js-sdk-v34

This commit is contained in:
Ajay Bura 2025-01-17 09:31:02 +05:30
commit ca3389a4eb
39 changed files with 1162 additions and 341 deletions

View file

@ -1,30 +1,30 @@
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 15
# version: 2
# updates:
# - package-ecosystem: npm
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 15
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
# - package-ecosystem: github-actions
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 5
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
# - package-ecosystem: docker
# directory: /
# schedule:
# interval: weekly
# day: "tuesday"
# time: "01:00"
# timezone: "Asia/Kolkata"
# open-pull-requests-limit: 5

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.7
uses: actions/checkout@v4.2.0
- name: Setup node
uses: actions/setup-node@v4.0.3
uses: actions/setup-node@v4.0.4
with:
node-version: 20.12.2
cache: 'npm'
@ -66,7 +66,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.7
uses: actions/checkout@v4.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
@ -84,13 +84,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v5.6.1
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v6.7.0
uses: docker/build-push-action@v6.10.0
with:
context: .
platforms: linux/amd64,linux/arm64

21
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "cinny",
"version": "4.2.1",
"version": "4.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"version": "4.2.1",
"version": "4.2.3",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@ -45,7 +45,7 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "34.5.0",
"matrix-js-sdk": "34.11.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.29.0",
@ -2280,9 +2280,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-7.0.0.tgz",
"integrity": "sha512-MOencXiW/gI5MuTtCNsuojjwT5DXCrjMqv9xOslJC9h2tPdLFFFMGr58dY5Lis4DRd9MRWcgrGowUIHOqieWTA==",
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-9.1.0.tgz",
"integrity": "sha512-CtPoNcoRW6ehwxpRQAksG3tR+NJ7k4DV02nMFYTDwQtie1V4R8OTY77BjEIs97NOblhtS26jU8m1lWsOBEz0Og==",
"license": "Apache-2.0",
"engines": {
"node": ">= 10"
@ -8270,12 +8270,13 @@
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
},
"node_modules/matrix-js-sdk": {
"version": "34.5.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-34.5.0.tgz",
"integrity": "sha512-pbp+IxAkSwGmefrlUGCrtrs3UWyqN2iWh4lKnJW+jFIlsksXq7A8vL4cS1z8LXmpcHQAg3mKNuj8n8uhm51t1A==",
"version": "34.11.1",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-34.11.1.tgz",
"integrity": "sha512-rDbIUIqEsN/pbHb6haBQmjxxgeb9G3Df2IhPPOotUbX6R1KseA8yJ6TAY0YySM2zVaBV3yZ6dnKWexF/uWvZfA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^7.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^9.0.0",
"@matrix-org/olm": "3.2.15",
"another-json": "^0.2.0",
"bs58": "^6.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.2.1",
"version": "4.2.3",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@ -56,7 +56,7 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "34.5.0",
"matrix-js-sdk": "34.11.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.29.0",

View file

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

View file

@ -57,14 +57,16 @@ export function EmoticonAutocomplete({
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.getImagesFor(PackUsage.Emoticon)),
emojis
)
}, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
const autoCompleteEmoticon = result ? result.items : recentEmoji;
const autoCompleteEmoticon = (result ? result.items : recentEmoji)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode));
useEffect(() => {
if (query.text) search(query.text);

View file

@ -468,7 +468,7 @@ export function SearchEmojiGroup({
return (
<EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji
? searchResult.map((emoji) =>
? searchResult.sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((emoji) =>
'unicode' in emoji ? (
<EmojiItem
key={emoji.unicode}
@ -523,7 +523,7 @@ export const CustomEmojiGroups = memo(
<>
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getEmojis().map((image) => (
{pack.getEmojis().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
<EmojiItem
key={image.shortcode}
label={image.body || image.shortcode}
@ -566,7 +566,7 @@ export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: Matr
)}
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getStickers().map((image) => (
{pack.getStickers().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
<StickerItem
key={image.shortcode}
label={image.body || image.shortcode}

View file

@ -172,6 +172,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
type RenderImageContentProps = {
body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent;
mimeType?: string;
url: string;
@ -282,7 +283,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
<FileHeader body={content.filename ?? content.body ?? 'Audio'} mimeType={safeMimeType} />
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
@ -322,14 +323,14 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={content.body ?? 'Unnamed File'}
body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
{renderFileContent({
body: content.body ?? 'File',
body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -103,7 +103,7 @@ import {
} from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
@ -270,6 +270,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else if (commandName === Command.Shrug) {
plainText = `${SHRUG} ${plainText}`;
customHtml = `${SHRUG} ${customHtml}`;
} else if (commandName === Command.TableFlip) {
plainText = `${TABLEFLIP} ${plainText}`;
customHtml = `${TABLEFLIP} ${customHtml}`;
} else if (commandName === Command.UnFlip) {
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) {
const commandContent = commands[commandName as Command];
if (commandContent) {

View file

@ -435,10 +435,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext();
const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
usePowerLevelsAPI(powerLevels);
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
const canRedact = canDoAction('redact', myPowerLevel);
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@ -985,6 +987,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
@ -995,7 +998,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
<Reply
mx={mx}
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
@ -1057,6 +1059,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
@ -1067,7 +1070,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={
replyEventId && (
<Reply
mx={mx}
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
@ -1165,6 +1167,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
@ -1553,17 +1556,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === 1 ? (
<>
<CompactPlaceholder />
<CompactPlaceholder />
<CompactPlaceholder />
<CompactPlaceholder />
<CompactPlaceholder ref={observeBackAnchor} />
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<DefaultPlaceholder />
<DefaultPlaceholder />
<DefaultPlaceholder ref={observeBackAnchor} />
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
@ -1572,17 +1591,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === 1 ? (
<>
<CompactPlaceholder ref={observeFrontAnchor} />
<CompactPlaceholder />
<CompactPlaceholder />
<CompactPlaceholder />
<CompactPlaceholder />
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<DefaultPlaceholder ref={observeFrontAnchor} />
<DefaultPlaceholder />
<DefaultPlaceholder />
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />

View file

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

View file

@ -35,6 +35,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { EventType, RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
BubbleLayout,
@ -51,7 +52,12 @@ import {
getMemberAvatarMxc,
getMemberDisplayName,
} from '../../../utils/room';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias, mxcUrlToHttp } from '../../../utils/matrix';
import {
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
isRoomAlias,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@ -68,6 +74,8 @@ import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { StateEvent } from '../../../../types/matrix/room';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -235,9 +243,9 @@ export const MessageSourceCodeItem = as<
const getContent = (evt: MatrixEvent) =>
evt.isEncrypted()
? {
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
[`<== ORIGINAL_EVENT ==>`]: evt.event,
}
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
[`<== ORIGINAL_EVENT ==>`]: evt.event,
}
: evt.event;
const getText = (): string => {
@ -340,6 +348,46 @@ export const MessageCopyLinkItem = as<
);
});
export const MessagePinItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const pinnedEvents = useRoomPinnedEvents(room);
const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
const handlePin = () => {
const eventId = mEvent.getId();
const pinContent: RoomPinnedEventsEventContent = {
pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
};
if (!isPinned && eventId) {
pinContent.pinned.push(eventId);
}
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pin} />}
radii="300"
onClick={handlePin}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{isPinned ? 'Unpin Message' : 'Pin Message'}
</Text>
</MenuItem>
);
});
export const MessageDeleteItem = as<
'button',
{
@ -611,6 +659,7 @@ export type MessageProps = {
edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
messageLayout: MessageLayout;
@ -634,6 +683,7 @@ export const Message = as<'div', MessageProps>(
edit,
canDelete,
canSendReaction,
canPinEvent,
imagePackRooms,
relations,
messageLayout,
@ -949,29 +999,32 @@ export const Message = as<'div', MessageProps>(
/>
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
{((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
</Menu>
</FocusTrap>
}
@ -1095,26 +1148,26 @@ export const Event = as<'div', EventProps>(
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
</Menu>
</FocusTrap>
}

View file

@ -42,7 +42,7 @@ const generateThumbnailContent = async (
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string
mxc: string,
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
@ -50,6 +50,7 @@ export const getImageMsgContent = async (
const content: IContent = {
msgtype: MsgType.Image,
filename: file.name,
body: file.name,
};
if (imgEl) {
@ -74,7 +75,7 @@ export const getImageMsgContent = async (
export const getVideoMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string
mxc: string,
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
@ -83,6 +84,7 @@ export const getVideoMsgContent = async (
const content: IContent = {
msgtype: MsgType.Video,
filename: file.name,
body: file.name,
};
if (videoEl) {
@ -122,6 +124,7 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent =>
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.Audio,
filename: file.name,
body: file.name,
info: {
mimetype: file.type,

View file

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

View file

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

View file

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

View file

@ -6,6 +6,8 @@ import * as roomActions from '../../client/action/room';
import { useRoomNavigate } from './useRoomNavigate';
export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
export const UNFLIP = '┬─┬ノ( º_º)';
export function parseUsersAndReason(payload: string): {
users: string[];
@ -48,6 +50,8 @@ export enum Command {
MyRoomAvatar = 'myroomavatar',
ConvertToDm = 'converttodm',
ConvertToRoom = 'converttoroom',
TableFlip = 'tableflip',
UnFlip = 'unflip',
}
export type CommandContent = {
@ -78,6 +82,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
description: 'Send ¯\\_(ツ)_/¯ as message',
exe: async () => undefined,
},
[Command.TableFlip]: {
name: Command.TableFlip,
description: `Send ${TABLEFLIP} as message`,
exe: async () => undefined,
},
[Command.UnFlip]: {
name: Command.UnFlip,
description: `Send ${UNFLIP} as message`,
exe: async () => undefined,
},
[Command.StartDm]: {
name: Command.StartDm,
description: 'Start direct message with user. Example: /startdm userId1',

View file

@ -14,6 +14,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLEl
const handleClick: ReactEventHandler<HTMLElement> = useCallback(
(evt) => {
evt.stopPropagation();
evt.preventDefault();
const target = evt.currentTarget;
const mentionId = target.getAttribute('data-mention-id');

View file

@ -0,0 +1,56 @@
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import to from 'await-to-js';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { useQuery } from '@tanstack/react-query';
import { useMatrixClient } from './useMatrixClient';
const useFetchEvent = (room: Room, eventId: string) => {
const mx = useMatrixClient();
const fetchEventCallback = useCallback(async () => {
const evt = await mx.fetchRoomEvent(room.roomId, eventId);
const mEvent = new MatrixEvent(evt);
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
return mEvent;
}, [mx, room.roomId, eventId]);
return fetchEventCallback;
};
/**
*
* @param room
* @param eventId
* @returns `MatrixEvent`, `undefined` means loading, `null` means failure
*/
export const useRoomEvent = (
room: Room,
eventId: string,
getLocally?: () => MatrixEvent | undefined
) => {
const event = useMemo(() => {
if (getLocally) return getLocally();
return room.findEventById(eventId);
}, [room, eventId, getLocally]);
const fetchEvent = useFetchEvent(room, eventId);
const { data, error } = useQuery({
enabled: event === undefined,
queryKey: [room.roomId, eventId],
queryFn: fetchEvent,
staleTime: Infinity,
gcTime: 60 * 60 * 1000, // 1hour
});
if (event) return event;
if (data) return data;
if (error) return null;
return undefined;
};

View file

@ -0,0 +1,15 @@
import { useMemo } from 'react';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
export const useRoomPinnedEvents = (room: Room): string[] => {
const pinEvent = useStateEvent(room, StateEvent.RoomPinnedEvents);
const events = useMemo(() => {
const content = pinEvent?.getContent<RoomPinnedEventsEventContent>();
return content?.pinned ?? [];
}, [pinEvent]);
return events;
};

View file

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

View file

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

View file

@ -427,7 +427,14 @@ function RoomNotificationsGroupComp({
userId={event.sender}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
? mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop'
) ?? undefined
: undefined
}
alt={displayName}
@ -459,7 +466,6 @@ function RoomNotificationsGroupComp({
</Box>
{replyEventId && (
<Reply
mx={mx}
room={room}
replyEventId={replyEventId}
threadRootId={threadRootId}

View file

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

View file

@ -5,7 +5,7 @@ export const onLightFontWeight = createTheme(config.fontWeight, {
W100: '100',
W200: '200',
W300: '300',
W400: '420',
W400: '400',
W500: '500',
W600: '600',
W700: '700',
@ -17,10 +17,10 @@ export const onDarkFontWeight = createTheme(config.fontWeight, {
W100: '100',
W200: '200',
W300: '300',
W400: '350',
W500: '450',
W600: '550',
W700: '650',
W800: '750',
W900: '850',
W400: '400',
W500: '500',
W600: '600',
W700: '700',
W800: '800',
W900: '900',
});

View file

@ -23,9 +23,14 @@ function fetchConfig(token?: string): RequestInit | undefined {
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'default',
};
}
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request;
if (method !== 'GET') return;

View file

@ -43,6 +43,7 @@ export type IThumbnailContent = {
export type IImageContent = {
msgtype: MsgType.Image;
body?: string;
filename?: string;
url?: string;
info?: IImageInfo & IThumbnailContent;
file?: IEncryptedFile;
@ -51,6 +52,7 @@ export type IImageContent = {
export type IVideoContent = {
msgtype: MsgType.Video;
body?: string;
filename?: string;
url?: string;
info?: IVideoInfo & IThumbnailContent;
file?: IEncryptedFile;
@ -59,6 +61,7 @@ export type IVideoContent = {
export type IAudioContent = {
msgtype: MsgType.Audio;
body?: string;
filename?: string;
url?: string;
info?: IAudioInfo;
file?: IEncryptedFile;
@ -67,6 +70,7 @@ export type IAudioContent = {
export type IFileContent = {
msgtype: MsgType.File;
body?: string;
filename?: string;
url?: string;
info?: IFileInfo & IThumbnailContent;
file?: IEncryptedFile;