mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Merge branch 'dev' into add-international-date
This commit is contained in:
		
						commit
						e2c0984620
					
				
					 8 changed files with 206 additions and 21 deletions
				
			
		| 
						 | 
				
			
			@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
 | 
			
		|||
            visibility="Hover"
 | 
			
		||||
            hideTrack
 | 
			
		||||
          >
 | 
			
		||||
            <div className={css.CodeBlockInternal}>{children}</div>
 | 
			
		||||
            <div className={css.CodeBlockInternal()}>{children}</div>
 | 
			
		||||
          </Scroll>
 | 
			
		||||
        </Text>
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
 | 
			
		|||
    position: 'absolute',
 | 
			
		||||
    top: 0,
 | 
			
		||||
    left: 0,
 | 
			
		||||
    zIndex: 1,
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -936,7 +936,7 @@ export function RoomTimeline({
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
 | 
			
		||||
    (evt) => {
 | 
			
		||||
    (evt, startThread = false) => {
 | 
			
		||||
      const replyId = evt.currentTarget.getAttribute('data-event-id');
 | 
			
		||||
      if (!replyId) {
 | 
			
		||||
        console.warn('Button should have "data-event-id" attribute!');
 | 
			
		||||
| 
						 | 
				
			
			@ -947,7 +947,9 @@ export function RoomTimeline({
 | 
			
		|||
      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
 | 
			
		||||
      const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
 | 
			
		||||
      const { body, formatted_body: formattedBody } = content;
 | 
			
		||||
      const { 'm.relates_to': relation } = replyEvt.getWireContent();
 | 
			
		||||
      const { 'm.relates_to': relation } = startThread
 | 
			
		||||
        ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
 | 
			
		||||
        : replyEvt.getWireContent();
 | 
			
		||||
      const senderId = replyEvt.getSender();
 | 
			
		||||
      if (senderId && typeof body === 'string') {
 | 
			
		||||
        setReplyDraft({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -669,7 +669,10 @@ export type MessageProps = {
 | 
			
		|||
  messageSpacing: MessageSpacing;
 | 
			
		||||
  onUserClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onReplyClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onReplyClick: (
 | 
			
		||||
    ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
 | 
			
		||||
    startThread?: boolean
 | 
			
		||||
  ) => void;
 | 
			
		||||
  onEditId?: (eventId?: string) => void;
 | 
			
		||||
  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
 | 
			
		||||
  reply?: ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -868,6 +871,8 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
      }, 100);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const isThreadedMessage = mEvent.threadRootId !== undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <MessageBase
 | 
			
		||||
        className={classNames(css.MessageBase, className)}
 | 
			
		||||
| 
						 | 
				
			
			@ -930,6 +935,17 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                >
 | 
			
		||||
                  <Icon src={Icons.ReplyArrow} size="100" />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
                {!isThreadedMessage && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={(ev) => onReplyClick(ev, true)}
 | 
			
		||||
                    data-event-id={mEvent.getId()}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.ThreadPlus} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )}
 | 
			
		||||
                {canEditEvent(mx, mEvent) && onEditId && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={() => onEditId(mEvent.getId())}
 | 
			
		||||
| 
						 | 
				
			
			@ -1009,6 +1025,27 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                              Reply
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </MenuItem>
 | 
			
		||||
                          {!isThreadedMessage && (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              size="300"
 | 
			
		||||
                              after={<Icon src={Icons.ThreadPlus} size="100" />}
 | 
			
		||||
                              radii="300"
 | 
			
		||||
                              data-event-id={mEvent.getId()}
 | 
			
		||||
                              onClick={(evt: any) => {
 | 
			
		||||
                                onReplyClick(evt, true);
 | 
			
		||||
                                closeMenu();
 | 
			
		||||
                              }}
 | 
			
		||||
                            >
 | 
			
		||||
                              <Text
 | 
			
		||||
                                className={css.MessageMenuItemText}
 | 
			
		||||
                                as="span"
 | 
			
		||||
                                size="T300"
 | 
			
		||||
                                truncate
 | 
			
		||||
                              >
 | 
			
		||||
                                Reply in Thread
 | 
			
		||||
                              </Text>
 | 
			
		||||
                            </MenuItem>
 | 
			
		||||
                          )}
 | 
			
		||||
                          {canEditEvent(mx, mEvent) && onEditId && (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              size="300"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Temporarily sets a boolean state.
 | 
			
		||||
 *
 | 
			
		||||
 * @param duration - Duration in milliseconds before resetting (default: 1500)
 | 
			
		||||
 * @param initial - Initial value (default: false)
 | 
			
		||||
 */
 | 
			
		||||
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
 | 
			
		||||
  const [active, setActive] = useState(initial);
 | 
			
		||||
  const timeoutRef = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  const clear = () => {
 | 
			
		||||
    if (timeoutRef.current !== null) {
 | 
			
		||||
      clearTimeout(timeoutRef.current);
 | 
			
		||||
      timeoutRef.current = null;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const trigger = useCallback(() => {
 | 
			
		||||
    setActive(!initial);
 | 
			
		||||
    clear();
 | 
			
		||||
    timeoutRef.current = window.setTimeout(() => {
 | 
			
		||||
      setActive(initial);
 | 
			
		||||
      timeoutRef.current = null;
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }, [duration, initial]);
 | 
			
		||||
 | 
			
		||||
  useEffect(
 | 
			
		||||
    () => () => {
 | 
			
		||||
      clear();
 | 
			
		||||
    },
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return [active, trigger];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,13 @@
 | 
			
		|||
/* eslint-disable jsx-a11y/alt-text */
 | 
			
		||||
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react';
 | 
			
		||||
import React, {
 | 
			
		||||
  ComponentPropsWithoutRef,
 | 
			
		||||
  ReactEventHandler,
 | 
			
		||||
  Suspense,
 | 
			
		||||
  lazy,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Element,
 | 
			
		||||
  Text as DOMText,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,10 +17,11 @@ import {
 | 
			
		|||
} from 'html-react-parser';
 | 
			
		||||
import { MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Scroll, Text } from 'folds';
 | 
			
		||||
import { Icon, IconButton, Icons, Scroll, Text } from 'folds';
 | 
			
		||||
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
 | 
			
		||||
import Linkify from 'linkify-react';
 | 
			
		||||
import { ErrorBoundary } from 'react-error-boundary';
 | 
			
		||||
import { ChildNode } from 'domhandler';
 | 
			
		||||
import * as css from '../styles/CustomHtml.css';
 | 
			
		||||
import {
 | 
			
		||||
  getMxIdLocalPart,
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +40,8 @@ import {
 | 
			
		|||
  testMatrixTo,
 | 
			
		||||
} from './matrix-to';
 | 
			
		||||
import { onEnterOrSpace } from '../utils/keyboard';
 | 
			
		||||
import { tryDecodeURIComponent } from '../utils/dom';
 | 
			
		||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
 | 
			
		||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
 | 
			
		||||
 | 
			
		||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -195,6 +205,82 @@ export const highlightText = (
 | 
			
		|||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) {
 | 
			
		||||
  const LINE_LIMIT = 14;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Recursively extracts and concatenates all text content from an array of ChildNode objects.
 | 
			
		||||
   *
 | 
			
		||||
   * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
 | 
			
		||||
   * @returns {string} The concatenated plain text content of all descendant text nodes.
 | 
			
		||||
   */
 | 
			
		||||
  const extractTextFromChildren = useCallback((nodes: ChildNode[]): string => {
 | 
			
		||||
    let text = '';
 | 
			
		||||
 | 
			
		||||
    nodes.forEach((node) => {
 | 
			
		||||
      if (node.type === 'text') {
 | 
			
		||||
        text += node.data;
 | 
			
		||||
      } else if (node instanceof Element && node.children) {
 | 
			
		||||
        text += extractTextFromChildren(node.children);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return text;
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [copied, setCopied] = useTimeoutToggle();
 | 
			
		||||
  const collapsible = useMemo(
 | 
			
		||||
    () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
 | 
			
		||||
    [children, extractTextFromChildren]
 | 
			
		||||
  );
 | 
			
		||||
  const [collapsed, setCollapsed] = useState(collapsible);
 | 
			
		||||
 | 
			
		||||
  const handleCopy = useCallback(() => {
 | 
			
		||||
    copyToClipboard(extractTextFromChildren(children));
 | 
			
		||||
    setCopied();
 | 
			
		||||
  }, [children, extractTextFromChildren, setCopied]);
 | 
			
		||||
 | 
			
		||||
  const toggleCollapse = useCallback(() => {
 | 
			
		||||
    setCollapsed((prev) => !prev);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className={css.CodeBlockControls}>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          variant="Secondary" // Needs a better copy icon
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          onClick={handleCopy}
 | 
			
		||||
          aria-label="Copy Code Block"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon src={copied ? Icons.Check : Icons.File} size="50" />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
        {collapsible && (
 | 
			
		||||
          <IconButton
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={toggleCollapse}
 | 
			
		||||
            aria-expanded={!collapsed}
 | 
			
		||||
            aria-pressed={!collapsed}
 | 
			
		||||
            aria-controls="code-block-content"
 | 
			
		||||
            aria-label={collapsed ? 'Show Full Code Block' : 'Show Code Block Preview'}
 | 
			
		||||
            style={collapsed ? { visibility: 'visible' } : {}}
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={collapsed ? Icons.ChevronBottom : Icons.ChevronTop} size="50" />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
      <Scroll direction="Both" variant="Secondary" size="300" visibility="Hover" hideTrack>
 | 
			
		||||
        <div id="code-block-content" className={css.CodeBlockInternal({ collapsed })}>
 | 
			
		||||
          {domToReact(children, opts)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </Scroll>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getReactCustomHtmlParser = (
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  roomId: string | undefined,
 | 
			
		||||
| 
						 | 
				
			
			@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = (
 | 
			
		|||
        if (name === 'pre') {
 | 
			
		||||
          return (
 | 
			
		||||
            <Text {...props} as="pre" className={css.CodeBlock}>
 | 
			
		||||
              <Scroll
 | 
			
		||||
                direction="Horizontal"
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                size="300"
 | 
			
		||||
                visibility="Hover"
 | 
			
		||||
                hideTrack
 | 
			
		||||
              >
 | 
			
		||||
                <div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
 | 
			
		||||
              </Scroll>
 | 
			
		||||
              {CodeBlock(children, opts)}
 | 
			
		||||
            </Text>
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -85,10 +85,35 @@ export const CodeBlock = style([
 | 
			
		|||
  MarginSpaced,
 | 
			
		||||
  {
 | 
			
		||||
    fontStyle: 'normal',
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
export const CodeBlockInternal = style({
 | 
			
		||||
  padding: `${config.space.S200} ${config.space.S200} 0`,
 | 
			
		||||
export const CodeBlockInternal = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    padding: `${config.space.S200} ${config.space.S200} 0`,
 | 
			
		||||
    minWidth: toRem(100),
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    collapsed: {
 | 
			
		||||
      true: {
 | 
			
		||||
        maxHeight: `calc(${config.lineHeight.T400} * 9.6)`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const CodeBlockControls = style({
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
  top: config.space.S200,
 | 
			
		||||
  right: config.space.S200,
 | 
			
		||||
  visibility: 'hidden',
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${CodeBlock}:hover &`]: {
 | 
			
		||||
      visibility: 'visible',
 | 
			
		||||
    },
 | 
			
		||||
    [`${CodeBlock}:focus-within &`]: {
 | 
			
		||||
      visibility: 'visible',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const List = style([
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -294,9 +294,14 @@ export const getDirectRoomAvatarUrl = (
 | 
			
		|||
  useAuthentication = false
 | 
			
		||||
): string | undefined => {
 | 
			
		||||
  const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
 | 
			
		||||
  return mxcUrl
 | 
			
		||||
    ? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  if (!mxcUrl) {
 | 
			
		||||
    return getRoomAvatarUrl(mx, room, size, useAuthentication);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const trimReplyFromBody = (body: string): string => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue