Compare commits

..

20 Commits

Author SHA1 Message Date
Krishan cead3cac50 Merge branch 'dev' into dm-calls 2026-05-13 18:53:36 +10:00
Ajay Bura 597785bae9 show call not supported message on incoming call notification 2026-05-13 10:09:13 +05:30
Ajay Bura 587e25db13 update call menu style 2026-05-13 10:01:11 +05:30
Ajay Bura 697d0a4e2f prevent call join if call not supported and started by other party 2026-05-13 09:24:15 +05:30
Ajay Bura 4cdace0ffc hide header call button from voice rooms 2026-05-07 15:51:04 +05:30
Ajay Bura 73c19555a5 Merge branch 'dev' into dm-calls 2026-05-07 18:30:02 +10:00
Ajay Bura d086b31530 send notification when starting call in non-voice rooms 2026-05-07 13:59:31 +05:30
Ajay Bura d5840ae37b allow option to start call in all rooms 2026-05-07 13:58:48 +05:30
Ajay Bura 5617a6edc6 fix call permission checks 2026-05-07 13:58:18 +05:30
Ajay Bura 37d6c5aece show incoming call dialog and play sound 2026-05-06 14:49:37 +05:30
Ajay Bura 084d442afa allow call widget to send call notification event 2026-04-27 20:15:25 +05:30
Ajay Bura c7c7f1ab42 only show call button if user have permission 2026-04-26 14:03:29 +05:30
Ajay Bura d6f19711ba Merge branch 'dev' into dm-calls 2026-04-26 18:16:27 +10:00
Ajay Bura 4a7eda1f8c add option to start voice/video call in dms 2026-04-26 13:45:49 +05:30
Ajay Bura ac89dbb4d0 update element call and widget api 2026-04-26 13:45:33 +05:30
Ajay Bura d3cc7ef822 add Atria call ringtone 2026-04-07 20:23:52 +05:30
Ajay Bura 0354709625 Merge branch 'dev' into dm-calls 2026-03-13 02:03:12 +11:00
Ajay Bura acd75838c3 show call view if call is active in room 2026-03-09 09:39:14 +05:30
Ajay Bura 374bfd1ce8 show speaker icon for dm's in call status name 2026-03-09 09:38:45 +05:30
Ajay Bura 13bdf654ef add option to start video all in DM 2026-03-09 09:27:35 +05:30
34 changed files with 5737 additions and 667 deletions
+3 -9
View File
@@ -21,15 +21,9 @@ jobs:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Validate and output pr number
- name: Output pr number
id: pr
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
@@ -48,7 +42,7 @@ jobs:
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
+30 -8
View File
@@ -1,23 +1,39 @@
name: Production deploy
on:
release:
types: [published]
workflow_dispatch:
jobs:
deploy-and-tarball:
name: Netlify deploy and tarball
outputs:
version: ${{ steps.vars.outputs.tag }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.node-version'
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Run semantic release
run: npm run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
GIT_COMMITTER_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
- name: Get version from tag
id: vars
run: |
TAG=$(git describe --tags --abbrev=0)
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
@@ -26,7 +42,7 @@ jobs:
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
deploy-message: 'Prod deploy ${{ steps.vars.outputs.tag }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
@@ -36,9 +52,6 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
timeout-minutes: 1
- name: Get version from tag
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
@@ -54,12 +67,16 @@ jobs:
- name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.vars.outputs.tag }}
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, GHCR
needs: deploy-and-tarball
env:
VERSION: ${{ needs.deploy-and-tarball.outputs.version }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -67,6 +84,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
@@ -89,6 +108,9 @@ jobs:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.VERSION }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -96,4 +118,4 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+8 -7
View File
@@ -6,20 +6,21 @@
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny:matrix.org",
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
"#mathematics-on:matrix.org",
"#stickers-and-emojis:tastytea.de"
],
"rooms": [
"#tuwunel:grin.hu",
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#gentoo:matrix.org"
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
+5411 -103
View File
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.12.2",
"version": "4.11.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"commit": "git-cz",
"bump": "node scripts/update-version.js"
"semantic-release": "semantic-release"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
@@ -29,6 +29,35 @@
"path": "./node_modules/cz-conventional-changelog"
}
},
"release": {
"branches": [
"dev"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec",
{
"prepareCmd": "node scripts/update-version.js ${nextRelease.version}"
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"package-lock.json",
"src/app/features/settings/about/About.tsx",
"src/app/pages/auth/AuthFooter.tsx",
"src/app/pages/client/WelcomePage.tsx"
],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
"@semantic-release/github"
]
},
"keywords": [],
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
@@ -67,7 +96,7 @@
"jotai": "2.6.0",
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "41.5.0",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.16.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
@@ -82,7 +111,7 @@
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.17.4",
"sanitize-html": "2.12.1",
"slate": "0.123.0",
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
@@ -94,6 +123,8 @@
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
"@semantic-release/exec": "7.1.0",
"@semantic-release/git": "10.0.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
@@ -102,7 +133,7 @@
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.16.1",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
@@ -119,6 +150,7 @@
"husky": "9.1.7",
"lint-staged": "16.3.2",
"prettier": "2.8.1",
"semantic-release": "25.0.3",
"typescript": "4.9.4",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
+73 -86
View File
@@ -1,6 +1,7 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
@@ -15,7 +16,6 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import {
EventTimelineSetHandlerMap,
@@ -54,8 +54,6 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
type IncomingCallInfo = {
room: Room;
@@ -77,8 +75,6 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
@@ -92,14 +88,12 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(
(members) => {
if (members.length === 0) {
onIgnore();
}
},
[onIgnore]
)
useCallback(() => {
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
if (members.length === 0) {
onIgnore();
}
}, [room, session, onIgnore])
);
const playSound = useCallback(() => {
@@ -116,7 +110,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
return (
<>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<OverlayCenter style={{ alignItems: 'start', paddingTop: config.space.S100 }}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
@@ -125,35 +119,72 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
escapeDeactivates: false,
}}
>
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="400"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
<Dialog>
<Box
style={{
padding: `${config.space.S300} ${config.space.S400} ${config.space.S400}`,
}}
direction="Column"
gap="500"
>
<Box direction="Column" gap="300">
<Box gap="200" alignItems="Center">
{info.intent === 'video' && <Icon size="50" src={Icons.VideoCamera} filled />}
<Text size="L400">Incoming Call</Text>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H3" align="Center" truncate>
{roomName}
<Box direction="Row" gap="300" alignItems="Center">
<Box shrink="No">
<Avatar size="400">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="400"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="0">
<Text size="H4" truncate>
{roomName}
</Text>
<Text size="T200">{info.sender}</Text>
</Box>
</Box>
</Box>
<Box gap="300">
<Button
style={{ flexGrow: 1 }}
variant="Critical"
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.PhoneDown} filled />}
>
<Text as="span" size="B400">
{dm ? 'Reject' : 'Ignore'}
</Text>
<Text size="T300">Incoming Call</Text>
</Box>
</Button>
<Button
style={{ flexGrow: 1 }}
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(room, info.intent === 'video')}
before={<Icon size="200" src={Icons.Phone} filled />}
disabled={!livekitSupported}
>
<Text as="span" size="B400">
Answer
</Text>
</Button>
</Box>
{!livekitSupported && (
<Text
@@ -164,49 +195,6 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
Your homeserver does not support calling.
</Text>
)}
{!webRTCSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
)}
<Box direction="Column" gap="300">
<Button
style={{ flexGrow: 1 }}
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(room, info.intent === 'video')}
before={
<Icon
size="200"
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
filled
/>
}
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
@@ -265,8 +253,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const refEventId = relation?.event_id;
const mention =
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
+17 -73
View File
@@ -255,67 +255,10 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
},
];
};
const parseListMarkdown = (
const parseListNode = (
node: Element,
processText: ProcessTextCallback,
depth = 0
): ParagraphElement[] => {
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
const prefix = node.attribs['data-md'] ?? md;
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
const lines: ParagraphElement[] = [];
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
const pushLine = (line: InlineElement[]) => {
lines.push({
type: BlockType.Paragraph,
children: [
{
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
},
...line,
],
});
if (typeof lineNo === 'string') {
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
} else {
lineNo += 1;
}
};
node.children.forEach((child) => {
if (isText(child)) {
pushLine([{ text: processText(child.data) }]);
return;
}
if (isTag(child)) {
if (child.name === 'ul' || child.name === 'ol') {
lines.push(...parseListMarkdown(child, processText, depth + 1));
return;
}
if (child.name === 'li') {
child.children.forEach((c) => {
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
lines.push(...parseListMarkdown(c, processText, depth + 1));
return;
}
pushLine(getInlineElement(c, processText));
});
return;
}
}
pushLine(getInlineElement(child, processText));
});
return lines;
};
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
@@ -326,7 +269,7 @@ const parseListLines = (children: ChildNode[], processText: ProcessTextCallback)
lineHolder = [];
};
children.forEach((child) => {
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: processText(child.data) });
return;
@@ -349,23 +292,24 @@ const parseListLines = (children: ChildNode[], processText: ProcessTextCallback)
});
appendLine();
return listLines;
};
const parseListNode = (
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
if (node.attribs['data-md'] !== undefined) {
return parseListMarkdown(node, processText);
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({
type: BlockType.Paragraph,
children: [
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
...lineChildren,
],
}));
}
const lines = parseListLines(node.childNodes, processText);
if (node.name === 'ol') {
return [
{
type: BlockType.OrderedList,
children: lines.map((lineChildren) => ({
children: listLines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@@ -376,7 +320,7 @@ const parseListNode = (
return [
{
type: BlockType.UnorderedList,
children: lines.map((lineChildren) => ({
children: listLines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@@ -28,11 +28,7 @@ import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
useMutualRooms,
useMutualRoomsSupport,
useUnstableMutualRoomsSupport,
} from '../../hooks/useMutualRooms';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -237,9 +233,7 @@ type MutualRoomsData = {
export function MutualRoomsChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
console.log(mutualRoomSupported, mutualRoomsState);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
const directs = useDirectRooms();
@@ -285,7 +279,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
if (
userId === mx.getSafeUserId() ||
(!mutualRoomSupported && !mutualRoomUnstable) ||
!mutualRoomSupported ||
mutualRoomsState.status === AsyncStatus.Error
) {
return null;
+1 -1
View File
@@ -22,7 +22,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const { room } = callEmbed;
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const callMembers = useCallMembers(room, callSession);
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
+1 -1
View File
@@ -82,7 +82,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
return (
<MenuItem
key={callMember.memberId}
key={callMember.membershipID}
size="400"
variant="Surface"
radii="300"
@@ -29,7 +29,7 @@ export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceP
return (
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
const userId = callMember.sender;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
@@ -39,7 +39,7 @@ export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceP
return (
<StackedAvatar
key={callMember.memberId}
key={callMember.membershipID}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
+12 -5
View File
@@ -1,4 +1,4 @@
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useState } from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -12,6 +12,12 @@ import { UserAvatar } from '../../components/user-avatar';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
interface MemberWithMembershipData {
membershipData?: SessionMembershipData & {
'm.call.intent': 'video' | 'audio';
};
}
type CallMemberCardProps = {
member: CallMembership;
};
@@ -22,7 +28,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
const openUserProfile = useOpenUserRoomProfile();
const { userId } = member;
const userId = member.sender;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
@@ -31,12 +37,13 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
const audioOnly = member.callIntent === 'audio';
const audioOnly =
(member as unknown as MemberWithMembershipData).membershipData?.['m.call.intent'] === 'audio';
return (
<SequenceCard
as="button"
key={member.memberId}
key={member.membershipID}
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
@@ -85,7 +92,7 @@ export function CallMemberRenderer({
return (
<>
{truncatedMembers.map((member) => (
<CallMemberCard key={member.memberId} member={member} />
<CallMemberCard key={member.membershipID} member={member} />
))}
{members.length > max && (
<SequenceCard
+3 -23
View File
@@ -14,7 +14,6 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
return (
@@ -24,27 +23,13 @@ function LivekitServerMissingMessage() {
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
function JoinMessage({
hasParticipant,
livekitSupported,
rtcSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
}
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
@@ -78,7 +63,6 @@ function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -90,13 +74,13 @@ function CallPrescreen() {
);
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const callMembers = useCallMembers(room, callSession);
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && livekitSupported && rtcSupported;
const canJoin = hasPermission && livekitSupported;
return (
<Scroll variant="Surface" hideTrack>
@@ -119,11 +103,7 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
) : (
<NoPermissionMessage />
))}
+3 -4
View File
@@ -60,7 +60,6 @@ import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
type RoomNavItemMenuProps = {
room: Room;
@@ -282,7 +281,7 @@ export function RoomNavItem({
const optionsVisible = hover || !!menuAnchor;
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const callMembers = useCallMembers(room, callSession);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
@@ -299,8 +298,8 @@ export function RoomNavItem({
mx.getSafeUserId()
);
// Do not join if missing permissions or no livekit support or no webRTC support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
// Do not join if missing permissions or no livekit support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo)) {
return;
}
@@ -54,7 +54,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
state: true,
key: StateEvent.GroupCallMemberPrefix,
},
name: 'Start or Join Call',
name: 'Join Call',
},
],
};
+1 -1
View File
@@ -27,7 +27,7 @@ export function Room() {
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const callMembers = useCallMembers(room, callSession);
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+2 -1
View File
@@ -27,6 +27,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import {
@@ -1474,7 +1475,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const content = mEvent.getContent();
const content = mEvent.getContent<SessionMembershipData>();
const prevContent = mEvent.getPrevContent();
const callJoined = content.application;
+1 -13
View File
@@ -71,7 +71,6 @@ import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
type RoomMenuProps = {
room: Room;
@@ -340,14 +339,6 @@ function CallButton() {
fill="None"
ref={triggerRef}
onClick={handleOpenMenu}
onContextMenu={(evt) => {
evt.preventDefault();
startCall(room, {
microphone: true,
video: true,
sound: true,
});
}}
disabled={inAnotherCall || callStarted}
aria-pressed={!!menuAnchor}
>
@@ -399,7 +390,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
mx.getSafeUserId()
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -594,9 +584,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{!room.isCallRoom() && livekitSupported && hasCallPermission && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+1 -1
View File
@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
<Box direction="Column" gap="100">
<Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text>
<Text size="T200">v4.12.2</Text>
<Text size="T200">v4.11.1</Text>
</Box>
<Text>Yet another matrix client.</Text>
</Box>
+22 -18
View File
@@ -2,7 +2,6 @@ import { Room } from 'matrix-js-sdk';
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap,
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { useEffect, useState } from 'react';
@@ -34,27 +33,32 @@ export const useCallSession = (room: Room): MatrixRTCSession => {
return session;
};
export const useCallMembersChange = (
session: MatrixRTCSession,
callback: (members: CallMembership[]) => void
): void => {
export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembership[] => {
const [memberships, setMemberships] = useState<CallMembership[]>(
MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription)
);
useEffect(() => {
const handleMembershipsChange: MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.MembershipsChanged] =
(oldestMembership, newMemberships) => {
callback(newMemberships);
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChange);
return () => {
session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, handleMembershipsChange);
const updateMemberships = () => {
setMemberships(MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription));
};
}, [session, callback]);
};
export const useCallMembers = (session: MatrixRTCSession): CallMembership[] => {
const [memberships, setMemberships] = useState<CallMembership[]>(session.memberships);
updateMemberships();
useCallMembersChange(session, setMemberships);
session.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
return () => {
session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
};
}, [session, room]);
return memberships;
};
export const useCallMembersChange = (session: MatrixRTCSession, callback: () => void): void => {
useEffect(() => {
session.on(MatrixRTCSessionEvent.MembershipsChanged, callback);
return () => {
session.removeListener(MatrixRTCSessionEvent.MembershipsChanged, callback);
};
}, [session, callback]);
};
+3 -1
View File
@@ -1,4 +1,5 @@
import { createContext, RefObject, useCallback, useContext, useEffect, useState } from 'react';
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import {
@@ -44,7 +45,8 @@ export const createCallEmbed = (
pref?: CallPreferences
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing = rtcSession.memberships.length > 0;
const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
+1 -1
View File
@@ -8,7 +8,7 @@ import { useCallJoined } from './useCallEmbed';
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
const [speakers, setSpeakers] = useState(new Set<string>());
const callSession = useCallSession(callEmbed.room);
const callMembers = useCallMembers(callSession);
const callMembers = useCallMembers(callEmbed.room, callSession);
const joined = useCallJoined(callEmbed);
const videoContainers = useMemo(() => {
+6 -50
View File
@@ -1,10 +1,9 @@
import { useCallback } from 'react';
import { MatrixClient, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
import { useSpecVersions } from './useSpecVersions';
export const useUnstableMutualRoomsSupport = (): boolean => {
export const useMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures } = useSpecVersions();
const supported =
@@ -15,59 +14,16 @@ export const useUnstableMutualRoomsSupport = (): boolean => {
return !!supported;
};
export const useMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures, versions } = useSpecVersions();
const supported =
versions.includes('v1.19') ||
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms.stable'];
return !!supported;
};
type MutualRoomsOK = {
joined: string[];
next_batch?: string;
count: number;
};
const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise<string[]> => {
const mutualRooms: Set<string> = new Set();
let nextBatch: string | undefined;
do {
// eslint-disable-next-line no-await-in-loop
const result = await mx.http.authedRequest<MutualRoomsOK>(
Method.Get,
'/mutual_rooms',
{
user_id: userId,
from: nextBatch,
},
undefined,
{
prefix: '/_matrix/client/v1',
}
);
result.joined.forEach((r) => mutualRooms.add(r));
nextBatch = result.next_batch;
} while (typeof nextBatch === 'string');
return Array.from(mutualRooms);
};
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
const mx = useMatrixClient();
const unstableSupport = useUnstableMutualRoomsSupport();
const support = useMutualRoomsSupport();
const supported = useMutualRoomsSupport();
const [mutualRoomsState] = useAsyncCallbackValue(
useCallback(() => {
if (support) return fetchAllMutualRooms(mx, userId);
if (unstableSupport) return mx._unstable_getSharedRooms(userId);
return Promise.resolve([]);
}, [mx, userId, unstableSupport, support])
useCallback(
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
[mx, userId, supported]
)
);
return mutualRoomsState;
+1 -1
View File
@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
v4.12.2
v4.11.1
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
+1 -1
View File
@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
v4.12.2
v4.11.1
</a>
</span>
}
+35 -20
View File
@@ -8,6 +8,7 @@ import {
type IWidgetApiErrorResponseDataDetails,
type ISearchUserDirectoryResult,
type IGetMediaConfigResult,
type UpdateDelayedEventAction,
OpenIDRequestState,
SimpleObservable,
IOpenIDUpdate,
@@ -52,11 +53,14 @@ export class CallWidgetDriver extends WidgetDriver {
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendEventDetails> {
const client = this.mx;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let r: { event_id: string } | null;
if (typeof stateKey === 'string') {
r = await this.mx.sendStateEvent(
r = await client.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
@@ -64,9 +68,9 @@ export class CallWidgetDriver extends WidgetDriver {
);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await this.mx.redactEvent(roomId, content.redacts);
r = await client.redactEvent(roomId, content.redacts);
} else {
r = await this.mx.sendEvent(
r = await client.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents]
@@ -84,8 +88,11 @@ export class CallWidgetDriver extends WidgetDriver {
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendDelayedEventDetails> {
const client = this.mx;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let delayOpts;
if (delay !== null) {
delayOpts = {
@@ -103,7 +110,7 @@ export class CallWidgetDriver extends WidgetDriver {
let r: SendDelayedEventResponse | null;
if (stateKey !== null) {
// state event
r = await this.mx._unstable_sendDelayedStateEvent(
r = await client._unstable_sendDelayedStateEvent(
roomId,
delayOpts,
eventType as keyof StateEvents,
@@ -112,7 +119,7 @@ export class CallWidgetDriver extends WidgetDriver {
);
} else {
// message event
r = await this.mx._unstable_sendDelayedEvent(
r = await client._unstable_sendDelayedEvent(
roomId,
delayOpts,
null,
@@ -127,16 +134,15 @@ export class CallWidgetDriver extends WidgetDriver {
};
}
public async cancelScheduledDelayedEvent(delayId: string): Promise<void> {
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
}
public async updateDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction
): Promise<void> {
const client = this.mx;
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
}
if (!client) throw new Error('Not in a room or not attached to a client');
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
await this.mx._unstable_sendScheduledDelayedEvent(delayId);
await client._unstable_updateDelayedEvent(delayId, action);
}
public async sendToDevice(
@@ -144,8 +150,10 @@ export class CallWidgetDriver extends WidgetDriver {
encrypted: boolean,
contentMap: { [userId: string]: { [deviceId: string]: object } }
): Promise<void> {
const client = this.mx;
if (encrypted) {
const crypto = this.mx.getCrypto();
const crypto = client.getCrypto();
if (!crypto) throw new Error('E2EE not enabled');
// attempt to re-batch these up into a single request
@@ -171,11 +179,11 @@ export class CallWidgetDriver extends WidgetDriver {
JSON.parse(stringifiedContent)
);
await this.mx.queueToDevice(batch);
await client.queueToDevice(batch);
})
);
} else {
await this.mx.queueToDevice({
await client.queueToDevice({
eventType,
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(([deviceId, content]) => ({
@@ -255,6 +263,7 @@ export class CallWidgetDriver extends WidgetDriver {
limit?: number,
direction?: 'f' | 'b'
): Promise<IReadEventRelationsResult> {
const client = this.mx;
const dir = direction as Direction;
const targetRoomId = roomId ?? this.inRoomId ?? undefined;
@@ -262,7 +271,7 @@ export class CallWidgetDriver extends WidgetDriver {
throw new Error('Error while reading the current room');
}
const { events, nextBatch, prevBatch } = await this.mx.relations(
const { events, nextBatch, prevBatch } = await client.relations(
targetRoomId,
eventId,
relationType ?? null,
@@ -281,7 +290,9 @@ export class CallWidgetDriver extends WidgetDriver {
searchTerm: string,
limit?: number
): Promise<ISearchUserDirectoryResult> {
const { limited, results } = await this.mx.searchUserDirectory({ term: searchTerm, limit });
const client = this.mx;
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
return {
limited,
@@ -294,11 +305,15 @@ export class CallWidgetDriver extends WidgetDriver {
}
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
return this.mx.getMediaConfig();
const client = this.mx;
return client.getMediaConfig();
}
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
const uploadResult = await this.mx.uploadContent(file);
const client = this.mx;
const uploadResult = await client.uploadContent(file);
return { contentUri: uploadResult.content_uri };
}
-2
View File
@@ -15,8 +15,6 @@ export function getCallCapabilities(
capabilities.add(MatrixCapabilities.Screenshots);
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
capabilities.add(MatrixCapabilities.MSC4039UploadFile);
capabilities.add(MatrixCapabilities.MSC4039DownloadFile);
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
+10 -2
View File
@@ -1,5 +1,12 @@
import { replaceMatch } from '../internal';
import { BlockQuoteRule, CodeBlockRule, ESC_BLOCK_SEQ, HeadingRule, ListRule } from './rules';
import {
BlockQuoteRule,
CodeBlockRule,
ESC_BLOCK_SEQ,
HeadingRule,
OrderedListRule,
UnorderedListRule,
} from './rules';
import { runBlockRule } from './runner';
import { BlockMDParser } from './type';
@@ -16,7 +23,8 @@ export const parseBlockMD: BlockMDParser = (text, parseInline) => {
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, ListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
// replace \n with <br/> because want to preserve empty lines
+48 -143
View File
@@ -10,22 +10,18 @@ export const HeadingRule: BlockMDRule = {
},
};
// opening fence: 3 or more backticks
// capture the exact fence length in group 1
// optional info string in group 2
// code content in group 3
// closing fence must match the exact same fence sequence via \1
const CODEBLOCK_REG_1 = /^(`{3,})(?!`)(\S*)\n((?:.*\n)+?)\1 *(?!.)\n?/m;
const CODEBLOCK_MD_1 = '```';
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
export const CodeBlockRule: BlockMDRule = {
match: (text) => text.match(CODEBLOCK_REG_1),
html: (match) => {
const [, fence, g1, g2] = match;
const [, g1, g2] = match;
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
const filename = g1 !== langCode ? g1 : null;
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
const filenameAtt = filename ? ` data-label="${filename}"` : '';
return `<pre data-md="${fence}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
},
};
@@ -52,146 +48,55 @@ export const BlockQuoteRule: BlockMDRule = {
};
const ORDERED_LIST_MD_1 = '-';
const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
const O_LIST_START = /^([\d])\./;
const O_LIST_TYPE = /^([aAiI])\./;
const O_LIST_TRAILING_NEWLINE = /\n$/;
const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
export const OrderedListRule: BlockMDRule = {
match: (text) => text.match(ORDERED_LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const [, listStart] = listText.match(O_LIST_START) ?? [];
const [, listType] = listText.match(O_LIST_TYPE) ?? [];
const lines = listText
.replace(O_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
const startAtt = listStart ? ` start="${listStart}"` : '';
const typeAtt = listType ? ` type="${listType}"` : '';
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
},
};
const UNORDERED_LIST_MD_1 = '*';
const LIST_ITEM_REG = /^( *)([-*]|[\da-zA-Z]\.) +(.+)$/;
type ListType = 'ol' | 'ul';
function getListType(marker: string): ListType {
return marker === '*' ? 'ul' : 'ol';
}
function getOrderedMeta(marker: string) {
const startMatch = marker.match(/^(\d)\./);
const typeMatch = marker.match(/^([aAiI])\./);
return {
start: startMatch?.[1],
type: typeMatch?.[1],
};
}
interface ParsedLine {
indent: number;
marker: string;
content: string;
listType: ListType;
}
function parseLines(text: string): ParsedLine[] {
return text
.replace(/\n$/, '')
.split('\n')
.map((line) => {
const match = line.match(LIST_ITEM_REG);
if (!match) return null;
const [, spaces, marker, content] = match;
return {
indent: spaces.length,
marker,
content,
listType: getListType(marker),
};
})
.filter(Boolean) as ParsedLine[];
}
function openList(line: ParsedLine) {
if (line.listType === 'ul') {
return `<ul data-md="${UNORDERED_LIST_MD_1}">`;
}
const { type, start } = getOrderedMeta(line.marker);
const dataMdAtt = `data-md="${type || start || ORDERED_LIST_MD_1}"`;
const startAtt = start ? ` start="${start}"` : '';
const typeAtt = type ? ` type="${type}"` : '';
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>`;
}
function closeList(listType: ListType) {
return listType === 'ul' ? '</ul>' : '</ol>';
}
function buildList(lines: ParsedLine[], parseInline?: (s: string) => string): string {
let html = '';
const stack: ('ul' | 'ol')[] = [];
lines.forEach((line, index) => {
const prev = lines[index - 1];
const next = lines[index + 1];
const content = parseInline ? parseInline(line.content) : line.content;
// FIRST ITEM
if (!prev) {
html += openList(line);
stack.push(line.listType);
}
// DEEPER INDENT > open nested list
else if (line.indent > prev.indent) {
html += openList(line);
stack.push(line.listType);
}
// SAME LEVEL
else if (line.indent === prev.indent) {
html += '</li>';
// different list type
if (line.listType !== prev.listType) {
html += closeList(stack.pop()!);
html += openList(line);
stack.push(line.listType);
}
}
// GOING BACK UP
else if (line.indent < prev.indent) {
html += '</li>';
while (stack.length > line.indent + 1) {
html += closeList(stack.pop()!);
html += '</li>';
}
if (line.listType !== stack[stack.length - 1]) {
html += closeList(stack.pop()!);
html += openList(line);
stack.push(line.listType);
}
}
html += `<li><p>${content}</p>`;
// LAST ITEM cleanup
if (!next) {
html += '</li>';
while (stack.length) {
html += closeList(stack.pop()!);
}
}
});
return html;
}
const LIST_REG_1 = /^(?: *(?:[-*]|[\da-zA-Z]\.) +.+\n?)+/m;
export const ListRule: BlockMDRule = {
match: (text) => text.match(LIST_REG_1),
const U_LIST_ITEM_PREFIX = /^\* */;
const U_LIST_TRAILING_NEWLINE = /\n$/;
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
export const UnorderedListRule: BlockMDRule = {
match: (text) => text.match(UNORDERED_LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const lines = parseLines(listText);
const lines = listText
.replace(U_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
const html = buildList(lines, parseInline);
return html;
return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
},
};
-47
View File
@@ -1,47 +0,0 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
const wobble = keyframes({
'0%': {
transform: 'translateX(0) rotateZ(0deg)',
},
'20%': {
transform: `translateX(-${toRem(4)}) rotateZ(-4deg)`,
},
'40%': {
transform: `translateX(${toRem(4)}) rotateZ(4deg)`,
},
'60%': {
transform: `translateX(-${toRem(3)}) rotateZ(-3deg)`,
},
'80%': {
transform: `translateX(${toRem(3)}) rotateZ(3deg)`,
},
'100%': {
transform: 'translateX(0) rotateZ(0deg)',
},
});
const glowPulse = keyframes({
'0%': {
boxShadow: `0 0 0 ${toRem(0)} ${color.Success.ContainerActive}`,
},
'100%': {
boxShadow: `0 0 0 ${toRem(8)} ${color.Success.ContainerActive}`,
},
});
export const WobbleAnimation = style({
animation: `${wobble} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
});
export const GlowAnimation = style({
animation: `${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
export const CallAvatarAnimation = style({
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
-11
View File
@@ -120,23 +120,12 @@ export const CodeBlockBottomShadow = style({
background: `linear-gradient(to top, #00000022, #00000000)`,
});
const BaseList = style({});
export const List = style([
BaseList,
DefaultReset,
MarginSpaced,
{
padding: `0 ${config.space.S100}`,
paddingLeft: config.space.S600,
selectors: {
'& &': {
marginTop: config.space.S200,
marginBottom: config.space.S200,
},
'li:last-child &': {
marginBottom: 0,
},
},
},
]);
+1 -9
View File
@@ -233,15 +233,7 @@ export const notificationPermission = (permission: NotificationPermission) => {
if ('Notification' in window) {
return window.Notification.permission === permission;
}
try {
// https://stackoverflow.com/questions/29774836/failed-to-construct-notification-illegal-constructor
// https://issues.chromium.org/issues/40415865
// eslint-disable-next-line no-new
new Notification('');
} catch {
return false;
}
return true;
return false;
};
export const getMouseEventCords = (event: MouseEvent) => ({
-5
View File
@@ -31,7 +31,6 @@ export const APPLICATION_MIME_TYPES = [
'application/javascript',
'application/xhtml+xml',
'application/xml',
'application/ogg',
];
export const TEXT_MIME_TYPE = [
@@ -116,10 +115,6 @@ export const getBlobSafeMimeType = (mimeType: string) => {
if (type === 'video/quicktime') {
return 'video/mp4';
}
// Fixes missing playback for Ogg audio
if (type === 'application/ogg') {
return 'audio/ogg';
}
return type;
};
-4
View File
@@ -1,4 +0,0 @@
export const webRTCSupported = () =>
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
(item) => item in window
);