mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Merge branch 'dev' into improve-space
This commit is contained in:
		
						commit
						ae75ee7fc3
					
				
					 26 changed files with 898 additions and 84 deletions
				
			
		| 
						 | 
				
			
			@ -1,36 +0,0 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { Capabilities } from 'matrix-js-sdk';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { MediaConfig } from '../hooks/useMediaConfig';
 | 
			
		||||
import { promiseFulfilledResult } from '../utils/common';
 | 
			
		||||
 | 
			
		||||
type CapabilitiesAndMediaConfigLoaderProps = {
 | 
			
		||||
  children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function CapabilitiesAndMediaConfigLoader({
 | 
			
		||||
  children,
 | 
			
		||||
}: CapabilitiesAndMediaConfigLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [state, load] = useAsyncCallback<
 | 
			
		||||
    [Capabilities | undefined, MediaConfig | undefined],
 | 
			
		||||
    unknown,
 | 
			
		||||
    []
 | 
			
		||||
  >(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
 | 
			
		||||
      const capabilities = promiseFulfilledResult(result[0]);
 | 
			
		||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
			
		||||
      return [capabilities, mediaConfig];
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
  }, [load]);
 | 
			
		||||
 | 
			
		||||
  const [capabilities, mediaConfig] =
 | 
			
		||||
    state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
 | 
			
		||||
  return children(capabilities, mediaConfig);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								src/app/components/ServerConfigsLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/app/components/ServerConfigsLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
import { ReactNode, useCallback, useMemo } from 'react';
 | 
			
		||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
 | 
			
		||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { MediaConfig } from '../hooks/useMediaConfig';
 | 
			
		||||
import { promiseFulfilledResult } from '../utils/common';
 | 
			
		||||
 | 
			
		||||
export type ServerConfigs = {
 | 
			
		||||
  capabilities?: Capabilities;
 | 
			
		||||
  mediaConfig?: MediaConfig;
 | 
			
		||||
  authMetadata?: ValidatedAuthMetadata;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ServerConfigsLoaderProps = {
 | 
			
		||||
  children: (configs: ServerConfigs) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const fallbackConfigs = useMemo(() => ({}), []);
 | 
			
		||||
 | 
			
		||||
  const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const result = await Promise.allSettled([
 | 
			
		||||
        mx.getCapabilities(),
 | 
			
		||||
        mx.getMediaConfig(),
 | 
			
		||||
        mx.getAuthMetadata(),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      const capabilities = promiseFulfilledResult(result[0]);
 | 
			
		||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
			
		||||
      const authMetadata = promiseFulfilledResult(result[2]);
 | 
			
		||||
      let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        validatedAuthMetadata = validateAuthMetadata(authMetadata);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        console.error(e);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        capabilities,
 | 
			
		||||
        mediaConfig,
 | 
			
		||||
        authMetadata: validatedAuthMetadata,
 | 
			
		||||
      };
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const configs: ServerConfigs =
 | 
			
		||||
    configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
 | 
			
		||||
 | 
			
		||||
  return children(configs);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
 | 
			
		|||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
			
		||||
import { AutocompleteMenu } from './AutocompleteMenu';
 | 
			
		||||
import { getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		||||
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
 | 
			
		||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 | 
			
		||||
import { onTabPress } from '../../../utils/keyboard';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
 | 
			
		|||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
 | 
			
		||||
  validMxId(`#${text}`)
 | 
			
		||||
  isRoomAlias(`#${text}`)
 | 
			
		||||
    ? `#${text}`
 | 
			
		||||
    : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ import {
 | 
			
		|||
import { onTabPress } from '../../../utils/keyboard';
 | 
			
		||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
 | 
			
		||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
 | 
			
		||||
import { UserAvatar } from '../../user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
 | 
			
		|||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
 | 
			
		||||
  validMxId(`@${text}`)
 | 
			
		||||
  isUserId(`@${text}`)
 | 
			
		||||
    ? `@${text}`
 | 
			
		||||
    : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										129
									
								
								src/app/components/time-date/DatePicker.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/app/components/time-date/DatePicker.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,129 @@
 | 
			
		|||
import React, { forwardRef } from 'react';
 | 
			
		||||
import { Menu, Box, Text, Chip } from 'folds';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import { PickerColumn } from './PickerColumn';
 | 
			
		||||
import { dateFor, daysInMonth, daysToMs } from '../../utils/time';
 | 
			
		||||
 | 
			
		||||
type DatePickerProps = {
 | 
			
		||||
  min: number;
 | 
			
		||||
  max: number;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onChange: (value: number) => void;
 | 
			
		||||
};
 | 
			
		||||
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
 | 
			
		||||
  ({ min, max, value, onChange }, ref) => {
 | 
			
		||||
    const selectedYear = dayjs(value).year();
 | 
			
		||||
    const selectedMonth = dayjs(value).month() + 1;
 | 
			
		||||
    const selectedDay = dayjs(value).date();
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = (newValue: number) => {
 | 
			
		||||
      onChange(Math.min(Math.max(min, newValue), max));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDay = (day: number) => {
 | 
			
		||||
      const seconds = daysToMs(day);
 | 
			
		||||
      const lastSeconds = daysToMs(selectedDay);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMonthAndYear = (month: number, year: number) => {
 | 
			
		||||
      const mDays = daysInMonth(month, year);
 | 
			
		||||
      const currentDate = dateFor(selectedYear, selectedMonth, selectedDay);
 | 
			
		||||
      const time = value - currentDate;
 | 
			
		||||
 | 
			
		||||
      const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay);
 | 
			
		||||
 | 
			
		||||
      const newValue = newDate + time;
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMonth = (month: number) => {
 | 
			
		||||
      handleMonthAndYear(month, selectedYear);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleYear = (year: number) => {
 | 
			
		||||
      handleMonthAndYear(selectedMonth, year);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const minYear = dayjs(min).year();
 | 
			
		||||
    const maxYear = dayjs(max).year();
 | 
			
		||||
    const yearsRange = maxYear - minYear + 1;
 | 
			
		||||
 | 
			
		||||
    const minMonth = dayjs(min).month() + 1;
 | 
			
		||||
    const maxMonth = dayjs(max).month() + 1;
 | 
			
		||||
 | 
			
		||||
    const minDay = dayjs(min).date();
 | 
			
		||||
    const maxDay = dayjs(max).date();
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu className={css.PickerMenu} ref={ref}>
 | 
			
		||||
        <Box direction="Row" gap="200" className={css.PickerContainer}>
 | 
			
		||||
          <PickerColumn title="Day">
 | 
			
		||||
            {Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys())
 | 
			
		||||
              .map((i) => i + 1)
 | 
			
		||||
              .map((day) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={day}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedDay === day}
 | 
			
		||||
                  onClick={() => handleDay(day)}
 | 
			
		||||
                  disabled={
 | 
			
		||||
                    (selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
 | 
			
		||||
                    (selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay)
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">{day}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Month">
 | 
			
		||||
            {Array.from(Array(12).keys())
 | 
			
		||||
              .map((i) => i + 1)
 | 
			
		||||
              .map((month) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={month}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedMonth === month}
 | 
			
		||||
                  onClick={() => handleMonth(month)}
 | 
			
		||||
                  disabled={
 | 
			
		||||
                    (selectedYear === minYear && month < minMonth) ||
 | 
			
		||||
                    (selectedYear === maxYear && month > maxMonth)
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    {dayjs()
 | 
			
		||||
                      .month(month - 1)
 | 
			
		||||
                      .format('MMM')}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Year">
 | 
			
		||||
            {Array.from(Array(yearsRange).keys())
 | 
			
		||||
              .map((i) => minYear + i)
 | 
			
		||||
              .map((year) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={year}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedYear === year}
 | 
			
		||||
                  onClick={() => handleYear(year)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">{year}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										23
									
								
								src/app/components/time-date/PickerColumn.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/app/components/time-date/PickerColumn.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Box, Text, Scroll } from 'folds';
 | 
			
		||||
import { CutoutCard } from '../cutout-card';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export function PickerColumn({ title, children }: { title: string; children: ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text className={css.PickerColumnLabel} size="L400">
 | 
			
		||||
        {title}
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <CutoutCard variant="Background">
 | 
			
		||||
          <Scroll variant="Background" size="300" hideTrack>
 | 
			
		||||
            <Box className={css.PickerColumnContent} direction="Column" gap="100">
 | 
			
		||||
              {children}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Scroll>
 | 
			
		||||
        </CutoutCard>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										132
									
								
								src/app/components/time-date/TimePicker.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/app/components/time-date/TimePicker.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,132 @@
 | 
			
		|||
import React, { forwardRef } from 'react';
 | 
			
		||||
import { Menu, Box, Text, Chip } from 'folds';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import { PickerColumn } from './PickerColumn';
 | 
			
		||||
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
 | 
			
		||||
 | 
			
		||||
type TimePickerProps = {
 | 
			
		||||
  min: number;
 | 
			
		||||
  max: number;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onChange: (value: number) => void;
 | 
			
		||||
};
 | 
			
		||||
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
 | 
			
		||||
  ({ min, max, value, onChange }, ref) => {
 | 
			
		||||
    const hour24 = dayjs(value).hour();
 | 
			
		||||
 | 
			
		||||
    const selectedHour = hour24to12(hour24);
 | 
			
		||||
    const selectedMinute = dayjs(value).minute();
 | 
			
		||||
    const selectedPM = hour24 >= 12;
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = (newValue: number) => {
 | 
			
		||||
      onChange(Math.min(Math.max(min, newValue), max));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleHour = (hour: number) => {
 | 
			
		||||
      const seconds = hoursToMs(hour12to24(hour, selectedPM));
 | 
			
		||||
      const lastSeconds = hoursToMs(hour24);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMinute = (minute: number) => {
 | 
			
		||||
      const seconds = minutesToMs(minute);
 | 
			
		||||
      const lastSeconds = minutesToMs(selectedMinute);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handlePeriod = (pm: boolean) => {
 | 
			
		||||
      const seconds = hoursToMs(hour12to24(selectedHour, pm));
 | 
			
		||||
      const lastSeconds = hoursToMs(hour24);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const minHour24 = dayjs(min).hour();
 | 
			
		||||
    const maxHour24 = dayjs(max).hour();
 | 
			
		||||
 | 
			
		||||
    const minMinute = dayjs(min).minute();
 | 
			
		||||
    const maxMinute = dayjs(max).minute();
 | 
			
		||||
    const minPM = minHour24 >= 12;
 | 
			
		||||
    const maxPM = maxHour24 >= 12;
 | 
			
		||||
 | 
			
		||||
    const minDay = inSameDay(min, value);
 | 
			
		||||
    const maxDay = inSameDay(max, value);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu className={css.PickerMenu} ref={ref}>
 | 
			
		||||
        <Box direction="Row" gap="200" className={css.PickerContainer}>
 | 
			
		||||
          <PickerColumn title="Hour">
 | 
			
		||||
            {Array.from(Array(12).keys())
 | 
			
		||||
              .map((i) => {
 | 
			
		||||
                if (i === 0) return 12;
 | 
			
		||||
                return i;
 | 
			
		||||
              })
 | 
			
		||||
              .map((hour) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={hour}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={hour === selectedHour ? 'Primary' : 'Background'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={hour === selectedHour}
 | 
			
		||||
                  onClick={() => handleHour(hour)}
 | 
			
		||||
                  disabled={
 | 
			
		||||
                    (minDay && hour12to24(hour, selectedPM) < minHour24) ||
 | 
			
		||||
                    (maxDay && hour12to24(hour, selectedPM) > maxHour24)
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Minutes">
 | 
			
		||||
            {Array.from(Array(60).keys()).map((minute) => (
 | 
			
		||||
              <Chip
 | 
			
		||||
                key={minute}
 | 
			
		||||
                size="500"
 | 
			
		||||
                variant={minute === selectedMinute ? 'Primary' : 'Background'}
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-selected={minute === selectedMinute}
 | 
			
		||||
                onClick={() => handleMinute(minute)}
 | 
			
		||||
                disabled={
 | 
			
		||||
                  (minDay && hour24 === minHour24 && minute < minMinute) ||
 | 
			
		||||
                  (maxDay && hour24 === maxHour24 && minute > maxMinute)
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="T300">{minute < 10 ? `0${minute}` : minute}</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Period">
 | 
			
		||||
            <Chip
 | 
			
		||||
              size="500"
 | 
			
		||||
              variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              aria-selected={!selectedPM}
 | 
			
		||||
              onClick={() => handlePeriod(false)}
 | 
			
		||||
              disabled={minDay && minPM}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="T300">AM</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
            <Chip
 | 
			
		||||
              size="500"
 | 
			
		||||
              variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              aria-selected={selectedPM}
 | 
			
		||||
              onClick={() => handlePeriod(true)}
 | 
			
		||||
              disabled={maxDay && !maxPM}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="T300">PM</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										2
									
								
								src/app/components/time-date/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/components/time-date/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export * from './TimePicker';
 | 
			
		||||
export * from './DatePicker';
 | 
			
		||||
							
								
								
									
										16
									
								
								src/app/components/time-date/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/app/components/time-date/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const PickerMenu = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
export const PickerContainer = style({
 | 
			
		||||
  maxHeight: toRem(250),
 | 
			
		||||
});
 | 
			
		||||
export const PickerColumnLabel = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
export const PickerColumnContent = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
  paddingRight: 0,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +65,8 @@ import {
 | 
			
		|||
  getRoomNotificationModeIcon,
 | 
			
		||||
  useRoomsNotificationPreferencesContext,
 | 
			
		||||
} from '../../hooks/useRoomsNotificationPreferences';
 | 
			
		||||
import { JumpToTime } from './jump-to-time';
 | 
			
		||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		||||
 | 
			
		||||
type RoomMenuProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +81,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
  const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
			
		||||
  const notificationPreferences = useRoomsNotificationPreferencesContext();
 | 
			
		||||
  const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
 | 
			
		||||
  const { navigateRoom } = useRoomNavigate();
 | 
			
		||||
 | 
			
		||||
  const handleMarkAsRead = () => {
 | 
			
		||||
    markAsRead(mx, room.roomId, hideActivity);
 | 
			
		||||
| 
						 | 
				
			
			@ -175,6 +178,33 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
            Room Settings
 | 
			
		||||
          </Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        <UseStateProvider initial={false}>
 | 
			
		||||
          {(promptJump, setPromptJump) => (
 | 
			
		||||
            <>
 | 
			
		||||
              <MenuItem
 | 
			
		||||
                onClick={() => setPromptJump(true)}
 | 
			
		||||
                size="300"
 | 
			
		||||
                after={<Icon size="100" src={Icons.RecentClock} />}
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-pressed={promptJump}
 | 
			
		||||
              >
 | 
			
		||||
                <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
                  Jump to Time
 | 
			
		||||
                </Text>
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
              {promptJump && (
 | 
			
		||||
                <JumpToTime
 | 
			
		||||
                  onSubmit={(eventId) => {
 | 
			
		||||
                    setPromptJump(false);
 | 
			
		||||
                    navigateRoom(room.roomId, eventId);
 | 
			
		||||
                    requestClose();
 | 
			
		||||
                  }}
 | 
			
		||||
                  onCancel={() => setPromptJump(false)}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </UseStateProvider>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Line variant="Surface" size="300" />
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										256
									
								
								src/app/features/room/jump-to-time/JumpToTime.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								src/app/features/room/jump-to-time/JumpToTime.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,256 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  color,
 | 
			
		||||
  Button,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Chip,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { Direction, MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useAlive } from '../../../hooks/useAlive';
 | 
			
		||||
import { useStateEvent } from '../../../hooks/useStateEvent';
 | 
			
		||||
import { useRoom } from '../../../hooks/useRoom';
 | 
			
		||||
import { StateEvent } from '../../../../types/matrix/room';
 | 
			
		||||
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
 | 
			
		||||
import { DatePicker, TimePicker } from '../../../components/time-date';
 | 
			
		||||
 | 
			
		||||
type JumpToTimeProps = {
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onSubmit: (eventId: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = useRoom();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
 | 
			
		||||
 | 
			
		||||
  const todayTs = getToday();
 | 
			
		||||
  const yesterdayTs = getYesterday();
 | 
			
		||||
  const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
 | 
			
		||||
  const [ts, setTs] = useState(() => Date.now());
 | 
			
		||||
 | 
			
		||||
  const [timePickerCords, setTimePickerCords] = useState<RectCords>();
 | 
			
		||||
  const [datePickerCords, setDatePickerCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setTimePickerCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
  const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setDatePickerCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleToday = () => {
 | 
			
		||||
    setTs(todayTs < createTs ? createTs : todayTs);
 | 
			
		||||
  };
 | 
			
		||||
  const handleYesterday = () => {
 | 
			
		||||
    setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
 | 
			
		||||
  };
 | 
			
		||||
  const handleBeginning = () => setTs(createTs);
 | 
			
		||||
 | 
			
		||||
  const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (newTs) => {
 | 
			
		||||
        const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
 | 
			
		||||
        return result.event_id;
 | 
			
		||||
      },
 | 
			
		||||
      [mx, room]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = () => {
 | 
			
		||||
    timestampToEvent(ts).then((eventId) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        onSubmit(eventId);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: onCancel,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Dialog variant="Surface">
 | 
			
		||||
            <Header
 | 
			
		||||
              style={{
 | 
			
		||||
                padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
              }}
 | 
			
		||||
              variant="Surface"
 | 
			
		||||
              size="500"
 | 
			
		||||
            >
 | 
			
		||||
              <Box grow="Yes">
 | 
			
		||||
                <Text size="H4">Jump to Time</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
 | 
			
		||||
              <Box direction="Row" gap="300">
 | 
			
		||||
                <Box direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400" priority="400">
 | 
			
		||||
                    Time
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Box gap="100" alignItems="Center">
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      size="500"
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      fill="None"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-pressed={!!timePickerCords}
 | 
			
		||||
                      after={<Icon size="50" src={Icons.ChevronBottom} />}
 | 
			
		||||
                      onClick={handleTimePicker}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">{timeHourMinute(ts)}</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                    <PopOut
 | 
			
		||||
                      anchor={timePickerCords}
 | 
			
		||||
                      offset={5}
 | 
			
		||||
                      position="Bottom"
 | 
			
		||||
                      align="Center"
 | 
			
		||||
                      content={
 | 
			
		||||
                        <FocusTrap
 | 
			
		||||
                          focusTrapOptions={{
 | 
			
		||||
                            initialFocus: false,
 | 
			
		||||
                            onDeactivate: () => setTimePickerCords(undefined),
 | 
			
		||||
                            clickOutsideDeactivates: true,
 | 
			
		||||
                            isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                            isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                            escapeDeactivates: stopPropagation,
 | 
			
		||||
                          }}
 | 
			
		||||
                        >
 | 
			
		||||
                          <TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
 | 
			
		||||
                        </FocusTrap>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Box direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400" priority="400">
 | 
			
		||||
                    Date
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Box gap="100" alignItems="Center">
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      size="500"
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      fill="None"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-pressed={!!datePickerCords}
 | 
			
		||||
                      after={<Icon size="50" src={Icons.ChevronBottom} />}
 | 
			
		||||
                      onClick={handleDatePicker}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">{timeDayMonthYear(ts)}</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                    <PopOut
 | 
			
		||||
                      anchor={datePickerCords}
 | 
			
		||||
                      offset={5}
 | 
			
		||||
                      position="Bottom"
 | 
			
		||||
                      align="Center"
 | 
			
		||||
                      content={
 | 
			
		||||
                        <FocusTrap
 | 
			
		||||
                          focusTrapOptions={{
 | 
			
		||||
                            initialFocus: false,
 | 
			
		||||
                            onDeactivate: () => setDatePickerCords(undefined),
 | 
			
		||||
                            clickOutsideDeactivates: true,
 | 
			
		||||
                            isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                            isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                            escapeDeactivates: stopPropagation,
 | 
			
		||||
                          }}
 | 
			
		||||
                        >
 | 
			
		||||
                          <DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
 | 
			
		||||
                        </FocusTrap>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Preset</Text>
 | 
			
		||||
                <Box gap="200">
 | 
			
		||||
                  {createTs < todayTs && (
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                      radii="Pill"
 | 
			
		||||
                      aria-pressed={ts === todayTs}
 | 
			
		||||
                      onClick={handleToday}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Today</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {createTs < yesterdayTs && (
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                      radii="Pill"
 | 
			
		||||
                      aria-pressed={ts === yesterdayTs}
 | 
			
		||||
                      onClick={handleYesterday}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Yesterday</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                  )}
 | 
			
		||||
                  <Chip
 | 
			
		||||
                    variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                    radii="Pill"
 | 
			
		||||
                    aria-pressed={ts === createTs}
 | 
			
		||||
                    onClick={handleBeginning}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="B300">Beginning</Text>
 | 
			
		||||
                  </Chip>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              {timestampState.status === AsyncStatus.Error && (
 | 
			
		||||
                <Text style={{ color: color.Critical.Main }} size="T300">
 | 
			
		||||
                  {timestampState.error.message}
 | 
			
		||||
                </Text>
 | 
			
		||||
              )}
 | 
			
		||||
              <Button
 | 
			
		||||
                type="submit"
 | 
			
		||||
                variant="Primary"
 | 
			
		||||
                before={
 | 
			
		||||
                  timestampState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                    <Spinner fill="Solid" variant="Primary" size="200" />
 | 
			
		||||
                  ) : undefined
 | 
			
		||||
                }
 | 
			
		||||
                aria-disabled={
 | 
			
		||||
                  timestampState.status === AsyncStatus.Loading ||
 | 
			
		||||
                  timestampState.status === AsyncStatus.Success
 | 
			
		||||
                }
 | 
			
		||||
                onClick={handleSubmit}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B400">Open Timeline</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room/jump-to-time/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room/jump-to-time/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './JumpToTime';
 | 
			
		||||
| 
						 | 
				
			
			@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
 | 
			
		|||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
 | 
			
		||||
import { VerifyOtherDeviceTile } from './Verification';
 | 
			
		||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
 | 
			
		||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
 | 
			
		||||
import { withSearchParam } from '../../../pages/pathUtils';
 | 
			
		||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
 | 
			
		||||
type OtherDevicesProps = {
 | 
			
		||||
  devices: IMyDevice[];
 | 
			
		||||
| 
						 | 
				
			
			@ -20,8 +24,39 @@ type OtherDevicesProps = {
 | 
			
		|||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const crypto = mx.getCrypto();
 | 
			
		||||
  const authMetadata = useAuthMetadata();
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
 | 
			
		||||
  const [deleted, setDeleted] = useState<Set<string>>(new Set());
 | 
			
		||||
 | 
			
		||||
  const handleDashboardOIDC = useCallback(() => {
 | 
			
		||||
    const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
 | 
			
		||||
    if (!authUrl) return;
 | 
			
		||||
 | 
			
		||||
    window.open(
 | 
			
		||||
      withSearchParam(authUrl, {
 | 
			
		||||
        action: accountManagementActions.sessionsList,
 | 
			
		||||
      }),
 | 
			
		||||
      '_blank'
 | 
			
		||||
    );
 | 
			
		||||
  }, [authMetadata, accountManagementActions]);
 | 
			
		||||
 | 
			
		||||
  const handleDeleteOIDC = useCallback(
 | 
			
		||||
    (deviceId: string) => {
 | 
			
		||||
      const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
 | 
			
		||||
      if (!authUrl) return;
 | 
			
		||||
 | 
			
		||||
      window.open(
 | 
			
		||||
        withSearchParam(authUrl, {
 | 
			
		||||
          action: accountManagementActions.sessionEnd,
 | 
			
		||||
          device_id: deviceId,
 | 
			
		||||
        }),
 | 
			
		||||
        '_blank'
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [authMetadata, accountManagementActions]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleToggleDelete = useCallback((deviceId: string) => {
 | 
			
		||||
    setDeleted((deviceIds) => {
 | 
			
		||||
      const newIds = new Set(deviceIds);
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
 | 
			
		|||
    <>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Others</Text>
 | 
			
		||||
        {authMetadata && (
 | 
			
		||||
          <SequenceCard
 | 
			
		||||
            className={SequenceCardStyle}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="400"
 | 
			
		||||
          >
 | 
			
		||||
            <SettingTile
 | 
			
		||||
              title="Device Dashboard"
 | 
			
		||||
              description="Manage your devices on OIDC dashboard."
 | 
			
		||||
              after={
 | 
			
		||||
                <Button
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  fill="Soft"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  outlined
 | 
			
		||||
                  onClick={handleDashboardOIDC}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Open</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </SequenceCard>
 | 
			
		||||
        )}
 | 
			
		||||
        {devices
 | 
			
		||||
          .sort((d1, d2) => {
 | 
			
		||||
            if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
 | 
			
		|||
                refreshDeviceList={refreshDeviceList}
 | 
			
		||||
                disabled={deleting}
 | 
			
		||||
                options={
 | 
			
		||||
                  <DeviceDeleteBtn
 | 
			
		||||
                    deviceId={device.device_id}
 | 
			
		||||
                    deleted={deleted.has(device.device_id)}
 | 
			
		||||
                    onDeleteToggle={handleToggleDelete}
 | 
			
		||||
                    disabled={deleting}
 | 
			
		||||
                  />
 | 
			
		||||
                  authMetadata ? (
 | 
			
		||||
                    <DeviceDeleteBtn
 | 
			
		||||
                      deviceId={device.device_id}
 | 
			
		||||
                      deleted={false}
 | 
			
		||||
                      onDeleteToggle={handleDeleteOIDC}
 | 
			
		||||
                    />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <DeviceDeleteBtn
 | 
			
		||||
                      deviceId={device.device_id}
 | 
			
		||||
                      deleted={deleted.has(device.device_id)}
 | 
			
		||||
                      onDeleteToggle={handleToggleDelete}
 | 
			
		||||
                      disabled={deleting}
 | 
			
		||||
                    />
 | 
			
		||||
                  )
 | 
			
		||||
                }
 | 
			
		||||
              />
 | 
			
		||||
              {showVerification && crypto && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,6 +32,9 @@ import {
 | 
			
		|||
  DeviceVerificationSetup,
 | 
			
		||||
} from '../../../components/DeviceVerificationSetup';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
 | 
			
		||||
import { withSearchParam } from '../../../pages/pathUtils';
 | 
			
		||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
 | 
			
		||||
 | 
			
		||||
type VerificationStatusBadgeProps = {
 | 
			
		||||
  verificationStatus: VerificationStatus;
 | 
			
		||||
| 
						 | 
				
			
			@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
 | 
			
		|||
 | 
			
		||||
export function DeviceVerificationOptions() {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const authMetadata = useAuthMetadata();
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
 | 
			
		||||
  const [reset, setReset] = useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
 | 
			
		|||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
 | 
			
		||||
    if (authMetadata) {
 | 
			
		||||
      const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
 | 
			
		||||
      window.open(
 | 
			
		||||
        withSearchParam(authUrl, {
 | 
			
		||||
          action: accountManagementActions.crossSigningReset,
 | 
			
		||||
        }),
 | 
			
		||||
        '_blank'
 | 
			
		||||
      );
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setReset(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										17
									
								
								src/app/hooks/useAccountManagement.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/hooks/useAccountManagement.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export const useAccountManagementActions = () => {
 | 
			
		||||
  const actions = useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      profile: 'org.matrix.profile',
 | 
			
		||||
      sessionsList: 'org.matrix.sessions_list',
 | 
			
		||||
      sessionView: 'org.matrix.session_view',
 | 
			
		||||
      sessionEnd: 'org.matrix.session_end',
 | 
			
		||||
      accountDeactivate: 'org.matrix.account_deactivate',
 | 
			
		||||
      crossSigningReset: 'org.matrix.cross_signing_reset',
 | 
			
		||||
    }),
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return actions;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										12
									
								
								src/app/hooks/useAuthMetadata.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/app/hooks/useAuthMetadata.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import { ValidatedAuthMetadata } from 'matrix-js-sdk';
 | 
			
		||||
import { createContext, useContext } from 'react';
 | 
			
		||||
 | 
			
		||||
const AuthMetadataContext = createContext<ValidatedAuthMetadata | undefined>(undefined);
 | 
			
		||||
 | 
			
		||||
export const AuthMetadataProvider = AuthMetadataContext.Provider;
 | 
			
		||||
 | 
			
		||||
export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => {
 | 
			
		||||
  const metadata = useContext(AuthMetadataContext);
 | 
			
		||||
 | 
			
		||||
  return metadata;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import {
 | 
			
		|||
  getSpaceRoomPath,
 | 
			
		||||
} from '../pages/pathUtils';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { getOrphanParents } from '../utils/room';
 | 
			
		||||
import { getOrphanParents, guessPerfectParent } from '../utils/room';
 | 
			
		||||
import { roomToParentsAtom } from '../state/room/roomToParents';
 | 
			
		||||
import { mDirectAtom } from '../state/mDirectList';
 | 
			
		||||
import { useSelectedSpace } from './router/useSelectedSpace';
 | 
			
		||||
| 
						 | 
				
			
			@ -39,19 +39,19 @@ export const useRoomNavigate = () => {
 | 
			
		|||
 | 
			
		||||
      const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
 | 
			
		||||
      if (orphanParents.length > 0) {
 | 
			
		||||
        const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(
 | 
			
		||||
          mx,
 | 
			
		||||
          spaceSelectedId && orphanParents.includes(spaceSelectedId)
 | 
			
		||||
            ? spaceSelectedId
 | 
			
		||||
            : orphanParents[0] // TODO: better orphan parent selection.
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (openSpaceTimeline) {
 | 
			
		||||
          navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomId, eventId), opts);
 | 
			
		||||
          return;
 | 
			
		||||
        let parentSpace: string;
 | 
			
		||||
        if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
 | 
			
		||||
          parentSpace = spaceSelectedId;
 | 
			
		||||
        } else {
 | 
			
		||||
          parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
 | 
			
		||||
        const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
 | 
			
		||||
 | 
			
		||||
        navigate(
 | 
			
		||||
          getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
 | 
			
		||||
          opts
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import './SpaceAddExisting.scss';
 | 
			
		|||
 | 
			
		||||
import cons from '../../../client/state/cons';
 | 
			
		||||
import navigation from '../../../client/state/navigation';
 | 
			
		||||
import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil';
 | 
			
		||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
 | 
			
		||||
import { Debounce } from '../../../util/common';
 | 
			
		||||
 | 
			
		||||
import Text from '../../atoms/text/Text';
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +21,6 @@ import Dialog from '../dialog/Dialog';
 | 
			
		|||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 | 
			
		||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 | 
			
		||||
 | 
			
		||||
import { useStore } from '../../hooks/useStore';
 | 
			
		||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
			
		||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
 | 
			
		||||
import { allRoomsAtom } from '../../state/room-list/roomList';
 | 
			
		||||
| 
						 | 
				
			
			@ -73,9 +72,6 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
 | 
			
		|||
    await rateLimitedActions(selected, async (rId) => {
 | 
			
		||||
      const room = mx.getRoom(rId);
 | 
			
		||||
      const via = getViaServers(room);
 | 
			
		||||
      if (via.length === 0) {
 | 
			
		||||
        via.push(getIdServer(rId));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await mx.sendStateEvent(
 | 
			
		||||
        roomId,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,21 @@
 | 
			
		|||
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
 | 
			
		||||
import { IIdentityProvider, createClient } from 'matrix-js-sdk';
 | 
			
		||||
import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk';
 | 
			
		||||
import React, { useMemo } from 'react';
 | 
			
		||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
 | 
			
		||||
 | 
			
		||||
type SSOLoginProps = {
 | 
			
		||||
  providers?: IIdentityProvider[];
 | 
			
		||||
  redirectUrl: string;
 | 
			
		||||
  action?: SSOAction;
 | 
			
		||||
  saveScreenSpace?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) {
 | 
			
		||||
export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) {
 | 
			
		||||
  const discovery = useAutoDiscoveryInfo();
 | 
			
		||||
  const baseUrl = discovery['m.homeserver'].base_url;
 | 
			
		||||
  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
 | 
			
		||||
 | 
			
		||||
  const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
 | 
			
		||||
  const getSSOIdUrl = (ssoId?: string): string =>
 | 
			
		||||
    mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action);
 | 
			
		||||
 | 
			
		||||
  const withoutIcon = providers
 | 
			
		||||
    ? providers.find(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import React, { useMemo } from 'react';
 | 
			
		||||
import { Box, Text, color } from 'folds';
 | 
			
		||||
import { Link, useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { SSOAction } from 'matrix-js-sdk';
 | 
			
		||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
 | 
			
		||||
import { useAuthServer } from '../../../hooks/useAuthServer';
 | 
			
		||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +77,7 @@ export function Login() {
 | 
			
		|||
          <SSOLogin
 | 
			
		||||
            providers={parsedFlows.sso.identity_providers}
 | 
			
		||||
            redirectUrl={ssoRedirectUrl}
 | 
			
		||||
            action={SSOAction.LOGIN}
 | 
			
		||||
            saveScreenSpace={parsedFlows.password !== undefined}
 | 
			
		||||
          />
 | 
			
		||||
          <span data-spacing-node />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import React, { useMemo } from 'react';
 | 
			
		||||
import { Box, Text, color } from 'folds';
 | 
			
		||||
import { Link, useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { SSOAction } from 'matrix-js-sdk';
 | 
			
		||||
import { useAuthServer } from '../../../hooks/useAuthServer';
 | 
			
		||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
 | 
			
		||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +84,7 @@ export function Register() {
 | 
			
		|||
          <SSOLogin
 | 
			
		||||
            providers={sso.identity_providers}
 | 
			
		||||
            redirectUrl={ssoRedirectUrl}
 | 
			
		||||
            action={SSOAction.REGISTER}
 | 
			
		||||
            saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
 | 
			
		||||
          />
 | 
			
		||||
          <span data-spacing-node />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,7 +25,7 @@ import {
 | 
			
		|||
} from '../../../client/initMatrix';
 | 
			
		||||
import { getSecret } from '../../../client/state/auth';
 | 
			
		||||
import { SplashScreen } from '../../components/splash-screen';
 | 
			
		||||
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
 | 
			
		||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
 | 
			
		||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
 | 
			
		||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
 | 
			
		||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		|||
import { useSyncState } from '../../hooks/useSyncState';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import { SyncStatus } from './SyncStatus';
 | 
			
		||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
 | 
			
		||||
 | 
			
		||||
function ClientRootLoading() {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
 | 
			
		|||
        <ClientRootLoading />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <MatrixClientProvider value={mx}>
 | 
			
		||||
          <CapabilitiesAndMediaConfigLoader>
 | 
			
		||||
            {(capabilities, mediaConfig) => (
 | 
			
		||||
              <CapabilitiesProvider value={capabilities ?? {}}>
 | 
			
		||||
                <MediaConfigProvider value={mediaConfig ?? {}}>
 | 
			
		||||
                  {children}
 | 
			
		||||
                  <Windows />
 | 
			
		||||
                  <Dialogs />
 | 
			
		||||
                  <ReusableContextMenu />
 | 
			
		||||
          <ServerConfigsLoader>
 | 
			
		||||
            {(serverConfigs) => (
 | 
			
		||||
              <CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
 | 
			
		||||
                <MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
 | 
			
		||||
                  <AuthMetadataProvider value={serverConfigs.authMetadata}>
 | 
			
		||||
                    {children}
 | 
			
		||||
                    <Windows />
 | 
			
		||||
                    <Dialogs />
 | 
			
		||||
                    <ReusableContextMenu />
 | 
			
		||||
                  </AuthMetadataProvider>
 | 
			
		||||
                </MediaConfigProvider>
 | 
			
		||||
              </CapabilitiesProvider>
 | 
			
		||||
            )}
 | 
			
		||||
          </CapabilitiesAndMediaConfigLoader>
 | 
			
		||||
          </ServerConfigsLoader>
 | 
			
		||||
        </MatrixClientProvider>
 | 
			
		||||
      )}
 | 
			
		||||
    </SpecVersions>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,9 +23,9 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
 | 
			
		|||
 | 
			
		||||
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
 | 
			
		||||
 | 
			
		||||
export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
 | 
			
		||||
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])(.+):(\S+)$/);
 | 
			
		||||
 | 
			
		||||
export const validMxId = (id: string): boolean => !!matchMxId(id);
 | 
			
		||||
const validMxId = (id: string): boolean => !!matchMxId(id);
 | 
			
		||||
 | 
			
		||||
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -33,7 +33,7 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI
 | 
			
		|||
 | 
			
		||||
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
 | 
			
		||||
 | 
			
		||||
export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!');
 | 
			
		||||
export const isRoomId = (id: string): boolean => id.startsWith('!');
 | 
			
		||||
 | 
			
		||||
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +50,11 @@ export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): str
 | 
			
		|||
  const room = mx.getRoom(roomId);
 | 
			
		||||
  if (!room) return roomId;
 | 
			
		||||
  if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
 | 
			
		||||
  return room.getCanonicalAlias() || roomId;
 | 
			
		||||
  const alias = room.getCanonicalAlias();
 | 
			
		||||
  if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) {
 | 
			
		||||
    return alias;
 | 
			
		||||
  }
 | 
			
		||||
  return roomId;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import {
 | 
			
		|||
  EventTimelineSet,
 | 
			
		||||
  EventType,
 | 
			
		||||
  IMentions,
 | 
			
		||||
  IPowerLevelsContent,
 | 
			
		||||
  IPushRule,
 | 
			
		||||
  IPushRules,
 | 
			
		||||
  JoinRule,
 | 
			
		||||
| 
						 | 
				
			
			@ -473,3 +474,43 @@ export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: st
 | 
			
		|||
    const banned = room.hasMembershipState(otherUserId, Membership.Ban);
 | 
			
		||||
    return banned;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export const guessPerfectParent = (
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  roomId: string,
 | 
			
		||||
  parents: string[]
 | 
			
		||||
): string | undefined => {
 | 
			
		||||
  if (parents.length === 1) {
 | 
			
		||||
    return parents[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const getSpecialUsers = (rId: string): string[] => {
 | 
			
		||||
    const r = mx.getRoom(rId);
 | 
			
		||||
    const powerLevels =
 | 
			
		||||
      r && getStateEvent(r, StateEvent.RoomPowerLevels)?.getContent<IPowerLevelsContent>();
 | 
			
		||||
 | 
			
		||||
    const { users_default: usersDefault, users } = powerLevels ?? {};
 | 
			
		||||
    if (typeof users !== 'object') return [];
 | 
			
		||||
 | 
			
		||||
    const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0;
 | 
			
		||||
    return Object.keys(users).filter((userId) => users[userId] > defaultPower);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let perfectParent: string | undefined;
 | 
			
		||||
  let score = 0;
 | 
			
		||||
 | 
			
		||||
  const roomSpecialUsers = getSpecialUsers(roomId);
 | 
			
		||||
  parents.forEach((parentId) => {
 | 
			
		||||
    const parentSpecialUsers = getSpecialUsers(parentId);
 | 
			
		||||
    const matchedUsersCount = parentSpecialUsers.filter((userId) =>
 | 
			
		||||
      roomSpecialUsers.includes(userId)
 | 
			
		||||
    ).length;
 | 
			
		||||
 | 
			
		||||
    if (matchedUsersCount > score) {
 | 
			
		||||
      score = matchedUsersCount;
 | 
			
		||||
      perfectParent = parentId;
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return perfectParent;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,12 +9,26 @@ export const today = (ts: number): boolean => dayjs(ts).isToday();
 | 
			
		|||
 | 
			
		||||
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
 | 
			
		||||
 | 
			
		||||
export const timeHour = (ts: number): string => dayjs(ts).format('hh');
 | 
			
		||||
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
 | 
			
		||||
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
 | 
			
		||||
export const timeDay = (ts: number): string => dayjs(ts).format('D');
 | 
			
		||||
export const timeMon = (ts: number): string => dayjs(ts).format('MMM');
 | 
			
		||||
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
 | 
			
		||||
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
 | 
			
		||||
 | 
			
		||||
export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A');
 | 
			
		||||
 | 
			
		||||
export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY');
 | 
			
		||||
 | 
			
		||||
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
 | 
			
		||||
 | 
			
		||||
export const daysInMonth = (month: number, year: number): number =>
 | 
			
		||||
  dayjs(`${year}-${month}-1`).daysInMonth();
 | 
			
		||||
 | 
			
		||||
export const dateFor = (year: number, month: number, day: number): number =>
 | 
			
		||||
  dayjs(`${year}-${month}-${day}`).valueOf();
 | 
			
		||||
 | 
			
		||||
export const inSameDay = (ts1: number, ts2: number): boolean => {
 | 
			
		||||
  const dt1 = new Date(ts1);
 | 
			
		||||
  const dt2 = new Date(ts2);
 | 
			
		||||
| 
						 | 
				
			
			@ -33,3 +47,37 @@ export const minuteDifference = (ts1: number, ts2: number): number => {
 | 
			
		|||
  diff /= 60;
 | 
			
		||||
  return Math.abs(Math.round(diff));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const hour24to12 = (hour24: number): number => {
 | 
			
		||||
  const h = hour24 % 12;
 | 
			
		||||
 | 
			
		||||
  if (h === 0) return 12;
 | 
			
		||||
  return h;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const hour12to24 = (hour: number, pm: boolean): number => {
 | 
			
		||||
  if (hour === 12) {
 | 
			
		||||
    return pm ? 12 : 0;
 | 
			
		||||
  }
 | 
			
		||||
  return pm ? hour + 12 : hour;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const secondsToMs = (seconds: number) => seconds * 1000;
 | 
			
		||||
 | 
			
		||||
export const minutesToMs = (minutes: number) => minutes * secondsToMs(60);
 | 
			
		||||
 | 
			
		||||
export const hoursToMs = (hour: number) => hour * minutesToMs(60);
 | 
			
		||||
 | 
			
		||||
export const daysToMs = (days: number) => days * hoursToMs(24);
 | 
			
		||||
 | 
			
		||||
export const getToday = () => {
 | 
			
		||||
  const nowTs = Date.now();
 | 
			
		||||
  const date = dayjs(nowTs);
 | 
			
		||||
  return dateFor(date.year(), date.month() + 1, date.date());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getYesterday = () => {
 | 
			
		||||
  const nowTs = Date.now() - daysToMs(1);
 | 
			
		||||
  const date = dayjs(nowTs);
 | 
			
		||||
  return dateFor(date.year(), date.month() + 1, date.date());
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -93,11 +93,8 @@ function convertToRoom(mx, roomId) {
 | 
			
		|||
 * @param {string[]} via
 | 
			
		||||
 */
 | 
			
		||||
async function join(mx, roomIdOrAlias, isDM = false, via = undefined) {
 | 
			
		||||
  const roomIdParts = roomIdOrAlias.split(':');
 | 
			
		||||
  const viaServers = via || [roomIdParts[1]];
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers });
 | 
			
		||||
    const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers: via });
 | 
			
		||||
 | 
			
		||||
    if (isDM) {
 | 
			
		||||
      const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue