Compare commits

..

17 Commits

Author SHA1 Message Date
Ajay Bura
444b2feb9e update folds 2025-11-03 15:30:52 +05:30
Ajay Bura
f35aa384e5 thread view - WIP 2025-11-03 15:29:51 +05:30
Ajay Bura
b5fd41f862 clear active thread state on logout 2025-11-03 15:28:52 +05:30
Ajay Bura
3d4c91c969 add onClick prop to thread selector 2025-11-03 15:28:42 +05:30
Ajay Bura
38cc6e6f3a add room to active thread atom 2025-11-03 15:27:02 +05:30
Ajay Bura
12bcbc2e78 load threads in My Threads menu 2025-10-22 16:22:31 +05:30
Ajay Bura
e44ca92422 remove avatar from threads selector 2025-10-22 16:21:33 +05:30
Ajay Bura
174b315278 move timeline utils functions to new file 2025-10-22 16:21:16 +05:30
Ajay Bura
d73428ee3d add option to inherit priority in time component 2025-10-22 16:20:22 +05:30
Ajay Bura
f2c5a595b9 Merge branch 'dev' into fix-257 2025-09-27 10:00:30 +05:30
Ajay Bura
a6a3ac3b24 redesign thread selector 2025-09-25 12:17:44 +05:30
Ajay Bura
67c6785bf3 inherit font weight for time component 2025-09-25 12:17:14 +05:30
Ajay Bura
d36938e1fd fix typo 2025-09-24 16:32:05 +05:30
Ajay Bura
1914606895 threads - WIP 2025-09-24 15:57:15 +05:30
Ajay Bura
19096c3543 Merge branch 'dev' into fix-257 2025-09-21 09:54:55 +05:30
Ajay Bura
737cc09fea Merge branch 'dev' into fix-257 2025-09-15 13:16:59 +05:30
Ajay Bura
154f234d0c thread menu - WIP 2025-09-15 13:16:43 +05:30
55 changed files with 1345 additions and 266 deletions

View File

@@ -72,12 +72,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@v3.11.1
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.6.0 uses: docker/login-action@v3.5.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry - name: Login to the Container registry
uses: docker/login-action@v3.6.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

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

View File

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

33
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.10.2", "version": "4.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "4.10.2", "version": "4.10.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -56,7 +56,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.30.3", "react-router-dom": "6.20.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
@@ -3699,10 +3699,9 @@
} }
}, },
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@@ -9606,12 +9605,11 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.30.3", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.2" "@remix-run/router": "1.13.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -9621,13 +9619,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.30.3", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.23.2", "@remix-run/router": "1.13.0",
"react-router": "6.30.3" "react-router": "6.20.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.10.2", "version": "4.10.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -67,7 +67,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.30.3", "react-router-dom": "6.20.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",

View File

