+
+ {child}
+
+ );
+ if (leaf.spoiler)
+ child = (
+
+ ${string}
`;
+ if (node.spoiler) string = `${string}`;
+ return string;
+};
+
+const elementToCustomHtml = (node: CustomElement, children: string): string => {
+ switch (node.type) {
+ case BlockType.Paragraph:
+ return `${children}
`; + case BlockType.Heading: + return `${children}
`;
+ case BlockType.QuoteLine:
+ return `${children}
`; + case BlockType.BlockQuote: + return `${children}`; + case BlockType.ListItem: + return `
${children}
= (...args: Q) => R; +export type DisposableContext= ( + ...args: P +) => DisposeCallback
; + +export const disposable =( + context: DisposableContext
+) => context; diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts new file mode 100644 index 0000000..d717adf --- /dev/null +++ b/src/app/utils/dom.ts @@ -0,0 +1,133 @@ +export const targetFromEvent = (evt: Event, selector: string): Element | undefined => { + const targets = evt.composedPath() as Element[]; + return targets.find((target) => target.matches?.(selector)); +}; + +export const editableActiveElement = (): boolean => + !!document.activeElement && + /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase()); + +export const inVisibleScrollArea = ( + scrollElement: HTMLElement, + childElement: HTMLElement +): boolean => { + const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop; + const scrollBottom = scrollTop + scrollElement.offsetHeight; + + const childTop = childElement.offsetTop; + const childBottom = childTop + childElement.clientHeight; + + if (childTop >= scrollTop && childTop < scrollBottom) return true; + if (childTop < scrollTop && childBottom > scrollTop) return true; + return false; +}; + +export type FilesOrFile
= T extends true ? File[] : File; + +export const selectFile = ( + accept: string, + multiple?: M +): Promise | undefined> => + new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + if (accept) input.accept = accept; + if (multiple) input.multiple = true; + + const changeHandler = () => { + const fileList = input.files; + if (!fileList) { + resolve(undefined); + } else { + const files: File[] = [...fileList].filter((file) => file); + resolve((multiple ? files : files[0]) as FilesOrFile ); + } + input.removeEventListener('change', changeHandler); + }; + + input.addEventListener('change', changeHandler); + input.click(); + }); + +export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => { + const fileList = dataTransfer.files; + const files = [...fileList].filter((file) => file); + if (files.length === 0) return undefined; + return files; +}; + +export const getImageUrlBlob = async (url: string) => { + const res = await fetch(url); + const blob = await res.blob(); + return blob; +}; + +export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob); + +export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob); + +export const loadImageElement = (url: string): Promise => + new Promise((resolve, reject) => { + const img = document.createElement('img'); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = url; + }); + +export const loadVideoElement = (url: string): Promise => + new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.playsInline = true; + video.muted = true; + + video.onloadeddata = () => { + resolve(video); + video.pause(); + }; + video.onerror = (e) => { + reject(e); + }; + + video.src = url; + video.load(); + video.play(); + }); + +export const getThumbnailDimensions = (width: number, height: number): [number, number] => { + const MAX_WIDTH = 400; + const MAX_HEIGHT = 300; + let targetWidth = width; + let targetHeight = height; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + return [targetWidth, targetHeight]; +}; + +export const getThumbnail = ( + img: HTMLImageElement | SVGImageElement | HTMLVideoElement, + width: number, + height: number, + thumbnailMimeType?: string +): Promise => + new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + resolve(undefined); + return; + } + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob((thumbnail) => { + resolve(thumbnail ?? undefined); + }, thumbnailMimeType ?? 'image/jpeg'); + }); diff --git a/src/app/utils/key-symbol.ts b/src/app/utils/key-symbol.ts new file mode 100644 index 0000000..7e758fd --- /dev/null +++ b/src/app/utils/key-symbol.ts @@ -0,0 +1,6 @@ +export enum KeySymbol { + Command = '⌘', + Shift = '⇧', + Option = '⌥', + Control = '⌃', +} diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts new file mode 100644 index 0000000..56eeb9f --- /dev/null +++ b/src/app/utils/keyboard.ts @@ -0,0 +1,25 @@ +import isHotkey from 'is-hotkey'; +import { KeyboardEventHandler } from 'react'; + +export interface KeyboardEventLike { + key: string; + which: number; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + preventDefault(): void; +} + +export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => { + if (isHotkey('tab', evt)) { + evt.preventDefault(); + callback(); + } +}; + +export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { + if (isHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { + evt.preventDefault(); + } +}; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts new file mode 100644 index 0000000..7f2fc0f --- /dev/null +++ b/src/app/utils/matrix.ts @@ -0,0 +1,118 @@ +import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment'; +import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk'; +import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; + +export const matchMxId = (id: string): RegExpMatchArray | null => + id.match(/^([@!$+#])(\S+):(\S+)$/); + +export const validMxId = (id: string): boolean => !!matchMxId(id); + +export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; + +export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2]; + +export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); + +export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { + const info: IImageInfo = {}; + info.w = img.width; + info.h = img.height; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => { + const info: IVideoInfo = {}; + info.duration = Number.isNaN(video.duration) ? undefined : video.duration; + info.w = video.videoWidth; + info.h = video.videoHeight; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getThumbnailContent = (thumbnailInfo: { + thumbnail: File | Blob; + encInfo: EncryptedAttachmentInfo | undefined; + mxc: string; + width: number; + height: number; +}): IThumbnailContent => { + const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo; + + const content: IThumbnailContent = { + thumbnail_info: { + mimetype: thumbnail.type, + size: thumbnail.size, + w: width, + h: height, + }, + }; + if (encInfo) { + content.thumbnail_file = { + ...encInfo, + url: mxc, + }; + } else { + content.thumbnail_url = mxc; + } + return content; +}; + +export const encryptFile = async ( + file: File | Blob +): Promise<{ + encInfo: EncryptedAttachmentInfo; + file: File; + originalFile: File | Blob; +}> => { + const dataBuffer = await file.arrayBuffer(); + const encryptedAttachment = await encryptAttachment(dataBuffer); + const encFile = new File([encryptedAttachment.data], file.name, { + type: file.type, + }); + return { + encInfo: encryptedAttachment.info, + file: encFile, + originalFile: file, + }; +}; + +export type TUploadContent = File | Blob; + +export type ContentUploadOptions = { + name?: string; + fileType?: string; + hideFilename?: boolean; + onPromise?: (promise: Promise ) => void; + onProgress?: (progress: UploadProgress) => void; + onSuccess: (mxc: string) => void; + onError: (error: MatrixError) => void; +}; + +export const uploadContent = async ( + mx: MatrixClient, + file: TUploadContent, + options: ContentUploadOptions +) => { + const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + progressHandler: onProgress, + }); + onPromise?.(uploadPromise); + try { + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + } catch (e: any) { + const error = typeof e?.message === 'string' ? e.message : undefined; + const errcode = typeof e?.name === 'string' ? e.message : undefined; + onError(new MatrixError({ error, errcode })); + } +}; diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts new file mode 100644 index 0000000..c432bdc --- /dev/null +++ b/src/app/utils/mimeTypes.ts @@ -0,0 +1,47 @@ +// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts +export const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + 'image/apng', + 'image/webp', + 'image/avif', + + 'video/mp4', + 'video/webm', + 'video/ogg', + 'video/quicktime', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export const getBlobSafeMimeType = (mimeType: string) => { + if (typeof mimeType !== 'string') return 'application/octet-stream'; + const [type] = mimeType.split(';'); + if (!ALLOWED_BLOB_MIMETYPES.includes(type)) { + return 'application/octet-stream'; + } + // Required for Chromium browsers + if (type === 'video/quicktime') { + return 'video/mp4'; + } + return type; +}; + +export const safeFile = (f: File) => { + const safeType = getBlobSafeMimeType(f.type); + if (safeType !== f.type) { + return new File([f], f.name, { type: safeType }); + } + return f; +}; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts new file mode 100644 index 0000000..daf9560 --- /dev/null +++ b/src/app/utils/room.ts @@ -0,0 +1,265 @@ +import { IconName, IconSrc } from 'folds'; + +import { + IPushRule, + IPushRules, + JoinRule, + MatrixClient, + MatrixEvent, + NotificationCountType, + Room, +} from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { + NotificationType, + RoomToParents, + RoomType, + StateEvent, + UnreadInfo, +} from '../../types/matrix/room'; + +export const getStateEvent = ( + room: Room, + eventType: StateEvent, + stateKey = '' +): MatrixEvent | undefined => room.currentState.getStateEvents(eventType, stateKey) ?? undefined; + +export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] => + room.currentState.getStateEvents(eventType); + +export const getAccountData = ( + mx: MatrixClient, + eventType: AccountDataEvent +): MatrixEvent | undefined => mx.getAccountData(eventType); + +export const getMDirects = (mDirectEvent: MatrixEvent): Set => { + const roomIds = new Set (); + const userIdToDirects = mDirectEvent?.getContent(); + + if (userIdToDirects === undefined) return roomIds; + + Object.keys(userIdToDirects).forEach((userId) => { + const directs = userIdToDirects[userId]; + if (Array.isArray(directs)) { + directs.forEach((id) => { + if (typeof id === 'string') roomIds.add(id); + }); + } + }); + + return roomIds; +}; + +export const isDirectInvite = (room: Room | null, myUserId: string | null): boolean => { + if (!room || !myUserId) return false; + const me = room.getMember(myUserId); + const memberEvent = me?.events?.member; + const content = memberEvent?.getContent(); + return content?.is_direct === true; +}; + +export const isSpace = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return false; + return event.getContent().type === RoomType.Space; +}; + +export const isRoom = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return false; + return event.getContent().type === undefined; +}; + +export const isUnsupportedRoom = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist + return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space; +}; + +export function isValidChild(mEvent: MatrixEvent): boolean { + return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0; +} + +export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set => { + const allParents = new Set (); + + const addAllParentIds = (rId: string) => { + if (allParents.has(rId)) return; + allParents.add(rId); + + const parents = roomToParents.get(rId); + parents?.forEach((id) => addAllParentIds(id)); + }; + addAllParentIds(roomId); + allParents.delete(roomId); + return allParents; +}; + +export const getSpaceChildren = (room: Room) => + getStateEvents(room, StateEvent.SpaceChild).reduce ((filtered, mEvent) => { + const stateKey = mEvent.getStateKey(); + if (isValidChild(mEvent) && stateKey) { + filtered.push(stateKey); + } + return filtered; + }, []); + +export const mapParentWithChildren = ( + roomToParents: RoomToParents, + roomId: string, + children: string[] +) => { + const allParents = getAllParents(roomToParents, roomId); + children.forEach((childId) => { + if (allParents.has(childId)) { + // Space cycle detected. + return; + } + const parents = roomToParents.get(childId) ?? new Set (); + parents.add(roomId); + roomToParents.set(childId, parents); + }); +}; + +export const getRoomToParents = (mx: MatrixClient): RoomToParents => { + const map: RoomToParents = new Map(); + mx.getRooms() + .filter((room) => isSpace(room)) + .forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room))); + + return map; +}; + +export const isMutedRule = (rule: IPushRule) => + rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; + +export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => + overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); + +export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => { + let roomPushRule: IPushRule | undefined; + try { + roomPushRule = mx.getRoomPushRule('global', roomId); + } catch { + roomPushRule = undefined; + } + + if (!roomPushRule) { + const overrideRules = mx.getAccountData('m.push_rules')?.getContent () + ?.global?.override; + if (!overrideRules) return NotificationType.Default; + + return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default; + } + + if (roomPushRule.actions[0] === 'notify') return NotificationType.AllMessages; + return NotificationType.MentionsAndKeywords; +}; + +export const isNotificationEvent = (mEvent: MatrixEvent) => { + const eType = mEvent.getType(); + if ( + ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find( + (type) => type === eType + ) + ) + return false; + if (eType === 'm.room.member') return false; + + if (mEvent.isRedacted()) return false; + if (mEvent.getRelation()?.rel_type === 'm.replace') return false; + + return true; +}; + +export const roomHaveUnread = (mx: MatrixClient, room: Room) => { + const userId = mx.getUserId(); + if (!userId) return false; + const readUpToId = room.getEventReadUpTo(userId); + const liveEvents = room.getLiveTimeline().getEvents(); + + if (liveEvents[liveEvents.length - 1]?.getSender() === userId) { + return false; + } + + for (let i = liveEvents.length - 1; i >= 0; i -= 1) { + const event = liveEvents[i]; + if (!event) return false; + if (event.getId() === readUpToId) return false; + if (isNotificationEvent(event)) return true; + } + return true; +}; + +export const getUnreadInfo = (room: Room): UnreadInfo => { + const total = room.getUnreadNotificationCount(NotificationCountType.Total); + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); + return { + roomId: room.roomId, + highlight, + total: highlight > total ? highlight : total, + }; +}; + +export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => { + const unreadInfos = mx.getRooms().reduce ((unread, room) => { + if (room.isSpaceRoom()) return unread; + if (room.getMyMembership() !== 'join') return unread; + if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread; + + if (roomHaveUnread(mx, room)) { + unread.push(getUnreadInfo(room)); + } + + return unread; + }, []); + return unreadInfos; +}; + +export const joinRuleToIconSrc = ( + icons: Record , + joinRule: JoinRule, + space: boolean +): IconSrc | undefined => { + if (joinRule === JoinRule.Restricted) { + return space ? icons.Space : icons.Hash; + } + if (joinRule === JoinRule.Knock) { + return space ? icons.SpaceLock : icons.HashLock; + } + if (joinRule === JoinRule.Invite) { + return space ? icons.SpaceLock : icons.HashLock; + } + if (joinRule === JoinRule.Public) { + return space ? icons.SpaceGlobe : icons.HashGlobe; + } + return undefined; +}; + +export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => { + const url = + room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ?? + undefined; + if (url) return url; + return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined; +}; + +export const parseReplyBody = (userId: string, body: string) => + `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`; + +export const parseReplyFormattedBody = ( + roomId: string, + userId: string, + eventId: string, + formattedBody: string +): string => { + const replyToLink = `In reply to`; + const userLink = `${userId}`; + + return ` `; +}; diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts new file mode 100644 index 0000000..555089d --- /dev/null +++ b/src/app/utils/sanitize.ts @@ -0,0 +1,10 @@ +export const sanitizeText = (body: string) => { + const tagsToReplace: Record ${replyToLink}${userLink}
${formattedBody}= { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag); +}; diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts new file mode 100644 index 0000000..61a903f --- /dev/null +++ b/src/app/utils/user-agent.ts @@ -0,0 +1,5 @@ +import { UAParser } from 'ua-parser-js'; + +export const ua = () => UAParser(window.navigator.userAgent); + +export const isMacOS = () => ua().os.name === 'Mac OS'; diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js index 420f315..9b8d1d8 100644 --- a/src/client/initMatrix.js +++ b/src/client/initMatrix.js @@ -23,6 +23,11 @@ class InitMatrix extends EventEmitter { } async init() { + if (this.matrixClient) { + console.warn('Client is already initialized!') + return; + } + await this.startClient(); this.setupSync(); this.listenEvents(); diff --git a/src/client/mx.ts b/src/client/mx.ts new file mode 100644 index 0000000..3090945 --- /dev/null +++ b/src/client/mx.ts @@ -0,0 +1,7 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import initMatrix from './initMatrix'; + +export const mx = (): MatrixClient => { + if (!initMatrix.matrixClient) console.error('Matrix client is used before initialization!'); + return initMatrix.matrixClient!; +}; diff --git a/src/client/state/RoomList.js b/src/client/state/RoomList.js index a157048..fc137ae 100644 --- a/src/client/state/RoomList.js +++ b/src/client/state/RoomList.js @@ -220,12 +220,6 @@ class RoomList extends EventEmitter { this.inviteRooms.clear(); this.matrixClient.getRooms().forEach((room) => { const { roomId } = room; - const tombstone = room.currentState.events.get('m.room.tombstone'); - if (tombstone?.get('') !== undefined) { - const repRoomId = tombstone.get('').getContent().replacement_room; - const repRoomMembership = this.matrixClient.getRoom(repRoomId)?.getMyMembership(); - if (repRoomMembership === 'join') return; - } if (room.getMyMembership() === 'invite') { if (this._isDMInvite(room)) this.inviteDirects.add(roomId); diff --git a/src/client/state/settings.js b/src/client/state/settings.js index 32f55fc..af2e279 100644 --- a/src/client/state/settings.js +++ b/src/client/state/settings.js @@ -1,7 +1,9 @@ +import { lightTheme } from 'folds'; import EventEmitter from 'events'; import appDispatcher from '../dispatcher'; import cons from './cons'; +import { darkTheme, butterTheme, silverTheme } from '../../colors.css'; function getSettings() { const settings = localStorage.getItem('settings'); @@ -20,6 +22,7 @@ class Settings extends EventEmitter { constructor() { super(); + this.themeClasses = [lightTheme, silverTheme, darkTheme, butterTheme]; this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme']; this.themeIndex = this.getThemeIndex(); @@ -31,6 +34,10 @@ class Settings extends EventEmitter { this._showNotifications = this.getShowNotifications(); this.isNotificationSounds = this.getIsNotificationSounds(); + this.darkModeQueryList = window.matchMedia('(prefers-color-scheme: dark)'); + + this.darkModeQueryList.addEventListener('change', () => this.applyTheme()) + this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); } @@ -49,20 +56,19 @@ class Settings extends EventEmitter { } _clearTheme() { - document.body.classList.remove('system-theme'); - this.themes.forEach((themeName) => { - if (themeName === '') return; - document.body.classList.remove(themeName); + this.themes.forEach((themeName, index) => { + if (themeName !== '') document.body.classList.remove(themeName); + document.body.classList.remove(this.themeClasses[index]); }); } applyTheme() { this._clearTheme(); - if (this.useSystemTheme) { - document.body.classList.add('system-theme'); - } else if (this.themes[this.themeIndex]) { - document.body.classList.add(this.themes[this.themeIndex]); - } + const autoThemeIndex = this.darkModeQueryList.matches ? 2 : 0; + const themeIndex = this.useSystemTheme ? autoThemeIndex : this.themeIndex; + if (this.themes[themeIndex] === undefined) return + if (this.themes[themeIndex]) document.body.classList.add(this.themes[themeIndex]); + document.body.classList.add(this.themeClasses[themeIndex]); } setTheme(themeIndex) { diff --git a/src/colors.css.ts b/src/colors.css.ts new file mode 100644 index 0000000..9b854be --- /dev/null +++ b/src/colors.css.ts @@ -0,0 +1,238 @@ +import { createTheme } from '@vanilla-extract/css'; +import { color } from 'folds'; + +export const silverTheme = createTheme(color, { + Background: { + Container: '#E6E6E6', + ContainerHover: '#DADADA', + ContainerActive: '#CECECE', + ContainerLine: '#C2C2C2', + OnContainer: '#000000', + }, + + Surface: { + Container: '#F2F2F2', + ContainerHover: '#E6E6E6', + ContainerActive: '#DADADA', + ContainerLine: '#CECECE', + OnContainer: '#000000', + }, + + SurfaceVariant: { + Container: '#E6E6E6', + ContainerHover: '#DADADA', + ContainerActive: '#CECECE', + ContainerLine: '#C2C2C2', + OnContainer: '#000000', + }, + + Primary: { + Main: '#1858D5', + MainHover: '#164FC0', + MainActive: '#144BB5', + MainLine: '#1346AA', + OnMain: '#FFFFFF', + Container: '#E8EEFB', + ContainerHover: '#DCE6F9', + ContainerActive: '#D1DEF7', + ContainerLine: '#C5D5F5', + OnContainer: '#113E95', + }, + + Secondary: { + Main: '#000000', + MainHover: '#0C0C0C', + MainActive: '#181818', + MainLine: '#303030', + OnMain: '#F2F2F2', + Container: '#CECECE', + ContainerHover: '#C2C2C2', + ContainerActive: '#B5B5B5', + ContainerLine: '#A9A9A9', + OnContainer: '#0C0C0C', + }, + + Success: { + Main: '#00844C', + MainHover: '#007744', + MainActive: '#007041', + MainLine: '#006A3D', + OnMain: '#FFFFFF', + Container: '#E5F3ED', + ContainerHover: '#D9EDE4', + ContainerActive: '#CCE6DB', + ContainerLine: '#BFE0D2', + OnContainer: '#005C35', + }, + + Warning: { + Main: '#A85400', + MainHover: '#974C00', + MainActive: '#8F4700', + MainLine: '#864300', + OnMain: '#FFFFFF', + Container: '#F6EEE5', + ContainerHover: '#F2E5D9', + ContainerActive: '#EEDDCC', + ContainerLine: '#E9D4BF', + OnContainer: '#763B00', + }, + + Critical: { + Main: '#C40E0E', + MainHover: '#AC0909', + MainActive: '#A60C0C', + MainLine: '#9C0B0B', + OnMain: '#FFFFFF', + Container: '#F9E7E7', + ContainerHover: '#F6DBDB', + ContainerActive: '#F3CFCF', + ContainerLine: '#F0C3C3', + OnContainer: '#890A0A', + }, + + Other: { + FocusRing: 'rgba(0 0 0 / 50%)', + Shadow: 'rgba(0 0 0 / 20%)', + Overlay: 'rgba(0 0 0 / 50%)', + }, +}); + +const darkThemeData = { + Background: { + Container: '#15171A', + ContainerHover: '#1F2326', + ContainerActive: '#2A2E33', + ContainerLine: '#343A40', + OnContainer: '#ffffff', + }, + + Surface: { + Container: '#1F2326', + ContainerHover: '#2A2E33', + ContainerActive: '#343A40', + ContainerLine: '#3F464D', + OnContainer: '#ffffff', + }, + + SurfaceVariant: { + Container: '#2A2E33', + ContainerHover: '#343A40', + ContainerActive: '#3F464D', + ContainerLine: '#495159', + OnContainer: '#ffffff', + }, + + Primary: { + Main: '#BDB6EC', + MainHover: '#B2AAE9', + MainActive: '#ADA3E8', + MainLine: '#A79DE6', + OnMain: '#2C2843', + Container: '#413C65', + ContainerHover: '#494370', + ContainerActive: '#50497B', + ContainerLine: '#575086', + OnContainer: '#E3E1F7', + }, + + Secondary: { + Main: '#D1E8FF', + MainHover: '#BCD1E5', + MainActive: '#B2C5D9', + MainLine: '#A7BACC', + OnMain: '#15171A', + Container: '#343A40', + ContainerHover: '#3F464D', + ContainerActive: '#495159', + ContainerLine: '#545D66', + OnContainer: '#C7DCF2', + }, + + Success: { + Main: '#85E0BA', + MainHover: '#70DBAF', + MainActive: '#66D9A9', + MainLine: '#5CD6A3', + OnMain: '#0F3D2A', + Container: '#175C3F', + ContainerHover: '#1A6646', + ContainerActive: '#1C704D', + ContainerLine: '#1F7A54', + OnContainer: '#CCF2E2', + }, + + Warning: { + Main: '#E3BA91', + MainHover: '#DFAF7E', + MainActive: '#DDA975', + MainLine: '#DAA36C', + OnMain: '#3F2A15', + Container: '#5E3F20', + ContainerHover: '#694624', + ContainerActive: '#734D27', + ContainerLine: '#7D542B', + OnContainer: '#F3E2D1', + }, + + Critical: { + Main: '#E69D9D', + MainHover: '#E28D8D', + MainActive: '#E08585', + MainLine: '#DE7D7D', + OnMain: '#401C1C', + Container: '#602929', + ContainerHover: '#6B2E2E', + ContainerActive: '#763333', + ContainerLine: '#803737', + OnContainer: '#F5D6D6', + }, + + Other: { + FocusRing: 'rgba(255, 255, 255, 0.5)', + Shadow: 'rgba(0, 0, 0, 1)', + Overlay: 'rgba(0, 0, 0, 0.6)', + }, +}; + +export const darkTheme = createTheme(color, darkThemeData); + +export const butterTheme = createTheme(color, { + ...darkThemeData, + Background: { + Container: '#1A1916', + ContainerHover: '#262621', + ContainerActive: '#33322C', + ContainerLine: '#403F38', + OnContainer: '#FFFBDE', + }, + + Surface: { + Container: '#262621', + ContainerHover: '#33322C', + ContainerActive: '#403F38', + ContainerLine: '#4D4B43', + OnContainer: '#FFFBDE', + }, + + SurfaceVariant: { + Container: '#33322C', + ContainerHover: '#403F38', + ContainerActive: '#4D4B43', + ContainerLine: '#59584E', + OnContainer: '#FFFBDE', + }, + + Secondary: { + Main: '#FFFBDE', + MainHover: '#E5E2C8', + MainActive: '#D9D5BD', + MainLine: '#CCC9B2', + OnMain: '#1A1916', + Container: '#403F38', + ContainerHover: '#4D4B43', + ContainerActive: '#59584E', + ContainerLine: '#666459', + OnContainer: '#F2EED3', + }, +}); diff --git a/src/ext.d.ts b/src/ext.d.ts new file mode 100644 index 0000000..55f5932 --- /dev/null +++ b/src/ext.d.ts @@ -0,0 +1,23 @@ +declare module 'browser-encrypt-attachment' { + export interface EncryptedAttachmentInfo { + v: string; + key: { + alg: string; + key_ops: string[]; + kty: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: { + [alg: string]: string; + }; + } + + export interface EncryptedAttachment { + data: ArrayBuffer; + info: EncryptedAttachmentInfo; + } + + export function encryptAttachment(dataBuffer: ArrayBuffer): Promise ; +} diff --git a/src/index.jsx b/src/index.jsx index a252f6f..e7256e2 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,5 +1,13 @@ +/* eslint-disable import/first */ import React from 'react'; import ReactDom from 'react-dom'; +import { enableMapSet } from 'immer'; +import '@fontsource/inter/variable.css'; +import 'folds/dist/style.css'; +import { configClass, varsClass } from 'folds'; + +enableMapSet(); + import './font'; import './index.scss'; @@ -7,6 +15,8 @@ import settings from './client/state/settings'; import App from './app/pages/App'; +document.body.classList.add(configClass, varsClass); + settings.applyTheme(); ReactDom.render( , document.getElementById('root')); diff --git a/src/index.scss b/src/index.scss index 39d0612..93443fe 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,14 +1,20 @@ @use './app/partials/screen'; -:root { +@font-face { + font-family: Twemoji; + src: url('../public/font/Twemoji.Mozilla.v.7.0.woff2'), + url('../public/font/Twemoji.Mozilla.v0.7.0.ttf'); + font-display: swap; +} +:root { /* background color | --bg-[background type]: value */ - --bg-surface: #FFFFFF; - --bg-surface-transparent: #FFFFFF00; - --bg-surface-low: #F6F6F6; - --bg-surface-low-transparent: #F6F6F600; - --bg-surface-extra-low: #F6F6F6; - --bg-surface-extra-low-transparent: #F6F6F600; + --bg-surface: #ffffff; + --bg-surface-transparent: #ffffff00; + --bg-surface-low: #f6f6f6; + --bg-surface-low-transparent: #f6f6f600; + --bg-surface-extra-low: #f6f6f6; + --bg-surface-extra-low-transparent: #f6f6f600; --bg-surface-hover: rgba(0, 0, 0, 3%); --bg-surface-active: rgba(0, 0, 0, 5%); --bg-surface-border: rgba(0, 0, 0, 6%); @@ -22,7 +28,7 @@ --bg-positive-hover: rgba(69, 184, 59, 8%); --bg-positive-active: rgba(69, 184, 59, 15%); --bg-positive-border: rgba(69, 184, 59, 40%); - + --bg-caution: rgb(255, 179, 0); --bg-caution-hover: rgba(255, 179, 0, 8%); --bg-caution-active: rgba(255, 179, 0, 15%); @@ -37,18 +43,18 @@ --bg-badge: #989898; --bg-ping: hsla(137deg, 100%, 68%, 40%); --bg-ping-hover: hsla(137deg, 100%, 68%, 50%); - --bg-divider: hsla(0, 0%, 0%, .1); + --bg-divider: hsla(0, 0%, 0%, 0.1); /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: #000000; --tc-surface-normal: rgba(0, 0, 0, 78%); --tc-surface-normal-low: rgba(0, 0, 0, 60%); --tc-surface-low: rgba(0, 0, 0, 48%); - + --tc-primary-high: #ffffff; --tc-primary-normal: rgba(255, 255, 255, 68%); --tc-primary-low: rgba(255, 255, 255, 40%); - + --tc-positive-high: var(--bg-positive); --tc-positive-normal: rgb(69, 184, 59, 80%); --tc-positive-low: rgb(69, 184, 59, 60%); @@ -56,7 +62,7 @@ --tc-caution-high: var(--bg-caution); --tc-caution-normal: rgb(255, 179, 0, 80%); --tc-caution-low: rgb(255, 179, 0, 60%); - + --tc-danger-high: var(--bg-danger); --tc-danger-normal: rgba(240, 71, 71, 88%); --tc-danger-low: rgba(240, 71, 71, 60%); @@ -66,7 +72,6 @@ --tc-tooltip: white; --tc-badge: white; - /* system icons | --ic-[background type]-[priority]: value */ --ic-surface-high: #272727; --ic-surface-normal: #626262; @@ -102,7 +107,6 @@ --av-small: 36px; --av-extra-small: 24px; - /* shadow and overlay */ --bg-overlay: rgba(0, 0, 0, 20%); --bg-overlay-low: rgba(0, 0, 0, 50%); @@ -124,11 +128,9 @@ --bs-danger-border: inset 0 0 0 1px var(--bg-danger-border); --bs-danger-outline: 0 0 0 2px var(--bg-danger-border); - /* border */ --bo-radius: 8px; - /* font styles: font-size, letter-spacing, line-hight */ --fs-h1: 36px; --ls-h1: -1.5px; @@ -160,7 +162,6 @@ --fw-medium: 500; --fw-bold: 700; - /* spacing | --sp-[space]: value */ --sp-none: 0px; --sp-ultra-tight: 4px; @@ -170,7 +171,6 @@ --sp-loose: 20px; --sp-extra-loose: 32px; - /* other */ --border-width: 1px; --header-height: 54px; @@ -180,7 +180,7 @@ --people-drawer-width: calc(268px - var(--border-width)); --popup-window-drawer-width: 280px; - + @include screen.smallerThan(tabletBreakpoint) { --navigation-drawer-width: calc(240px + var(--border-width)); --people-drawer-width: calc(256px - var(--border-width)); @@ -191,11 +191,11 @@ --fluid-push: cubic-bezier(0, 0.8, 0.67, 0.97); --fluid-slide-down: cubic-bezier(0.02, 0.82, 0.4, 0.96); --fluid-slide-up: cubic-bezier(0.13, 0.56, 0.25, 0.99); - - --font-primary: 'Roboto', sans-serif; - --font-secondary: 'Roboto', sans-serif; -} + --font-emoji: 'Twemoji'; + --font-primary: 'Roboto', var(--font-emoji), sans-serif; + --font-secondary: 'Roboto', var(--font-emoji), sans-serif; +} .silver-theme { /* background color | --bg-[background type]: value */ @@ -207,7 +207,8 @@ --bg-surface-extra-low-transparent: hsla(0, 0%, 91%, 0); } -@mixin dark-mode() { +.dark-theme, +.butter-theme { /* background color | --bg-[background type]: value */ --bg-surface: hsl(208, 8%, 20%); --bg-surface-transparent: hsla(208, 8%, 20%, 0); @@ -228,15 +229,14 @@ --bg-badge: hsl(0, 0%, 75%); --bg-ping: hsla(137deg, 100%, 38%, 40%); --bg-ping-hover: hsla(137deg, 100%, 38%, 50%); - --bg-divider: hsla(0, 0%, 100%, .1); - + --bg-divider: hsla(0, 0%, 100%, 0.1); /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: rgba(255, 255, 255, 98%); --tc-surface-normal: rgba(255, 255, 255, 94%); --tc-surface-normal-low: rgba(255, 255, 255, 60%); --tc-surface-low: rgba(255, 255, 255, 58%); - + --tc-primary-high: #ffffff; --tc-primary-normal: rgba(255, 255, 255, 0.68); --tc-primary-low: rgba(255, 255, 255, 0.4); @@ -262,7 +262,7 @@ --mx-uc-7: hsl(243, 100%, 74%); --mx-uc-8: hsl(94, 66%, 50%); } - + /* shadow and overlay */ --bg-overlay: rgba(0, 0, 0, 60%); --bg-overlay-low: rgba(0, 0, 0, 80%); @@ -274,7 +274,7 @@ --bs-primary-border: inset 0 0 0 1px var(--bg-primary-border); --bs-primary-outline: 0 0 0 2px var(--bg-primary-border); - + /* font styles: font-size, letter-spacing, line-hight */ --fs-h1: 35.6px; @@ -292,18 +292,7 @@ /* override normal font weight for dark mode */ --fw-normal: 350; - --font-secondary: 'InterVariable', 'Roboto', sans-serif; -} - -.dark-theme, -.butter-theme { - @include dark-mode(); -} - -@media (prefers-color-scheme: dark) { - .system-theme { - @include dark-mode(); - } + --font-secondary: 'InterVariable', 'Roboto', var(--font-emoji), sans-serif; } .butter-theme { @@ -317,14 +306,12 @@ --bg-badge: #c4c1ab; - /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: rgb(255, 251, 222, 94%); --tc-surface-normal: rgba(255, 251, 222, 94%); - --tc-surface-normal-low: rgba(255, 251, 222, 60%); + --tc-surface-normal-low: rgba(255, 251, 222, 60%); --tc-surface-low: rgba(255, 251, 222, 58%); - /* system icons | --ic-[background type]-[priority]: value */ --ic-surface-high: rgb(255, 251, 222); --ic-surface-normal: rgba(255, 251, 222, 84%); @@ -387,9 +374,11 @@ body { height: 100%; } -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; - -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-tap-highlight-color: transparent; } a { @@ -428,16 +417,16 @@ button { textarea, input, input[type], -input[type=text], -input[type=username], -input[type=password], -input[type=email], -input[type=checkbox] { +input[type='text'], +input[type='username'], +input[type='password'], +input[type='email'], +input[type='checkbox'] { -webkit-appearance: none; -moz-appearance: none; appearance: none; } -input[type=checkbox] { +input[type='checkbox'] { margin: 0; padding: 0; width: 20px; @@ -451,7 +440,7 @@ input[type=checkbox] { &:checked { background-color: var(--bg-primary); &::before { - content: ""; + content: ''; display: inline-block; width: 12px; height: 6px; @@ -468,11 +457,11 @@ textarea { } .noselect { -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Old versions of Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ } @@ -484,4 +473,4 @@ audio:not([controls]) { display: flex; justify-content: center; align-items: center; -} \ No newline at end of file +} diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts new file mode 100644 index 0000000..1078cb3 --- /dev/null +++ b/src/types/matrix/accountData.ts @@ -0,0 +1,12 @@ +export enum AccountDataEvent { + PushRules = 'm.push_rules', + Direct = 'm.direct', + IgnoredUserList = 'm.ignored_user_list', + + CinnySpaces = 'in.cinny.spaces', + + ElementRecentEmoji = 'io.element.recent_emoji', + + PoniesUserEmotes = 'im.ponies.user_emotes', + PoniesEmoteRooms = 'im.ponies.emote_rooms', +} diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts new file mode 100644 index 0000000..94a46a9 --- /dev/null +++ b/src/types/matrix/common.ts @@ -0,0 +1,22 @@ +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; + +export type IImageInfo = { + w?: number; + h?: number; + mimetype?: string; + size?: number; +}; + +export type IVideoInfo = IImageInfo & { + duration?: number; +}; + +export type IEncryptedFile = EncryptedAttachmentInfo & { + url: string; +}; + +export type IThumbnailContent = { + thumbnail_info?: IImageInfo; + thumbnail_file?: IEncryptedFile; + thumbnail_url?: string; +}; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts new file mode 100644 index 0000000..93e8761 --- /dev/null +++ b/src/types/matrix/room.ts @@ -0,0 +1,61 @@ +export enum Membership { + Invite = 'invite', + Knock = 'knock', + Join = 'join', + Leave = 'leave', + Ban = 'ban', +} + +export enum StateEvent { + RoomCanonicalAlias = 'm.room.canonical_alias', + RoomCreate = 'm.room.create', + RoomJoinRules = 'm.room.join_rules', + RoomMember = 'm.room.member', + RoomThirdPartyInvite = 'm.room.third_party_invite', + RoomPowerLevels = 'm.room.power_levels', + RoomName = 'm.room.name', + RoomTopic = 'm.room.topic', + RoomAvatar = 'm.room.avatar', + RoomPinnedEvents = 'm.room.pinned_events', + RoomEncryption = 'm.room.encryption', + RoomHistoryVisibility = 'm.room.history_visibility', + RoomGuestAccess = 'm.room.guest_access', + RoomServerAcl = 'm.room.server_acl', + RoomTombstone = 'm.room.tombstone', + + SpaceChild = 'm.space.child', + SpaceParent = 'm.space.parent', + + PoniesRoomEmotes = 'im.ponies.room_emotes', +} + +export enum RoomType { + Space = 'm.space', +} + +export enum NotificationType { + Default = 'default', + AllMessages = 'all_messages', + MentionsAndKeywords = 'mentions_and_keywords', + Mute = 'mute', +} + +export type RoomToParents = Map >; +export type RoomToUnread = Map< + string, + { + total: number; + highlight: number; + from: Set | null; + } +>; +export type UnreadInfo = { + roomId: string; + total: number; + highlight: number; +}; + +export type MuteChanges = { + added: string[]; + removed: string[]; +}; diff --git a/src/util/sanitize.js b/src/util/sanitize.js index 79cc041..3723a11 100644 --- a/src/util/sanitize.js +++ b/src/util/sanitize.js @@ -6,7 +6,7 @@ let mx = null; const permittedHtmlTags = [ 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', - 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', + 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 's', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption', 'pre', 'span', 'img', 'details', 'summary', ]; diff --git a/tsconfig.json b/tsconfig.json index e109a97..02eb184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,9 @@ "compilerOptions": { "sourceMap": true, "jsx": "react", - "target": "ES6", + "target": "ES2016", "allowJs": true, + "strict": true, "esModuleInterop": true, "moduleResolution": "Node", "outDir": "dist", diff --git a/vite.config.js b/vite.config.js index 979e9aa..6a44316 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { wasm } from '@rollup/plugin-wasm'; import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; import inject from '@rollup/plugin-inject'; import { svgLoader } from './viteSvgLoader'; @@ -37,6 +38,7 @@ export default defineConfig({ }, plugins: [ viteStaticCopy(copyFiles), + vanillaExtractPlugin(), svgLoader(), wasm(), react(),