diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml index 9c0bea78..b330c3c1 100644 --- a/.github/workflows/deploy-pull-request.yml +++ b/.github/workflows/deploy-pull-request.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Download pr number - uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} @@ -24,7 +24,7 @@ jobs: id: pr run: echo "id=$(> $GITHUB_OUTPUT - name: Download artifact - uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 with: workflow: ${{ github.event.workflow.id }} run_id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 0a758c51..d4a814b0 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -52,7 +52,7 @@ jobs: gpg --export | xxd -p echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz - name: Upload tagged release - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 with: files: | cinny-${{ steps.vars.outputs.tag }}.tar.gz diff --git a/Dockerfile b/Dockerfile index abb65ee5..718fed72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm run build ## App -FROM nginx:1.27.4-alpine +FROM nginx:1.29.0-alpine COPY --from=builder /src/dist /app COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf diff --git a/package-lock.json b/package-lock.json index 7dd2bf0a..3fc29010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "5.4.15", + "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", "vite-plugin-top-level-await": "1.4.4" @@ -11331,9 +11331,9 @@ } }, "node_modules/vite": { - "version": "5.4.15", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", - "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", diff --git a/package.json b/package.json index 3c1cef8c..81d0e20a 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "5.4.15", + "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", "vite-plugin-top-level-await": "1.4.4" diff --git a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx b/src/app/components/CapabilitiesAndMediaConfigLoader.tsx deleted file mode 100644 index 574d0ca7..00000000 --- a/src/app/components/CapabilitiesAndMediaConfigLoader.tsx +++ /dev/null @@ -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); -} diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx new file mode 100644 index 00000000..3c8ce8eb --- /dev/null +++ b/src/app/components/ServerConfigsLoader.tsx @@ -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( + 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); +} diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index cc431f58..b0c64f60 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -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() ?? '')}`; diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index d6c0f302..7a8012eb 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -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() ?? '')}`; diff --git a/src/app/components/time-date/DatePicker.tsx b/src/app/components/time-date/DatePicker.tsx new file mode 100644 index 00000000..faa43a3f --- /dev/null +++ b/src/app/components/time-date/DatePicker.tsx @@ -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( + ({ 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 ( + + + + {Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys()) + .map((i) => i + 1) + .map((day) => ( + handleDay(day)} + disabled={ + (selectedYear === minYear && selectedMonth === minMonth && day < minDay) || + (selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay) + } + > + {day} + + ))} + + + {Array.from(Array(12).keys()) + .map((i) => i + 1) + .map((month) => ( + handleMonth(month)} + disabled={ + (selectedYear === minYear && month < minMonth) || + (selectedYear === maxYear && month > maxMonth) + } + > + + {dayjs() + .month(month - 1) + .format('MMM')} + + + ))} + + + {Array.from(Array(yearsRange).keys()) + .map((i) => minYear + i) + .map((year) => ( + handleYear(year)} + > + {year} + + ))} + + + + ); + } +); diff --git a/src/app/components/time-date/PickerColumn.tsx b/src/app/components/time-date/PickerColumn.tsx new file mode 100644 index 00000000..c31daf43 --- /dev/null +++ b/src/app/components/time-date/PickerColumn.tsx @@ -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 ( + + + {title} + + + + + + {children} + + + + + + ); +} diff --git a/src/app/components/time-date/TimePicker.tsx b/src/app/components/time-date/TimePicker.tsx new file mode 100644 index 00000000..1dd0958b --- /dev/null +++ b/src/app/components/time-date/TimePicker.tsx @@ -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( + ({ 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 ( + + + + {Array.from(Array(12).keys()) + .map((i) => { + if (i === 0) return 12; + return i; + }) + .map((hour) => ( + handleHour(hour)} + disabled={ + (minDay && hour12to24(hour, selectedPM) < minHour24) || + (maxDay && hour12to24(hour, selectedPM) > maxHour24) + } + > + {hour < 10 ? `0${hour}` : hour} + + ))} + + + {Array.from(Array(60).keys()).map((minute) => ( + handleMinute(minute)} + disabled={ + (minDay && hour24 === minHour24 && minute < minMinute) || + (maxDay && hour24 === maxHour24 && minute > maxMinute) + } + > + {minute < 10 ? `0${minute}` : minute} + + ))} + + + handlePeriod(false)} + disabled={minDay && minPM} + > + AM + + handlePeriod(true)} + disabled={maxDay && !maxPM} + > + PM + + + + + ); + } +); diff --git a/src/app/components/time-date/index.ts b/src/app/components/time-date/index.ts new file mode 100644 index 00000000..592c5af7 --- /dev/null +++ b/src/app/components/time-date/index.ts @@ -0,0 +1,2 @@ +export * from './TimePicker'; +export * from './DatePicker'; diff --git a/src/app/components/time-date/styles.css.ts b/src/app/components/time-date/styles.css.ts new file mode 100644 index 00000000..97926d3f --- /dev/null +++ b/src/app/components/time-date/styles.css.ts @@ -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, +}); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 352ae4b5..63e9d55d 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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(({ 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(({ room, requestClose Room Settings + + {(promptJump, setPromptJump) => ( + <> + setPromptJump(true)} + size="300" + after={} + radii="300" + aria-pressed={promptJump} + > + + Jump to Time + + + {promptJump && ( + { + setPromptJump(false); + navigateRoom(room.roomId, eventId); + requestClose(); + }} + onCancel={() => setPromptJump(false)} + /> + )} + + )} + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx new file mode 100644 index 00000000..8c4e2c0b --- /dev/null +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -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(); + const [datePickerCords, setDatePickerCords] = useState(); + + const handleTimePicker: MouseEventHandler = (evt) => { + setTimePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleDatePicker: MouseEventHandler = (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( + 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 ( + }> + + + +
+ + Jump to Time + + + + +
+ + + + + Time + + + } + onClick={handleTimePicker} + > + {timeHourMinute(ts)} + + setTimePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + +
+ } + /> +
+ + + + Date + + + } + onClick={handleDatePicker} + > + {timeDayMonthYear(ts)} + + setDatePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + /> + + + + + Preset + + {createTs < todayTs && ( + + Today + + )} + {createTs < yesterdayTs && ( + + Yesterday + + )} + + Beginning + + + + {timestampState.status === AsyncStatus.Error && ( + + {timestampState.error.message} + + )} + + + + + + + ); +} diff --git a/src/app/features/room/jump-to-time/index.ts b/src/app/features/room/jump-to-time/index.ts new file mode 100644 index 00000000..9bdc2c74 --- /dev/null +++ b/src/app/features/room/jump-to-time/index.ts @@ -0,0 +1 @@ +export * from './JumpToTime'; diff --git a/src/app/features/settings/devices/OtherDevices.tsx b/src/app/features/settings/devices/OtherDevices.tsx index 0d879e59..4bd83dd6 100644 --- a/src/app/features/settings/devices/OtherDevices.tsx +++ b/src/app/features/settings/devices/OtherDevices.tsx @@ -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>(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 <> Others + {authMetadata && ( + + + Open + + } + /> + + )} {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={ - + authMetadata ? ( + + ) : ( + + ) } /> {showVerification && crypto && ( diff --git a/src/app/features/settings/devices/Verification.tsx b/src/app/features/settings/devices/Verification.tsx index 59fa6b67..6c7eab17 100644 --- a/src/app/features/settings/devices/Verification.tsx +++ b/src/app/features/settings/devices/Verification.tsx @@ -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(); + 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); }; diff --git a/src/app/hooks/useAccountManagement.ts b/src/app/hooks/useAccountManagement.ts new file mode 100644 index 00000000..5eafedc4 --- /dev/null +++ b/src/app/hooks/useAccountManagement.ts @@ -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; +}; diff --git a/src/app/hooks/useAuthMetadata.ts b/src/app/hooks/useAuthMetadata.ts new file mode 100644 index 00000000..db967463 --- /dev/null +++ b/src/app/hooks/useAuthMetadata.ts @@ -0,0 +1,12 @@ +import { ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { createContext, useContext } from 'react'; + +const AuthMetadataContext = createContext(undefined); + +export const AuthMetadataProvider = AuthMetadataContext.Provider; + +export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => { + const metadata = useContext(AuthMetadataContext); + + return metadata; +}; diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index e626c06b..b2d7a91a 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -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; } diff --git a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx index b084a1ad..05b8d85f 100644 --- a/src/app/molecules/space-add-existing/SpaceAddExisting.jsx +++ b/src/app/molecules/space-add-existing/SpaceAddExisting.jsx @@ -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, diff --git a/src/app/pages/auth/SSOLogin.tsx b/src/app/pages/auth/SSOLogin.tsx index d0cdaeb6..3ff1a229 100644 --- a/src/app/pages/auth/SSOLogin.tsx +++ b/src/app/pages/auth/SSOLogin.tsx @@ -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( diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 6b9f1223..2f04a733 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -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() { diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index d2986d70..7176489b 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -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() { diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 846d8ff3..c48dbf53 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -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) { ) : ( - - {(capabilities, mediaConfig) => ( - - - {children} - - - + + {(serverConfigs) => ( + + + + {children} + + + + )} - + )} diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index a495e8d5..610ef0af 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -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 => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 79dcff9e..cae23514 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -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(); + + 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; +}; diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index 5b9a9e4b..a9acb99c 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -9,6 +9,14 @@ 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, hour24Clock: boolean): string => dayjs(ts).format(hour24Clock ? 'HH:mm' : 'hh:mm A'); @@ -17,6 +25,12 @@ export const timeDayMonYear = (ts: number, dateFormatString: string): string => 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); @@ -35,3 +49,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()); +}; diff --git a/src/client/action/room.js b/src/client/action/room.js index 90b74810..e39aeed8 100644 --- a/src/client/action/room.js +++ b/src/client/action/room.js @@ -12,7 +12,7 @@ function addRoomToMDirect(mx, roomId, userId) { const mDirectsEvent = mx.getAccountData('m.direct'); let userIdToRoomIds = {}; - if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent(); + if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); // remove it from the lists of any others users // (it can only be a DM room for one person) @@ -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());