@@ -88,8 +88,6 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => { {autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon; const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode; const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return ( return (
<MenuItem <MenuItem
key={emoticon.shortcode + key} key={emoticon.shortcode + key}
@@ -100,11 +98,11 @@ export function EmoticonAutocomplete({
} }
onClick={() => handleAutocomplete(key, emoticon.shortcode)} onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={ before={
isCustomEmoji && customEmojiUrl ? ( isCustomEmoji ? (
<Box <Box
shrink="No" shrink="No"
as="img" as="img"
src={customEmojiUrl} src={mxcUrlToHttp(mx, key, useAuthentication) || key}
alt={emoticon.shortcode} alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }} style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/> />

View File

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

View File

@@ -202,7 +202,8 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
pack.meta.avatar;
return ( return (
<ImageGroupIcon <ImageGroupIcon
@@ -265,7 +266,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar;
return ( return (
<ImageGroupIcon <ImageGroupIcon

View File

@@ -68,7 +68,7 @@ export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiIte
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/> />
</Box> </Box>
); );
@@ -98,7 +98,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps)
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/> />
</Box> </Box>
); );

View File

@@ -27,8 +27,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const Time = style({
fontWeight: 'inherit',
flexShrink: 0,
});

View File

@@ -1,12 +1,15 @@
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import classNames from 'classnames';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import * as css from './Time.css';
export type TimeProps = { export type TimeProps = {
compact?: boolean; compact?: boolean;
ts: number; ts: number;
hour24Clock: boolean; hour24Clock: boolean;
dateFormatString: string; dateFormatString: string;
inheritPriority?: boolean;
}; };
/** /**
@@ -22,7 +25,7 @@ export type TimeProps = {
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time. * @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
*/ */
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>( export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => { ({ compact, hour24Clock, dateFormatString, ts, inheritPriority, className, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock); const formattedTime = timeHourMinute(ts, hour24Clock);
let time = ''; let time = '';
@@ -33,11 +36,18 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
} else if (yesterday(ts)) { } else if (yesterday(ts)) {
time = `Yesterday ${formattedTime}`; time = `Yesterday ${formattedTime}`;
} else { } else {
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`; time = `${timeDayMonYear(ts, dateFormatString)}, ${formattedTime}`;
} }
return ( return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}> <Text
as="time"
className={classNames(css.Time, className)}
size="T200"
priority={inheritPriority ? undefined : '300'}
{...props}
ref={ref}
>
{time} {time}
</Text> </Text>
); );

View File

@@ -54,8 +54,7 @@ export function AudioContent({
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -86,8 +86,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const [textState, loadText] = useAsyncCallback( const [textState, loadText] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -177,8 +176,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const [pdfState, loadPdf] = useAsyncCallback( const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -255,8 +253,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -87,8 +87,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)

View File

@@ -23,8 +23,7 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
throw new Error('Failed to load thumbnail'); throw new Error('Failed to load thumbnail');
} }
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)

View File

@@ -81,8 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo) decryptFile(encBuf, mimeType, encInfo)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { ThreadView } from './thread-view';
import { useActiveThread } from '../../state/hooks/roomToActiveThread';
export function Room() { export function Room() {
const { eventId } = useParams(); const { eventId } = useParams();
@@ -25,6 +27,8 @@ export function Room() {
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
const threadId = useActiveThread(room.roomId);
useKeyDown( useKeyDown(
window, window,
useCallback( useCallback(
@@ -41,11 +45,19 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
<RoomView room={room} eventId={eventId} /> <RoomView room={room} eventId={eventId} />
{screenSize === ScreenSize.Desktop && isDrawer && ( {threadId ? (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Surface" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} /> <ThreadView threadId={threadId} />
</> </>
) : (
screenSize === ScreenSize.Desktop &&
isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)
)} )}
</Box> </Box>
</PowerLevelsContextProvider> </PowerLevelsContextProvider>

View File

@@ -77,6 +77,7 @@ import {
decryptAllTimelineEvent, decryptAllTimelineEvent,
getEditedEvent, getEditedEvent,
getEventReactions, getEventReactions,
getEventThreadDetail,
getLatestEditableEvt, getLatestEditableEvt,
getMemberDisplayName, getMemberDisplayName,
getReactionContent, getReactionContent,
@@ -126,6 +127,18 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { ThreadSelector, ThreadSelectorContainer } from './message/thread-selector';
import {
getEventIdAbsoluteIndex,
getFirstLinkedTimeline,
getLinkedTimelines,
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
timelineToEventsCount,
} from './utils';
import { useThreadSelector } from '../../state/hooks/roomToActiveThread';
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@@ -150,79 +163,6 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
) )
); );
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};
type RoomTimelineProps = { type RoomTimelineProps = {
room: Room; room: Room;
eventId?: string; eventId?: string;
@@ -232,6 +172,14 @@ type RoomTimelineProps = {
const PAGINATION_LIMIT = 80; const PAGINATION_LIMIT = 80;
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
type Timeline = { type Timeline = {
linkedTimelines: EventTimeline[]; linkedTimelines: EventTimeline[];
range: ItemRange; range: ItemRange;
@@ -471,7 +419,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId()); const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
@@ -483,6 +430,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const handleThreadClick = useThreadSelector(room.roomId);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -1015,6 +963,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}, },
[editor] [editor]
); );
const { t } = useTranslation(); const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer< const renderMatrixEvent = useMatrixEventRenderer<
@@ -1035,6 +984,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const threadDetail = getEventThreadDetail(mEvent);
return ( return (
<Message <Message
@@ -1048,7 +998,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1108,6 +1058,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
/> />
)} )}
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={mEventId}
threadDetail={threadDetail}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
outlined={messageLayout === MessageLayout.Bubble}
onClick={handleThreadClick}
/>
</ThreadSelectorContainer>
)}
</Message> </Message>
); );
}, },
@@ -1130,7 +1094,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1248,7 +1212,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
messageLayout={messageLayout} messageLayout={messageLayout}
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}

View File

@@ -69,6 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ThreadsMenu } from './threads-menu';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@@ -263,6 +264,7 @@ export function RoomViewHeader() {
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const [threadsMenuAnchor, setThreadsMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
@@ -295,6 +297,10 @@ export function RoomViewHeader() {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleOpenThreadsMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setThreadsMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PageHeader balance={screenSize === ScreenSize.Mobile}> <PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
@@ -424,6 +430,27 @@ export function RoomViewHeader() {
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>My Threads</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
style={{ position: 'relative' }}
onClick={handleOpenThreadsMenu}
ref={triggerRef}
aria-pressed={!!threadsMenuAnchor}
>
<Icon size="400" src={Icons.Thread} filled={!!threadsMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut <PopOut
anchor={pinMenuAnchor} anchor={pinMenuAnchor}
position="Bottom" position="Bottom"
@@ -443,6 +470,25 @@ export function RoomViewHeader() {
</FocusTrap> </FocusTrap>
} }
/> />
<PopOut
anchor={threadsMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setThreadsMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<ThreadsMenu room={room} requestClose={() => setThreadsMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View File

@@ -0,0 +1,83 @@
import { Box, Icon, Icons, Line, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { IThreadBundledRelationship, Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { getMemberDisplayName } from '../../../../utils/room';
import { getMxIdLocalPart } from '../../../../utils/matrix';
import { Time } from '../../../../components/message';
export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
return <Box className={css.ThreadSelectorContainer}>{children}</Box>;
}
type ThreadSelectorProps = {
room: Room;
threadId: string;
threadDetail: IThreadBundledRelationship;
outlined?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onClick?: (threadId: string) => void;
};
export function ThreadSelector({
room,
threadId,
threadDetail,
outlined,
hour24Clock,
dateFormatString,
onClick,
}: ThreadSelectorProps) {
const latestEvent = threadDetail.latest_event;
const latestSenderId = latestEvent.sender;
const latestDisplayName =
getMemberDisplayName(room, latestSenderId) ??
getMxIdLocalPart(latestSenderId) ??
latestSenderId;
const latestEventTs = latestEvent.origin_server_ts;
return (
<Box
as="button"
type="button"
className={classNames(css.ThreadSelector, outlined && css.ThreadSectorOutlined)}
alignItems="Center"
gap="300"
onClick={() => onClick?.(threadId)}
>
<Box className={css.ThreadRepliesCount} shrink="No" alignItems="Center" gap="200">
<Icon size="100" src={Icons.Thread} filled />
<Text size="L400">
{threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
</Text>
</Box>
{latestSenderId && (
<>
<Line
className={css.ThreadSelectorDivider}
direction="Vertical"
variant="SurfaceVariant"
/>
<Box gap="200" alignItems="Inherit">
<Text size="T200" truncate>
<span>Last reply by </span>
<b>{latestDisplayName}</b>
<span> </span>
<Time
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
ts={latestEventTs}
inheritPriority
/>
</Text>
<Icon size="100" src={Icons.ChevronRight} />
</Box>
</>
)}
</Box>
);
}

View File

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

View File

@@ -0,0 +1,37 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { ContainerColor } from '../../../../styles/ContainerColor.css';
export const ThreadSelectorContainer = style({
marginTop: config.space.S200,
});
export const ThreadSelector = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: config.space.S200,
borderRadius: config.radii.R400,
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
'&:active': {
backgroundColor: color.SurfaceVariant.ContainerActive,
},
},
},
]);
export const ThreadSectorOutlined = style({
borderWidth: config.borderWidth.B300,
});
export const ThreadSelectorDivider = style({
height: toRem(16),
});
export const ThreadRepliesCount = style({
color: color.Primary.Main,
});

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider } from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { Page, PageHeader } from '../../../components/page';
import * as css from './styles.css';
import { useRoom } from '../../../hooks/useRoom';
import { useThreadClose } from '../../../state/hooks/roomToActiveThread';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
type ThreadViewProps = {
threadId: string;
};
export function ThreadView({ threadId }: ThreadViewProps) {
const room = useRoom();
const screenSize = useScreenSizeContext();
const floating = screenSize !== ScreenSize.Desktop;
const closeThread = useThreadClose(room.roomId);
const thread = room.getThread(threadId);
const events = thread?.events ?? [];
return (
<FocusTrap
paused={!floating}
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: floating ? closeThread : undefined,
}}
>
<Page
className={classNames(css.ThreadView, {
[css.ThreadViewFloating]: floating,
})}
>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes">
<Text size="H5" truncate>
Thread
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={closeThread}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<Scroll visibility="Hover" hideTrack>
<div>
{events.map((mEvent) => (
<p style={{ padding: `8px 16px` }} key={mEvent.getId()}>
{mEvent.sender?.name}: {mEvent.getContent().body}
</p>
))}
</div>
</Scroll>
</Box>
</Page>
</FocusTrap>
);
}

View File

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

View File

@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadView = style({
width: toRem(456),
flexShrink: 0,
flexGrow: 0,
});
export const ThreadViewFloating = style({
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
zIndex: 1,
maxWidth: toRem(456),
flexShrink: 1,
width: '100vw',
boxShadow: config.shadow.E400,
});

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Icon, Icons, toRem, Text, config } from 'folds';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { BreakWord } from '../../../styles/Text.css';
export function ThreadsError({ error }: { error: Error }) {
return (
<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.Warning} size="600" />
<Box style={{ maxWidth: toRem(300) }} direction="Column" gap="200" alignItems="Center">
<Text size="H4" align="Center">
{error.name}
</Text>
<Text className={BreakWord} size="T400" align="Center">
{error.message}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, config, Spinner } from 'folds';
import React from 'react';
import { ContainerColor } from '../../../styles/ContainerColor.css';
export function ThreadsLoading() {
return (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: config.space.S700,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Spinner variant="Secondary" />
</Box>
);
}

View File

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

View File

@@ -0,0 +1,99 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, useMemo } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { Box, config, Header, Icon, IconButton, Icons, Menu, Scroll, Text, toRem } from 'folds';
import * as css from './ThreadsMenu.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { useRoomMyThreads } from '../../../hooks/useRoomThreads';
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
import { getLinkedTimelines, getTimelinesEventsCount } from '../utils';
import { ThreadsTimeline } from './ThreadsTimeline';
import { ThreadsLoading } from './ThreadsLoading';
import { ThreadsError } from './ThreadsError';
const getTimelines = (timelineSet: EventTimelineSet) => {
const liveTimeline = timelineSet.getLiveTimeline();
const linkedTimelines = getLinkedTimelines(liveTimeline);
return linkedTimelines;
};
function NoThreads() {
return (
<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.Thread} size="600" />
<Box style={{ maxWidth: toRem(300) }} direction="Column" gap="200" alignItems="Center">
<Text size="H4" align="Center">
No Threads Yet
</Text>
<Text size="T400" align="Center">
Threads youre participating in will appear here.
</Text>
</Box>
</Box>
);
}
type ThreadsMenuProps = {
room: Room;
requestClose: () => void;
};
export const ThreadsMenu = forwardRef<HTMLDivElement, ThreadsMenuProps>(
({ room, requestClose }, ref) => {
const threadsState = useRoomMyThreads(room);
const threadsTimelineSet =
threadsState.status === AsyncStatus.Success ? threadsState.data : undefined;
const linkedTimelines = useMemo(() => {
if (!threadsTimelineSet) return undefined;
return getTimelines(threadsTimelineSet);
}, [threadsTimelineSet]);
const hasEvents = linkedTimelines && getTimelinesEventsCount(linkedTimelines) > 0;
return (
<Menu ref={ref} className={css.ThreadsMenu}>
<Box grow="Yes" direction="Column">
<Header className={css.ThreadsMenuHeader} size="500">
<Box grow="Yes">
<Text size="H5">My Threads</Text>
</Box>
<Box shrink="No">
<IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</Box>
</Header>
<Box grow="Yes">
{threadsState.status === AsyncStatus.Success && hasEvents ? (
<ThreadsTimeline timelines={linkedTimelines} requestClose={requestClose} />
) : (
<Scroll size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
{(threadsState.status === AsyncStatus.Loading ||
threadsState.status === AsyncStatus.Idle) && <ThreadsLoading />}
{threadsState.status === AsyncStatus.Success && !hasEvents && <NoThreads />}
{threadsState.status === AsyncStatus.Error && (
<ThreadsError error={threadsState.error} />
)}
</Box>
</Scroll>
)}
</Box>
</Box>
</Menu>
);
}
);

View File

@@ -0,0 +1,488 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Direction, EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
import { Avatar, Box, Chip, config, Icon, Icons, Scroll, Text } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SequenceCard } from '../../../components/sequence-card';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import {
AvatarBase,
ImageContent,
MessageNotDecryptedContent,
MessageUnsupportedContent,
ModernLayout,
MSticker,
RedactedContent,
Reply,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getEditedEvent,
getEventThreadDetail,
getMemberAvatarMxc,
getMemberDisplayName,
} from '../../../utils/room';
import { GetContentCallback, MessageEvent } from '../../../../types/matrix/room';
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 { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import {
GetMemberPowerTag,
getPowerTagIconSrc,
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector';
import {
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
} from '../utils';
import { ThreadsLoading } from './ThreadsLoading';
import { VirtualTile } from '../../../components/virtualizer';
import { useAlive } from '../../../hooks/useAlive';
import * as css from './ThreadsMenu.css';
type ThreadMessageProps = {
room: Room;
event: MatrixEvent;
renderContent: RenderMatrixEvent<[string, MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map<string, string>;
legacyUsernameColor: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function ThreadMessage({
room,
event,
renderContent,
onOpen,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: ThreadMessageProps) {
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id');
if (!evtId) return;
onOpen(room.roomId, evtId);
};
const renderOptions = () => (
<Box shrink="No" gap="200" alignItems="Center">
<Chip
data-event-id={event.getId()}
onClick={handleOpenClick}
variant="Secondary"
radii="Pill"
>
<Text size="T200">Open</Text>
</Chip>
</Box>
);
const sender = event.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => event.getContent()) as GetContentCallback;
const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const tagIconSrc = memberPowerTag?.icon
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor;
const mEventId = event.getId();
if (!mEventId) return null;
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">
<Box alignItems="Center" gap="200">
<Username style={{ color: usernameColor }}>
<Text as="span" truncate>
<UsernameBold>{displayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={event.getTs()} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
</Box>
{renderOptions()}
</Box>
{event.replyEventId && (
<Reply
room={room}
replyEventId={event.replyEventId}
threadRootId={event.threadRootId}
onClick={handleOpenClick}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderContent(event.getType(), false, mEventId, event, displayName, getContent)}
</ModernLayout>
);
}
type ThreadsTimelineProps = {
timelines: EventTimeline[];
requestClose: () => void;
};
export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProps) {
const mx = useMatrixClient();
const { navigateRoom } = useRoomNavigate();
const alive = useAlive();
const scrollRef = useRef<HTMLDivElement>(null);
const room = useRoom();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
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<
[string, MatrixEvent, string, GetContentCallback]
>(
{
[MessageEvent.RoomMessage]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
const threadDetail = getEventThreadDetail(event);
return (
<>
<RenderMessageContent
displayName={displayName}
msgType={event.getContent().msgtype ?? ''}
ts={event.getTs()}
getContent={getContent}
edited={!!event.replacingEvent()}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={eventId}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
[MessageEvent.RoomMessageEncrypted]: (eventId, mEvent, displayName) => {
const evtTimeline = room.getTimelineForEvent(eventId);
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 =
evtTimeline && 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 || !!mEvent.replacingEvent()}
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]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
}
const threadDetail = getEventThreadDetail(event);
return (
<>
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadId={eventId}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
},
undefined,
(eventId, 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();
};
const eventsLength = getTimelinesEventsCount(timelines);
const timelineToPaginate = timelines[timelines.length - 1];
const [paginationToken, setPaginationToken] = useState(
timelineToPaginate.getPaginationToken(Direction.Backward)
);
const virtualizer = useVirtualizer({
count: eventsLength,
getScrollElement: () => scrollRef.current,
estimateSize: () => 122,
overscan: 10,
});
const vItems = virtualizer.getVirtualItems();
const paginate = useCallback(async () => {
const moreToLoad = await mx.paginateEventTimeline(timelineToPaginate, {
backwards: true,
limit: 30,
});
if (alive()) {
setPaginationToken(
moreToLoad ? timelineToPaginate.getPaginationToken(Direction.Backward) : null
);
}
}, [mx, alive, timelineToPaginate]);
// auto paginate when scroll reach bottom
useEffect(() => {
const lastVItem = vItems.length > 0 ? vItems[vItems.length - 1] : undefined;
if (paginationToken && lastVItem && lastVItem.index === eventsLength - 1) {
paginate();
}
}, [vItems, paginationToken, eventsLength, paginate]);
return (
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const reverseTimelineIndex = eventsLength - vItem.index - 1;
const [timeline, baseIndex] = getTimelineAndBaseIndex(timelines, reverseTimelineIndex);
if (!timeline) return null;
const event = getTimelineEvent(
timeline,
getTimelineRelativeIndex(reverseTimelineIndex, baseIndex)
);
if (!event?.getId()) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SequenceCard
key={event.getId()}
style={{ padding: config.space.S400, borderRadius: config.radii.R300 }}
variant="SurfaceVariant"
direction="Column"
>
<ThreadMessage
room={room}
event={event}
renderContent={renderMatrixEvent}
onOpen={handleOpen}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</SequenceCard>
</VirtualTile>
);
})}
</div>
{paginationToken && <ThreadsLoading />}
</Box>
</Scroll>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
import { Room, EventTimeline, Direction, MatrixEvent } from 'matrix-js-sdk';
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
export const useRoomMyThreads = (room: Room): AsyncState<EventTimelineSet, Error> => {
const [threadsState] = useAsyncCallbackValue<EventTimelineSet, Error>(
useCallback(async () => {
await room.createThreadsTimelineSets();
await room.fetchRoomThreads();
const timelineSet = room.threadsTimelineSets[0];
if (timelineSet === undefined) {
throw new Error('Failed to fetch My Threads!');
}
return timelineSet;
}, [room])
);
return threadsState;
};

View File

@@ -68,7 +68,6 @@ import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { pushSessionToSW } from '../../sw-session';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@@ -107,8 +106,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route <Route
loader={() => { loader={() => {
const session = getFallbackSession(); if (!getFallbackSession()) {
if (!session) {
const afterLoginPath = getAppPathFromHref( const afterLoginPath = getAppPathFromHref(
getOriginBaseUrl(hashRouter), getOriginBaseUrl(hashRouter),
window.location.href window.location.href
@@ -116,7 +114,6 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath()); return redirect(getLoginPath());
} }
pushSessionToSW(session.baseUrl, session.accessToken);
return null; return null;
}} }}
element={ element={

View File

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

View File

@@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath';
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath'; import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder'; import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder'; import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
import { makeRoomToActiveThreadAtom } from '../../state/roomToActiveThread';
import { RoomToActiveThreadProvider } from '../../state/hooks/roomToActiveThread';
type ClientInitStorageAtomProps = { type ClientInitStorageAtomProps = {
children: ReactNode; children: ReactNode;
@@ -22,15 +24,19 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps)
const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]); const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
const roomToActiveThreadAtom = useMemo(() => makeRoomToActiveThreadAtom(userId), [userId]);
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]); const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
return ( return (
<ClosedNavCategoriesProvider value={closedNavCategoriesAtom}> <ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
<ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}> <ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
<NavToActivePathProvider value={navToActivePathAtom}> <NavToActivePathProvider value={navToActivePathAtom}>
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}> <RoomToActiveThreadProvider value={roomToActiveThreadAtom}>
{children} <OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
</OpenedSidebarFolderProvider> {children}
</OpenedSidebarFolderProvider>
</RoomToActiveThreadProvider>
</NavToActivePathProvider> </NavToActivePathProvider>
</ClosedLobbyCategoriesProvider> </ClosedLobbyCategoriesProvider>
</ClosedNavCategoriesProvider> </ClosedNavCategoriesProvider>

View File

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

View File

@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { RoomToActiveThreadAtom } from '../roomToActiveThread';
const RoomToActiveThreadAtomContext = createContext<RoomToActiveThreadAtom | null>(null);
export const RoomToActiveThreadProvider = RoomToActiveThreadAtomContext.Provider;
export const useRoomToActiveThreadAtom = (): RoomToActiveThreadAtom => {
const anAtom = useContext(RoomToActiveThreadAtomContext);
if (!anAtom) {
throw new Error('RoomToActiveThreadAtom is not provided!');
}
return anAtom;
};
export const useThreadSelector = (roomId: string): ((threadId: string) => void) => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
const onThreadSelect = useCallback(
(threadId: string) => {
setRoomToActiveThread({
type: 'PUT',
roomId,
threadId,
});
},
[roomId, setRoomToActiveThread]
);
return onThreadSelect;
};
export const useActiveThread = (roomId: string): string | undefined => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const roomToActiveThread = useAtomValue(roomToActiveThreadAtom);
return roomToActiveThread.get(roomId);
};
export const useThreadClose = (roomId: string): (() => void) => {
const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
const closeThread = useCallback(() => {
setRoomToActiveThread({
type: 'DELETE',
roomId,
});
}, [roomId, setRoomToActiveThread]);
return closeThread;
};

View File

@@ -0,0 +1,75 @@
import { atom, WritableAtom } from 'jotai';
import produce from 'immer';
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const ROOM_TO_ACTIVE_THREAD = 'roomToActiveThread';
const getStoreKey = (userId: string): string => `${ROOM_TO_ACTIVE_THREAD}${userId}`;
type RoomToActiveThread = Map<string, string>;
type RoomToActiveThreadAction =
| {
type: 'PUT';
roomId: string;
threadId: string;
}
| {
type: 'DELETE';
roomId: string;
};
export type RoomToActiveThreadAtom = WritableAtom<
RoomToActiveThread,
[RoomToActiveThreadAction],
undefined
>;
export const makeRoomToActiveThreadAtom = (userId: string): RoomToActiveThreadAtom => {
const storeKey = getStoreKey(userId);
const baseRoomToActiveThread = atomWithLocalStorage<RoomToActiveThread>(
storeKey,
(key) => {
const obj: Record<string, string> = getLocalStorageItem(key, {});
return new Map(Object.entries(obj));
},
(key, value) => {
const obj: Record<string, string> = Object.fromEntries(value);
setLocalStorageItem(key, obj);
}
);
const navToActivePathAtom = atom<RoomToActiveThread, [RoomToActiveThreadAction], undefined>(
(get) => get(baseRoomToActiveThread),
(get, set, action) => {
if (action.type === 'DELETE') {
set(
baseRoomToActiveThread,
produce(get(baseRoomToActiveThread), (draft) => {
draft.delete(action.roomId);
})
);
return;
}
if (action.type === 'PUT') {
set(
baseRoomToActiveThread,
produce(get(baseRoomToActiveThread), (draft) => {
draft.set(action.roomId, action.threadId);
})
);
}
}
);
return navToActivePathAtom;
};
export const clearRoomToActiveThreadStore = (userId: string) => {
localStorage.removeItem(getStoreKey(userId));
};

View File

@@ -1,6 +1,6 @@
import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt?: boolean) { export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return; if (!room) return;
@@ -19,15 +19,8 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
const latestEvent = getLatestValidEvent(); const latestEvent = getLatestValidEvent();
if (latestEvent === null) return; if (latestEvent === null) return;
const latestEventId = latestEvent.getId(); await mx.sendReadReceipt(
if (!latestEventId) return; latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
// Set both the read receipt AND the fully_read marker
// The fully_read marker is what persists your read position across sessions
await mx.setRoomReadMarkers(
roomId,
latestEventId, // m.fully_read marker
latestEvent, // m.read receipt event
privateReceipt ? { receiptType: ReceiptType.ReadPrivate } : undefined
); );
} }

View File

@@ -8,6 +8,7 @@ import {
IPowerLevelsContent, IPowerLevelsContent,
IPushRule, IPushRule,
IPushRules, IPushRules,
IThreadBundledRelationship,
JoinRule, JoinRule,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@@ -16,6 +17,7 @@ import {
RelationType, RelationType,
Room, Room,
RoomMember, RoomMember,
THREAD_RELATION_TYPE,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -160,8 +162,7 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
}; };
export const isMutedRule = (rule: IPushRule) => export const isMutedRule = (rule: IPushRule) =>
// Check for empty actions (new spec) or dont_notify (deprecated) rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
@@ -202,6 +203,7 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
if (mEvent.isRedacted()) return false; if (mEvent.isRedacted()) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false; if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
if (mEvent.getRelation()?.rel_type === 'm.thread') return false;
return true; return true;
}; };
@@ -552,3 +554,13 @@ export const guessPerfectParent = (
return perfectParent; return perfectParent;
}; };
export const getEventThreadDetail = (
mEvent: MatrixEvent
): IThreadBundledRelationship | undefined => {
const details = mEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name
);
return details;
};

View File

@@ -2,7 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys'; import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { pushSessionToSW } from '../sw-session'; import { clearRoomToActiveThreadStore } from '../app/state/roomToActiveThread';
type Session = { type Session = {
baseUrl: string; baseUrl: string;
@@ -43,18 +43,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
export const startClient = async (mx: MatrixClient) => { export const startClient = async (mx: MatrixClient) => {
await mx.startClient({ await mx.startClient({
lazyLoadMembers: true, lazyLoadMembers: true,
threadSupport: true,
}); });
}; };
export const clearCacheAndReload = async (mx: MatrixClient) => { export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient(); mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId()); clearNavToActivePathStore(mx.getSafeUserId());
clearRoomToActiveThreadStore(mx.getSafeUserId());
await mx.store.deleteAllData(); await mx.store.deleteAllData();
window.location.reload(); window.location.reload();
}; };
export const logoutClient = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW();
mx.stopClient(); mx.stopClient();
try { try {
await mx.logout(); await mx.logout();

View File

@@ -15,8 +15,6 @@ import App from './app/pages/App';
// import i18n (needs to be bundled ;)) // import i18n (needs to be bundled ;))
import './app/i18n'; import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
@@ -27,24 +25,17 @@ if ('serviceWorker' in navigator) {
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`; : `/dev-sw.js?dev-sw`;
const sendSessionToSW = () => { navigator.serviceWorker.register(swUrl);
const session = getFallbackSession(); navigator.serviceWorker.addEventListener('message', (event) => {
pushSessionToSW(session?.baseUrl, session?.accessToken); if (event.data?.type === 'token' && event.data?.responseKey) {
}; // Get the token for SW.
const token = localStorage.getItem('cinny_access_token') ?? undefined;
navigator.serviceWorker.register(swUrl).then(sendSessionToSW); event.source!.postMessage({
navigator.serviceWorker.ready.then(sendSessionToSW); responseKey: event.data.responseKey,
window.addEventListener('load', sendSessionToSW); token,
});
// When returning from background
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
sendSessionToSW();
} }
}); });
// When restored from bfcache (important on iOS)
window.addEventListener('pageshow', sendSessionToSW);
} }
const mountApp = () => { const mountApp = () => {

View File

@@ -1,10 +0,0 @@
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
if (!('serviceWorker' in navigator)) return;
if (!navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'setSession',
accessToken,
baseUrl,
});
}

View File

@@ -3,64 +3,22 @@
export type {}; export type {};
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
self.addEventListener('install', () => { async function askForAccessToken(client: Client): Promise<string | undefined> {
self.skipWaiting(); return new Promise((resolve) => {
}); const responseKey = Math.random().toString(36);
const listener = (event: ExtendableMessageEvent) => {
self.addEventListener('activate', (event: ExtendableEvent) => { if (event.data.responseKey !== responseKey) return;
event.waitUntil(self.clients.claim()); resolve(event.data.token);
}); self.removeEventListener('message', listener);
};
type SessionInfo = { self.addEventListener('message', listener);
accessToken: string; client.postMessage({ responseKey, type: 'token' });
baseUrl: string;
};
/**
* Store session per client (tab)
*/
const sessions = new Map<string, SessionInfo>();
async function cleanupDeadClients() {
const activeClients = await self.clients.matchAll();
const activeIds = new Set(activeClients.map((c) => c.id));
Array.from(sessions.keys()).forEach((id) => {
if (!activeIds.has(id)) {
sessions.delete(id);
}
}); });
} }
/** function fetchConfig(token?: string): RequestInit | undefined {
* Receive session updates from clients if (!token) return undefined;
*/
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
if (type !== 'setSession') return;
cleanupDeadClients();
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(client.id, { accessToken, baseUrl });
} else {
// Logout or invalid session
sessions.delete(client.id);
}
});
function validMediaRequest(url: string, baseUrl: string): boolean {
const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl);
const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl);
return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href);
}
function fetchConfig(token: string): RequestInit {
return { return {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -69,16 +27,26 @@ function fetchConfig(token: string): RequestInit {
}; };
} }
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event: FetchEvent) => { self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request; const { url, method } = event.request;
if (method !== 'GET') return; if (method !== 'GET') return;
if (!event.clientId) return; if (
!url.includes('/_matrix/client/v1/media/download') &&
!url.includes('/_matrix/client/v1/media/thumbnail')
) {
return;
}
event.respondWith(
(async (): Promise<Response> => {
const client = await self.clients.get(event.clientId);
let token: string | undefined;
if (client) token = await askForAccessToken(client);
const session = sessions.get(event.clientId); return fetch(url, fetchConfig(token));
if (!session) return; })()
);
if (!validMediaRequest(url, session.baseUrl)) return;
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
}); });