mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	URL navigation in interface and other improvements (#1633)
* load room on url change * add direct room list * render space room list * fix css syntax error * update scroll virtualizer * render subspaces room list * improve sidebar notification badge perf * add nav category components * add space recursive direct component * use nav category component in home, direct and space room list * add empty home and direct list layout * fix unread room menu ref * add more navigation items in room, direct and space tab * add more navigation * fix unread room menu to links * fix space lobby and search link * add explore navigation section * add notifications navigation menu * redirect to initial path after login * include unsupported room in rooms * move router hooks in hooks/router folder * add featured explore - WIP * load featured room with room summary * fix room card topic line clamp * add react query * load room summary using react query * add join button in room card * add content component * use content component in featured community content * fix content width * add responsive room card grid * fix async callback error status * add room card error button * fix client drawer shrink * add room topic viewer * open room card topic in viewer * fix room topic close btn * add get orphan parent util * add room card error dialog * add view featured room or space btn * refactor orphanParent to orphanParents * WIP - explore server * show space hint in room card * add room type filters * add per page item limit popout * reset scroll on public rooms load * refactor explore ui * refactor public rooms component * reset search on server change * fix typo * add empty featured section info * display user server on top * make server room card view btn clickable * add user server as default redirect for explore path * make home empty btn clickable * add thirdparty instance filter in server explore * remove since param on instance change * add server button in explore menu * rename notifications path to inbox * update react-virtual * Add notification messages inbox - WIP * add scroll top container component * add useInterval hook * add visibility change callback prop to scroll top container component * auto refresh notifications every 10 seconds * make message related component reusable * refactor matrix event renderer hoook * render notification message content * refactor matrix event renderer hook * update sequence card styles * move room navigate hook in global hooks * add open message button in notifications * add mark room as read button in notification group * show error in notification messages * add more featured spaces * render reply in notification messages * make notification message reply clickable * add outline prop for attachments * make old settings dialog viewable * add open featured communities as default config option * add invite count notification badge in sidebar and inbox menu * add element size observer hook * improve element size observer hook props * improve screen size hook * fix room avatar util function * allow Text props in Time component * fix dm room util function * add invitations * add no invites and notification cards * fix inbox tab unread badge visible without invite count * update folds and change inbox icon * memo search param construction * add message search in home * fix default message search order * fix display edited message new content * highlight search text in search messages * fix message search loading * disable log in production * add use space context * add useRoom context * fix space room list * fix inbox tab active state * add hook to get space child room recursive * add search for space * add virtual tile component * virtualize home and directs room list * update nav category component * use virtual tile component in more places * fix message highlight when click on reply twice * virtualize space room list * fix space room list lag issue * update folds * add room nav item component in space room list * use room nav item in home and direct room list * make space categories closable and save it in local storage * show unread room when category is collapsed * make home and direct room list category closable * rename room nav item show avatar prop * fix explore server category text alignment * rename closedRoomCategories to closedNavCategories * add nav category handler hook * save and restore last navigation path on space select * filter space rooms category by activity when it is closed * save and restore home and direct nav path state * save and restore inbox active path on open * save and restore explore tab active path * remove notification badge unread menu * add join room or space before navigate screen * move room component to features folder and add new room header * update folds * add room header menu * fix home room list activity sorting * do not hide selected room item on category closed in home and direct tab * replace old select room/tab call with navigate hook * improve state event hooks * show room card summary for joined rooms * prevent room from opening in wrong tab * only show message sender id on hover in modern layout * revert state event hooks changes * add key prop to room provider components * add welcome page * prevent excessive redirects * fix sidebar style with no spaces * move room settings in popup window * remove invite option from room settings * fix open room list search * add leave room prompt * standardize room and user avatar * fix avatar text size * add new reply layout * rename space hierarchy hook * add room topic hook * add room name hook * add room avatar hook and add direct room avatar util * space lobby - WIP * hide invalid space child event from space hierarchy in lobby * move lobby to features * fix element size observer hook width and height * add lobby header and hero section * add hierarchy room item error and loading state * add first and last child prop in sequence card * redirect to lobby from index path * memo and retry hierarchy room summary error * fix hierarchy room item styles * rename lobby hierarchy item card to room item card * show direct room avatar in space lobby * add hierarchy space item * add space item unknown room join button * fix space hierarchy hook refresh after new space join * change user avatar color and fallback render to user icon * change room avatar fallback to room icon * rename room/user avatar renderInitial prop to renderFallback * add room join and view button in space lobby * make power level api more reusable * fix space hierarchy not updating on child update * add menu to suggest or remove space children * show reply arrow in place of reply bend in message * fix typeerror in search because of wrong js-sdk t.ds * do not refetch hierarchy room summary on window focus * make room/user avatar un-draggable * change welcome page support button copy * drag-and-drop ordering of lobby spaces/rooms - WIP * add ASCIILexicalTable algorithms * fix wrong power level check in lobby items options * fix lobby can drop checks * fix join button error crash * fix reply spacing * fix m direct updated with other account data * add option to open room/space settings from lobby * add option in lobby to add new or existing room/spaces * fix room nav item selected styles * add space children reorder mechanism * fix space child reorder bug * fix hierarchy item sort function * Apply reorder of lobby into room list * add and improve space lobby menu items * add existing spaces menu in lobby * change restricted room allow params when dragging outside space * move featured servers config from homeserver list * removed unused features from space settings * add canonical alias as name fallback in lobby item * fix unreliable unread count update bug * fix after login redirect * fix room card topic hover style * Add dnd and folders in sidebar spaces * fix orphan space not visible in sidebar * fix sso login has mix of icon and button * fix space children not visible in home upon leaving space * recalculate notification on updating any space child * fix user color saturation/lightness * add user color to user avatar * add background colors to room avatar * show 2 length initial in sidebar space avatar * improve link color * add nav button component * open legacy create room and create direct * improve page route structure * handle hash router in path utils * mobile friendly router and navigation * make room header member drawer icon mobile friendly * setup index redirect for inbox and explore server route * add leave space prompt * improve member drawer filter menu * add space context menu * add context menu in home * add leave button in lobby items * render user tab avatar on sidebar * force overwrite netlify - test * netlify test * fix reset-password path without server redirected to login * add message link copy button in message menu * reset unread on sync prepared * fix stuck typing notifications * show typing indication in room nav item * refactor closedNavCategories atom to use userId in store key * refactor closedLobbyCategoriesAtom to include userId in store key * refactor navToActivePathAtom to use userId in storage key * remove unused file * refactor openedSidebarFolderAtom to include userId in storage key * add context menu for sidebar space tab * fix eslint not working * add option to pin/unpin child spaces * add context menu for directs tab * add context menu for direct and home tab * show lock icon for non-public space in header * increase matrix max listener count * wrap lobby add space room in callback hook
This commit is contained in:
		
							parent
							
								
									2b7d825694
								
							
						
					
					
						commit
						4c76a7fd18
					
				
					 290 changed files with 17447 additions and 3224 deletions
				
			
		
							
								
								
									
										20
									
								
								config.json
									
										
									
									
									
								
							
							
						
						
									
										20
									
								
								config.json
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -10,6 +10,26 @@
 | 
			
		|||
  ],
 | 
			
		||||
  "allowCustomHomeservers": true,
 | 
			
		||||
 | 
			
		||||
  "featuredCommunities": {
 | 
			
		||||
    "openAsDefault": false,
 | 
			
		||||
    "spaces": [
 | 
			
		||||
      "#cinny-space:matrix.org",
 | 
			
		||||
      "#community:matrix.org",
 | 
			
		||||
      "#space:envs.net",
 | 
			
		||||
      "#science-space:matrix.org",
 | 
			
		||||
      "#libregaming-games:tchncs.de",
 | 
			
		||||
      "#mathematics-on:matrix.org"
 | 
			
		||||
    ],
 | 
			
		||||
    "rooms": [
 | 
			
		||||
      "#cinny:matrix.org",
 | 
			
		||||
      "#foundation-office:matrix.org",
 | 
			
		||||
      "#thisweekinmatrix:matrix.org",
 | 
			
		||||
      "#matrix-dev:matrix.org",
 | 
			
		||||
      "#matrix:matrix.org"
 | 
			
		||||
    ],
 | 
			
		||||
    "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  "hashRouter": {
 | 
			
		||||
    "enabled": false,
 | 
			
		||||
    "basename": "/"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,9 +9,10 @@
 | 
			
		|||
  status = 200
 | 
			
		||||
  
 | 
			
		||||
[[redirects]]
 | 
			
		||||
  from = "/olm.wasm"
 | 
			
		||||
  from = "*/olm.wasm"
 | 
			
		||||
  to = "/olm.wasm"
 | 
			
		||||
  status = 200
 | 
			
		||||
  force = true
 | 
			
		||||
  
 | 
			
		||||
[[redirects]]
 | 
			
		||||
  from = "/pdf.worker.min.js"
 | 
			
		||||
| 
						 | 
				
			
			@ -31,4 +32,5 @@
 | 
			
		|||
[[redirects]]
 | 
			
		||||
  from = "/*"
 | 
			
		||||
  to = "/index.html"
 | 
			
		||||
  status = 200
 | 
			
		||||
  status = 200
 | 
			
		||||
  force = true
 | 
			
		||||
							
								
								
									
										121
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										121
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -9,10 +9,15 @@
 | 
			
		|||
      "version": "3.2.0",
 | 
			
		||||
      "license": "AGPL-3.0-only",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
        "@fontsource/inter": "4.5.14",
 | 
			
		||||
        "@khanacademy/simple-markdown": "0.8.6",
 | 
			
		||||
        "@matrix-org/olm": "3.2.14",
 | 
			
		||||
        "@tanstack/react-virtual": "3.0.0-beta.54",
 | 
			
		||||
        "@tanstack/react-query": "5.24.1",
 | 
			
		||||
        "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
        "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
        "@tippyjs/react": "4.2.6",
 | 
			
		||||
        "@vanilla-extract/css": "1.9.3",
 | 
			
		||||
        "@vanilla-extract/recipes": "0.3.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +34,7 @@
 | 
			
		|||
        "file-saver": "2.0.5",
 | 
			
		||||
        "flux": "4.0.3",
 | 
			
		||||
        "focus-trap-react": "10.0.2",
 | 
			
		||||
        "folds": "1.5.1",
 | 
			
		||||
        "folds": "2.0.0",
 | 
			
		||||
        "formik": "2.2.9",
 | 
			
		||||
        "html-dom-parser": "4.0.0",
 | 
			
		||||
        "html-react-parser": "4.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -111,6 +116,34 @@
 | 
			
		|||
        "node": ">=6.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@atlaskit/pragmatic-drag-and-drop": {
 | 
			
		||||
      "version": "1.1.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.6.tgz",
 | 
			
		||||
      "integrity": "sha512-+jGspaRMyHWB6g9w+N1KImS5I+xt0ML89pwUyCueEhf2KGsl6zyH9ZxjTVKfrbY89FyZvuuXT9oFRHTUKGBi/w==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@babel/runtime": "^7.0.0",
 | 
			
		||||
        "bind-event-listener": "^3.0.0",
 | 
			
		||||
        "raf-schd": "^4.0.3"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": {
 | 
			
		||||
      "version": "1.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-1.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-8wjKAI5qSrLojt8ZJ2WhoS5P75oBu5g0yMpAnTDgfqFyQnkt5Uc1txCRWpG26SS1mv19nm8ak9XHF2DOugVfpw==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
 | 
			
		||||
        "@babel/runtime": "^7.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": {
 | 
			
		||||
      "version": "1.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop": "^1.1.0",
 | 
			
		||||
        "@babel/runtime": "^7.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@babel/code-frame": {
 | 
			
		||||
      "version": "7.23.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -3093,25 +3126,75 @@
 | 
			
		|||
        "@swc/counter": "^0.1.3"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-virtual": {
 | 
			
		||||
      "version": "3.0.0-beta.54",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.54.tgz",
 | 
			
		||||
      "integrity": "sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==",
 | 
			
		||||
    "node_modules/@tanstack/query-core": {
 | 
			
		||||
      "version": "5.24.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz",
 | 
			
		||||
      "integrity": "sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/query-devtools": {
 | 
			
		||||
      "version": "5.24.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.24.0.tgz",
 | 
			
		||||
      "integrity": "sha512-pThim455t69zrZaQKa7IRkEIK8UBTS+gHVAdNfhO72Xh4rWpMc63ovRje5/n6iw63+d6QiJzVadsJVdPoodSeQ==",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-query": {
 | 
			
		||||
      "version": "5.24.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.24.1.tgz",
 | 
			
		||||
      "integrity": "sha512-4+09JEdO4d6+Gc8Y/g2M/MuxDK5IY0QV8+2wL2304wPKJgJ54cBbULd3nciJ5uvh/as8rrxx6s0mtIwpRuGd1g==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/virtual-core": "3.0.0-beta.54"
 | 
			
		||||
        "@tanstack/query-core": "5.24.1"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
 | 
			
		||||
        "react": "^18.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-query-devtools": {
 | 
			
		||||
      "version": "5.24.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.24.1.tgz",
 | 
			
		||||
      "integrity": "sha512-qa4SEugN+EF8JJXcpsM9Lu05HfUv5cvHvLuB0uw/81eJZyNHFdtHFBi5RLCgpBrOyVMDfH8UQ3VBMqXzFKV68A==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/query-devtools": "5.24.0"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@tanstack/react-query": "^5.24.1",
 | 
			
		||||
        "react": "^18.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-virtual": {
 | 
			
		||||
      "version": "3.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/virtual-core": "3.2.0"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
 | 
			
		||||
        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/virtual-core": {
 | 
			
		||||
      "version": "3.0.0-beta.54",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.54.tgz",
 | 
			
		||||
      "integrity": "sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==",
 | 
			
		||||
      "version": "3.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
| 
						 | 
				
			
			@ -4065,6 +4148,11 @@
 | 
			
		|||
        "node": ">=8"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/bind-event-listener": {
 | 
			
		||||
      "version": "3.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/blurhash": {
 | 
			
		||||
      "version": "2.0.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5665,9 +5753,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/folds": {
 | 
			
		||||
      "version": "1.5.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.1.tgz",
 | 
			
		||||
      "integrity": "sha512-2QxyA+FRKjPKXDTMDoD7NmOUiReWrKYO0Msg44QqlzTkTrRVEzJgyPIfC/Ia4/u0ByQpk6dbq8UQxomKmneJ/g==",
 | 
			
		||||
      "version": "2.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/folds/-/folds-2.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-lKv31vij4GEpEzGKWk5c3ar78fMZ9Di5n1XFR14Z2wnnpqhiiM5JTIzr127Gk5dOfy4mJkjnv/ZfMZvM2k+OQg==",
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@vanilla-extract/css": "^1.9.2",
 | 
			
		||||
        "@vanilla-extract/recipes": "^0.3.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -7580,6 +7668,11 @@
 | 
			
		|||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/raf-schd": {
 | 
			
		||||
      "version": "4.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react": {
 | 
			
		||||
      "version": "18.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,10 +20,15 @@
 | 
			
		|||
  "author": "Ajay Bura",
 | 
			
		||||
  "license": "AGPL-3.0-only",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
 | 
			
		||||
    "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
    "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
    "@fontsource/inter": "4.5.14",
 | 
			
		||||
    "@khanacademy/simple-markdown": "0.8.6",
 | 
			
		||||
    "@matrix-org/olm": "3.2.14",
 | 
			
		||||
    "@tanstack/react-virtual": "3.0.0-beta.54",
 | 
			
		||||
    "@tanstack/react-query": "5.24.1",
 | 
			
		||||
    "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
    "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
    "@tippyjs/react": "4.2.6",
 | 
			
		||||
    "@vanilla-extract/css": "1.9.3",
 | 
			
		||||
    "@vanilla-extract/recipes": "0.3.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +45,7 @@
 | 
			
		|||
    "file-saver": "2.0.5",
 | 
			
		||||
    "flux": "4.0.3",
 | 
			
		||||
    "focus-trap-react": "10.0.2",
 | 
			
		||||
    "folds": "1.5.1",
 | 
			
		||||
    "folds": "2.0.0",
 | 
			
		||||
    "formik": "2.2.9",
 | 
			
		||||
    "html-dom-parser": "4.0.0",
 | 
			
		||||
    "html-react-parser": "4.2.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										36
									
								
								src/app/components/CapabilitiesAndMediaConfigLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/app/components/CapabilitiesAndMediaConfigLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
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(true), 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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								src/app/components/CapabilitiesLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/components/CapabilitiesLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { Capabilities } from 'matrix-js-sdk';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
 | 
			
		||||
type CapabilitiesLoaderProps = {
 | 
			
		||||
  children: (capabilities: Capabilities | undefined) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
  }, [load]);
 | 
			
		||||
 | 
			
		||||
  return children(state.status === AsyncStatus.Success ? state.data : undefined);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								src/app/components/MediaConfigLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/components/MediaConfigLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { MediaConfig } from '../hooks/useMediaConfig';
 | 
			
		||||
 | 
			
		||||
type MediaConfigLoaderProps = {
 | 
			
		||||
  children: (mediaConfig: MediaConfig | undefined) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
  }, [load]);
 | 
			
		||||
 | 
			
		||||
  return children(state.status === AsyncStatus.Success ? state.data : undefined);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
/* eslint-disable no-param-reassign */
 | 
			
		||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
 | 
			
		||||
import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
| 
						 | 
				
			
			@ -13,6 +13,7 @@ import {
 | 
			
		|||
  Input,
 | 
			
		||||
  Menu,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +49,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
			
		|||
    const isError =
 | 
			
		||||
      pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
 | 
			
		||||
    const [pageNo, setPageNo] = useState(1);
 | 
			
		||||
    const [openJump, setOpenJump] = useState(false);
 | 
			
		||||
    const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      loadPdfJS();
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +87,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
			
		|||
      if (!jumpInput) return;
 | 
			
		||||
      const jumpTo = parseInt(jumpInput.value, 10);
 | 
			
		||||
      setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
 | 
			
		||||
      setOpenJump(false);
 | 
			
		||||
      setJumpAnchor(undefined);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handlePrevPage = () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -98,6 +99,10 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
			
		|||
      setPageNo((n) => Math.min(n + 1, docState.data.numPages));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
      setJumpAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
 | 
			
		||||
        <Header className={css.PdfViewerHeader} size="400">
 | 
			
		||||
| 
						 | 
				
			
			@ -187,14 +192,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
			
		|||
            </Chip>
 | 
			
		||||
            <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
 | 
			
		||||
              <PopOut
 | 
			
		||||
                open={openJump}
 | 
			
		||||
                anchor={jumpAnchor}
 | 
			
		||||
                align="Center"
 | 
			
		||||
                position="Top"
 | 
			
		||||
                content={
 | 
			
		||||
                  <FocusTrap
 | 
			
		||||
                    focusTrapOptions={{
 | 
			
		||||
                      initialFocus: false,
 | 
			
		||||
                      onDeactivate: () => setOpenJump(false),
 | 
			
		||||
                      onDeactivate: () => setJumpAnchor(undefined),
 | 
			
		||||
                      clickOutsideDeactivates: true,
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
| 
						 | 
				
			
			@ -227,17 +232,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
			
		|||
                  </FocusTrap>
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {(anchorRef) => (
 | 
			
		||||
                  <Chip
 | 
			
		||||
                    onClick={() => setOpenJump(!openJump)}
 | 
			
		||||
                    ref={anchorRef}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    aria-pressed={openJump}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
 | 
			
		||||
                  </Chip>
 | 
			
		||||
                )}
 | 
			
		||||
                <Chip
 | 
			
		||||
                  onClick={handleOpenJump}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-pressed={jumpAnchor !== undefined}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              </PopOut>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Chip
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										245
									
								
								src/app/components/RenderMessageContent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								src/app/components/RenderMessageContent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,245 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { MsgType } from 'matrix-js-sdk';
 | 
			
		||||
import { HTMLReactParserOptions } from 'html-react-parser';
 | 
			
		||||
import {
 | 
			
		||||
  AudioContent,
 | 
			
		||||
  DownloadFile,
 | 
			
		||||
  FileContent,
 | 
			
		||||
  ImageContent,
 | 
			
		||||
  MAudio,
 | 
			
		||||
  MBadEncrypted,
 | 
			
		||||
  MEmote,
 | 
			
		||||
  MFile,
 | 
			
		||||
  MImage,
 | 
			
		||||
  MLocation,
 | 
			
		||||
  MNotice,
 | 
			
		||||
  MText,
 | 
			
		||||
  MVideo,
 | 
			
		||||
  ReadPdfFile,
 | 
			
		||||
  ReadTextFile,
 | 
			
		||||
  RenderBody,
 | 
			
		||||
  ThumbnailContent,
 | 
			
		||||
  UnsupportedContent,
 | 
			
		||||
  VideoContent,
 | 
			
		||||
} from './message';
 | 
			
		||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
 | 
			
		||||
import { Image, MediaControl, Video } from './media';
 | 
			
		||||
import { ImageViewer } from './image-viewer';
 | 
			
		||||
import { PdfViewer } from './Pdf-viewer';
 | 
			
		||||
import { TextViewer } from './text-viewer';
 | 
			
		||||
 | 
			
		||||
type RenderMessageContentProps = {
 | 
			
		||||
  displayName: string;
 | 
			
		||||
  msgType: string;
 | 
			
		||||
  ts: number;
 | 
			
		||||
  edited?: boolean;
 | 
			
		||||
  getContent: <T>() => T;
 | 
			
		||||
  mediaAutoLoad?: boolean;
 | 
			
		||||
  urlPreview?: boolean;
 | 
			
		||||
  highlightRegex?: RegExp;
 | 
			
		||||
  htmlReactParserOptions: HTMLReactParserOptions;
 | 
			
		||||
  outlineAttachment?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function RenderMessageContent({
 | 
			
		||||
  displayName,
 | 
			
		||||
  msgType,
 | 
			
		||||
  ts,
 | 
			
		||||
  edited,
 | 
			
		||||
  getContent,
 | 
			
		||||
  mediaAutoLoad,
 | 
			
		||||
  urlPreview,
 | 
			
		||||
  highlightRegex,
 | 
			
		||||
  htmlReactParserOptions,
 | 
			
		||||
  outlineAttachment,
 | 
			
		||||
}: RenderMessageContentProps) {
 | 
			
		||||
  const renderFile = () => (
 | 
			
		||||
    <MFile
 | 
			
		||||
      content={getContent()}
 | 
			
		||||
      renderFileContent={({ body, mimeType, info, encInfo, url }) => (
 | 
			
		||||
        <FileContent
 | 
			
		||||
          body={body}
 | 
			
		||||
          mimeType={mimeType}
 | 
			
		||||
          renderAsPdfFile={() => (
 | 
			
		||||
            <ReadPdfFile
 | 
			
		||||
              body={body}
 | 
			
		||||
              mimeType={mimeType}
 | 
			
		||||
              url={url}
 | 
			
		||||
              encInfo={encInfo}
 | 
			
		||||
              renderViewer={(p) => <PdfViewer {...p} />}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          renderAsTextFile={() => (
 | 
			
		||||
            <ReadTextFile
 | 
			
		||||
              body={body}
 | 
			
		||||
              mimeType={mimeType}
 | 
			
		||||
              url={url}
 | 
			
		||||
              encInfo={encInfo}
 | 
			
		||||
              renderViewer={(p) => <TextViewer {...p} />}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
 | 
			
		||||
        </FileContent>
 | 
			
		||||
      )}
 | 
			
		||||
      outlined={outlineAttachment}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Text) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MText
 | 
			
		||||
        edited={edited}
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderBody={(props) => (
 | 
			
		||||
          <RenderBody
 | 
			
		||||
            {...props}
 | 
			
		||||
            highlightRegex={highlightRegex}
 | 
			
		||||
            htmlReactParserOptions={htmlReactParserOptions}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        renderUrlsPreview={
 | 
			
		||||
          urlPreview
 | 
			
		||||
            ? (urls) => (
 | 
			
		||||
                <UrlPreviewHolder>
 | 
			
		||||
                  {urls.map((url) => (
 | 
			
		||||
                    <UrlPreviewCard key={url} url={url} ts={ts} />
 | 
			
		||||
                  ))}
 | 
			
		||||
                </UrlPreviewHolder>
 | 
			
		||||
              )
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Emote) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MEmote
 | 
			
		||||
        displayName={displayName}
 | 
			
		||||
        edited={edited}
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderBody={(props) => (
 | 
			
		||||
          <RenderBody
 | 
			
		||||
            {...props}
 | 
			
		||||
            highlightRegex={highlightRegex}
 | 
			
		||||
            htmlReactParserOptions={htmlReactParserOptions}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        renderUrlsPreview={
 | 
			
		||||
          urlPreview
 | 
			
		||||
            ? (urls) => (
 | 
			
		||||
                <UrlPreviewHolder>
 | 
			
		||||
                  {urls.map((url) => (
 | 
			
		||||
                    <UrlPreviewCard key={url} url={url} ts={ts} />
 | 
			
		||||
                  ))}
 | 
			
		||||
                </UrlPreviewHolder>
 | 
			
		||||
              )
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Notice) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MNotice
 | 
			
		||||
        edited={edited}
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderBody={(props) => (
 | 
			
		||||
          <RenderBody
 | 
			
		||||
            {...props}
 | 
			
		||||
            highlightRegex={highlightRegex}
 | 
			
		||||
            htmlReactParserOptions={htmlReactParserOptions}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        renderUrlsPreview={
 | 
			
		||||
          urlPreview
 | 
			
		||||
            ? (urls) => (
 | 
			
		||||
                <UrlPreviewHolder>
 | 
			
		||||
                  {urls.map((url) => (
 | 
			
		||||
                    <UrlPreviewCard key={url} url={url} ts={ts} />
 | 
			
		||||
                  ))}
 | 
			
		||||
                </UrlPreviewHolder>
 | 
			
		||||
              )
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Image) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MImage
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderImageContent={(props) => (
 | 
			
		||||
          <ImageContent
 | 
			
		||||
            {...props}
 | 
			
		||||
            autoPlay={mediaAutoLoad}
 | 
			
		||||
            renderImage={(p) => <Image {...p} loading="lazy" />}
 | 
			
		||||
            renderViewer={(p) => <ImageViewer {...p} />}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        outlined={outlineAttachment}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Video) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MVideo
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderAsFile={renderFile}
 | 
			
		||||
        renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
 | 
			
		||||
          <VideoContent
 | 
			
		||||
            body={body}
 | 
			
		||||
            info={info}
 | 
			
		||||
            mimeType={mimeType}
 | 
			
		||||
            url={url}
 | 
			
		||||
            encInfo={encInfo}
 | 
			
		||||
            renderThumbnail={
 | 
			
		||||
              mediaAutoLoad
 | 
			
		||||
                ? () => (
 | 
			
		||||
                    <ThumbnailContent
 | 
			
		||||
                      info={info}
 | 
			
		||||
                      renderImage={(src) => (
 | 
			
		||||
                        <Image alt={body} title={body} src={src} loading="lazy" />
 | 
			
		||||
                      )}
 | 
			
		||||
                    />
 | 
			
		||||
                  )
 | 
			
		||||
                : undefined
 | 
			
		||||
            }
 | 
			
		||||
            renderVideo={(p) => <Video {...p} />}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        outlined={outlineAttachment}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Audio) {
 | 
			
		||||
    return (
 | 
			
		||||
      <MAudio
 | 
			
		||||
        content={getContent()}
 | 
			
		||||
        renderAsFile={renderFile}
 | 
			
		||||
        renderAudioContent={(props) => (
 | 
			
		||||
          <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
 | 
			
		||||
        )}
 | 
			
		||||
        outlined={outlineAttachment}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.File) {
 | 
			
		||||
    return renderFile();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === MsgType.Location) {
 | 
			
		||||
    return <MLocation content={getContent()} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (msgType === 'm.bad.encrypted') {
 | 
			
		||||
    return <MBadEncrypted />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <UnsupportedContent />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										90
									
								
								src/app/components/RoomSummaryLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/app/components/RoomSummaryLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,90 @@
 | 
			
		|||
import { ReactNode, useCallback, useState } from 'react';
 | 
			
		||||
import { MatrixClient, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
 | 
			
		||||
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
 | 
			
		||||
 | 
			
		||||
type RoomSummaryLoaderProps = {
 | 
			
		||||
  roomIdOrAlias: string;
 | 
			
		||||
  children: (roomSummary?: IRoomSummary) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
 | 
			
		||||
 | 
			
		||||
  const { data } = useQuery({
 | 
			
		||||
    queryKey: [roomIdOrAlias, `summary`],
 | 
			
		||||
    queryFn: fetchSummary,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return children(data);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LocalRoomSummaryLoader({
 | 
			
		||||
  room,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  room: Room;
 | 
			
		||||
  children: (roomSummary: LocalRoomSummary) => ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  const summary = useLocalRoomSummary(room);
 | 
			
		||||
 | 
			
		||||
  return children(summary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function HierarchyRoomSummaryLoader({
 | 
			
		||||
  roomId,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
 | 
			
		||||
  const [errorMemo, setError] = useState<Error>();
 | 
			
		||||
 | 
			
		||||
  const { data, error } = useQuery({
 | 
			
		||||
    queryKey: [roomId, `hierarchy`],
 | 
			
		||||
    queryFn: fetchSummary,
 | 
			
		||||
    retryOnMount: false,
 | 
			
		||||
    refetchOnWindowFocus: false,
 | 
			
		||||
    retry: (failureCount, err) => {
 | 
			
		||||
      setError(err);
 | 
			
		||||
      if (failureCount > 3) return false;
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let state: AsyncState<IHierarchyRoom, Error> = {
 | 
			
		||||
    status: AsyncStatus.Loading,
 | 
			
		||||
  };
 | 
			
		||||
  if (error) {
 | 
			
		||||
    state = {
 | 
			
		||||
      status: AsyncStatus.Error,
 | 
			
		||||
      error,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  if (errorMemo) {
 | 
			
		||||
    state = {
 | 
			
		||||
      status: AsyncStatus.Error,
 | 
			
		||||
      error: errorMemo,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const summary = data?.rooms[0] ?? undefined;
 | 
			
		||||
  if (summary) {
 | 
			
		||||
    state = {
 | 
			
		||||
      status: AsyncStatus.Success,
 | 
			
		||||
      data: summary,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return children(state);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/components/RoomUnreadProvider.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/components/RoomUnreadProvider.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import { ReactElement } from 'react';
 | 
			
		||||
import { Unread } from '../../types/matrix/room';
 | 
			
		||||
import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
 | 
			
		||||
import { roomToUnreadAtom } from '../state/room/roomToUnread';
 | 
			
		||||
 | 
			
		||||
type RoomUnreadProviderProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  children: (unread?: Unread) => ReactElement;
 | 
			
		||||
};
 | 
			
		||||
export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
 | 
			
		||||
  const unread = useRoomUnread(roomId, roomToUnreadAtom);
 | 
			
		||||
 | 
			
		||||
  return children(unread);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RoomsUnreadProviderProps = {
 | 
			
		||||
  rooms: string[];
 | 
			
		||||
  children: (unread?: Unread) => ReactElement;
 | 
			
		||||
};
 | 
			
		||||
export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
 | 
			
		||||
  const unread = useRoomsUnread(rooms, roomToUnreadAtom);
 | 
			
		||||
 | 
			
		||||
  return children(unread);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								src/app/components/SpaceChildDirectsProvider.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/app/components/SpaceChildDirectsProvider.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
import { ReactNode } from 'react';
 | 
			
		||||
import { RoomToParents } from '../../types/matrix/room';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { allRoomsAtom } from '../state/room-list/roomList';
 | 
			
		||||
import { useChildDirectScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
 | 
			
		||||
 | 
			
		||||
type SpaceChildDirectsProviderProps = {
 | 
			
		||||
  spaceId: string;
 | 
			
		||||
  mDirects: Set<string>;
 | 
			
		||||
  roomToParents: RoomToParents;
 | 
			
		||||
  children: (rooms: string[]) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function SpaceChildDirectsProvider({
 | 
			
		||||
  spaceId,
 | 
			
		||||
  roomToParents,
 | 
			
		||||
  mDirects,
 | 
			
		||||
  children,
 | 
			
		||||
}: SpaceChildDirectsProviderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const childDirects = useSpaceChildren(
 | 
			
		||||
    allRoomsAtom,
 | 
			
		||||
    spaceId,
 | 
			
		||||
    useChildDirectScopeFactory(mx, mDirects, roomToParents)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return children(childDirects);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								src/app/components/SpaceChildRoomsProvider.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/app/components/SpaceChildRoomsProvider.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
import { ReactNode } from 'react';
 | 
			
		||||
import { RoomToParents } from '../../types/matrix/room';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { allRoomsAtom } from '../state/room-list/roomList';
 | 
			
		||||
import { useChildRoomScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
 | 
			
		||||
 | 
			
		||||
type SpaceChildRoomsProviderProps = {
 | 
			
		||||
  spaceId: string;
 | 
			
		||||
  mDirects: Set<string>;
 | 
			
		||||
  roomToParents: RoomToParents;
 | 
			
		||||
  children: (rooms: string[]) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function SpaceChildRoomsProvider({
 | 
			
		||||
  spaceId,
 | 
			
		||||
  roomToParents,
 | 
			
		||||
  mDirects,
 | 
			
		||||
  children,
 | 
			
		||||
}: SpaceChildRoomsProviderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const childRooms = useSpaceChildren(
 | 
			
		||||
    allRoomsAtom,
 | 
			
		||||
    spaceId,
 | 
			
		||||
    useChildRoomScopeFactory(mx, mDirects, roomToParents)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return children(childRooms);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,20 +1,25 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { SpecVersions, specVersions } from '../cs-api';
 | 
			
		||||
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
 | 
			
		||||
 | 
			
		||||
type SpecVersionsLoaderProps = {
 | 
			
		||||
  baseUrl: string;
 | 
			
		||||
  fallback?: () => ReactNode;
 | 
			
		||||
  error?: (err: unknown) => ReactNode;
 | 
			
		||||
  error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
 | 
			
		||||
  children: (versions: SpecVersions) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
 | 
			
		||||
  const autoDiscoveryInfo = useAutoDiscoveryInfo();
 | 
			
		||||
  const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
 | 
			
		||||
 | 
			
		||||
export function SpecVersionsLoader({
 | 
			
		||||
  baseUrl,
 | 
			
		||||
  fallback,
 | 
			
		||||
  error,
 | 
			
		||||
  children,
 | 
			
		||||
}: SpecVersionsLoaderProps) {
 | 
			
		||||
  const [state, load] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
 | 
			
		||||
  );
 | 
			
		||||
  const [ignoreError, setIgnoreError] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const ignoreCallback = useCallback(() => setIgnoreError(true), []);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
| 
						 | 
				
			
			@ -24,9 +29,15 @@ export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLo
 | 
			
		|||
    return fallback?.();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (state.status === AsyncStatus.Error) {
 | 
			
		||||
    return error?.(state.error);
 | 
			
		||||
  if (!ignoreError && state.status === AsyncStatus.Error) {
 | 
			
		||||
    return error?.(state.error, load, ignoreCallback);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return children(state.data);
 | 
			
		||||
  return children(
 | 
			
		||||
    state.status === AsyncStatus.Success
 | 
			
		||||
      ? state.data
 | 
			
		||||
      : {
 | 
			
		||||
          versions: [],
 | 
			
		||||
        }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,13 +10,14 @@ import {
 | 
			
		|||
  Line,
 | 
			
		||||
  Menu,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Text,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import React, { ReactNode, useState } from 'react';
 | 
			
		||||
import React, { MouseEventHandler, ReactNode, useState } from 'react';
 | 
			
		||||
import { ReactEditor, useSlate } from 'slate-react';
 | 
			
		||||
import {
 | 
			
		||||
  headingLevel,
 | 
			
		||||
| 
						 | 
				
			
			@ -119,26 +120,33 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
 | 
			
		|||
export function HeadingBlockButton() {
 | 
			
		||||
  const editor = useSlate();
 | 
			
		||||
  const level = headingLevel(editor);
 | 
			
		||||
  const [open, setOpen] = useState(false);
 | 
			
		||||
  const [anchor, setAnchor] = useState<RectCords>();
 | 
			
		||||
  const isActive = isBlockActive(editor, BlockType.Heading);
 | 
			
		||||
  const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
 | 
			
		||||
 | 
			
		||||
  const handleMenuSelect = (selectedLevel: HeadingLevel) => {
 | 
			
		||||
    setOpen(false);
 | 
			
		||||
    setAnchor(undefined);
 | 
			
		||||
    toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
 | 
			
		||||
    ReactEditor.focus(editor);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    if (isActive) {
 | 
			
		||||
      toggleBlock(editor, BlockType.Heading);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    setAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <PopOut
 | 
			
		||||
      open={open}
 | 
			
		||||
      anchor={anchor}
 | 
			
		||||
      offset={5}
 | 
			
		||||
      position="Top"
 | 
			
		||||
      content={
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: () => setOpen(false),
 | 
			
		||||
            onDeactivate: () => setAnchor(undefined),
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
| 
						 | 
				
			
			@ -197,20 +205,17 @@ export function HeadingBlockButton() {
 | 
			
		|||
        </FocusTrap>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {(ref) => (
 | 
			
		||||
        <IconButton
 | 
			
		||||
          style={{ width: 'unset' }}
 | 
			
		||||
          ref={ref}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
 | 
			
		||||
          aria-pressed={isActive}
 | 
			
		||||
          size="400"
 | 
			
		||||
          radii="300"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
 | 
			
		||||
          <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      )}
 | 
			
		||||
      <IconButton
 | 
			
		||||
        style={{ width: 'unset' }}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        onClick={handleMenuOpen}
 | 
			
		||||
        aria-pressed={isActive}
 | 
			
		||||
        size="400"
 | 
			
		||||
        radii="300"
 | 
			
		||||
      >
 | 
			
		||||
        <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
 | 
			
		||||
        <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
 | 
			
		||||
      </IconButton>
 | 
			
		||||
    </PopOut>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,11 @@
 | 
			
		|||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
 | 
			
		||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
 | 
			
		||||
import { Editor } from 'slate';
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
 | 
			
		||||
import { MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
 | 
			
		||||
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import { useAtomValue } from 'jotai';
 | 
			
		||||
 | 
			
		||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
			
		||||
import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
 | 
			
		||||
import { roomIdByActivity } from '../../../../util/sort';
 | 
			
		||||
import initMatrix from '../../../../client/initMatrix';
 | 
			
		||||
import { getDirectRoomAvatarUrl } from '../../../utils/room';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
			
		||||
import { AutocompleteMenu } from './AutocompleteMenu';
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +13,10 @@ import { getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		|||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 | 
			
		||||
import { onTabPress } from '../../../utils/keyboard';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
import { mDirectAtom } from '../../../state/mDirectList';
 | 
			
		||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
 | 
			
		||||
import { factoryRoomIdByActivity } from '../../../utils/sort';
 | 
			
		||||
import { RoomAvatar, RoomIcon } from '../../room-avatar';
 | 
			
		||||
 | 
			
		||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -74,15 +77,12 @@ export function RoomMentionAutocomplete({
 | 
			
		|||
  requestClose,
 | 
			
		||||
}: RoomMentionAutocompleteProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
 | 
			
		||||
  const mDirects = useAtomValue(mDirectAtom);
 | 
			
		||||
 | 
			
		||||
  const allRoomId: string[] = useMemo(() => {
 | 
			
		||||
    const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
 | 
			
		||||
    return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
 | 
			
		||||
  }, []);
 | 
			
		||||
  const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
 | 
			
		||||
 | 
			
		||||
  const [result, search, resetSearch] = useAsyncSearch(
 | 
			
		||||
    allRoomId,
 | 
			
		||||
    allRooms,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (rId) => {
 | 
			
		||||
        const r = mx.getRoom(rId);
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +96,7 @@ export function RoomMentionAutocomplete({
 | 
			
		|||
    SEARCH_OPTIONS
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
 | 
			
		||||
  const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (query.text) search(query.text);
 | 
			
		||||
| 
						 | 
				
			
			@ -136,9 +136,7 @@ export function RoomMentionAutocomplete({
 | 
			
		|||
        autoCompleteRoomIds.map((rId) => {
 | 
			
		||||
          const room = mx.getRoom(rId);
 | 
			
		||||
          if (!room) return null;
 | 
			
		||||
          const dm = dms.has(room.roomId);
 | 
			
		||||
          const avatarUrl = getRoomAvatarUrl(mx, room);
 | 
			
		||||
          const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
 | 
			
		||||
          const dm = mDirects.has(room.roomId);
 | 
			
		||||
 | 
			
		||||
          const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -158,17 +156,21 @@ export function RoomMentionAutocomplete({
 | 
			
		|||
              }
 | 
			
		||||
              before={
 | 
			
		||||
                <Avatar size="200">
 | 
			
		||||
                  {iconSrc && <Icon src={iconSrc} size="100" />}
 | 
			
		||||
                  {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
 | 
			
		||||
                  {!avatarUrl && !iconSrc && (
 | 
			
		||||
                    <AvatarFallback
 | 
			
		||||
                      style={{
 | 
			
		||||
                        backgroundColor: color.Secondary.Container,
 | 
			
		||||
                        color: color.Secondary.OnContainer,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="H6">{room.name[0]}</Text>
 | 
			
		||||
                    </AvatarFallback>
 | 
			
		||||
                  {dm ? (
 | 
			
		||||
                    <RoomAvatar
 | 
			
		||||
                      roomId={room.roomId}
 | 
			
		||||
                      src={getDirectRoomAvatarUrl(mx, room)}
 | 
			
		||||
                      alt={room.name}
 | 
			
		||||
                      renderFallback={() => (
 | 
			
		||||
                        <RoomIcon
 | 
			
		||||
                          size="50"
 | 
			
		||||
                          joinRule={room.getJoinRule() ?? JoinRule.Restricted}
 | 
			
		||||
                          filled
 | 
			
		||||
                        />
 | 
			
		||||
                      )}
 | 
			
		||||
                    />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
 | 
			
		||||
                  )}
 | 
			
		||||
                </Avatar>
 | 
			
		||||
              }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
 | 
			
		||||
import { Editor } from 'slate';
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
 | 
			
		||||
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
 | 
			
		||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
 | 
			
		||||
 | 
			
		||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
			
		|||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
 | 
			
		||||
import { UserAvatar } from '../../user-avatar';
 | 
			
		||||
 | 
			
		||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -26,12 +27,10 @@ const userIdFromQueryText = (mx: MatrixClient, text: string) =>
 | 
			
		|||
    : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
			
		||||
 | 
			
		||||
function UnknownMentionItem({
 | 
			
		||||
  query,
 | 
			
		||||
  userId,
 | 
			
		||||
  name,
 | 
			
		||||
  handleAutocomplete,
 | 
			
		||||
}: {
 | 
			
		||||
  query: AutocompleteQuery<string>;
 | 
			
		||||
  userId: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  handleAutocomplete: MentionAutoCompleteHandler;
 | 
			
		||||
| 
						 | 
				
			
			@ -46,14 +45,10 @@ function UnknownMentionItem({
 | 
			
		|||
      onClick={() => handleAutocomplete(userId, name)}
 | 
			
		||||
      before={
 | 
			
		||||
        <Avatar size="200">
 | 
			
		||||
          <AvatarFallback
 | 
			
		||||
            style={{
 | 
			
		||||
              backgroundColor: color.Secondary.Container,
 | 
			
		||||
              color: color.Secondary.OnContainer,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="H6">{query.text[0]}</Text>
 | 
			
		||||
          </AvatarFallback>
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
| 
						 | 
				
			
			@ -135,7 +130,6 @@ export function UserMentionAutocomplete({
 | 
			
		|||
    <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
 | 
			
		||||
      {query.text === 'room' && (
 | 
			
		||||
        <UnknownMentionItem
 | 
			
		||||
          query={query}
 | 
			
		||||
          userId={roomAliasOrId}
 | 
			
		||||
          name="@room"
 | 
			
		||||
          handleAutocomplete={handleAutocomplete}
 | 
			
		||||
| 
						 | 
				
			
			@ -143,7 +137,6 @@ export function UserMentionAutocomplete({
 | 
			
		|||
      )}
 | 
			
		||||
      {autoCompleteMembers.length === 0 ? (
 | 
			
		||||
        <UnknownMentionItem
 | 
			
		||||
          query={query}
 | 
			
		||||
          userId={userIdFromQueryText(mx, query.text)}
 | 
			
		||||
          name={userIdFromQueryText(mx, query.text)}
 | 
			
		||||
          handleAutocomplete={handleAutocomplete}
 | 
			
		||||
| 
						 | 
				
			
			@ -167,18 +160,12 @@ export function UserMentionAutocomplete({
 | 
			
		|||
              }
 | 
			
		||||
              before={
 | 
			
		||||
                <Avatar size="200">
 | 
			
		||||
                  {avatarUrl ? (
 | 
			
		||||
                    <AvatarImage src={avatarUrl} alt={getName(roomMember)} />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <AvatarFallback
 | 
			
		||||
                      style={{
 | 
			
		||||
                        backgroundColor: color.Secondary.Container,
 | 
			
		||||
                        color: color.Secondary.OnContainer,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="H6">{getName(roomMember)[0]}</Text>
 | 
			
		||||
                    </AvatarFallback>
 | 
			
		||||
                  )}
 | 
			
		||||
                  <UserAvatar
 | 
			
		||||
                    userId={roomMember.userId}
 | 
			
		||||
                    src={avatarUrl ?? undefined}
 | 
			
		||||
                    alt={getName(roomMember)}
 | 
			
		||||
                    renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
			
		||||
                  />
 | 
			
		||||
                </Avatar>
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,6 @@ import React from 'react';
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import {
 | 
			
		||||
  Avatar,
 | 
			
		||||
  AvatarFallback,
 | 
			
		||||
  AvatarImage,
 | 
			
		||||
  Box,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
| 
						 | 
				
			
			@ -21,8 +19,8 @@ import { getMemberDisplayName } from '../../utils/room';
 | 
			
		|||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
			
		||||
import * as css from './EventReaders.css';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import colorMXID from '../../../util/colorMXID';
 | 
			
		||||
import { openProfileViewer } from '../../../client/action/navigation';
 | 
			
		||||
import { UserAvatar } from '../user-avatar';
 | 
			
		||||
 | 
			
		||||
export type EventReadersProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -72,18 +70,12 @@ export const EventReaders = as<'div', EventReadersProps>(
 | 
			
		|||
                    }}
 | 
			
		||||
                    before={
 | 
			
		||||
                      <Avatar size="200">
 | 
			
		||||
                        {avatarUrl ? (
 | 
			
		||||
                          <AvatarImage src={avatarUrl} />
 | 
			
		||||
                        ) : (
 | 
			
		||||
                          <AvatarFallback
 | 
			
		||||
                            style={{
 | 
			
		||||
                              background: colorMXID(readerId),
 | 
			
		||||
                              color: 'white',
 | 
			
		||||
                            }}
 | 
			
		||||
                          >
 | 
			
		||||
                            <Text size="H6">{name[0]}</Text>
 | 
			
		||||
                          </AvatarFallback>
 | 
			
		||||
                        )}
 | 
			
		||||
                        <UserAvatar
 | 
			
		||||
                          userId={readerId}
 | 
			
		||||
                          src={avatarUrl ?? undefined}
 | 
			
		||||
                          alt={name}
 | 
			
		||||
                          renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Avatar>
 | 
			
		||||
                    }
 | 
			
		||||
                  >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										106
									
								
								src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,106 @@
 | 
			
		|||
import React, { useCallback, useEffect } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  color,
 | 
			
		||||
  Button,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
type LeaveRoomPromptProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  onDone: () => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      mx.leave(roomId);
 | 
			
		||||
    }, [mx, roomId])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleLeave = () => {
 | 
			
		||||
    leaveRoom();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (leaveState.status === AsyncStatus.Success) {
 | 
			
		||||
      onDone();
 | 
			
		||||
    }
 | 
			
		||||
  }, [leaveState, onDone]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: onCancel,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <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">Leave Room</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
              <Box direction="Column" gap="200">
 | 
			
		||||
                <Text priority="400">Are you sure you want to leave this room?</Text>
 | 
			
		||||
                {leaveState.status === AsyncStatus.Error && (
 | 
			
		||||
                  <Text style={{ color: color.Critical.Main }} size="T300">
 | 
			
		||||
                    Failed to leave room! {leaveState.error.message}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Button
 | 
			
		||||
                type="submit"
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                onClick={handleLeave}
 | 
			
		||||
                before={
 | 
			
		||||
                  leaveState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                    <Spinner fill="Solid" variant="Critical" size="200" />
 | 
			
		||||
                  ) : undefined
 | 
			
		||||
                }
 | 
			
		||||
                aria-disabled={
 | 
			
		||||
                  leaveState.status === AsyncStatus.Loading ||
 | 
			
		||||
                  leaveState.status === AsyncStatus.Success
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B400">
 | 
			
		||||
                  {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/leave-room-prompt/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/leave-room-prompt/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './LeaveRoomPrompt';
 | 
			
		||||
							
								
								
									
										106
									
								
								src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,106 @@
 | 
			
		|||
import React, { useCallback, useEffect } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  color,
 | 
			
		||||
  Button,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
type LeaveSpacePromptProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  onDone: () => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      mx.leave(roomId);
 | 
			
		||||
    }, [mx, roomId])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleLeave = () => {
 | 
			
		||||
    leaveRoom();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (leaveState.status === AsyncStatus.Success) {
 | 
			
		||||
      onDone();
 | 
			
		||||
    }
 | 
			
		||||
  }, [leaveState, onDone]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: onCancel,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <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">Leave Space</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
              <Box direction="Column" gap="200">
 | 
			
		||||
                <Text priority="400">Are you sure you want to leave this space?</Text>
 | 
			
		||||
                {leaveState.status === AsyncStatus.Error && (
 | 
			
		||||
                  <Text style={{ color: color.Critical.Main }} size="T300">
 | 
			
		||||
                    Failed to leave space! {leaveState.error.message}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Button
 | 
			
		||||
                type="submit"
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                onClick={handleLeave}
 | 
			
		||||
                before={
 | 
			
		||||
                  leaveState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                    <Spinner fill="Solid" variant="Critical" size="200" />
 | 
			
		||||
                  ) : undefined
 | 
			
		||||
                }
 | 
			
		||||
                aria-disabled={
 | 
			
		||||
                  leaveState.status === AsyncStatus.Loading ||
 | 
			
		||||
                  leaveState.status === AsyncStatus.Success
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B400">
 | 
			
		||||
                  {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/leave-space-prompt/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/leave-space-prompt/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './LeaveSpacePrompt';
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import { Badge, Box, Text, as, toRem } from 'folds';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { mimeTypeToExt } from '../../../utils/mimeTypes';
 | 
			
		||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
 | 
			
		||||
 | 
			
		||||
const badgeStyles = { maxWidth: toRem(100) };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										398
									
								
								src/app/components/message/MsgTypeRenderers.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										398
									
								
								src/app/components/message/MsgTypeRenderers.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,398 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
 | 
			
		||||
import { IContent } from 'matrix-js-sdk';
 | 
			
		||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
 | 
			
		||||
import { trimReplyFromBody } from '../../utils/room';
 | 
			
		||||
import { MessageTextBody } from './layout';
 | 
			
		||||
import {
 | 
			
		||||
  MessageBadEncryptedContent,
 | 
			
		||||
  MessageBrokenContent,
 | 
			
		||||
  MessageDeletedContent,
 | 
			
		||||
  MessageEditedContent,
 | 
			
		||||
  MessageUnsupportedContent,
 | 
			
		||||
} from './content';
 | 
			
		||||
import {
 | 
			
		||||
  IAudioContent,
 | 
			
		||||
  IAudioInfo,
 | 
			
		||||
  IEncryptedFile,
 | 
			
		||||
  IFileContent,
 | 
			
		||||
  IFileInfo,
 | 
			
		||||
  IImageContent,
 | 
			
		||||
  IImageInfo,
 | 
			
		||||
  IThumbnailContent,
 | 
			
		||||
  IVideoContent,
 | 
			
		||||
  IVideoInfo,
 | 
			
		||||
} from '../../../types/matrix/common';
 | 
			
		||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
 | 
			
		||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
 | 
			
		||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
 | 
			
		||||
import { FileHeader } from './FileHeader';
 | 
			
		||||
 | 
			
		||||
export function MBadEncrypted() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Text>
 | 
			
		||||
      <MessageBadEncryptedContent />
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RedactedContentProps = {
 | 
			
		||||
  reason?: string;
 | 
			
		||||
};
 | 
			
		||||
export function RedactedContent({ reason }: RedactedContentProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Text>
 | 
			
		||||
      <MessageDeletedContent reason={reason} />
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function UnsupportedContent() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Text>
 | 
			
		||||
      <MessageUnsupportedContent />
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function BrokenContent() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Text>
 | 
			
		||||
      <MessageBrokenContent />
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RenderBodyProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  customBody?: string;
 | 
			
		||||
};
 | 
			
		||||
type MTextProps = {
 | 
			
		||||
  edited?: boolean;
 | 
			
		||||
  content: Record<string, unknown>;
 | 
			
		||||
  renderBody: (props: RenderBodyProps) => ReactNode;
 | 
			
		||||
  renderUrlsPreview?: (urls: string[]) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) {
 | 
			
		||||
  const { body, formatted_body: customBody } = content;
 | 
			
		||||
 | 
			
		||||
  if (typeof body !== 'string') return <BrokenContent />;
 | 
			
		||||
  const trimmedBody = trimReplyFromBody(body);
 | 
			
		||||
  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
 | 
			
		||||
  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <MessageTextBody
 | 
			
		||||
        preWrap={typeof customBody !== 'string'}
 | 
			
		||||
        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
 | 
			
		||||
      >
 | 
			
		||||
        {renderBody({
 | 
			
		||||
          body: trimmedBody,
 | 
			
		||||
          customBody: typeof customBody === 'string' ? customBody : undefined,
 | 
			
		||||
        })}
 | 
			
		||||
        {edited && <MessageEditedContent />}
 | 
			
		||||
      </MessageTextBody>
 | 
			
		||||
      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MEmoteProps = {
 | 
			
		||||
  displayName: string;
 | 
			
		||||
  edited?: boolean;
 | 
			
		||||
  content: Record<string, unknown>;
 | 
			
		||||
  renderBody: (props: RenderBodyProps) => ReactNode;
 | 
			
		||||
  renderUrlsPreview?: (urls: string[]) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function MEmote({
 | 
			
		||||
  displayName,
 | 
			
		||||
  edited,
 | 
			
		||||
  content,
 | 
			
		||||
  renderBody,
 | 
			
		||||
  renderUrlsPreview,
 | 
			
		||||
}: MEmoteProps) {
 | 
			
		||||
  const { body, formatted_body: customBody } = content;
 | 
			
		||||
 | 
			
		||||
  if (typeof body !== 'string') return <BrokenContent />;
 | 
			
		||||
  const trimmedBody = trimReplyFromBody(body);
 | 
			
		||||
  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
 | 
			
		||||
  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <MessageTextBody
 | 
			
		||||
        emote
 | 
			
		||||
        preWrap={typeof customBody !== 'string'}
 | 
			
		||||
        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
 | 
			
		||||
      >
 | 
			
		||||
        <b>{`${displayName} `}</b>
 | 
			
		||||
        {renderBody({
 | 
			
		||||
          body: trimmedBody,
 | 
			
		||||
          customBody: typeof customBody === 'string' ? customBody : undefined,
 | 
			
		||||
        })}
 | 
			
		||||
        {edited && <MessageEditedContent />}
 | 
			
		||||
      </MessageTextBody>
 | 
			
		||||
      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MNoticeProps = {
 | 
			
		||||
  edited?: boolean;
 | 
			
		||||
  content: Record<string, unknown>;
 | 
			
		||||
  renderBody: (props: RenderBodyProps) => ReactNode;
 | 
			
		||||
  renderUrlsPreview?: (urls: string[]) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
 | 
			
		||||
  const { body, formatted_body: customBody } = content;
 | 
			
		||||
 | 
			
		||||
  if (typeof body !== 'string') return <BrokenContent />;
 | 
			
		||||
  const trimmedBody = trimReplyFromBody(body);
 | 
			
		||||
  const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
 | 
			
		||||
  const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <MessageTextBody
 | 
			
		||||
        notice
 | 
			
		||||
        preWrap={typeof customBody !== 'string'}
 | 
			
		||||
        jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
 | 
			
		||||
      >
 | 
			
		||||
        {renderBody({
 | 
			
		||||
          body: trimmedBody,
 | 
			
		||||
          customBody: typeof customBody === 'string' ? customBody : undefined,
 | 
			
		||||
        })}
 | 
			
		||||
        {edited && <MessageEditedContent />}
 | 
			
		||||
      </MessageTextBody>
 | 
			
		||||
      {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RenderImageContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  info?: IImageInfo & IThumbnailContent;
 | 
			
		||||
  mimeType?: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: IEncryptedFile;
 | 
			
		||||
};
 | 
			
		||||
type MImageProps = {
 | 
			
		||||
  content: IImageContent;
 | 
			
		||||
  renderImageContent: (props: RenderImageContentProps) => ReactNode;
 | 
			
		||||
  outlined?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
 | 
			
		||||
  const imgInfo = content?.info;
 | 
			
		||||
  const mxcUrl = content.file?.url ?? content.url;
 | 
			
		||||
  if (typeof mxcUrl !== 'string') {
 | 
			
		||||
    return <BrokenContent />;
 | 
			
		||||
  }
 | 
			
		||||
  const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Attachment outlined={outlined}>
 | 
			
		||||
      <AttachmentBox
 | 
			
		||||
        style={{
 | 
			
		||||
          height: toRem(height < 48 ? 48 : height),
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {renderImageContent({
 | 
			
		||||
          body: content.body || 'Image',
 | 
			
		||||
          info: imgInfo,
 | 
			
		||||
          mimeType: imgInfo?.mimetype,
 | 
			
		||||
          url: mxcUrl,
 | 
			
		||||
          encInfo: content.file,
 | 
			
		||||
        })}
 | 
			
		||||
      </AttachmentBox>
 | 
			
		||||
    </Attachment>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RenderVideoContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  info: IVideoInfo & IThumbnailContent;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: IEncryptedFile;
 | 
			
		||||
};
 | 
			
		||||
type MVideoProps = {
 | 
			
		||||
  content: IVideoContent;
 | 
			
		||||
  renderAsFile: () => ReactNode;
 | 
			
		||||
  renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
 | 
			
		||||
  outlined?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
 | 
			
		||||
  const videoInfo = content?.info;
 | 
			
		||||
  const mxcUrl = content.file?.url ?? content.url;
 | 
			
		||||
  const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
 | 
			
		||||
 | 
			
		||||
  if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
 | 
			
		||||
    if (mxcUrl) {
 | 
			
		||||
      return renderAsFile();
 | 
			
		||||
    }
 | 
			
		||||
    return <BrokenContent />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Attachment outlined={outlined}>
 | 
			
		||||
      <AttachmentBox
 | 
			
		||||
        style={{
 | 
			
		||||
          height: toRem(height < 48 ? 48 : height),
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {renderVideoContent({
 | 
			
		||||
          body: content.body || 'Video',
 | 
			
		||||
          info: videoInfo,
 | 
			
		||||
          mimeType: safeMimeType,
 | 
			
		||||
          url: mxcUrl,
 | 
			
		||||
          encInfo: content.file,
 | 
			
		||||
        })}
 | 
			
		||||
      </AttachmentBox>
 | 
			
		||||
    </Attachment>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RenderAudioContentProps = {
 | 
			
		||||
  info: IAudioInfo;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: IEncryptedFile;
 | 
			
		||||
};
 | 
			
		||||
type MAudioProps = {
 | 
			
		||||
  content: IAudioContent;
 | 
			
		||||
  renderAsFile: () => ReactNode;
 | 
			
		||||
  renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
 | 
			
		||||
  outlined?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
 | 
			
		||||
  const audioInfo = content?.info;
 | 
			
		||||
  const mxcUrl = content.file?.url ?? content.url;
 | 
			
		||||
  const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
 | 
			
		||||
 | 
			
		||||
  if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
 | 
			
		||||
    if (mxcUrl) {
 | 
			
		||||
      return renderAsFile();
 | 
			
		||||
    }
 | 
			
		||||
    return <BrokenContent />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Attachment outlined={outlined}>
 | 
			
		||||
      <AttachmentHeader>
 | 
			
		||||
        <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
 | 
			
		||||
      </AttachmentHeader>
 | 
			
		||||
      <AttachmentBox>
 | 
			
		||||
        <AttachmentContent>
 | 
			
		||||
          {renderAudioContent({
 | 
			
		||||
            info: audioInfo,
 | 
			
		||||
            mimeType: safeMimeType,
 | 
			
		||||
            url: mxcUrl,
 | 
			
		||||
            encInfo: content.file,
 | 
			
		||||
          })}
 | 
			
		||||
        </AttachmentContent>
 | 
			
		||||
      </AttachmentBox>
 | 
			
		||||
    </Attachment>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RenderFileContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  info: IFileInfo & IThumbnailContent;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: IEncryptedFile;
 | 
			
		||||
};
 | 
			
		||||
type MFileProps = {
 | 
			
		||||
  content: IFileContent;
 | 
			
		||||
  renderFileContent: (props: RenderFileContentProps) => ReactNode;
 | 
			
		||||
  outlined?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function MFile({ content, renderFileContent, outlined }: MFileProps) {
 | 
			
		||||
  const fileInfo = content?.info;
 | 
			
		||||
  const mxcUrl = content.file?.url ?? content.url;
 | 
			
		||||
 | 
			
		||||
  if (typeof mxcUrl !== 'string') {
 | 
			
		||||
    return <BrokenContent />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Attachment outlined={outlined}>
 | 
			
		||||
      <AttachmentHeader>
 | 
			
		||||
        <FileHeader
 | 
			
		||||
          body={content.body ?? 'Unnamed File'}
 | 
			
		||||
          mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
 | 
			
		||||
        />
 | 
			
		||||
      </AttachmentHeader>
 | 
			
		||||
      <AttachmentBox>
 | 
			
		||||
        <AttachmentContent>
 | 
			
		||||
          {renderFileContent({
 | 
			
		||||
            body: content.body ?? 'File',
 | 
			
		||||
            info: fileInfo ?? {},
 | 
			
		||||
            mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
 | 
			
		||||
            url: mxcUrl,
 | 
			
		||||
            encInfo: content.file,
 | 
			
		||||
          })}
 | 
			
		||||
        </AttachmentContent>
 | 
			
		||||
      </AttachmentBox>
 | 
			
		||||
    </Attachment>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MLocationProps = {
 | 
			
		||||
  content: IContent;
 | 
			
		||||
};
 | 
			
		||||
export function MLocation({ content }: MLocationProps) {
 | 
			
		||||
  const geoUri = content.geo_uri;
 | 
			
		||||
  if (typeof geoUri !== 'string') return <BrokenContent />;
 | 
			
		||||
  const location = parseGeoUri(geoUri);
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" alignItems="Start" gap="100">
 | 
			
		||||
      <Text size="T400">{geoUri}</Text>
 | 
			
		||||
      <Chip
 | 
			
		||||
        as="a"
 | 
			
		||||
        size="400"
 | 
			
		||||
        href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noreferrer noopener"
 | 
			
		||||
        variant="Primary"
 | 
			
		||||
        radii="Pill"
 | 
			
		||||
        before={<Icon src={Icons.External} size="50" />}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B300">Open Location</Text>
 | 
			
		||||
      </Chip>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MStickerProps = {
 | 
			
		||||
  content: IImageContent;
 | 
			
		||||
  renderImageContent: (props: RenderImageContentProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function MSticker({ content, renderImageContent }: MStickerProps) {
 | 
			
		||||
  const imgInfo = content?.info;
 | 
			
		||||
  const mxcUrl = content.file?.url ?? content.url;
 | 
			
		||||
  if (typeof mxcUrl !== 'string') {
 | 
			
		||||
    return <MessageBrokenContent />;
 | 
			
		||||
  }
 | 
			
		||||
  const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AttachmentBox
 | 
			
		||||
      style={{
 | 
			
		||||
        height: toRem(height < 48 ? 48 : height),
 | 
			
		||||
        width: toRem(152),
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {renderImageContent({
 | 
			
		||||
        body: content.body || 'Sticker',
 | 
			
		||||
        info: imgInfo,
 | 
			
		||||
        mimeType: imgInfo?.mimetype,
 | 
			
		||||
        url: mxcUrl,
 | 
			
		||||
        encInfo: content.file,
 | 
			
		||||
      })}
 | 
			
		||||
    </AttachmentBox>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								src/app/components/message/RenderBody.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/components/message/RenderBody.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import parse, { HTMLReactParserOptions } from 'html-react-parser';
 | 
			
		||||
import Linkify from 'linkify-react';
 | 
			
		||||
import { MessageEmptyContent } from './content';
 | 
			
		||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
 | 
			
		||||
import {
 | 
			
		||||
  LINKIFY_OPTS,
 | 
			
		||||
  highlightText,
 | 
			
		||||
  scaleSystemEmoji,
 | 
			
		||||
} from '../../plugins/react-custom-html-parser';
 | 
			
		||||
 | 
			
		||||
type RenderBodyProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  customBody?: string;
 | 
			
		||||
 | 
			
		||||
  highlightRegex?: RegExp;
 | 
			
		||||
  htmlReactParserOptions: HTMLReactParserOptions;
 | 
			
		||||
};
 | 
			
		||||
export function RenderBody({
 | 
			
		||||
  body,
 | 
			
		||||
  customBody,
 | 
			
		||||
  highlightRegex,
 | 
			
		||||
  htmlReactParserOptions,
 | 
			
		||||
}: RenderBodyProps) {
 | 
			
		||||
  if (body === '') <MessageEmptyContent />;
 | 
			
		||||
  if (customBody) {
 | 
			
		||||
    if (customBody === '') <MessageEmptyContent />;
 | 
			
		||||
    return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <Linkify options={LINKIFY_OPTS}>
 | 
			
		||||
      {highlightRegex
 | 
			
		||||
        ? highlightText(highlightRegex, scaleSystemEmoji(body))
 | 
			
		||||
        : scaleSystemEmoji(body)}
 | 
			
		||||
    </Linkify>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,13 +1,20 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const ReplyBend = style({
 | 
			
		||||
  flexShrink: 0,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const Reply = style({
 | 
			
		||||
  padding: `0 ${config.space.S100}`,
 | 
			
		||||
  marginBottom: toRem(1),
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
  minWidth: 0,
 | 
			
		||||
  maxWidth: '100%',
 | 
			
		||||
  minHeight: config.lineHeight.T300,
 | 
			
		||||
  selectors: {
 | 
			
		||||
    'button&': {
 | 
			
		||||
      cursor: 'pointer',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const ReplyContent = style({
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +26,3 @@ export const ReplyContent = style({
 | 
			
		|||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const ReplyContentText = style({
 | 
			
		||||
  paddingRight: config.space.S100,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
 | 
			
		||||
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 | 
			
		||||
import React, { useEffect, useState } from 'react';
 | 
			
		||||
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import to from 'await-to-js';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import colorMXID from '../../../util/colorMXID';
 | 
			
		||||
| 
						 | 
				
			
			@ -10,94 +10,105 @@ import { getMxIdLocalPart } from '../../utils/matrix';
 | 
			
		|||
import { LinePlaceholder } from './placeholder';
 | 
			
		||||
import { randomNumberBetween } from '../../utils/common';
 | 
			
		||||
import * as css from './Reply.css';
 | 
			
		||||
import {
 | 
			
		||||
  MessageBadEncryptedContent,
 | 
			
		||||
  MessageDeletedContent,
 | 
			
		||||
  MessageFailedContent,
 | 
			
		||||
} from './MessageContentFallback';
 | 
			
		||||
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
 | 
			
		||||
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 | 
			
		||||
 | 
			
		||||
type ReplyLayoutProps = {
 | 
			
		||||
  userColor?: string;
 | 
			
		||||
  username?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
 | 
			
		||||
  ({ username, userColor, className, children, ...props }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
      className={classNames(css.Reply, className)}
 | 
			
		||||
      alignItems="Center"
 | 
			
		||||
      gap="100"
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    >
 | 
			
		||||
      <Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
 | 
			
		||||
        <Icon size="100" src={Icons.ReplyArrow} />
 | 
			
		||||
        {username}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box grow="Yes" className={css.ReplyContent}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type ReplyProps = {
 | 
			
		||||
  mx: MatrixClient;
 | 
			
		||||
  room: Room;
 | 
			
		||||
  timelineSet: EventTimelineSet;
 | 
			
		||||
  timelineSet?: EventTimelineSet;
 | 
			
		||||
  eventId: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Reply = as<'div', ReplyProps>(
 | 
			
		||||
  ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
 | 
			
		||||
    const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
 | 
			
		||||
      timelineSet.findEventById(eventId)
 | 
			
		||||
    );
 | 
			
		||||
export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => {
 | 
			
		||||
  const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
 | 
			
		||||
    timelineSet?.findEventById(eventId)
 | 
			
		||||
  );
 | 
			
		||||
  const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
 | 
			
		||||
 | 
			
		||||
    const { body } = replyEvent?.getContent() ?? {};
 | 
			
		||||
    const sender = replyEvent?.getSender();
 | 
			
		||||
  const { body } = replyEvent?.getContent() ?? {};
 | 
			
		||||
  const sender = replyEvent?.getSender();
 | 
			
		||||
 | 
			
		||||
    const fallbackBody = replyEvent?.isRedacted() ? (
 | 
			
		||||
      <MessageDeletedContent />
 | 
			
		||||
    ) : (
 | 
			
		||||
      <MessageFailedContent />
 | 
			
		||||
    );
 | 
			
		||||
  const fallbackBody = replyEvent?.isRedacted() ? (
 | 
			
		||||
    <MessageDeletedContent />
 | 
			
		||||
  ) : (
 | 
			
		||||
    <MessageFailedContent />
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      let disposed = false;
 | 
			
		||||
      const loadEvent = async () => {
 | 
			
		||||
        const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
 | 
			
		||||
        const mEvent = new MatrixEvent(evt);
 | 
			
		||||
        if (disposed) return;
 | 
			
		||||
        if (err) {
 | 
			
		||||
          setReplyEvent(null);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (mEvent.isEncrypted() && mx.getCrypto()) {
 | 
			
		||||
          await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
 | 
			
		||||
        }
 | 
			
		||||
        setReplyEvent(mEvent);
 | 
			
		||||
      };
 | 
			
		||||
      if (replyEvent === undefined) loadEvent();
 | 
			
		||||
      return () => {
 | 
			
		||||
        disposed = true;
 | 
			
		||||
      };
 | 
			
		||||
    }, [replyEvent, mx, room, eventId]);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    let disposed = false;
 | 
			
		||||
    const loadEvent = async () => {
 | 
			
		||||
      const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
 | 
			
		||||
      const mEvent = new MatrixEvent(evt);
 | 
			
		||||
      if (disposed) return;
 | 
			
		||||
      if (err) {
 | 
			
		||||
        setReplyEvent(null);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (mEvent.isEncrypted() && mx.getCrypto()) {
 | 
			
		||||
        await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
 | 
			
		||||
      }
 | 
			
		||||
      setReplyEvent(mEvent);
 | 
			
		||||
    };
 | 
			
		||||
    if (replyEvent === undefined) loadEvent();
 | 
			
		||||
    return () => {
 | 
			
		||||
      disposed = true;
 | 
			
		||||
    };
 | 
			
		||||
  }, [replyEvent, mx, room, eventId]);
 | 
			
		||||
 | 
			
		||||
    const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
 | 
			
		||||
    const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
 | 
			
		||||
  const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
 | 
			
		||||
  const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box
 | 
			
		||||
        className={classNames(css.Reply, className)}
 | 
			
		||||
        alignItems="Center"
 | 
			
		||||
        gap="100"
 | 
			
		||||
        {...props}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
      >
 | 
			
		||||
        <Box
 | 
			
		||||
          style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
 | 
			
		||||
          alignItems="Center"
 | 
			
		||||
          shrink="No"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon src={Icons.ReplyArrow} size="50" />
 | 
			
		||||
          {sender && (
 | 
			
		||||
            <Text size="T300" truncate>
 | 
			
		||||
              {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box grow="Yes" className={css.ReplyContent}>
 | 
			
		||||
          {replyEvent !== undefined ? (
 | 
			
		||||
            <Text className={css.ReplyContentText} size="T300" truncate>
 | 
			
		||||
              {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
 | 
			
		||||
            </Text>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <LinePlaceholder
 | 
			
		||||
              style={{
 | 
			
		||||
                backgroundColor: color.SurfaceVariant.ContainerActive,
 | 
			
		||||
                maxWidth: toRem(randomNumberBetween(40, 400)),
 | 
			
		||||
                width: '100%',
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
  return (
 | 
			
		||||
    <ReplyLayout
 | 
			
		||||
      userColor={sender ? colorMXID(sender) : undefined}
 | 
			
		||||
      username={
 | 
			
		||||
        sender && (
 | 
			
		||||
          <Text size="T300" truncate>
 | 
			
		||||
            <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    >
 | 
			
		||||
      {replyEvent !== undefined ? (
 | 
			
		||||
        <Text size="T300" truncate>
 | 
			
		||||
          {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
 | 
			
		||||
        </Text>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <LinePlaceholder
 | 
			
		||||
          style={{
 | 
			
		||||
            backgroundColor: color.SurfaceVariant.ContainerActive,
 | 
			
		||||
            maxWidth: toRem(placeholderWidth),
 | 
			
		||||
            width: '100%',
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </ReplyLayout>
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import React, { ComponentProps } from 'react';
 | 
			
		||||
import { Text, as } from 'folds';
 | 
			
		||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -7,21 +7,23 @@ export type TimeProps = {
 | 
			
		|||
  ts: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
 | 
			
		||||
  let time = '';
 | 
			
		||||
  if (compact) {
 | 
			
		||||
    time = timeHourMinute(ts);
 | 
			
		||||
  } else if (today(ts)) {
 | 
			
		||||
    time = timeHourMinute(ts);
 | 
			
		||||
  } else if (yesterday(ts)) {
 | 
			
		||||
    time = `Yesterday ${timeHourMinute(ts)}`;
 | 
			
		||||
  } else {
 | 
			
		||||
    time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
 | 
			
		||||
  }
 | 
			
		||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
 | 
			
		||||
  ({ compact, ts, ...props }, ref) => {
 | 
			
		||||
    let time = '';
 | 
			
		||||
    if (compact) {
 | 
			
		||||
      time = timeHourMinute(ts);
 | 
			
		||||
    } else if (today(ts)) {
 | 
			
		||||
      time = timeHourMinute(ts);
 | 
			
		||||
    } else if (yesterday(ts)) {
 | 
			
		||||
      time = `Yesterday ${timeHourMinute(ts)}`;
 | 
			
		||||
    } else {
 | 
			
		||||
      time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
 | 
			
		||||
      {time}
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
    return (
 | 
			
		||||
      <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
 | 
			
		||||
        {time}
 | 
			
		||||
      </Text>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										199
									
								
								src/app/components/message/content/AudioContent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								src/app/components/message/content/AudioContent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,199 @@
 | 
			
		|||
/* eslint-disable jsx-a11y/media-has-caption */
 | 
			
		||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
 | 
			
		||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
 | 
			
		||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
 | 
			
		||||
import { Range } from 'react-range';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { getFileSrcUrl } from './util';
 | 
			
		||||
import { IAudioInfo } from '../../../../types/matrix/common';
 | 
			
		||||
import {
 | 
			
		||||
  PlayTimeCallback,
 | 
			
		||||
  useMediaLoading,
 | 
			
		||||
  useMediaPlay,
 | 
			
		||||
  useMediaPlayTimeCallback,
 | 
			
		||||
  useMediaSeek,
 | 
			
		||||
  useMediaVolume,
 | 
			
		||||
} from '../../../hooks/media';
 | 
			
		||||
import { useThrottle } from '../../../hooks/useThrottle';
 | 
			
		||||
import { secondsToMinutesAndSeconds } from '../../../utils/common';
 | 
			
		||||
 | 
			
		||||
const PLAY_TIME_THROTTLE_OPS = {
 | 
			
		||||
  wait: 500,
 | 
			
		||||
  immediate: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type RenderMediaControlProps = {
 | 
			
		||||
  after: ReactNode;
 | 
			
		||||
  leftControl: ReactNode;
 | 
			
		||||
  rightControl: ReactNode;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export type AudioContentProps = {
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  info: IAudioInfo;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
  renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function AudioContent({
 | 
			
		||||
  mimeType,
 | 
			
		||||
  url,
 | 
			
		||||
  info,
 | 
			
		||||
  encInfo,
 | 
			
		||||
  renderMediaControl,
 | 
			
		||||
}: AudioContentProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [srcState, loadSrc] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
 | 
			
		||||
      [mx, url, mimeType, encInfo]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const audioRef = useRef<HTMLAudioElement | null>(null);
 | 
			
		||||
 | 
			
		||||
  const [currentTime, setCurrentTime] = useState(0);
 | 
			
		||||
  // duration in seconds. (NOTE: info.duration is in milliseconds)
 | 
			
		||||
  const infoDuration = info.duration ?? 0;
 | 
			
		||||
  const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
 | 
			
		||||
 | 
			
		||||
  const getAudioRef = useCallback(() => audioRef.current, []);
 | 
			
		||||
  const { loading } = useMediaLoading(getAudioRef);
 | 
			
		||||
  const { playing, setPlaying } = useMediaPlay(getAudioRef);
 | 
			
		||||
  const { seek } = useMediaSeek(getAudioRef);
 | 
			
		||||
  const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
 | 
			
		||||
  const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
 | 
			
		||||
    setDuration(d);
 | 
			
		||||
    setCurrentTime(ct);
 | 
			
		||||
  }, []);
 | 
			
		||||
  useMediaPlayTimeCallback(
 | 
			
		||||
    getAudioRef,
 | 
			
		||||
    useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handlePlay = () => {
 | 
			
		||||
    if (srcState.status === AsyncStatus.Success) {
 | 
			
		||||
      setPlaying(!playing);
 | 
			
		||||
    } else if (srcState.status !== AsyncStatus.Loading) {
 | 
			
		||||
      loadSrc();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return renderMediaControl({
 | 
			
		||||
    after: (
 | 
			
		||||
      <Range
 | 
			
		||||
        step={1}
 | 
			
		||||
        min={0}
 | 
			
		||||
        max={duration || 1}
 | 
			
		||||
        values={[currentTime]}
 | 
			
		||||
        onChange={(values) => seek(values[0])}
 | 
			
		||||
        renderTrack={(params) => (
 | 
			
		||||
          <div {...params.props}>
 | 
			
		||||
            {params.children}
 | 
			
		||||
            <ProgressBar
 | 
			
		||||
              as="div"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              size="300"
 | 
			
		||||
              min={0}
 | 
			
		||||
              max={duration}
 | 
			
		||||
              value={currentTime}
 | 
			
		||||
              radii="300"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        renderThumb={(params) => (
 | 
			
		||||
          <Badge
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Solid"
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            outlined
 | 
			
		||||
            {...params.props}
 | 
			
		||||
            style={{
 | 
			
		||||
              ...params.props.style,
 | 
			
		||||
              zIndex: 0,
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
    ),
 | 
			
		||||
    leftControl: (
 | 
			
		||||
      <>
 | 
			
		||||
        <Chip
 | 
			
		||||
          onClick={handlePlay}
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          disabled={srcState.status === AsyncStatus.Loading}
 | 
			
		||||
          before={
 | 
			
		||||
            srcState.status === AsyncStatus.Loading || loading ? (
 | 
			
		||||
              <Spinner variant="Secondary" size="50" />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
 | 
			
		||||
        </Chip>
 | 
			
		||||
 | 
			
		||||
        <Text size="T200">{`${secondsToMinutesAndSeconds(
 | 
			
		||||
          currentTime
 | 
			
		||||
        )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    rightControl: (
 | 
			
		||||
      <>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="Pill"
 | 
			
		||||
          onClick={() => setMute(!mute)}
 | 
			
		||||
          aria-pressed={mute}
 | 
			
		||||
        >
 | 
			
		||||
          <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
        <Range
 | 
			
		||||
          step={0.1}
 | 
			
		||||
          min={0}
 | 
			
		||||
          max={1}
 | 
			
		||||
          values={[volume]}
 | 
			
		||||
          onChange={(values) => setVolume(values[0])}
 | 
			
		||||
          renderTrack={(params) => (
 | 
			
		||||
            <div {...params.props}>
 | 
			
		||||
              {params.children}
 | 
			
		||||
              <ProgressBar
 | 
			
		||||
                style={{ width: toRem(48) }}
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                size="300"
 | 
			
		||||
                min={0}
 | 
			
		||||
                max={1}
 | 
			
		||||
                value={volume}
 | 
			
		||||
                radii="300"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          renderThumb={(params) => (
 | 
			
		||||
            <Badge
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Solid"
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              outlined
 | 
			
		||||
              {...params.props}
 | 
			
		||||
              style={{
 | 
			
		||||
                ...params.props.style,
 | 
			
		||||
                zIndex: 0,
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    children: (
 | 
			
		||||
      <audio controls={false} autoPlay ref={audioRef}>
 | 
			
		||||
        {srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
 | 
			
		||||
      </audio>
 | 
			
		||||
    ),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import { Box, Icon, IconSrc } from 'folds';
 | 
			
		||||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { CompactLayout, ModernLayout } from '../../../components/message';
 | 
			
		||||
import { CompactLayout, ModernLayout } from '..';
 | 
			
		||||
 | 
			
		||||
export type EventContentProps = {
 | 
			
		||||
  messageLayout: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import React, { useCallback, useState } from 'react';
 | 
			
		||||
import React, { ReactNode, useCallback, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
| 
						 | 
				
			
			@ -22,23 +22,13 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		|||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { getFileSrcUrl, getSrcFile } from './util';
 | 
			
		||||
import { bytesToSize } from '../../../utils/common';
 | 
			
		||||
import { TextViewer } from '../../../components/text-viewer';
 | 
			
		||||
import {
 | 
			
		||||
  READABLE_EXT_TO_MIME_TYPE,
 | 
			
		||||
  READABLE_TEXT_MIME_TYPES,
 | 
			
		||||
  getFileNameExt,
 | 
			
		||||
  mimeTypeToExt,
 | 
			
		||||
} from '../../../utils/mimeTypes';
 | 
			
		||||
import { PdfViewer } from '../../../components/Pdf-viewer';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export type FileContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  info: IFileInfo;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
};
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
 | 
			
		||||
const renderErrorButton = (retry: () => void, text: string) => (
 | 
			
		||||
  <TooltipProvider
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +59,20 @@ const renderErrorButton = (retry: () => void, text: string) => (
 | 
			
		|||
  </TooltipProvider>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
 | 
			
		||||
type RenderTextViewerProps = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  text: string;
 | 
			
		||||
  langName: string;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
type ReadTextFileProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
  renderViewer: (props: RenderTextViewerProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [textViewer, setTextViewer] = useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -105,16 +108,14 @@ function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, '
 | 
			
		|||
                size="500"
 | 
			
		||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
              >
 | 
			
		||||
                <TextViewer
 | 
			
		||||
                  name={body}
 | 
			
		||||
                  text={textState.data}
 | 
			
		||||
                  langName={
 | 
			
		||||
                    READABLE_TEXT_MIME_TYPES.includes(mimeType)
 | 
			
		||||
                      ? mimeTypeToExt(mimeType)
 | 
			
		||||
                      : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
 | 
			
		||||
                  }
 | 
			
		||||
                  requestClose={() => setTextViewer(false)}
 | 
			
		||||
                />
 | 
			
		||||
                {renderViewer({
 | 
			
		||||
                  name: body,
 | 
			
		||||
                  text: textState.data,
 | 
			
		||||
                  langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
 | 
			
		||||
                    ? mimeTypeToExt(mimeType)
 | 
			
		||||
                    : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
 | 
			
		||||
                  requestClose: () => setTextViewer(false),
 | 
			
		||||
                })}
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +150,19 @@ function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, '
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
 | 
			
		||||
type RenderPdfViewerProps = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  src: string;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export type ReadPdfFileProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
  renderViewer: (props: RenderPdfViewerProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [pdfViewer, setPdfViewer] = useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -178,11 +191,11 @@ function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'i
 | 
			
		|||
                size="500"
 | 
			
		||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
              >
 | 
			
		||||
                <PdfViewer
 | 
			
		||||
                  name={body}
 | 
			
		||||
                  src={pdfState.data}
 | 
			
		||||
                  requestClose={() => setPdfViewer(false)}
 | 
			
		||||
                />
 | 
			
		||||
                {renderViewer({
 | 
			
		||||
                  name: body,
 | 
			
		||||
                  src: pdfState.data,
 | 
			
		||||
                  requestClose: () => setPdfViewer(false),
 | 
			
		||||
                })}
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
| 
						 | 
				
			
			@ -215,7 +228,14 @@ function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'i
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
 | 
			
		||||
export type DownloadFileProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  info: IFileInfo;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
};
 | 
			
		||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [downloadState, download] = useAsyncCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -253,17 +273,20 @@ function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps)
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FileContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  renderAsTextFile: () => ReactNode;
 | 
			
		||||
  renderAsPdfFile: () => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const FileContent = as<'div', FileContentProps>(
 | 
			
		||||
  ({ body, mimeType, url, info, encInfo, ...props }, ref) => (
 | 
			
		||||
  ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
 | 
			
		||||
    <Box direction="Column" gap="300" {...props} ref={ref}>
 | 
			
		||||
      {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
 | 
			
		||||
        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
 | 
			
		||||
        <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
 | 
			
		||||
      )}
 | 
			
		||||
      {mimeType === 'application/pdf' && (
 | 
			
		||||
        <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
 | 
			
		||||
      )}
 | 
			
		||||
      <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
 | 
			
		||||
        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
 | 
			
		||||
        renderAsTextFile()}
 | 
			
		||||
      {mimeType === 'application/pdf' && renderAsPdfFile()}
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
| 
						 | 
				
			
			@ -23,12 +23,24 @@ import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/ma
 | 
			
		|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { getFileSrcUrl } from './util';
 | 
			
		||||
import { Image } from '../../../components/media';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { bytesToSize } from '../../../utils/common';
 | 
			
		||||
import { ImageViewer } from '../../../components/image-viewer';
 | 
			
		||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
 | 
			
		||||
 | 
			
		||||
type RenderViewerProps = {
 | 
			
		||||
  src: string;
 | 
			
		||||
  alt: string;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
type RenderImageProps = {
 | 
			
		||||
  alt: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  src: string;
 | 
			
		||||
  onLoad: () => void;
 | 
			
		||||
  onError: () => void;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
  tabIndex: number;
 | 
			
		||||
};
 | 
			
		||||
export type ImageContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -36,9 +48,25 @@ export type ImageContentProps = {
 | 
			
		|||
  info?: IImageInfo;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
  autoPlay?: boolean;
 | 
			
		||||
  renderViewer: (props: RenderViewerProps) => ReactNode;
 | 
			
		||||
  renderImage: (props: RenderImageProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const ImageContent = as<'div', ImageContentProps>(
 | 
			
		||||
  ({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      className,
 | 
			
		||||
      body,
 | 
			
		||||
      mimeType,
 | 
			
		||||
      url,
 | 
			
		||||
      info,
 | 
			
		||||
      encInfo,
 | 
			
		||||
      autoPlay,
 | 
			
		||||
      renderViewer,
 | 
			
		||||
      renderImage,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -87,11 +115,11 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
			
		|||
                  size="500"
 | 
			
		||||
                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
                >
 | 
			
		||||
                  <ImageViewer
 | 
			
		||||
                    src={srcState.data}
 | 
			
		||||
                    alt={body}
 | 
			
		||||
                    requestClose={() => setViewer(false)}
 | 
			
		||||
                  />
 | 
			
		||||
                  {renderViewer({
 | 
			
		||||
                    src: srcState.data,
 | 
			
		||||
                    alt: body,
 | 
			
		||||
                    requestClose: () => setViewer(false),
 | 
			
		||||
                  })}
 | 
			
		||||
                </Modal>
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            </OverlayCenter>
 | 
			
		||||
| 
						 | 
				
			
			@ -122,16 +150,15 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
			
		|||
        )}
 | 
			
		||||
        {srcState.status === AsyncStatus.Success && (
 | 
			
		||||
          <Box className={css.AbsoluteContainer}>
 | 
			
		||||
            <Image
 | 
			
		||||
              alt={body}
 | 
			
		||||
              title={body}
 | 
			
		||||
              src={srcState.data}
 | 
			
		||||
              loading="lazy"
 | 
			
		||||
              onLoad={handleLoad}
 | 
			
		||||
              onError={handleError}
 | 
			
		||||
              onClick={() => setViewer(true)}
 | 
			
		||||
              tabIndex={0}
 | 
			
		||||
            />
 | 
			
		||||
            {renderImage({
 | 
			
		||||
              alt: body,
 | 
			
		||||
              title: body,
 | 
			
		||||
              src: srcState.data,
 | 
			
		||||
              onLoad: handleLoad,
 | 
			
		||||
              onError: handleError,
 | 
			
		||||
              onClick: () => setViewer(true),
 | 
			
		||||
              tabIndex: 0,
 | 
			
		||||
            })}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
			
		||||
							
								
								
									
										34
									
								
								src/app/components/message/content/ThumbnailContent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/app/components/message/content/ThumbnailContent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { IThumbnailContent } from '../../../../types/matrix/common';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { getFileSrcUrl } from './util';
 | 
			
		||||
 | 
			
		||||
export type ThumbnailContentProps = {
 | 
			
		||||
  info: IThumbnailContent;
 | 
			
		||||
  renderImage: (src: string) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const thumbInfo = info.thumbnail_info;
 | 
			
		||||
      const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
 | 
			
		||||
      if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
 | 
			
		||||
        throw new Error('Failed to load thumbnail');
 | 
			
		||||
      }
 | 
			
		||||
      return getFileSrcUrl(
 | 
			
		||||
        mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
 | 
			
		||||
        thumbInfo.mimetype,
 | 
			
		||||
        info.thumbnail_file
 | 
			
		||||
      );
 | 
			
		||||
    }, [mx, info])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    loadThumbSrc();
 | 
			
		||||
  }, [loadThumbSrc]);
 | 
			
		||||
 | 
			
		||||
  return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,25 +19,47 @@ import {
 | 
			
		|||
  IVideoInfo,
 | 
			
		||||
  MATRIX_BLUR_HASH_PROPERTY_NAME,
 | 
			
		||||
} from '../../../../types/matrix/common';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { getFileSrcUrl } from './util';
 | 
			
		||||
import { Image, Video } from '../../../components/media';
 | 
			
		||||
import { bytesToSize } from '../../../../util/common';
 | 
			
		||||
import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
 | 
			
		||||
 | 
			
		||||
export type VideoContentProps = {
 | 
			
		||||
type RenderVideoProps = {
 | 
			
		||||
  title: string;
 | 
			
		||||
  src: string;
 | 
			
		||||
  onLoadedMetadata: () => void;
 | 
			
		||||
  onError: () => void;
 | 
			
		||||
  autoPlay: boolean;
 | 
			
		||||
  controls: boolean;
 | 
			
		||||
};
 | 
			
		||||
type VideoContentProps = {
 | 
			
		||||
  body: string;
 | 
			
		||||
  mimeType: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  info: IVideoInfo & IThumbnailContent;
 | 
			
		||||
  encInfo?: EncryptedAttachmentInfo;
 | 
			
		||||
  autoPlay?: boolean;
 | 
			
		||||
  loadThumbnail?: boolean;
 | 
			
		||||
  renderThumbnail?: () => ReactNode;
 | 
			
		||||
  renderVideo: (props: RenderVideoProps) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		||||
  ({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      className,
 | 
			
		||||
      body,
 | 
			
		||||
      mimeType,
 | 
			
		||||
      url,
 | 
			
		||||
      info,
 | 
			
		||||
      encInfo,
 | 
			
		||||
      autoPlay,
 | 
			
		||||
      renderThumbnail,
 | 
			
		||||
      renderVideo,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -50,20 +72,6 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		|||
        [mx, url, mimeType, encInfo]
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
    const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
 | 
			
		||||
      useCallback(() => {
 | 
			
		||||
        const thumbInfo = info.thumbnail_info;
 | 
			
		||||
        const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
 | 
			
		||||
        if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
 | 
			
		||||
          throw new Error('Failed to load thumbnail');
 | 
			
		||||
        }
 | 
			
		||||
        return getFileSrcUrl(
 | 
			
		||||
          mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
 | 
			
		||||
          thumbInfo.mimetype,
 | 
			
		||||
          info.thumbnail_file
 | 
			
		||||
        );
 | 
			
		||||
      }, [mx, info])
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleLoad = () => {
 | 
			
		||||
      setLoad(true);
 | 
			
		||||
| 
						 | 
				
			
			@ -81,9 +89,6 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		|||
    useEffect(() => {
 | 
			
		||||
      if (autoPlay) loadSrc();
 | 
			
		||||
    }, [autoPlay, loadSrc]);
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (loadThumbnail) loadThumbSrc();
 | 
			
		||||
    }, [loadThumbnail, loadThumbSrc]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
 | 
			
		||||
| 
						 | 
				
			
			@ -96,9 +101,9 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		|||
            punch={1}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {thumbSrcState.status === AsyncStatus.Success && !load && (
 | 
			
		||||
        {renderThumbnail && !load && (
 | 
			
		||||
          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
			
		||||
            <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
 | 
			
		||||
            {renderThumbnail()}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        {!autoPlay && srcState.status === AsyncStatus.Idle && (
 | 
			
		||||
| 
						 | 
				
			
			@ -117,14 +122,14 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		|||
        )}
 | 
			
		||||
        {srcState.status === AsyncStatus.Success && (
 | 
			
		||||
          <Box className={css.AbsoluteContainer}>
 | 
			
		||||
            <Video
 | 
			
		||||
              title={body}
 | 
			
		||||
              src={srcState.data}
 | 
			
		||||
              onLoadedMetadata={handleLoad}
 | 
			
		||||
              onError={handleError}
 | 
			
		||||
              autoPlay
 | 
			
		||||
              controls
 | 
			
		||||
            />
 | 
			
		||||
            {renderVideo({
 | 
			
		||||
              title: body,
 | 
			
		||||
              src: srcState.data,
 | 
			
		||||
              onLoadedMetadata: handleLoad,
 | 
			
		||||
              onError: handleError,
 | 
			
		||||
              autoPlay: true,
 | 
			
		||||
              controls: true,
 | 
			
		||||
            })}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
			
		||||
							
								
								
									
										7
									
								
								src/app/components/message/content/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/app/components/message/content/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
export * from './ThumbnailContent';
 | 
			
		||||
export * from './ImageContent';
 | 
			
		||||
export * from './VideoContent';
 | 
			
		||||
export * from './AudioContent';
 | 
			
		||||
export * from './FileContent';
 | 
			
		||||
export * from './FallbackContent';
 | 
			
		||||
export * from './EventContent';
 | 
			
		||||
							
								
								
									
										37
									
								
								src/app/components/message/content/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/components/message/content/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const RelativeBase = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const AbsoluteContainer = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    top: 0,
 | 
			
		||||
    left: 0,
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const AbsoluteFooter = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    bottom: config.space.S100,
 | 
			
		||||
    left: config.space.S100,
 | 
			
		||||
    right: config.space.S100,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ModalWide = style({
 | 
			
		||||
  minWidth: '85vw',
 | 
			
		||||
  minHeight: '90vh',
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -3,5 +3,8 @@ export * from './placeholder';
 | 
			
		|||
export * from './Reaction';
 | 
			
		||||
export * from './attachment';
 | 
			
		||||
export * from './Reply';
 | 
			
		||||
export * from './MessageContentFallback';
 | 
			
		||||
export * from './content';
 | 
			
		||||
export * from './Time';
 | 
			
		||||
export * from './MsgTypeRenderers';
 | 
			
		||||
export * from './FileHeader';
 | 
			
		||||
export * from './RenderBody';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,6 +61,7 @@ const highlightAnime = keyframes({
 | 
			
		|||
const HighlightVariant = styleVariants({
 | 
			
		||||
  true: {
 | 
			
		||||
    animation: `${highlightAnime} 2000ms ease-in-out`,
 | 
			
		||||
    animationIterationCount: 'infinite',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -143,12 +144,14 @@ export const BubbleContent = style({
 | 
			
		|||
});
 | 
			
		||||
 | 
			
		||||
export const Username = style({
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
  overflow: 'hidden',
 | 
			
		||||
  whiteSpace: 'nowrap',
 | 
			
		||||
  textOverflow: 'ellipsis',
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&:hover, &:focus-visible': {
 | 
			
		||||
    'button&': {
 | 
			
		||||
      cursor: 'pointer',
 | 
			
		||||
    },
 | 
			
		||||
    'button&:hover, button&:focus-visible': {
 | 
			
		||||
      textDecoration: 'underline',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										11
									
								
								src/app/components/nav/NavCategory.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/components/nav/NavCategory.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
type NavCategoryProps = {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										19
									
								
								src/app/components/nav/NavCategoryHeader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/components/nav/NavCategoryHeader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Header, as } from 'folds';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export type NavCategoryHeaderProps = {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export const NavCategoryHeader = as<'div', NavCategoryHeaderProps>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Header
 | 
			
		||||
      className={classNames(css.NavCategoryHeader, className)}
 | 
			
		||||
      variant="Background"
 | 
			
		||||
      size="300"
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										40
									
								
								src/app/components/nav/NavEmptyLayout.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/app/components/nav/NavEmptyLayout.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import { Box, config } from 'folds';
 | 
			
		||||
import React, { ReactNode } from 'react';
 | 
			
		||||
 | 
			
		||||
export function NavEmptyCenter({ children }: { children: ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      style={{
 | 
			
		||||
        padding: config.space.S500,
 | 
			
		||||
      }}
 | 
			
		||||
      grow="Yes"
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      justifyContent="Center"
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type NavEmptyLayoutProps = {
 | 
			
		||||
  icon?: ReactNode;
 | 
			
		||||
  title?: ReactNode;
 | 
			
		||||
  content?: ReactNode;
 | 
			
		||||
  options?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function NavEmptyLayout({ icon, title, content, options }: NavEmptyLayoutProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Box direction="Column" alignItems="Center" gap="200">
 | 
			
		||||
        {icon}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" gap="100" alignItems="Center">
 | 
			
		||||
        {title}
 | 
			
		||||
        {content}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" gap="200">
 | 
			
		||||
        {options}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								src/app/components/nav/NavItem.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/app/components/nav/NavItem.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import React, { ComponentProps, forwardRef } from 'react';
 | 
			
		||||
import { Link } from 'react-router-dom';
 | 
			
		||||
import { as } from 'folds';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export const NavItem = as<
 | 
			
		||||
  'div',
 | 
			
		||||
  {
 | 
			
		||||
    highlight?: boolean;
 | 
			
		||||
  } & css.RoomSelectorVariants
 | 
			
		||||
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
 | 
			
		||||
  <AsNavItem
 | 
			
		||||
    className={classNames(css.NavItem({ variant, radii }), className)}
 | 
			
		||||
    data-highlight={highlight}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  >
 | 
			
		||||
    {children}
 | 
			
		||||
  </AsNavItem>
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Link className={classNames(css.NavLink, className)} {...props} ref={ref} />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const NavButton = as<'button'>(
 | 
			
		||||
  ({ as: AsNavButton = 'button', className, ...props }, ref) => (
 | 
			
		||||
    <AsNavButton className={classNames(css.NavLink, className)} {...props} ref={ref} />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/components/nav/NavItemContent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/components/nav/NavItemContent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import React, { ComponentProps } from 'react';
 | 
			
		||||
import { Text, as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Text className={classNames(css.NavItemContent, className)} size="T400" {...props} ref={ref} />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										17
									
								
								src/app/components/nav/NavItemOptions.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/components/nav/NavItemOptions.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import React, { ComponentProps } from 'react';
 | 
			
		||||
import { Box, as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export const NavItemOptions = as<'div', ComponentProps<typeof Box>>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
      className={classNames(css.NavItemOptions, className)}
 | 
			
		||||
      alignItems="Center"
 | 
			
		||||
      shrink="No"
 | 
			
		||||
      gap="0"
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										6
									
								
								src/app/components/nav/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/app/components/nav/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
export * from './NavCategory';
 | 
			
		||||
export * from './NavCategoryHeader';
 | 
			
		||||
export * from './NavEmptyLayout';
 | 
			
		||||
export * from './NavItem';
 | 
			
		||||
export * from './NavItemContent';
 | 
			
		||||
export * from './NavItemOptions';
 | 
			
		||||
							
								
								
									
										127
									
								
								src/app/components/nav/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/app/components/nav/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,127 @@
 | 
			
		|||
import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css';
 | 
			
		||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
 | 
			
		||||
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const NavCategory = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const NavCategoryHeader = style({
 | 
			
		||||
  gap: config.space.S100,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const NavLink = style({
 | 
			
		||||
  color: 'inherit',
 | 
			
		||||
  minWidth: 0,
 | 
			
		||||
  display: 'flex',
 | 
			
		||||
  alignItems: 'center',
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
  flexGrow: 1,
 | 
			
		||||
  ':hover': {
 | 
			
		||||
    textDecoration: 'unset',
 | 
			
		||||
  },
 | 
			
		||||
  ':focus': {
 | 
			
		||||
    outline: 'none',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const Container = createVar();
 | 
			
		||||
const ContainerHover = createVar();
 | 
			
		||||
const ContainerActive = createVar();
 | 
			
		||||
const ContainerLine = createVar();
 | 
			
		||||
const OnContainer = createVar();
 | 
			
		||||
 | 
			
		||||
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
 | 
			
		||||
  vars: {
 | 
			
		||||
    [Container]: color[variant].Container,
 | 
			
		||||
    [ContainerHover]: color[variant].ContainerHover,
 | 
			
		||||
    [ContainerActive]: color[variant].ContainerActive,
 | 
			
		||||
    [ContainerLine]: color[variant].ContainerLine,
 | 
			
		||||
    [OnContainer]: color[variant].OnContainer,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const NavItemBase = style({
 | 
			
		||||
  width: '100%',
 | 
			
		||||
  display: 'flex',
 | 
			
		||||
  justifyContent: 'start',
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
  backgroundColor: Container,
 | 
			
		||||
  color: OnContainer,
 | 
			
		||||
  outline: 'none',
 | 
			
		||||
  minHeight: toRem(38),
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&:hover, &:focus-visible': {
 | 
			
		||||
      backgroundColor: ContainerHover,
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-hover=true]': {
 | 
			
		||||
      backgroundColor: ContainerHover,
 | 
			
		||||
    },
 | 
			
		||||
    [`&:has(.${NavLink}:active)`]: {
 | 
			
		||||
      backgroundColor: ContainerActive,
 | 
			
		||||
    },
 | 
			
		||||
    '&[aria-selected=true]': {
 | 
			
		||||
      backgroundColor: ContainerActive,
 | 
			
		||||
    },
 | 
			
		||||
    [`&:has(.${NavLink}:focus-visible)`]: {
 | 
			
		||||
      outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
 | 
			
		||||
      outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  '@supports': {
 | 
			
		||||
    [`not selector(:has(.${NavLink}:focus-visible))`]: {
 | 
			
		||||
      ':focus-within': {
 | 
			
		||||
        outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
 | 
			
		||||
        outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const NavItem = recipe({
 | 
			
		||||
  base: [DefaultReset, NavItemBase, Disabled],
 | 
			
		||||
  variants: {
 | 
			
		||||
    variant: {
 | 
			
		||||
      Background: getVariant('Background'),
 | 
			
		||||
      Surface: getVariant('Surface'),
 | 
			
		||||
      SurfaceVariant: getVariant('SurfaceVariant'),
 | 
			
		||||
      Primary: getVariant('Primary'),
 | 
			
		||||
      Secondary: getVariant('Secondary'),
 | 
			
		||||
      Success: getVariant('Success'),
 | 
			
		||||
      Warning: getVariant('Warning'),
 | 
			
		||||
      Critical: getVariant('Critical'),
 | 
			
		||||
    },
 | 
			
		||||
    radii: RadiiVariant,
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    variant: 'Surface',
 | 
			
		||||
    radii: '400',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type RoomSelectorVariants = RecipeVariants<typeof NavItem>;
 | 
			
		||||
export const NavItemContent = style({
 | 
			
		||||
  paddingLeft: config.space.S200,
 | 
			
		||||
  paddingRight: config.space.S300,
 | 
			
		||||
  height: 'inherit',
 | 
			
		||||
  minWidth: 0,
 | 
			
		||||
  flexGrow: 1,
 | 
			
		||||
  display: 'flex',
 | 
			
		||||
  alignItems: 'center',
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&:hover': {
 | 
			
		||||
      textDecoration: 'unset',
 | 
			
		||||
    },
 | 
			
		||||
    [`.${NavItemBase}[data-highlight=true] &`]: {
 | 
			
		||||
      fontWeight: config.fontWeight.W600,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const NavItemOptions = style({
 | 
			
		||||
  paddingRight: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										146
									
								
								src/app/components/page/Page.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/app/components/page/Page.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,146 @@
 | 
			
		|||
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
 | 
			
		||||
import { Box, Header, Line, Scroll, Text, as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { ContainerColor } from '../../styles/ContainerColor.css';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 | 
			
		||||
 | 
			
		||||
type PageRootProps = {
 | 
			
		||||
  nav: ReactNode;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function PageRoot({ nav, children }: PageRootProps) {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
 | 
			
		||||
      {nav}
 | 
			
		||||
      {screenSize !== ScreenSize.Mobile && (
 | 
			
		||||
        <Line variant="Background" size="300" direction="Vertical" />
 | 
			
		||||
      )}
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ClientDrawerLayoutProps = {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function PageNav({ children }: ClientDrawerLayoutProps) {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      grow={isMobile ? 'Yes' : undefined}
 | 
			
		||||
      className={css.PageNav}
 | 
			
		||||
      shrink={isMobile ? 'Yes' : 'No'}
 | 
			
		||||
    >
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        {children}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Header
 | 
			
		||||
    className={classNames(css.PageNavHeader, className)}
 | 
			
		||||
    variant="Background"
 | 
			
		||||
    size="600"
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export function PageNavContent({
 | 
			
		||||
  scrollRef,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
  scrollRef?: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" direction="Column">
 | 
			
		||||
      <Scroll
 | 
			
		||||
        ref={scrollRef}
 | 
			
		||||
        variant="Background"
 | 
			
		||||
        direction="Vertical"
 | 
			
		||||
        size="300"
 | 
			
		||||
        hideTrack
 | 
			
		||||
        visibility="Hover"
 | 
			
		||||
      >
 | 
			
		||||
        <div className={css.PageNavContent}>{children}</div>
 | 
			
		||||
      </Scroll>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Page = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Box
 | 
			
		||||
    grow="Yes"
 | 
			
		||||
    direction="Column"
 | 
			
		||||
    className={classNames(ContainerColor({ variant: 'Surface' }), className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const PageHeader = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Header
 | 
			
		||||
    as="header"
 | 
			
		||||
    size="600"
 | 
			
		||||
    className={classNames(css.PageHeader, className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div className={classNames(css.PageContent, className)} {...props} ref={ref} />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      className={classNames(css.PageHeroSection, className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export function PageHero({
 | 
			
		||||
  icon,
 | 
			
		||||
  title,
 | 
			
		||||
  subTitle,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  icon: ReactNode;
 | 
			
		||||
  title: ReactNode;
 | 
			
		||||
  subTitle: ReactNode;
 | 
			
		||||
  children?: ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Box direction="Column" alignItems="Center" gap="200">
 | 
			
		||||
        {icon}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box as="h2" direction="Column" gap="200" alignItems="Center">
 | 
			
		||||
        <Text align="Center" size="H2">
 | 
			
		||||
          {title}
 | 
			
		||||
        </Text>
 | 
			
		||||
        <Text align="Center" priority="400">
 | 
			
		||||
          {subTitle}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/page/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/page/index.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Page';
 | 
			
		||||
							
								
								
									
										69
									
								
								src/app/components/page/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/app/components/page/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,69 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, color, config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const PageNav = style({
 | 
			
		||||
  width: toRem(280),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const PageNavHeader = style({
 | 
			
		||||
  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
 | 
			
		||||
  flexShrink: 0,
 | 
			
		||||
  borderBottomWidth: 1,
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    'button&': {
 | 
			
		||||
      cursor: 'pointer',
 | 
			
		||||
    },
 | 
			
		||||
    'button&[aria-pressed=true]': {
 | 
			
		||||
      backgroundColor: color.Background.ContainerActive,
 | 
			
		||||
    },
 | 
			
		||||
    'button&:hover, button&:focus-visible': {
 | 
			
		||||
      backgroundColor: color.Background.ContainerHover,
 | 
			
		||||
    },
 | 
			
		||||
    'button&:active': {
 | 
			
		||||
      backgroundColor: color.Background.ContainerActive,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const PageNavContent = style({
 | 
			
		||||
  minHeight: '100%',
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
  paddingRight: 0,
 | 
			
		||||
  paddingBottom: config.space.S700,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const PageHeader = style({
 | 
			
		||||
  paddingLeft: config.space.S400,
 | 
			
		||||
  paddingRight: config.space.S200,
 | 
			
		||||
  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const PageContent = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    paddingTop: config.space.S400,
 | 
			
		||||
    paddingLeft: config.space.S400,
 | 
			
		||||
    paddingRight: 0,
 | 
			
		||||
    paddingBottom: toRem(100),
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const PageHeroSection = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    padding: '40px 0',
 | 
			
		||||
    maxWidth: toRem(466),
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    margin: 'auto',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const PageContentCenter = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    maxWidth: toRem(964),
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    margin: 'auto',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
							
								
								
									
										14
									
								
								src/app/components/room-avatar/RoomAvatar.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/app/components/room-avatar/RoomAvatar.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { color } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const RoomAvatar = style({
 | 
			
		||||
  backgroundColor: color.Secondary.Container,
 | 
			
		||||
  color: color.Secondary.OnContainer,
 | 
			
		||||
  textTransform: 'capitalize',
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-image-loaded="true"]': {
 | 
			
		||||
      backgroundColor: 'transparent',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										56
									
								
								src/app/components/room-avatar/RoomAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/app/components/room-avatar/RoomAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
import { JoinRule } from 'matrix-js-sdk';
 | 
			
		||||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
 | 
			
		||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
 | 
			
		||||
import * as css from './RoomAvatar.css';
 | 
			
		||||
import { joinRuleToIconSrc } from '../../utils/room';
 | 
			
		||||
import colorMXID from '../../../util/colorMXID';
 | 
			
		||||
 | 
			
		||||
type RoomAvatarProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  src?: string;
 | 
			
		||||
  alt?: string;
 | 
			
		||||
  renderFallback: () => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
 | 
			
		||||
    evt.currentTarget.setAttribute('data-image-loaded', 'true');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!src || error) {
 | 
			
		||||
    return (
 | 
			
		||||
      <AvatarFallback
 | 
			
		||||
        style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
 | 
			
		||||
        className={css.RoomAvatar}
 | 
			
		||||
      >
 | 
			
		||||
        {renderFallback()}
 | 
			
		||||
      </AvatarFallback>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AvatarImage
 | 
			
		||||
      className={css.RoomAvatar}
 | 
			
		||||
      src={src}
 | 
			
		||||
      alt={alt}
 | 
			
		||||
      onError={() => setError(true)}
 | 
			
		||||
      onLoad={handleLoad}
 | 
			
		||||
      draggable={false}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const RoomIcon = forwardRef<
 | 
			
		||||
  SVGSVGElement,
 | 
			
		||||
  Omit<ComponentProps<typeof Icon>, 'src'> & {
 | 
			
		||||
    joinRule: JoinRule;
 | 
			
		||||
    space?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
>(({ joinRule, space, ...props }, ref) => (
 | 
			
		||||
  <Icon
 | 
			
		||||
    src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/room-avatar/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/room-avatar/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './RoomAvatar';
 | 
			
		||||
							
								
								
									
										314
									
								
								src/app/components/room-card/RoomCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										314
									
								
								src/app/components/room-card/RoomCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,314 @@
 | 
			
		|||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
 | 
			
		||||
import { MatrixError, Room } from 'matrix-js-sdk';
 | 
			
		||||
import {
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
  as,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { RoomAvatar } from '../room-avatar';
 | 
			
		||||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { millify } from '../../plugins/millify';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { onEnterOrSpace } from '../../utils/keyboard';
 | 
			
		||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
 | 
			
		||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
 | 
			
		||||
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
 | 
			
		||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
 | 
			
		||||
 | 
			
		||||
type GridColumnCount = '1' | '2' | '3';
 | 
			
		||||
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
 | 
			
		||||
  if (gridWidth <= 498) return '1';
 | 
			
		||||
  if (gridWidth <= 748) return '2';
 | 
			
		||||
  return '3';
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => {
 | 
			
		||||
  grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function RoomCardGrid({ children }: { children: ReactNode }) {
 | 
			
		||||
  const gridRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  useElementSizeObserver(
 | 
			
		||||
    useCallback(() => gridRef.current, []),
 | 
			
		||||
    useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box className={css.CardGrid} direction="Row" gap="400" wrap="Wrap" ref={gridRef}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Box
 | 
			
		||||
    direction="Column"
 | 
			
		||||
    gap="300"
 | 
			
		||||
    className={classNames(css.RoomCardBase, className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const RoomCardName = as<'h6'>(({ ...props }, ref) => (
 | 
			
		||||
  <Text as="h6" size="H6" truncate {...props} ref={ref} />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Text
 | 
			
		||||
    as="p"
 | 
			
		||||
    size="T200"
 | 
			
		||||
    className={classNames(css.RoomCardTopic, className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
    priority="400"
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
function ErrorDialog({
 | 
			
		||||
  title,
 | 
			
		||||
  message,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  title: string;
 | 
			
		||||
  message: string;
 | 
			
		||||
  children: (openError: () => void) => ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  const [viewError, setViewError] = useState(false);
 | 
			
		||||
  const closeError = () => setViewError(false);
 | 
			
		||||
  const openError = () => setViewError(true);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {children(openError)}
 | 
			
		||||
      <Overlay open={viewError} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              onDeactivate: closeError,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="100">
 | 
			
		||||
                  <Text>{title}</Text>
 | 
			
		||||
                  <Text style={{ color: color.Critical.Main }} size="T300" priority="400">
 | 
			
		||||
                    {message}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button size="400" variant="Secondary" fill="Soft" onClick={closeError}>
 | 
			
		||||
                  <Text size="B400">Cancel</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RoomCardProps = {
 | 
			
		||||
  roomIdOrAlias: string;
 | 
			
		||||
  allRooms: string[];
 | 
			
		||||
  avatarUrl?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  topic?: string;
 | 
			
		||||
  memberCount?: number;
 | 
			
		||||
  roomType?: string;
 | 
			
		||||
  onView?: (roomId: string) => void;
 | 
			
		||||
  renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const RoomCard = as<'div', RoomCardProps>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      roomIdOrAlias,
 | 
			
		||||
      allRooms,
 | 
			
		||||
      avatarUrl,
 | 
			
		||||
      name,
 | 
			
		||||
      topic,
 | 
			
		||||
      memberCount,
 | 
			
		||||
      roomType,
 | 
			
		||||
      onView,
 | 
			
		||||
      renderTopicViewer,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
 | 
			
		||||
    const joinedRoom = mx.getRoom(joinedRoomId);
 | 
			
		||||
    const [topicEvent, setTopicEvent] = useState(() =>
 | 
			
		||||
      joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
 | 
			
		||||
    const fallbackTopic = roomIdOrAlias;
 | 
			
		||||
 | 
			
		||||
    const avatar = joinedRoom
 | 
			
		||||
      ? getRoomAvatarUrl(mx, joinedRoom, 96)
 | 
			
		||||
      : avatarUrl && mx.mxcUrlToHttp(avatarUrl, 96, 96, 'crop');
 | 
			
		||||
 | 
			
		||||
    const roomName = joinedRoom?.name || name || fallbackName;
 | 
			
		||||
    const roomTopic =
 | 
			
		||||
      (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
 | 
			
		||||
    const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
 | 
			
		||||
 | 
			
		||||
    useStateEventCallback(
 | 
			
		||||
      mx,
 | 
			
		||||
      useCallback(
 | 
			
		||||
        (event) => {
 | 
			
		||||
          if (
 | 
			
		||||
            joinedRoom &&
 | 
			
		||||
            event.getRoomId() === joinedRoom.roomId &&
 | 
			
		||||
            event.getType() === StateEvent.RoomTopic
 | 
			
		||||
          ) {
 | 
			
		||||
            setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        [joinedRoom]
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
 | 
			
		||||
      useCallback(() => mx.joinRoom(roomIdOrAlias), [mx, roomIdOrAlias])
 | 
			
		||||
    );
 | 
			
		||||
    const joining =
 | 
			
		||||
      joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
 | 
			
		||||
 | 
			
		||||
    const [viewTopic, setViewTopic] = useState(false);
 | 
			
		||||
    const closeTopic = () => setViewTopic(false);
 | 
			
		||||
    const openTopic = () => setViewTopic(true);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <RoomCardBase {...props} ref={ref}>
 | 
			
		||||
        <Box gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
          <Avatar size="500">
 | 
			
		||||
            <RoomAvatar
 | 
			
		||||
              roomId={roomIdOrAlias}
 | 
			
		||||
              src={avatar ?? undefined}
 | 
			
		||||
              alt={roomIdOrAlias}
 | 
			
		||||
              renderFallback={() => (
 | 
			
		||||
                <Text as="span" size="H3">
 | 
			
		||||
                  {nameInitials(roomName)}
 | 
			
		||||
                </Text>
 | 
			
		||||
              )}
 | 
			
		||||
            />
 | 
			
		||||
          </Avatar>
 | 
			
		||||
          {(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
 | 
			
		||||
            <Badge variant="Secondary" fill="Soft" outlined>
 | 
			
		||||
              <Text size="L400">Space</Text>
 | 
			
		||||
            </Badge>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <RoomCardName>{roomName}</RoomCardName>
 | 
			
		||||
          <RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
 | 
			
		||||
            {roomTopic}
 | 
			
		||||
          </RoomCardTopic>
 | 
			
		||||
 | 
			
		||||
          <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
            <OverlayCenter>
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  onDeactivate: closeTopic,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                {renderTopicViewer(roomName, roomTopic, closeTopic)}
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            </OverlayCenter>
 | 
			
		||||
          </Overlay>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {typeof joinedMemberCount === 'number' && (
 | 
			
		||||
          <Box gap="100">
 | 
			
		||||
            <Icon size="50" src={Icons.User} />
 | 
			
		||||
            <Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        {typeof joinedRoomId === 'string' && (
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={onView ? () => onView(joinedRoomId) : undefined}
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            size="300"
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300" truncate>
 | 
			
		||||
              View
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
        {typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={join}
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            size="300"
 | 
			
		||||
            disabled={joining}
 | 
			
		||||
            before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300" truncate>
 | 
			
		||||
              {joining ? 'Joining' : 'Join'}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
        {typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
 | 
			
		||||
          <Box gap="200">
 | 
			
		||||
            <Button
 | 
			
		||||
              onClick={join}
 | 
			
		||||
              className={css.ActionButton}
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="Solid"
 | 
			
		||||
              size="300"
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300" truncate>
 | 
			
		||||
                Retry
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
            <ErrorDialog
 | 
			
		||||
              title="Join Error"
 | 
			
		||||
              message={joinState.error.message || 'Failed to join. Unknown Error.'}
 | 
			
		||||
            >
 | 
			
		||||
              {(openError) => (
 | 
			
		||||
                <Button
 | 
			
		||||
                  onClick={openError}
 | 
			
		||||
                  className={css.ActionButton}
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  fill="Soft"
 | 
			
		||||
                  outlined
 | 
			
		||||
                  size="300"
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300" truncate>
 | 
			
		||||
                    View Error
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              )}
 | 
			
		||||
            </ErrorDialog>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </RoomCardBase>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/room-card/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/room-card/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './RoomCard';
 | 
			
		||||
							
								
								
									
										36
									
								
								src/app/components/room-card/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/app/components/room-card/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, config } from 'folds';
 | 
			
		||||
import { ContainerColor } from '../../styles/ContainerColor.css';
 | 
			
		||||
 | 
			
		||||
export const CardGrid = style({
 | 
			
		||||
  display: 'grid',
 | 
			
		||||
  gridTemplateColumns: 'repeat(3, 1fr)',
 | 
			
		||||
  gap: config.space.S400,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const RoomCardBase = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  ContainerColor({ variant: 'SurfaceVariant' }),
 | 
			
		||||
  {
 | 
			
		||||
    padding: config.space.S500,
 | 
			
		||||
    borderRadius: config.radii.R500,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const RoomCardTopic = style({
 | 
			
		||||
  minHeight: `calc(3 * ${config.lineHeight.T200})`,
 | 
			
		||||
  display: '-webkit-box',
 | 
			
		||||
  WebkitLineClamp: 3,
 | 
			
		||||
  WebkitBoxOrient: 'vertical',
 | 
			
		||||
  overflow: 'hidden',
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
 | 
			
		||||
  ':hover': {
 | 
			
		||||
    textDecoration: 'underline',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const ActionButton = style({
 | 
			
		||||
  flex: '1 1 0',
 | 
			
		||||
  minWidth: 1,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,14 +1,19 @@
 | 
			
		|||
import React, { useCallback } from 'react';
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
 | 
			
		||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
 | 
			
		||||
import { Room } from 'matrix-js-sdk';
 | 
			
		||||
import { openInviteUser, selectRoom } from '../../../client/action/navigation';
 | 
			
		||||
import { useStateEvent } from '../../hooks/useStateEvent';
 | 
			
		||||
import { useAtomValue } from 'jotai';
 | 
			
		||||
import { openInviteUser } from '../../../client/action/navigation';
 | 
			
		||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
 | 
			
		||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		||||
import { RoomAvatar } from '../room-avatar';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 | 
			
		||||
import { mDirectAtom } from '../../state/mDirectList';
 | 
			
		||||
 | 
			
		||||
export type RoomIntroProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -16,21 +21,21 @@ export type RoomIntroProps = {
 | 
			
		|||
 | 
			
		||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const createEvent = getStateEvent(room, StateEvent.RoomCreate);
 | 
			
		||||
  const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
 | 
			
		||||
  const nameEvent = useStateEvent(room, StateEvent.RoomName);
 | 
			
		||||
  const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
 | 
			
		||||
  const createContent = createEvent?.getContent<IRoomCreateContent>();
 | 
			
		||||
  const { navigateRoom } = useRoomNavigate();
 | 
			
		||||
  const mDirects = useAtomValue(mDirectAtom);
 | 
			
		||||
 | 
			
		||||
  const createEvent = getStateEvent(room, StateEvent.RoomCreate);
 | 
			
		||||
  const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
 | 
			
		||||
  const name = useRoomName(room);
 | 
			
		||||
  const topic = useRoomTopic(room);
 | 
			
		||||
  const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
 | 
			
		||||
 | 
			
		||||
  const createContent = createEvent?.getContent<IRoomCreateContent>();
 | 
			
		||||
  const ts = createEvent?.getTs();
 | 
			
		||||
  const creatorId = createEvent?.getSender();
 | 
			
		||||
  const creatorName =
 | 
			
		||||
    creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
 | 
			
		||||
  const prevRoomId = createContent?.predecessor?.room_id;
 | 
			
		||||
  const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
 | 
			
		||||
  const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
 | 
			
		||||
  const name = (nameEvent?.getContent().name || room.name) as string;
 | 
			
		||||
  const topic = (topicEvent?.getContent().topic as string) || undefined;
 | 
			
		||||
 | 
			
		||||
  const [prevRoomState, joinPrevRoom] = useAsyncCallback(
 | 
			
		||||
    useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
 | 
			
		||||
| 
						 | 
				
			
			@ -40,18 +45,12 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
 | 
			
		|||
    <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
 | 
			
		||||
      <Box>
 | 
			
		||||
        <Avatar size="500">
 | 
			
		||||
          {avatarHttpUrl ? (
 | 
			
		||||
            <AvatarImage src={avatarHttpUrl} alt={name} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <AvatarFallback
 | 
			
		||||
              style={{
 | 
			
		||||
                backgroundColor: color.SurfaceVariant.Container,
 | 
			
		||||
                color: color.SurfaceVariant.OnContainer,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="H2">{name[0]}</Text>
 | 
			
		||||
            </AvatarFallback>
 | 
			
		||||
          )}
 | 
			
		||||
          <RoomAvatar
 | 
			
		||||
            roomId={room.roomId}
 | 
			
		||||
            src={avatarHttpUrl ?? undefined}
 | 
			
		||||
            alt={name}
 | 
			
		||||
            renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" gap="300">
 | 
			
		||||
| 
						 | 
				
			
			@ -82,7 +81,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
 | 
			
		|||
          {typeof prevRoomId === 'string' &&
 | 
			
		||||
            (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
 | 
			
		||||
              <Button
 | 
			
		||||
                onClick={() => selectRoom(prevRoomId)}
 | 
			
		||||
                onClick={() => navigateRoom(prevRoomId)}
 | 
			
		||||
                variant="Success"
 | 
			
		||||
                size="300"
 | 
			
		||||
                fill="Soft"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										41
									
								
								src/app/components/room-topic-viewer/RoomTopicViewer.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/app/components/room-topic-viewer/RoomTopicViewer.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import Linkify from 'linkify-react';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 | 
			
		||||
 | 
			
		||||
export const RoomTopicViewer = as<
 | 
			
		||||
  'div',
 | 
			
		||||
  {
 | 
			
		||||
    name: string;
 | 
			
		||||
    topic: string;
 | 
			
		||||
    requestClose: () => void;
 | 
			
		||||
  }
 | 
			
		||||
>(({ name, topic, requestClose, className, ...props }, ref) => (
 | 
			
		||||
  <Modal
 | 
			
		||||
    size="300"
 | 
			
		||||
    flexHeight
 | 
			
		||||
    className={classNames(css.ModalFlex, className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  >
 | 
			
		||||
    <Header className={css.ModalHeader} variant="Surface" size="500">
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Text size="H4" truncate>
 | 
			
		||||
          {name}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <IconButton size="300" onClick={requestClose} radii="300">
 | 
			
		||||
        <Icon src={Icons.Cross} />
 | 
			
		||||
      </IconButton>
 | 
			
		||||
    </Header>
 | 
			
		||||
    <Scroll className={css.ModalScroll} size="300" hideTrack>
 | 
			
		||||
      <Box className={css.ModalContent} direction="Column" gap="100">
 | 
			
		||||
        <Text size="T300" className={css.ModalTopic} priority="400">
 | 
			
		||||
          <Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topic)}</Linkify>
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Scroll>
 | 
			
		||||
  </Modal>
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/room-topic-viewer/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/room-topic-viewer/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './RoomTopicViewer';
 | 
			
		||||
							
								
								
									
										23
									
								
								src/app/components/room-topic-viewer/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/app/components/room-topic-viewer/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const ModalFlex = style({
 | 
			
		||||
  display: 'flex',
 | 
			
		||||
  flexDirection: 'column',
 | 
			
		||||
});
 | 
			
		||||
export const ModalHeader = style({
 | 
			
		||||
  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
});
 | 
			
		||||
export const ModalScroll = style({
 | 
			
		||||
  flexGrow: 1,
 | 
			
		||||
});
 | 
			
		||||
export const ModalContent = style({
 | 
			
		||||
  padding: config.space.S400,
 | 
			
		||||
  paddingRight: config.space.S200,
 | 
			
		||||
  paddingBottom: config.space.S700,
 | 
			
		||||
});
 | 
			
		||||
export const ModalTopic = style({
 | 
			
		||||
  whiteSpace: 'pre-wrap',
 | 
			
		||||
  wordBreak: 'break-word',
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
import React, { RefObject, useCallback, useState } from 'react';
 | 
			
		||||
import { Box, as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import {
 | 
			
		||||
  getIntersectionObserverEntry,
 | 
			
		||||
  useIntersectionObserver,
 | 
			
		||||
} from '../../hooks/useIntersectionObserver';
 | 
			
		||||
 | 
			
		||||
export const ScrollTopContainer = as<
 | 
			
		||||
  'div',
 | 
			
		||||
  {
 | 
			
		||||
    scrollRef?: RefObject<HTMLElement>;
 | 
			
		||||
    anchorRef: RefObject<HTMLElement>;
 | 
			
		||||
    onVisibilityChange?: (onTop: boolean) => void;
 | 
			
		||||
  }
 | 
			
		||||
>(({ className, scrollRef, anchorRef, onVisibilityChange, ...props }, ref) => {
 | 
			
		||||
  const [onTop, setOnTop] = useState(true);
 | 
			
		||||
 | 
			
		||||
  useIntersectionObserver(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (intersectionEntries) => {
 | 
			
		||||
        if (!anchorRef.current) return;
 | 
			
		||||
        const entry = getIntersectionObserverEntry(anchorRef.current, intersectionEntries);
 | 
			
		||||
        if (entry) {
 | 
			
		||||
          setOnTop(entry.isIntersecting);
 | 
			
		||||
          onVisibilityChange?.(entry.isIntersecting);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [anchorRef, onVisibilityChange]
 | 
			
		||||
    ),
 | 
			
		||||
    useCallback(() => ({ root: scrollRef?.current }), [scrollRef]),
 | 
			
		||||
    useCallback(() => anchorRef.current, [anchorRef])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (onTop) return null;
 | 
			
		||||
 | 
			
		||||
  return <Box className={classNames(css.ScrollTopContainer, className)} {...props} ref={ref} />;
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/scroll-top-container/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/scroll-top-container/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './ScrollTopContainer';
 | 
			
		||||
							
								
								
									
										20
									
								
								src/app/components/scroll-top-container/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/app/components/scroll-top-container/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { keyframes, style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
const ScrollContainerAnime = keyframes({
 | 
			
		||||
  '0%': {
 | 
			
		||||
    transform: `translate(-50%, -100%) scale(0)`,
 | 
			
		||||
  },
 | 
			
		||||
  '100%': {
 | 
			
		||||
    transform: `translate(-50%, 0) scale(1)`,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const ScrollTopContainer = style({
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
  top: config.space.S200,
 | 
			
		||||
  left: '50%',
 | 
			
		||||
  transform: 'translateX(-50%)',
 | 
			
		||||
  zIndex: config.zIndex.Z100,
 | 
			
		||||
  animation: `${ScrollContainerAnime} 100ms`,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										18
									
								
								src/app/components/sequence-card/SequenceCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/app/components/sequence-card/SequenceCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import React, { ComponentProps } from 'react';
 | 
			
		||||
import { Box, as } from 'folds';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { ContainerColor, ContainerColorVariants } from '../../styles/ContainerColor.css';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
 | 
			
		||||
export const SequenceCard = as<
 | 
			
		||||
  'div',
 | 
			
		||||
  ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
 | 
			
		||||
>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
 | 
			
		||||
  <Box
 | 
			
		||||
    className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
 | 
			
		||||
    data-first-child={firstChild}
 | 
			
		||||
    data-last-child={lastChild}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/sequence-card/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/sequence-card/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './SequenceCard';
 | 
			
		||||
							
								
								
									
										52
									
								
								src/app/components/sequence-card/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/app/components/sequence-card/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
import { createVar } from '@vanilla-extract/css';
 | 
			
		||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
const outlinedWidth = createVar('0');
 | 
			
		||||
export const SequenceCard = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    vars: {
 | 
			
		||||
      [outlinedWidth]: '0',
 | 
			
		||||
    },
 | 
			
		||||
    borderStyle: 'solid',
 | 
			
		||||
    borderWidth: outlinedWidth,
 | 
			
		||||
    borderBottomWidth: 0,
 | 
			
		||||
    selectors: {
 | 
			
		||||
      '&:first-child, :not(&) + &': {
 | 
			
		||||
        borderTopLeftRadius: config.radii.R400,
 | 
			
		||||
        borderTopRightRadius: config.radii.R400,
 | 
			
		||||
      },
 | 
			
		||||
      '&:last-child, &:not(:has(+&))': {
 | 
			
		||||
        borderBottomLeftRadius: config.radii.R400,
 | 
			
		||||
        borderBottomRightRadius: config.radii.R400,
 | 
			
		||||
        borderBottomWidth: outlinedWidth,
 | 
			
		||||
      },
 | 
			
		||||
      [`&[data-first-child="true"]`]: {
 | 
			
		||||
        borderTopLeftRadius: config.radii.R400,
 | 
			
		||||
        borderTopRightRadius: config.radii.R400,
 | 
			
		||||
      },
 | 
			
		||||
      [`&[data-first-child="false"]`]: {
 | 
			
		||||
        borderTopLeftRadius: 0,
 | 
			
		||||
        borderTopRightRadius: 0,
 | 
			
		||||
      },
 | 
			
		||||
      [`&[data-last-child="true"]`]: {
 | 
			
		||||
        borderBottomLeftRadius: config.radii.R400,
 | 
			
		||||
        borderBottomRightRadius: config.radii.R400,
 | 
			
		||||
      },
 | 
			
		||||
      [`&[data-last-child="false"]`]: {
 | 
			
		||||
        borderBottomLeftRadius: 0,
 | 
			
		||||
        borderBottomRightRadius: 0,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        vars: {
 | 
			
		||||
          [outlinedWidth]: config.borderWidth.B300,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { createVar, style } from '@vanilla-extract/css';
 | 
			
		||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
 | 
			
		||||
import { color, config, DefaultReset, toRem } from 'folds';
 | 
			
		||||
import { color, config, DefaultReset, Disabled, FocusOutline, toRem } from 'folds';
 | 
			
		||||
import { ContainerColor } from '../../styles/ContainerColor.css';
 | 
			
		||||
 | 
			
		||||
export const Sidebar = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,13 +29,49 @@ export const SidebarStack = style([
 | 
			
		|||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const DropLineDist = createVar();
 | 
			
		||||
export const DropTarget = style({
 | 
			
		||||
  vars: {
 | 
			
		||||
    [DropLineDist]: toRem(-8),
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-inside-folder=true]': {
 | 
			
		||||
      vars: {
 | 
			
		||||
        [DropLineDist]: toRem(-6),
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-drop-child=true]': {
 | 
			
		||||
      outline: `${config.borderWidth.B700} solid ${color.Success.Main}`,
 | 
			
		||||
      borderRadius: config.radii.R400,
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-drop-above=true]::after, &[data-drop-below=true]::after': {
 | 
			
		||||
      content: '',
 | 
			
		||||
      display: 'block',
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      left: toRem(0),
 | 
			
		||||
      width: '100%',
 | 
			
		||||
      height: config.borderWidth.B700,
 | 
			
		||||
      backgroundColor: color.Success.Main,
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-drop-above=true]::after': {
 | 
			
		||||
      top: DropLineDist,
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-drop-below=true]::after': {
 | 
			
		||||
      bottom: DropLineDist,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const PUSH_X = 2;
 | 
			
		||||
export const SidebarAvatarBox = recipe({
 | 
			
		||||
export const SidebarItem = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    DefaultReset,
 | 
			
		||||
    {
 | 
			
		||||
      minWidth: toRem(42),
 | 
			
		||||
      display: 'flex',
 | 
			
		||||
      alignItems: 'center',
 | 
			
		||||
      justifyContent: 'center',
 | 
			
		||||
      position: 'relative',
 | 
			
		||||
      transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +96,8 @@ export const SidebarAvatarBox = recipe({
 | 
			
		|||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    Disabled,
 | 
			
		||||
    DropTarget,
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    active: {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,26 +115,27 @@ export const SidebarAvatarBox = recipe({
 | 
			
		|||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SidebarItemVariants = RecipeVariants<typeof SidebarItem>;
 | 
			
		||||
 | 
			
		||||
export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
 | 
			
		||||
 | 
			
		||||
export const SidebarBadgeBox = recipe({
 | 
			
		||||
export const SidebarItemBadge = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    DefaultReset,
 | 
			
		||||
    {
 | 
			
		||||
      pointerEvents: 'none',
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      zIndex: 1,
 | 
			
		||||
      lineHeight: 0,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    hasCount: {
 | 
			
		||||
      true: {
 | 
			
		||||
        top: toRem(-6),
 | 
			
		||||
        right: toRem(-6),
 | 
			
		||||
        left: toRem(-6),
 | 
			
		||||
      },
 | 
			
		||||
      false: {
 | 
			
		||||
        top: toRem(-2),
 | 
			
		||||
        right: toRem(-2),
 | 
			
		||||
        left: toRem(-2),
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -103,9 +143,107 @@ export const SidebarBadgeBox = recipe({
 | 
			
		|||
    hasCount: false,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
 | 
			
		||||
 | 
			
		||||
export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
 | 
			
		||||
 | 
			
		||||
export const SidebarBadgeOutline = style({
 | 
			
		||||
  boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
 | 
			
		||||
export const SidebarAvatar = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    {
 | 
			
		||||
      selectors: {
 | 
			
		||||
        'button&': {
 | 
			
		||||
          cursor: 'pointer',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    size: {
 | 
			
		||||
      '200': {
 | 
			
		||||
        width: toRem(16),
 | 
			
		||||
        height: toRem(16),
 | 
			
		||||
        fontSize: toRem(10),
 | 
			
		||||
        lineHeight: config.lineHeight.T200,
 | 
			
		||||
        letterSpacing: config.letterSpacing.T200,
 | 
			
		||||
      },
 | 
			
		||||
      '300': {
 | 
			
		||||
        width: toRem(34),
 | 
			
		||||
        height: toRem(34),
 | 
			
		||||
      },
 | 
			
		||||
      '400': {
 | 
			
		||||
        width: toRem(42),
 | 
			
		||||
        height: toRem(42),
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    size: '400',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
 | 
			
		||||
 | 
			
		||||
export const SidebarFolder = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    ContainerColor({ variant: 'Background' }),
 | 
			
		||||
    {
 | 
			
		||||
      padding: config.space.S100,
 | 
			
		||||
      width: toRem(42),
 | 
			
		||||
      minHeight: toRem(42),
 | 
			
		||||
      display: 'flex',
 | 
			
		||||
      flexWrap: 'wrap',
 | 
			
		||||
      outline: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
 | 
			
		||||
      position: 'relative',
 | 
			
		||||
 | 
			
		||||
      selectors: {
 | 
			
		||||
        'button&': {
 | 
			
		||||
          cursor: 'pointer',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    FocusOutline,
 | 
			
		||||
    DropTarget,
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    state: {
 | 
			
		||||
      Close: {
 | 
			
		||||
        gap: toRem(2),
 | 
			
		||||
        borderRadius: config.radii.R400,
 | 
			
		||||
      },
 | 
			
		||||
      Open: {
 | 
			
		||||
        paddingLeft: 0,
 | 
			
		||||
        paddingRight: 0,
 | 
			
		||||
        flexDirection: 'column',
 | 
			
		||||
        alignItems: 'center',
 | 
			
		||||
        gap: config.space.S200,
 | 
			
		||||
        borderRadius: config.radii.R500,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    state: 'Close',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SidebarFolderVariants = RecipeVariants<typeof SidebarFolder>;
 | 
			
		||||
 | 
			
		||||
export const SidebarFolderDropTarget = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: toRem(8),
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    left: 0,
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    position: {
 | 
			
		||||
      Top: {
 | 
			
		||||
        top: toRem(-4),
 | 
			
		||||
      },
 | 
			
		||||
      Bottom: {
 | 
			
		||||
        bottom: toRem(-4),
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type SidebarFolderDropTargetVariants = RecipeVariants<typeof SidebarFolderDropTarget>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,75 +0,0 @@
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
 | 
			
		||||
import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
 | 
			
		||||
import * as css from './Sidebar.css';
 | 
			
		||||
 | 
			
		||||
const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
 | 
			
		||||
  ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
 | 
			
		||||
    <AsSidebarAvatarBox
 | 
			
		||||
      className={classNames(css.SidebarAvatarBox({ active }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const SidebarAvatar = forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
  css.SidebarAvatarBoxVariants &
 | 
			
		||||
    css.SidebarBadgeBoxVariants & {
 | 
			
		||||
      outlined?: boolean;
 | 
			
		||||
      avatarChildren: ReactNode;
 | 
			
		||||
      tooltip: ReactNode | string;
 | 
			
		||||
      notificationBadge?: (badgeClassName: string) => ReactNode;
 | 
			
		||||
      onClick?: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
      onContextMenu?: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
    }
 | 
			
		||||
>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      active,
 | 
			
		||||
      hasCount,
 | 
			
		||||
      outlined,
 | 
			
		||||
      avatarChildren,
 | 
			
		||||
      tooltip,
 | 
			
		||||
      notificationBadge,
 | 
			
		||||
      onClick,
 | 
			
		||||
      onContextMenu,
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => (
 | 
			
		||||
    <SidebarAvatarBox active={active} ref={ref}>
 | 
			
		||||
      <TooltipProvider
 | 
			
		||||
        delay={0}
 | 
			
		||||
        position="Right"
 | 
			
		||||
        tooltip={
 | 
			
		||||
          <Tooltip>
 | 
			
		||||
            <Text size="T300">{tooltip}</Text>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        {(avRef) => (
 | 
			
		||||
          <Avatar
 | 
			
		||||
            ref={avRef}
 | 
			
		||||
            as="button"
 | 
			
		||||
            onClick={onClick}
 | 
			
		||||
            onContextMenu={onContextMenu}
 | 
			
		||||
            style={{
 | 
			
		||||
              border: outlined
 | 
			
		||||
                ? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
 | 
			
		||||
                : undefined,
 | 
			
		||||
              cursor: 'pointer',
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {avatarChildren}
 | 
			
		||||
          </Avatar>
 | 
			
		||||
        )}
 | 
			
		||||
      </TooltipProvider>
 | 
			
		||||
      {notificationBadge && (
 | 
			
		||||
        <Box className={css.SidebarBadgeBox({ hasCount })}>
 | 
			
		||||
          {notificationBadge(css.SidebarBadgeOutline)}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
    </SidebarAvatarBox>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Box, Scroll } from 'folds';
 | 
			
		||||
import { Box } from 'folds';
 | 
			
		||||
 | 
			
		||||
type SidebarContentProps = {
 | 
			
		||||
  scrollable: ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -9,9 +9,7 @@ export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
 | 
			
		|||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Box direction="Column" grow="Yes">
 | 
			
		||||
        <Scroll variant="Background" size="0">
 | 
			
		||||
          {scrollable}
 | 
			
		||||
        </Scroll>
 | 
			
		||||
        {scrollable}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" shrink="No">
 | 
			
		||||
        {sticky}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										81
									
								
								src/app/components/sidebar/SidebarItem.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/app/components/sidebar/SidebarItem.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import { as, Avatar, Text, Tooltip, TooltipProvider, toRem } from 'folds';
 | 
			
		||||
import React, { ComponentProps, ReactNode, RefCallback } from 'react';
 | 
			
		||||
import * as css from './Sidebar.css';
 | 
			
		||||
 | 
			
		||||
export const SidebarItem = as<'div', css.SidebarItemVariants>(
 | 
			
		||||
  ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
 | 
			
		||||
    <AsSidebarAvatarBox
 | 
			
		||||
      className={classNames(css.SidebarItem({ active }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const SidebarItemBadge = as<'div', css.SidebarItemBadgeVariants>(
 | 
			
		||||
  ({ as: AsSidebarBadgeBox = 'div', className, hasCount, ...props }, ref) => (
 | 
			
		||||
    <AsSidebarBadgeBox
 | 
			
		||||
      className={classNames(css.SidebarItemBadge({ hasCount }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export function SidebarItemTooltip({
 | 
			
		||||
  tooltip,
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  tooltip?: ReactNode | string;
 | 
			
		||||
  children: (triggerRef: RefCallback<HTMLElement | SVGElement>) => ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  if (!tooltip) {
 | 
			
		||||
    return children(() => undefined);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipProvider
 | 
			
		||||
      delay={400}
 | 
			
		||||
      position="Right"
 | 
			
		||||
      tooltip={
 | 
			
		||||
        <Tooltip style={{ maxWidth: toRem(280) }}>
 | 
			
		||||
          <Text size="H5">{tooltip}</Text>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </TooltipProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProps<typeof Avatar>>(
 | 
			
		||||
  ({ className, size, outlined, radii, ...props }, ref) => (
 | 
			
		||||
    <Avatar
 | 
			
		||||
      className={classNames(css.SidebarAvatar({ size, outlined }), className)}
 | 
			
		||||
      radii={radii}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
 | 
			
		||||
  ({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
 | 
			
		||||
    <AsSidebarFolder
 | 
			
		||||
      className={classNames(css.SidebarFolder({ state }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const SidebarFolderDropTarget = as<'div', css.SidebarFolderDropTargetVariants>(
 | 
			
		||||
  ({ as: AsSidebarFolderDropTarget = 'div', className, position, ...props }, ref) => (
 | 
			
		||||
    <AsSidebarFolderDropTarget
 | 
			
		||||
      className={classNames(css.SidebarFolderDropTarget({ position }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
export * from './Sidebar';
 | 
			
		||||
export * from './SidebarAvatar';
 | 
			
		||||
export * from './SidebarItem';
 | 
			
		||||
export * from './SidebarContent';
 | 
			
		||||
export * from './SidebarStack';
 | 
			
		||||
export * from './SidebarStackSeparator';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,10 +17,14 @@ export const TypingDot = recipe({
 | 
			
		|||
      backgroundColor: 'currentColor',
 | 
			
		||||
      borderRadius: '50%',
 | 
			
		||||
      transform: 'translateY(15%)',
 | 
			
		||||
      animation: `${TypingDotAnime} 0.6s infinite alternate`,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    animated: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animation: `${TypingDotAnime} 0.6s infinite alternate`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    size: {
 | 
			
		||||
      '300': {
 | 
			
		||||
        width: toRem(4),
 | 
			
		||||
| 
						 | 
				
			
			@ -45,5 +49,6 @@ export const TypingDot = recipe({
 | 
			
		|||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    size: '400',
 | 
			
		||||
    animated: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,19 +4,22 @@ import * as css from './TypingIndicator.css';
 | 
			
		|||
 | 
			
		||||
export type TypingIndicatorProps = {
 | 
			
		||||
  size?: '300' | '400';
 | 
			
		||||
  disableAnimation?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
 | 
			
		||||
  <Box
 | 
			
		||||
    as="span"
 | 
			
		||||
    alignItems="Center"
 | 
			
		||||
    shrink="No"
 | 
			
		||||
    style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  >
 | 
			
		||||
    <span className={css.TypingDot({ size, index: '0' })} />
 | 
			
		||||
    <span className={css.TypingDot({ size, index: '1' })} />
 | 
			
		||||
    <span className={css.TypingDot({ size, index: '2' })} />
 | 
			
		||||
  </Box>
 | 
			
		||||
));
 | 
			
		||||
export const TypingIndicator = as<'div', TypingIndicatorProps>(
 | 
			
		||||
  ({ size, disableAnimation, style, ...props }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
      as="span"
 | 
			
		||||
      alignItems="Center"
 | 
			
		||||
      shrink="No"
 | 
			
		||||
      style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    >
 | 
			
		||||
      <span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
 | 
			
		||||
      <span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
 | 
			
		||||
      <span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
 | 
			
		||||
    </Box>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										36
									
								
								src/app/components/unread-badge/UnreadBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/app/components/unread-badge/UnreadBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
import React, { CSSProperties, ReactNode } from 'react';
 | 
			
		||||
import { Box, Badge, toRem, Text } from 'folds';
 | 
			
		||||
import { millify } from '../../plugins/millify';
 | 
			
		||||
 | 
			
		||||
type UnreadBadgeProps = {
 | 
			
		||||
  highlight?: boolean;
 | 
			
		||||
  count: number;
 | 
			
		||||
};
 | 
			
		||||
const styles: CSSProperties = {
 | 
			
		||||
  minWidth: toRem(16),
 | 
			
		||||
};
 | 
			
		||||
export function UnreadBadgeCenter({ children }: { children: ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="span" style={styles} shrink="No" alignItems="Center" justifyContent="Center">
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Badge
 | 
			
		||||
      variant={highlight ? 'Success' : 'Secondary'}
 | 
			
		||||
      size={count > 0 ? '400' : '200'}
 | 
			
		||||
      fill={count > 0 ? 'Solid' : 'Soft'}
 | 
			
		||||
      radii="Pill"
 | 
			
		||||
      outlined
 | 
			
		||||
    >
 | 
			
		||||
      {count > 0 && (
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          {millify(count)}
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </Badge>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/unread-badge/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/unread-badge/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './UnreadBadge';
 | 
			
		||||
							
								
								
									
										47
									
								
								src/app/components/url-preview/UrlPreviewCard.css.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/app/components/url-preview/UrlPreviewCard.css.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
import { recipe } from '@vanilla-extract/recipes';
 | 
			
		||||
import { DefaultReset, color, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const UrlPreviewHolderGradient = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    DefaultReset,
 | 
			
		||||
    {
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      height: '100%',
 | 
			
		||||
      width: toRem(10),
 | 
			
		||||
      zIndex: 1,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    position: {
 | 
			
		||||
      Left: {
 | 
			
		||||
        left: 0,
 | 
			
		||||
        background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
 | 
			
		||||
      },
 | 
			
		||||
      Right: {
 | 
			
		||||
        right: 0,
 | 
			
		||||
        background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const UrlPreviewHolderBtn = recipe({
 | 
			
		||||
  base: [
 | 
			
		||||
    DefaultReset,
 | 
			
		||||
    {
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      zIndex: 1,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  variants: {
 | 
			
		||||
    position: {
 | 
			
		||||
      Left: {
 | 
			
		||||
        left: 0,
 | 
			
		||||
        transform: 'translateX(-25%)',
 | 
			
		||||
      },
 | 
			
		||||
      Right: {
 | 
			
		||||
        right: 0,
 | 
			
		||||
        transform: 'translateX(25%)',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,19 +1,14 @@
 | 
			
		|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
 | 
			
		||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import {
 | 
			
		||||
  UrlPreview,
 | 
			
		||||
  UrlPreviewContent,
 | 
			
		||||
  UrlPreviewDescription,
 | 
			
		||||
  UrlPreviewImg,
 | 
			
		||||
} from '../../../components/url-preview';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
 | 
			
		||||
import {
 | 
			
		||||
  getIntersectionObserverEntry,
 | 
			
		||||
  useIntersectionObserver,
 | 
			
		||||
} from '../../../hooks/useIntersectionObserver';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
} from '../../hooks/useIntersectionObserver';
 | 
			
		||||
import * as css from './UrlPreviewCard.css';
 | 
			
		||||
 | 
			
		||||
const linkStyles = { color: color.Success.Main };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +1,2 @@
 | 
			
		|||
export * from './UrlPreview';
 | 
			
		||||
export * from './UrlPreviewCard';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								src/app/components/user-avatar/UserAvatar.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/app/components/user-avatar/UserAvatar.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { color } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const UserAvatar = style({
 | 
			
		||||
  backgroundColor: color.Secondary.Container,
 | 
			
		||||
  color: color.Secondary.OnContainer,
 | 
			
		||||
  textTransform: 'capitalize',
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-image-loaded="true"]': {
 | 
			
		||||
      backgroundColor: 'transparent',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										40
									
								
								src/app/components/user-avatar/UserAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/app/components/user-avatar/UserAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import { AvatarFallback, AvatarImage, color } from 'folds';
 | 
			
		||||
import React, { ReactEventHandler, ReactNode, useState } from 'react';
 | 
			
		||||
import * as css from './UserAvatar.css';
 | 
			
		||||
import colorMXID from '../../../util/colorMXID';
 | 
			
		||||
 | 
			
		||||
type UserAvatarProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  src?: string;
 | 
			
		||||
  alt?: string;
 | 
			
		||||
  renderFallback: () => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function UserAvatar({ userId, src, alt, renderFallback }: UserAvatarProps) {
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
 | 
			
		||||
    evt.currentTarget.setAttribute('data-image-loaded', 'true');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!src || error) {
 | 
			
		||||
    return (
 | 
			
		||||
      <AvatarFallback
 | 
			
		||||
        style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
 | 
			
		||||
        className={css.UserAvatar}
 | 
			
		||||
      >
 | 
			
		||||
        {renderFallback()}
 | 
			
		||||
      </AvatarFallback>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AvatarImage
 | 
			
		||||
      className={css.UserAvatar}
 | 
			
		||||
      src={src}
 | 
			
		||||
      alt={alt}
 | 
			
		||||
      onError={() => setError(true)}
 | 
			
		||||
      onLoad={handleLoad}
 | 
			
		||||
      draggable={false}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/user-avatar/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/user-avatar/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './UserAvatar';
 | 
			
		||||
							
								
								
									
										20
									
								
								src/app/components/virtualizer/VirtualTile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/app/components/virtualizer/VirtualTile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { VirtualItem } from '@tanstack/react-virtual';
 | 
			
		||||
import { as } from 'folds';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
 | 
			
		||||
type VirtualTileProps = {
 | 
			
		||||
  virtualItem: VirtualItem;
 | 
			
		||||
};
 | 
			
		||||
export const VirtualTile = as<'div', VirtualTileProps>(
 | 
			
		||||
  ({ className, virtualItem, style, ...props }, ref) => (
 | 
			
		||||
    <div
 | 
			
		||||
      className={classNames(css.VirtualTile, className)}
 | 
			
		||||
      style={{ top: virtualItem.start, ...style }}
 | 
			
		||||
      data-index={virtualItem.index}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/virtualizer/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/virtualizer/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './VirtualTile';
 | 
			
		||||
							
								
								
									
										11
									
								
								src/app/components/virtualizer/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/components/virtualizer/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const VirtualTile = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    left: 0,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
| 
						 | 
				
			
			@ -104,7 +104,7 @@ export const specVersions = async (
 | 
			
		|||
  request: typeof fetch,
 | 
			
		||||
  baseUrl: string
 | 
			
		||||
): Promise<SpecVersions> => {
 | 
			
		||||
  const res = await request(`${baseUrl}/_matrix/client/versions`);
 | 
			
		||||
  const res = await request(`${trimTrailingSlash(baseUrl)}/_matrix/client/versions`);
 | 
			
		||||
 | 
			
		||||
  const data = (await res.json()) as unknown;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										61
									
								
								src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Scroll, Text, toRem } from 'folds';
 | 
			
		||||
import { useAtomValue } from 'jotai';
 | 
			
		||||
import { RoomCard } from '../../components/room-card';
 | 
			
		||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
 | 
			
		||||
import { Page, PageHeader } from '../../components/page';
 | 
			
		||||
import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
 | 
			
		||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { allRoomsAtom } from '../../state/room-list/roomList';
 | 
			
		||||
 | 
			
		||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string };
 | 
			
		||||
export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const allRooms = useAtomValue(allRoomsAtom);
 | 
			
		||||
  const { navigateRoom, navigateSpace } = useRoomNavigate();
 | 
			
		||||
 | 
			
		||||
  const handleView = (roomId: string) => {
 | 
			
		||||
    if (mx.getRoom(roomId)?.isSpaceRoom()) {
 | 
			
		||||
      navigateSpace(roomId);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    navigateRoom(roomId);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader>
 | 
			
		||||
        <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
 | 
			
		||||
          <Text size="H3" truncate>
 | 
			
		||||
            {roomIdOrAlias}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover" size="0">
 | 
			
		||||
          <Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
 | 
			
		||||
            <RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
 | 
			
		||||
              {(summary) => (
 | 
			
		||||
                <RoomCard
 | 
			
		||||
                  style={{ maxWidth: toRem(364), width: '100%' }}
 | 
			
		||||
                  roomIdOrAlias={roomIdOrAlias}
 | 
			
		||||
                  allRooms={allRooms}
 | 
			
		||||
                  avatarUrl={summary?.avatar_url}
 | 
			
		||||
                  name={summary?.name}
 | 
			
		||||
                  topic={summary?.topic}
 | 
			
		||||
                  memberCount={summary?.num_joined_members}
 | 
			
		||||
                  roomType={summary?.room_type}
 | 
			
		||||
                  renderTopicViewer={(name, topic, requestClose) => (
 | 
			
		||||
                    <RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
 | 
			
		||||
                  )}
 | 
			
		||||
                  onView={handleView}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </RoomSummaryLoader>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/join-before-navigate/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/join-before-navigate/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './JoinBeforeNavigate';
 | 
			
		||||
							
								
								
									
										91
									
								
								src/app/features/lobby/DnD.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/app/features/lobby/DnD.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,91 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { color, config, toRem } from 'folds';
 | 
			
		||||
import { ContainerColor } from '../../styles/ContainerColor.css';
 | 
			
		||||
 | 
			
		||||
export const ItemDraggableTarget = style([
 | 
			
		||||
  ContainerColor({ variant: 'SurfaceVariant' }),
 | 
			
		||||
  {
 | 
			
		||||
    height: '100%',
 | 
			
		||||
    position: 'absolute',
 | 
			
		||||
    left: 0,
 | 
			
		||||
    top: 0,
 | 
			
		||||
    zIndex: 1,
 | 
			
		||||
    cursor: 'grab',
 | 
			
		||||
    borderRadius: config.radii.R400,
 | 
			
		||||
    opacity: config.opacity.P300,
 | 
			
		||||
 | 
			
		||||
    ':active': {
 | 
			
		||||
      cursor: 'ns-resize',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const LineHeight = 4;
 | 
			
		||||
const DropTargetLine = style({
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-hover=true]:before': {
 | 
			
		||||
      content: '',
 | 
			
		||||
      display: 'block',
 | 
			
		||||
      width: '100%',
 | 
			
		||||
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      left: 0,
 | 
			
		||||
      top: '50%',
 | 
			
		||||
      zIndex: 1,
 | 
			
		||||
      transform: 'translateY(-50%)',
 | 
			
		||||
 | 
			
		||||
      borderBottom: `${toRem(LineHeight)} solid currentColor`,
 | 
			
		||||
    },
 | 
			
		||||
    '&[data-hover=true]:after': {
 | 
			
		||||
      content: '',
 | 
			
		||||
      display: 'block',
 | 
			
		||||
      width: toRem(LineHeight * 3),
 | 
			
		||||
      height: toRem(LineHeight * 3),
 | 
			
		||||
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      left: 0,
 | 
			
		||||
      top: '50%',
 | 
			
		||||
      zIndex: 1,
 | 
			
		||||
      transform: 'translate(-50%, -50%)',
 | 
			
		||||
 | 
			
		||||
      backgroundColor: color.Surface.Container,
 | 
			
		||||
      border: `${toRem(LineHeight)} solid currentColor`,
 | 
			
		||||
      borderRadius: '50%',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const BaseAfterRoomItemDropTarget = style({
 | 
			
		||||
  width: '100%',
 | 
			
		||||
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
  left: 0,
 | 
			
		||||
  bottom: 0,
 | 
			
		||||
  zIndex: 99,
 | 
			
		||||
 | 
			
		||||
  color: color.Success.Main,
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-error=true]': {
 | 
			
		||||
      color: color.Critical.Main,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
const RoomTargetHeight = 32;
 | 
			
		||||
export const AfterRoomItemDropTarget = style([
 | 
			
		||||
  BaseAfterRoomItemDropTarget,
 | 
			
		||||
  {
 | 
			
		||||
    height: toRem(RoomTargetHeight),
 | 
			
		||||
    transform: `translateY(${toRem(RoomTargetHeight / 2 + LineHeight / 2)})`,
 | 
			
		||||
  },
 | 
			
		||||
  DropTargetLine,
 | 
			
		||||
]);
 | 
			
		||||
const SpaceTargetHeight = 14;
 | 
			
		||||
export const AfterSpaceItemDropTarget = style([
 | 
			
		||||
  BaseAfterRoomItemDropTarget,
 | 
			
		||||
  {
 | 
			
		||||
    height: toRem(SpaceTargetHeight),
 | 
			
		||||
    transform: `translateY(calc(100% - ${toRem(4)}))`,
 | 
			
		||||
  },
 | 
			
		||||
  DropTargetLine,
 | 
			
		||||
]);
 | 
			
		||||
							
								
								
									
										146
									
								
								src/app/features/lobby/DnD.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/app/features/lobby/DnD.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,146 @@
 | 
			
		|||
import React, { RefObject, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  dropTargetForElements,
 | 
			
		||||
  draggable,
 | 
			
		||||
  monitorForElements,
 | 
			
		||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
 | 
			
		||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
 | 
			
		||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Box, Icon, Icons, as } from 'folds';
 | 
			
		||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 | 
			
		||||
import * as css from './DnD.css';
 | 
			
		||||
 | 
			
		||||
export type DropContainerData = {
 | 
			
		||||
  item: HierarchyItem;
 | 
			
		||||
  nextRoomId?: string;
 | 
			
		||||
};
 | 
			
		||||
export type CanDropCallback = (item: HierarchyItem, container: DropContainerData) => boolean;
 | 
			
		||||
 | 
			
		||||
export const useDraggableItem = (
 | 
			
		||||
  item: HierarchyItem,
 | 
			
		||||
  targetRef: RefObject<HTMLElement>,
 | 
			
		||||
  onDragging: (item?: HierarchyItem) => void,
 | 
			
		||||
  dragHandleRef?: RefObject<HTMLElement>
 | 
			
		||||
): boolean => {
 | 
			
		||||
  const [dragging, setDragging] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const target = targetRef.current;
 | 
			
		||||
    const dragHandle = dragHandleRef?.current ?? undefined;
 | 
			
		||||
 | 
			
		||||
    return !target
 | 
			
		||||
      ? undefined
 | 
			
		||||
      : draggable({
 | 
			
		||||
          element: target,
 | 
			
		||||
          dragHandle,
 | 
			
		||||
          getInitialData: () => item,
 | 
			
		||||
          onDragStart: () => {
 | 
			
		||||
            setDragging(true);
 | 
			
		||||
            onDragging(item);
 | 
			
		||||
          },
 | 
			
		||||
          onDrop: () => {
 | 
			
		||||
            setDragging(false);
 | 
			
		||||
            onDragging(undefined);
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
  }, [targetRef, dragHandleRef, item, onDragging]);
 | 
			
		||||
 | 
			
		||||
  return dragging;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ItemDraggableTarget = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		||||
  <Box
 | 
			
		||||
    justifyContent="Center"
 | 
			
		||||
    alignItems="Center"
 | 
			
		||||
    className={classNames(css.ItemDraggableTarget, className)}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
    <Icon size="50" src={Icons.VerticalDots} />
 | 
			
		||||
  </Box>
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
type AfterItemDropTargetProps = {
 | 
			
		||||
  item: HierarchyItem;
 | 
			
		||||
  afterSpace?: boolean;
 | 
			
		||||
  nextRoomId?: string;
 | 
			
		||||
  canDrop: CanDropCallback;
 | 
			
		||||
};
 | 
			
		||||
export function AfterItemDropTarget({
 | 
			
		||||
  item,
 | 
			
		||||
  afterSpace,
 | 
			
		||||
  nextRoomId,
 | 
			
		||||
  canDrop,
 | 
			
		||||
}: AfterItemDropTargetProps) {
 | 
			
		||||
  const targetRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [dropState, setDropState] = useState<'idle' | 'allow' | 'not-allow'>('idle');
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const target = targetRef.current;
 | 
			
		||||
    if (!target) {
 | 
			
		||||
      throw Error('drop target ref is not set properly');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return dropTargetForElements({
 | 
			
		||||
      element: target,
 | 
			
		||||
      getData: () => {
 | 
			
		||||
        const container: DropContainerData = {
 | 
			
		||||
          item,
 | 
			
		||||
          nextRoomId,
 | 
			
		||||
        };
 | 
			
		||||
        return container;
 | 
			
		||||
      },
 | 
			
		||||
      onDragEnter: ({ source }) => {
 | 
			
		||||
        if (
 | 
			
		||||
          canDrop(source.data as HierarchyItem, {
 | 
			
		||||
            item,
 | 
			
		||||
            nextRoomId,
 | 
			
		||||
          })
 | 
			
		||||
        ) {
 | 
			
		||||
          setDropState('allow');
 | 
			
		||||
        } else {
 | 
			
		||||
          setDropState('not-allow');
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      onDragLeave: () => setDropState('idle'),
 | 
			
		||||
      onDrop: () => setDropState('idle'),
 | 
			
		||||
    });
 | 
			
		||||
  }, [item, nextRoomId, canDrop]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={afterSpace ? css.AfterSpaceItemDropTarget : css.AfterRoomItemDropTarget}
 | 
			
		||||
      data-hover={dropState !== 'idle'}
 | 
			
		||||
      data-error={dropState === 'not-allow'}
 | 
			
		||||
      ref={targetRef}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useDnDMonitor = (
 | 
			
		||||
  scrollRef: RefObject<HTMLElement>,
 | 
			
		||||
  onDragging: (item?: HierarchyItem) => void,
 | 
			
		||||
  onReorder: (item: HierarchyItem, container: DropContainerData) => void
 | 
			
		||||
) => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const scrollElement = scrollRef.current;
 | 
			
		||||
    if (!scrollElement) {
 | 
			
		||||
      throw Error('Scroll element ref not configured');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return combine(
 | 
			
		||||
      monitorForElements({
 | 
			
		||||
        onDrop: ({ source, location }) => {
 | 
			
		||||
          onDragging(undefined);
 | 
			
		||||
          const { dropTargets } = location.current;
 | 
			
		||||
          if (dropTargets.length === 0) return;
 | 
			
		||||
          onReorder(source.data as HierarchyItem, dropTargets[0].data as DropContainerData);
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
      autoScrollForElements({
 | 
			
		||||
        element: scrollElement,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
  }, [scrollRef, onDragging, onReorder]);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										306
									
								
								src/app/features/lobby/HierarchyItemMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										306
									
								
								src/app/features/lobby/HierarchyItemMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,306 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  Text,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  config,
 | 
			
		||||
  Line,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import {
 | 
			
		||||
  openInviteUser,
 | 
			
		||||
  openSpaceSettings,
 | 
			
		||||
  toggleRoomSettings,
 | 
			
		||||
} from '../../../client/action/navigation';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 | 
			
		||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 | 
			
		||||
 | 
			
		||||
type HierarchyItemWithParent = HierarchyItem & {
 | 
			
		||||
  parentId: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function SuggestMenuItem({
 | 
			
		||||
  item,
 | 
			
		||||
  requestClose,
 | 
			
		||||
}: {
 | 
			
		||||
  item: HierarchyItemWithParent;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const { roomId, parentId, content } = item;
 | 
			
		||||
 | 
			
		||||
  const [toggleState, handleToggleSuggested] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
 | 
			
		||||
      return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
 | 
			
		||||
    }, [mx, parentId, roomId, content])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (toggleState.status === AsyncStatus.Success) {
 | 
			
		||||
      requestClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [requestClose, toggleState]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MenuItem
 | 
			
		||||
      onClick={handleToggleSuggested}
 | 
			
		||||
      size="300"
 | 
			
		||||
      radii="300"
 | 
			
		||||
      before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
 | 
			
		||||
      disabled={toggleState.status === AsyncStatus.Loading}
 | 
			
		||||
    >
 | 
			
		||||
      <Text as="span" size="T300" truncate>
 | 
			
		||||
        {content.suggested ? 'Unset Suggested' : 'Set Suggested'}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </MenuItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function RemoveMenuItem({
 | 
			
		||||
  item,
 | 
			
		||||
  requestClose,
 | 
			
		||||
}: {
 | 
			
		||||
  item: HierarchyItemWithParent;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const { roomId, parentId } = item;
 | 
			
		||||
 | 
			
		||||
  const [removeState, handleRemove] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      () => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
 | 
			
		||||
      [mx, parentId, roomId]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (removeState.status === AsyncStatus.Success) {
 | 
			
		||||
      requestClose();
 | 
			
		||||
    }
 | 
			
		||||
  }, [requestClose, removeState]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MenuItem
 | 
			
		||||
      onClick={handleRemove}
 | 
			
		||||
      variant="Critical"
 | 
			
		||||
      fill="None"
 | 
			
		||||
      size="300"
 | 
			
		||||
      radii="300"
 | 
			
		||||
      before={
 | 
			
		||||
        removeState.status === AsyncStatus.Loading && (
 | 
			
		||||
          <Spinner variant="Critical" fill="Soft" size="100" />
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      disabled={removeState.status === AsyncStatus.Loading}
 | 
			
		||||
    >
 | 
			
		||||
      <Text as="span" size="T300" truncate>
 | 
			
		||||
        Remove
 | 
			
		||||
      </Text>
 | 
			
		||||
    </MenuItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function InviteMenuItem({
 | 
			
		||||
  item,
 | 
			
		||||
  requestClose,
 | 
			
		||||
  disabled,
 | 
			
		||||
}: {
 | 
			
		||||
  item: HierarchyItemWithParent;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const handleInvite = () => {
 | 
			
		||||
    openInviteUser(item.roomId);
 | 
			
		||||
    requestClose();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MenuItem
 | 
			
		||||
      onClick={handleInvite}
 | 
			
		||||
      size="300"
 | 
			
		||||
      radii="300"
 | 
			
		||||
      variant="Primary"
 | 
			
		||||
      fill="None"
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
      <Text as="span" size="T300" truncate>
 | 
			
		||||
        Invite
 | 
			
		||||
      </Text>
 | 
			
		||||
    </MenuItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SettingsMenuItem({
 | 
			
		||||
  item,
 | 
			
		||||
  requestClose,
 | 
			
		||||
  disabled,
 | 
			
		||||
}: {
 | 
			
		||||
  item: HierarchyItemWithParent;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const handleSettings = () => {
 | 
			
		||||
    if (item.space) {
 | 
			
		||||
      openSpaceSettings(item.roomId);
 | 
			
		||||
    } else {
 | 
			
		||||
      toggleRoomSettings(item.roomId);
 | 
			
		||||
    }
 | 
			
		||||
    requestClose();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
 | 
			
		||||
      <Text as="span" size="T300" truncate>
 | 
			
		||||
        Settings
 | 
			
		||||
      </Text>
 | 
			
		||||
    </MenuItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type HierarchyItemMenuProps = {
 | 
			
		||||
  item: HierarchyItem & {
 | 
			
		||||
    parentId: string;
 | 
			
		||||
  };
 | 
			
		||||
  joined: boolean;
 | 
			
		||||
  canInvite: boolean;
 | 
			
		||||
  canEditChild: boolean;
 | 
			
		||||
  pinned?: boolean;
 | 
			
		||||
  onTogglePin?: (roomId: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function HierarchyItemMenu({
 | 
			
		||||
  item,
 | 
			
		||||
  joined,
 | 
			
		||||
  canInvite,
 | 
			
		||||
  canEditChild,
 | 
			
		||||
  pinned,
 | 
			
		||||
  onTogglePin,
 | 
			
		||||
}: HierarchyItemMenuProps) {
 | 
			
		||||
  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
 | 
			
		||||
 | 
			
		||||
  if (!joined && !canEditChild) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box gap="200" alignItems="Center" shrink="No">
 | 
			
		||||
      <IconButton
 | 
			
		||||
        onClick={handleOpenMenu}
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        fill="None"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        aria-pressed={!!menuAnchor}
 | 
			
		||||
      >
 | 
			
		||||
        <Icon size="50" src={Icons.VerticalDots} />
 | 
			
		||||
      </IconButton>
 | 
			
		||||
      {menuAnchor && (
 | 
			
		||||
        <PopOut
 | 
			
		||||
          anchor={menuAnchor}
 | 
			
		||||
          position="Bottom"
 | 
			
		||||
          align="End"
 | 
			
		||||
          content={
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                returnFocusOnDeactivate: false,
 | 
			
		||||
                onDeactivate: () => setMenuAnchor(undefined),
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
 | 
			
		||||
                isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
 | 
			
		||||
                {joined && (
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                    {onTogglePin && (
 | 
			
		||||
                      <MenuItem
 | 
			
		||||
                        size="300"
 | 
			
		||||
                        radii="300"
 | 
			
		||||
                        onClick={() => {
 | 
			
		||||
                          onTogglePin(item.roomId);
 | 
			
		||||
                          handleRequestClose();
 | 
			
		||||
                        }}
 | 
			
		||||
                      >
 | 
			
		||||
                        <Text as="span" size="T300" truncate>
 | 
			
		||||
                          {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </MenuItem>
 | 
			
		||||
                    )}
 | 
			
		||||
                    <InviteMenuItem
 | 
			
		||||
                      item={item}
 | 
			
		||||
                      requestClose={handleRequestClose}
 | 
			
		||||
                      disabled={!canInvite}
 | 
			
		||||
                    />
 | 
			
		||||
                    <SettingsMenuItem item={item} requestClose={handleRequestClose} />
 | 
			
		||||
                    <UseStateProvider initial={false}>
 | 
			
		||||
                      {(promptLeave, setPromptLeave) => (
 | 
			
		||||
                        <>
 | 
			
		||||
                          <MenuItem
 | 
			
		||||
                            onClick={() => setPromptLeave(true)}
 | 
			
		||||
                            variant="Critical"
 | 
			
		||||
                            fill="None"
 | 
			
		||||
                            size="300"
 | 
			
		||||
                            after={<Icon size="100" src={Icons.ArrowGoLeft} />}
 | 
			
		||||
                            radii="300"
 | 
			
		||||
                            aria-pressed={promptLeave}
 | 
			
		||||
                          >
 | 
			
		||||
                            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
                              Leave
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </MenuItem>
 | 
			
		||||
                          {promptLeave &&
 | 
			
		||||
                            (item.space ? (
 | 
			
		||||
                              <LeaveSpacePrompt
 | 
			
		||||
                                roomId={item.roomId}
 | 
			
		||||
                                onDone={handleRequestClose}
 | 
			
		||||
                                onCancel={() => setPromptLeave(false)}
 | 
			
		||||
                              />
 | 
			
		||||
                            ) : (
 | 
			
		||||
                              <LeaveRoomPrompt
 | 
			
		||||
                                roomId={item.roomId}
 | 
			
		||||
                                onDone={handleRequestClose}
 | 
			
		||||
                                onCancel={() => setPromptLeave(false)}
 | 
			
		||||
                              />
 | 
			
		||||
                            ))}
 | 
			
		||||
                        </>
 | 
			
		||||
                      )}
 | 
			
		||||
                    </UseStateProvider>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                )}
 | 
			
		||||
                {(joined || canEditChild) && (
 | 
			
		||||
                  <Line size="300" variant="Surface" direction="Horizontal" />
 | 
			
		||||
                )}
 | 
			
		||||
                {canEditChild && (
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                    <SuggestMenuItem item={item} requestClose={handleRequestClose} />
 | 
			
		||||
                    <RemoveMenuItem item={item} requestClose={handleRequestClose} />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                )}
 | 
			
		||||
              </Menu>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										528
									
								
								src/app/features/lobby/Lobby.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										528
									
								
								src/app/features/lobby/Lobby.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,528 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
 | 
			
		||||
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
 | 
			
		||||
import { useVirtualizer } from '@tanstack/react-virtual';
 | 
			
		||||
import { useAtom, useAtomValue } from 'jotai';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { useSpace } from '../../hooks/useSpace';
 | 
			
		||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
 | 
			
		||||
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
 | 
			
		||||
import { VirtualTile } from '../../components/virtualizer';
 | 
			
		||||
import { spaceRoomsAtom } from '../../state/spaceRooms';
 | 
			
		||||
import { MembersDrawer } from '../room/MembersDrawer';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
import { LobbyHeader } from './LobbyHeader';
 | 
			
		||||
import { LobbyHero } from './LobbyHero';
 | 
			
		||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
 | 
			
		||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
 | 
			
		||||
import {
 | 
			
		||||
  IPowerLevels,
 | 
			
		||||
  PowerLevelsContextProvider,
 | 
			
		||||
  powerLevelAPI,
 | 
			
		||||
  usePowerLevels,
 | 
			
		||||
  useRoomsPowerLevels,
 | 
			
		||||
} from '../../hooks/usePowerLevels';
 | 
			
		||||
import { RoomItemCard } from './RoomItem';
 | 
			
		||||
import { mDirectAtom } from '../../state/mDirectList';
 | 
			
		||||
import { SpaceItemCard } from './SpaceItem';
 | 
			
		||||
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
 | 
			
		||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { allRoomsAtom } from '../../state/room-list/roomList';
 | 
			
		||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
 | 
			
		||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
 | 
			
		||||
import { HierarchyItemMenu } from './HierarchyItemMenu';
 | 
			
		||||
import { StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
 | 
			
		||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
 | 
			
		||||
import { getStateEvent } from '../../utils/room';
 | 
			
		||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
 | 
			
		||||
import {
 | 
			
		||||
  makeCinnySpacesContent,
 | 
			
		||||
  sidebarItemWithout,
 | 
			
		||||
  useSidebarItems,
 | 
			
		||||
} from '../../hooks/useSidebarItems';
 | 
			
		||||
import { useOrphanSpaces } from '../../state/hooks/roomList';
 | 
			
		||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
			
		||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
 | 
			
		||||
 | 
			
		||||
export function Lobby() {
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const mDirects = useAtomValue(mDirectAtom);
 | 
			
		||||
  const allRooms = useAtomValue(allRoomsAtom);
 | 
			
		||||
  const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
 | 
			
		||||
  const space = useSpace();
 | 
			
		||||
  const spacePowerLevels = usePowerLevels(space);
 | 
			
		||||
  const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
 | 
			
		||||
 | 
			
		||||
  const scrollRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const heroSectionRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
 | 
			
		||||
  const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
 | 
			
		||||
  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const [onTop, setOnTop] = useState(true);
 | 
			
		||||
  const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
 | 
			
		||||
  const [sidebarItems] = useSidebarItems(
 | 
			
		||||
    useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
 | 
			
		||||
  );
 | 
			
		||||
  const sidebarSpaces = useMemo(() => {
 | 
			
		||||
    const sideSpaces = sidebarItems.flatMap((item) => {
 | 
			
		||||
      if (typeof item === 'string') return item;
 | 
			
		||||
      return item.content;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return new Set(sideSpaces);
 | 
			
		||||
  }, [sidebarItems]);
 | 
			
		||||
 | 
			
		||||
  useElementSizeObserver(
 | 
			
		||||
    useCallback(() => heroSectionRef.current, []),
 | 
			
		||||
    useCallback((w, height) => setHeroSectionHeight(height), [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const getRoom = useCallback(
 | 
			
		||||
    (rId: string) => {
 | 
			
		||||
      if (allJoinedRooms.has(rId)) {
 | 
			
		||||
        return mx.getRoom(rId) ?? undefined;
 | 
			
		||||
      }
 | 
			
		||||
      return undefined;
 | 
			
		||||
    },
 | 
			
		||||
    [mx, allJoinedRooms]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canEditSpaceChild = useCallback(
 | 
			
		||||
    (powerLevels: IPowerLevels) =>
 | 
			
		||||
      powerLevelAPI.canSendStateEvent(
 | 
			
		||||
        powerLevels,
 | 
			
		||||
        StateEvent.SpaceChild,
 | 
			
		||||
        powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
 | 
			
		||||
      ),
 | 
			
		||||
    [mx]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
 | 
			
		||||
  const flattenHierarchy = useSpaceHierarchy(
 | 
			
		||||
    space.roomId,
 | 
			
		||||
    spaceRooms,
 | 
			
		||||
    getRoom,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (childId) =>
 | 
			
		||||
        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
 | 
			
		||||
      [closedCategories, space.roomId, draggingItem]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const virtualizer = useVirtualizer({
 | 
			
		||||
    count: flattenHierarchy.length,
 | 
			
		||||
    getScrollElement: () => scrollRef.current,
 | 
			
		||||
    estimateSize: () => 1,
 | 
			
		||||
    overscan: 2,
 | 
			
		||||
    paddingStart: heroSectionHeight ?? 258,
 | 
			
		||||
  });
 | 
			
		||||
  const vItems = virtualizer.getVirtualItems();
 | 
			
		||||
 | 
			
		||||
  const roomsPowerLevels = useRoomsPowerLevels(
 | 
			
		||||
    useMemo(
 | 
			
		||||
      () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
 | 
			
		||||
      [mx, flattenHierarchy]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canDrop: CanDropCallback = useCallback(
 | 
			
		||||
    (item, container): boolean => {
 | 
			
		||||
      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
 | 
			
		||||
      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
 | 
			
		||||
        // can not drop before or after itself
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (item.space) {
 | 
			
		||||
        if (!container.item.space) return false;
 | 
			
		||||
        const containerSpaceId = space.roomId;
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
          !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
        ) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const containerSpaceId = container.item.space
 | 
			
		||||
        ? container.item.roomId
 | 
			
		||||
        : container.item.parentId;
 | 
			
		||||
 | 
			
		||||
      const dropOutsideSpace = item.parentId !== containerSpaceId;
 | 
			
		||||
 | 
			
		||||
      if (dropOutsideSpace && restrictedItem) {
 | 
			
		||||
        // do not allow restricted room to drop outside
 | 
			
		||||
        // current space if can't change join rule allow
 | 
			
		||||
        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
 | 
			
		||||
        const userPLInItem = powerLevelAPI.getPowerLevel(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          mx.getUserId() ?? undefined
 | 
			
		||||
        );
 | 
			
		||||
        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          StateEvent.RoomJoinRules,
 | 
			
		||||
          userPLInItem
 | 
			
		||||
        );
 | 
			
		||||
        if (!canChangeJoinRuleAllow) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
      ) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
    [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const reorderSpace = useCallback(
 | 
			
		||||
    (item: HierarchyItem, containerItem: HierarchyItem) => {
 | 
			
		||||
      if (!item.parentId) return;
 | 
			
		||||
 | 
			
		||||
      const childItems = flattenHierarchy
 | 
			
		||||
        .filter((i) => i.parentId && i.space)
 | 
			
		||||
        .filter((i) => i.roomId !== item.roomId);
 | 
			
		||||
 | 
			
		||||
      const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
 | 
			
		||||
      const insertIndex = beforeIndex + 1;
 | 
			
		||||
 | 
			
		||||
      childItems.splice(insertIndex, 0, {
 | 
			
		||||
        ...item,
 | 
			
		||||
        content: { ...item.content, order: undefined },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const currentOrders = childItems.map((i) => {
 | 
			
		||||
        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
          return i.content.order;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
 | 
			
		||||
      newOrders?.forEach((orderKey, index) => {
 | 
			
		||||
        const itm = childItems[index];
 | 
			
		||||
        if (!itm || !itm.parentId) return;
 | 
			
		||||
        const parentPL = roomsPowerLevels.get(itm.parentId);
 | 
			
		||||
        const canEdit = parentPL && canEditSpaceChild(parentPL);
 | 
			
		||||
        if (canEdit && orderKey !== currentOrders[index]) {
 | 
			
		||||
          mx.sendStateEvent(
 | 
			
		||||
            itm.parentId,
 | 
			
		||||
            StateEvent.SpaceChild,
 | 
			
		||||
            { ...itm.content, order: orderKey },
 | 
			
		||||
            itm.roomId
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const reorderRoom = useCallback(
 | 
			
		||||
    (item: HierarchyItem, containerItem: HierarchyItem): void => {
 | 
			
		||||
      const itemRoom = mx.getRoom(item.roomId);
 | 
			
		||||
      if (!item.parentId) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const containerParentId: string = containerItem.space
 | 
			
		||||
        ? containerItem.roomId
 | 
			
		||||
        : containerItem.parentId;
 | 
			
		||||
      const itemContent = item.content;
 | 
			
		||||
 | 
			
		||||
      if (item.parentId !== containerParentId) {
 | 
			
		||||
        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        itemRoom &&
 | 
			
		||||
        itemRoom.getJoinRule() === JoinRule.Restricted &&
 | 
			
		||||
        item.parentId !== containerParentId
 | 
			
		||||
      ) {
 | 
			
		||||
        // change join rule allow parameter when dragging
 | 
			
		||||
        // restricted room from one space to another
 | 
			
		||||
        const joinRuleContent = getStateEvent(
 | 
			
		||||
          itemRoom,
 | 
			
		||||
          StateEvent.RoomJoinRules
 | 
			
		||||
        )?.getContent<IJoinRuleEventContent>();
 | 
			
		||||
 | 
			
		||||
        if (joinRuleContent) {
 | 
			
		||||
          const allow =
 | 
			
		||||
            joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
 | 
			
		||||
          allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
 | 
			
		||||
          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
 | 
			
		||||
            ...joinRuleContent,
 | 
			
		||||
            allow,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const childItems = flattenHierarchy
 | 
			
		||||
        .filter((i) => i.parentId === containerParentId && !i.space)
 | 
			
		||||
        .filter((i) => i.roomId !== item.roomId);
 | 
			
		||||
 | 
			
		||||
      const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
 | 
			
		||||
      const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
 | 
			
		||||
      const insertIndex = beforeIndex + 1;
 | 
			
		||||
 | 
			
		||||
      childItems.splice(insertIndex, 0, {
 | 
			
		||||
        ...item,
 | 
			
		||||
        parentId: containerParentId,
 | 
			
		||||
        content: { ...itemContent, order: undefined },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const currentOrders = childItems.map((i) => {
 | 
			
		||||
        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
          return i.content.order;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
 | 
			
		||||
      newOrders?.forEach((orderKey, index) => {
 | 
			
		||||
        const itm = childItems[index];
 | 
			
		||||
        if (itm && orderKey !== currentOrders[index]) {
 | 
			
		||||
          mx.sendStateEvent(
 | 
			
		||||
            containerParentId,
 | 
			
		||||
            StateEvent.SpaceChild,
 | 
			
		||||
            { ...itm.content, order: orderKey },
 | 
			
		||||
            itm.roomId
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [mx, flattenHierarchy, lex]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useDnDMonitor(
 | 
			
		||||
    scrollRef,
 | 
			
		||||
    setDraggingItem,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (item, container) => {
 | 
			
		||||
        if (!canDrop(item, container)) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (item.space) {
 | 
			
		||||
          reorderSpace(item, container.item);
 | 
			
		||||
        } else {
 | 
			
		||||
          reorderRoom(item, container.item);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [reorderRoom, reorderSpace, canDrop]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const addSpaceRoom = useCallback(
 | 
			
		||||
    (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
 | 
			
		||||
    [setSpaceRooms]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
 | 
			
		||||
    closedCategories.has(categoryId)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    const rId = evt.currentTarget.getAttribute('data-room-id');
 | 
			
		||||
    if (!rId) return;
 | 
			
		||||
    const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
 | 
			
		||||
    navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const togglePinToSidebar = useCallback(
 | 
			
		||||
    (rId: string) => {
 | 
			
		||||
      const newItems = sidebarItemWithout(sidebarItems, rId);
 | 
			
		||||
      if (!sidebarSpaces.has(rId)) {
 | 
			
		||||
        newItems.push(rId);
 | 
			
		||||
      }
 | 
			
		||||
      const newSpacesContent = makeCinnySpacesContent(mx, newItems);
 | 
			
		||||
      mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
 | 
			
		||||
    },
 | 
			
		||||
    [mx, sidebarItems, sidebarSpaces]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <PowerLevelsContextProvider value={spacePowerLevels}>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Page>
 | 
			
		||||
          <LobbyHeader
 | 
			
		||||
            showProfile={!onTop}
 | 
			
		||||
            powerLevels={roomsPowerLevels.get(space.roomId) ?? {}}
 | 
			
		||||
          />
 | 
			
		||||
          <Box style={{ position: 'relative' }} grow="Yes">
 | 
			
		||||
            <Scroll ref={scrollRef} hideTrack visibility="Hover">
 | 
			
		||||
              <PageContent>
 | 
			
		||||
                <PageContentCenter>
 | 
			
		||||
                  <ScrollTopContainer
 | 
			
		||||
                    scrollRef={scrollRef}
 | 
			
		||||
                    anchorRef={heroSectionRef}
 | 
			
		||||
                    onVisibilityChange={setOnTop}
 | 
			
		||||
                  >
 | 
			
		||||
                    <IconButton
 | 
			
		||||
                      onClick={() => virtualizer.scrollToOffset(0)}
 | 
			
		||||
                      variant="SurfaceVariant"
 | 
			
		||||
                      radii="Pill"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      aria-label="Scroll to Top"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Icon src={Icons.ChevronTop} size="300" />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </ScrollTopContainer>
 | 
			
		||||
                  <div
 | 
			
		||||
                    style={{
 | 
			
		||||
                      position: 'relative',
 | 
			
		||||
                      height: virtualizer.getTotalSize(),
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <PageHeroSection ref={heroSectionRef} style={{ paddingTop: 0 }}>
 | 
			
		||||
                      <LobbyHero />
 | 
			
		||||
                    </PageHeroSection>
 | 
			
		||||
                    {vItems.map((vItem) => {
 | 
			
		||||
                      const item = flattenHierarchy[vItem.index];
 | 
			
		||||
                      if (!item) return null;
 | 
			
		||||
                      const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
 | 
			
		||||
                      const userPLInItem = powerLevelAPI.getPowerLevel(
 | 
			
		||||
                        itemPowerLevel,
 | 
			
		||||
                        mx.getUserId() ?? undefined
 | 
			
		||||
                      );
 | 
			
		||||
                      const canInvite = powerLevelAPI.canDoAction(
 | 
			
		||||
                        itemPowerLevel,
 | 
			
		||||
                        'invite',
 | 
			
		||||
                        userPLInItem
 | 
			
		||||
                      );
 | 
			
		||||
                      const isJoined = allJoinedRooms.has(item.roomId);
 | 
			
		||||
 | 
			
		||||
                      const nextRoomId: string | undefined =
 | 
			
		||||
                        flattenHierarchy[vItem.index + 1]?.roomId;
 | 
			
		||||
 | 
			
		||||
                      const dragging =
 | 
			
		||||
                        draggingItem?.roomId === item.roomId &&
 | 
			
		||||
                        draggingItem.parentId === item.parentId;
 | 
			
		||||
 | 
			
		||||
                      if (item.space) {
 | 
			
		||||
                        const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
 | 
			
		||||
                        const { parentId } = item;
 | 
			
		||||
                        const parentPowerLevels = parentId
 | 
			
		||||
                          ? roomsPowerLevels.get(parentId) ?? {}
 | 
			
		||||
                          : undefined;
 | 
			
		||||
 | 
			
		||||
                        return (
 | 
			
		||||
                          <VirtualTile
 | 
			
		||||
                            virtualItem={vItem}
 | 
			
		||||
                            style={{
 | 
			
		||||
                              paddingTop: vItem.index === 0 ? 0 : config.space.S500,
 | 
			
		||||
                            }}
 | 
			
		||||
                            ref={virtualizer.measureElement}
 | 
			
		||||
                            key={vItem.index}
 | 
			
		||||
                          >
 | 
			
		||||
                            <SpaceItemCard
 | 
			
		||||
                              item={item}
 | 
			
		||||
                              joined={allJoinedRooms.has(item.roomId)}
 | 
			
		||||
                              categoryId={categoryId}
 | 
			
		||||
                              closed={closedCategories.has(categoryId) || !!draggingItem?.space}
 | 
			
		||||
                              handleClose={handleCategoryClick}
 | 
			
		||||
                              getRoom={getRoom}
 | 
			
		||||
                              canEditChild={canEditSpaceChild(
 | 
			
		||||
                                roomsPowerLevels.get(item.roomId) ?? {}
 | 
			
		||||
                              )}
 | 
			
		||||
                              canReorder={
 | 
			
		||||
                                parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
 | 
			
		||||
                              }
 | 
			
		||||
                              options={
 | 
			
		||||
                                parentId &&
 | 
			
		||||
                                parentPowerLevels && (
 | 
			
		||||
                                  <HierarchyItemMenu
 | 
			
		||||
                                    item={{ ...item, parentId }}
 | 
			
		||||
                                    canInvite={canInvite}
 | 
			
		||||
                                    joined={isJoined}
 | 
			
		||||
                                    canEditChild={canEditSpaceChild(parentPowerLevels)}
 | 
			
		||||
                                    pinned={sidebarSpaces.has(item.roomId)}
 | 
			
		||||
                                    onTogglePin={togglePinToSidebar}
 | 
			
		||||
                                  />
 | 
			
		||||
                                )
 | 
			
		||||
                              }
 | 
			
		||||
                              before={item.parentId ? undefined : undefined}
 | 
			
		||||
                              after={
 | 
			
		||||
                                <AfterItemDropTarget
 | 
			
		||||
                                  item={item}
 | 
			
		||||
                                  nextRoomId={nextRoomId}
 | 
			
		||||
                                  afterSpace
 | 
			
		||||
                                  canDrop={canDrop}
 | 
			
		||||
                                />
 | 
			
		||||
                              }
 | 
			
		||||
                              onDragging={setDraggingItem}
 | 
			
		||||
                              data-dragging={dragging}
 | 
			
		||||
                            />
 | 
			
		||||
                          </VirtualTile>
 | 
			
		||||
                        );
 | 
			
		||||
                      }
 | 
			
		||||
 | 
			
		||||
                      const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
 | 
			
		||||
                      const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
 | 
			
		||||
                      const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
 | 
			
		||||
                      return (
 | 
			
		||||
                        <VirtualTile
 | 
			
		||||
                          virtualItem={vItem}
 | 
			
		||||
                          style={{ paddingTop: config.space.S100 }}
 | 
			
		||||
                          ref={virtualizer.measureElement}
 | 
			
		||||
                          key={vItem.index}
 | 
			
		||||
                        >
 | 
			
		||||
                          <RoomItemCard
 | 
			
		||||
                            item={item}
 | 
			
		||||
                            onSpaceFound={addSpaceRoom}
 | 
			
		||||
                            dm={mDirects.has(item.roomId)}
 | 
			
		||||
                            firstChild={!prevItem || prevItem.space === true}
 | 
			
		||||
                            lastChild={!nextItem || nextItem.space === true}
 | 
			
		||||
                            onOpen={handleOpenRoom}
 | 
			
		||||
                            getRoom={getRoom}
 | 
			
		||||
                            canReorder={canEditSpaceChild(parentPowerLevels)}
 | 
			
		||||
                            options={
 | 
			
		||||
                              <HierarchyItemMenu
 | 
			
		||||
                                item={item}
 | 
			
		||||
                                canInvite={canInvite}
 | 
			
		||||
                                joined={isJoined}
 | 
			
		||||
                                canEditChild={canEditSpaceChild(parentPowerLevels)}
 | 
			
		||||
                              />
 | 
			
		||||
                            }
 | 
			
		||||
                            after={
 | 
			
		||||
                              <AfterItemDropTarget
 | 
			
		||||
                                item={item}
 | 
			
		||||
                                nextRoomId={nextRoomId}
 | 
			
		||||
                                canDrop={canDrop}
 | 
			
		||||
                              />
 | 
			
		||||
                            }
 | 
			
		||||
                            data-dragging={dragging}
 | 
			
		||||
                            onDragging={setDraggingItem}
 | 
			
		||||
                          />
 | 
			
		||||
                        </VirtualTile>
 | 
			
		||||
                      );
 | 
			
		||||
                    })}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </PageContentCenter>
 | 
			
		||||
              </PageContent>
 | 
			
		||||
            </Scroll>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Page>
 | 
			
		||||
        {screenSize === ScreenSize.Desktop && isDrawer && (
 | 
			
		||||
          <>
 | 
			
		||||
            <Line variant="Background" direction="Vertical" size="300" />
 | 
			
		||||
            <MembersDrawer room={space} />
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </PowerLevelsContextProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								src/app/features/lobby/LobbyHeader.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/app/features/lobby/LobbyHeader.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const Header = style({
 | 
			
		||||
  borderBottomColor: 'transparent',
 | 
			
		||||
});
 | 
			
		||||
export const HeaderTopic = style({
 | 
			
		||||
  ':hover': {
 | 
			
		||||
    cursor: 'pointer',
 | 
			
		||||
    opacity: config.opacity.P500,
 | 
			
		||||
    textDecoration: 'underline',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										214
									
								
								src/app/features/lobby/LobbyHeader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										214
									
								
								src/app/features/lobby/LobbyHeader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,214 @@
 | 
			
		|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Box,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Line,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Text,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  config,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { PageHeader } from '../../components/page';
 | 
			
		||||
import { useSetSetting } from '../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
 | 
			
		||||
import { useSpace } from '../../hooks/useSpace';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { RoomAvatar } from '../../components/room-avatar';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import * as css from './LobbyHeader.css';
 | 
			
		||||
import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
 | 
			
		||||
import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
 | 
			
		||||
 | 
			
		||||
type LobbyMenuProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  powerLevels: IPowerLevels;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
 | 
			
		||||
  ({ roomId, powerLevels, requestClose }, ref) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
 | 
			
		||||
    const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
			
		||||
 | 
			
		||||
    const handleInvite = () => {
 | 
			
		||||
      openInviteUser(roomId);
 | 
			
		||||
      requestClose();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleRoomSettings = () => {
 | 
			
		||||
      openSpaceSettings(roomId);
 | 
			
		||||
      requestClose();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleInvite}
 | 
			
		||||
            variant="Primary"
 | 
			
		||||
            fill="None"
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.UserPlus} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={!canInvite}
 | 
			
		||||
          >
 | 
			
		||||
            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
              Invite
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleRoomSettings}
 | 
			
		||||
            size="300"
 | 
			
		||||
            after={<Icon size="100" src={Icons.Setting} />}
 | 
			
		||||
            radii="300"
 | 
			
		||||
          >
 | 
			
		||||
            <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
              Space Settings
 | 
			
		||||
            </Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Line variant="Surface" size="300" />
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <UseStateProvider initial={false}>
 | 
			
		||||
            {(promptLeave, setPromptLeave) => (
 | 
			
		||||
              <>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  onClick={() => setPromptLeave(true)}
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  after={<Icon size="100" src={Icons.ArrowGoLeft} />}
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-pressed={promptLeave}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
                    Leave Space
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
                {promptLeave && (
 | 
			
		||||
                  <LeaveSpacePrompt
 | 
			
		||||
                    roomId={roomId}
 | 
			
		||||
                    onDone={requestClose}
 | 
			
		||||
                    onCancel={() => setPromptLeave(false)}
 | 
			
		||||
                  />
 | 
			
		||||
                )}
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </UseStateProvider>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type LobbyHeaderProps = {
 | 
			
		||||
  showProfile?: boolean;
 | 
			
		||||
  powerLevels: IPowerLevels;
 | 
			
		||||
};
 | 
			
		||||
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const space = useSpace();
 | 
			
		||||
  const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
 | 
			
		||||
  const [menuAnchor, setMenuAnchor] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const name = useRoomName(space);
 | 
			
		||||
  const avatarMxc = useRoomAvatar(space);
 | 
			
		||||
  const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
 | 
			
		||||
 | 
			
		||||
  const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <PageHeader className={showProfile ? undefined : css.Header}>
 | 
			
		||||
      <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
        <Box grow="Yes" basis="No" />
 | 
			
		||||
        <Box justifyContent="Center" alignItems="Center" gap="300">
 | 
			
		||||
          {showProfile && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Avatar size="300">
 | 
			
		||||
                <RoomAvatar
 | 
			
		||||
                  roomId={space.roomId}
 | 
			
		||||
                  src={avatarUrl}
 | 
			
		||||
                  alt={name}
 | 
			
		||||
                  renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
 | 
			
		||||
                />
 | 
			
		||||
              </Avatar>
 | 
			
		||||
              <Text size="H3" truncate>
 | 
			
		||||
                {name}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" grow="Yes" basis="No" justifyContent="End">
 | 
			
		||||
          <TooltipProvider
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            offset={4}
 | 
			
		||||
            tooltip={
 | 
			
		||||
              <Tooltip>
 | 
			
		||||
                <Text>Members</Text>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            {(triggerRef) => (
 | 
			
		||||
              <IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
 | 
			
		||||
                <Icon size="400" src={Icons.User} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )}
 | 
			
		||||
          </TooltipProvider>
 | 
			
		||||
          <TooltipProvider
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align="End"
 | 
			
		||||
            offset={4}
 | 
			
		||||
            tooltip={
 | 
			
		||||
              <Tooltip>
 | 
			
		||||
                <Text>More Options</Text>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            {(triggerRef) => (
 | 
			
		||||
              <IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
 | 
			
		||||
                <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )}
 | 
			
		||||
          </TooltipProvider>
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuAnchor}
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align="End"
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  returnFocusOnDeactivate: false,
 | 
			
		||||
                  onDeactivate: () => setMenuAnchor(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
 | 
			
		||||
                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <LobbyMenu
 | 
			
		||||
                  roomId={space.roomId}
 | 
			
		||||
                  powerLevels={powerLevels}
 | 
			
		||||
                  requestClose={() => setMenuAnchor(undefined)}
 | 
			
		||||
                />
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </PageHeader>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								src/app/features/lobby/LobbyHero.css.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/features/lobby/LobbyHero.css.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const LobbyHeroTopic = style({
 | 
			
		||||
  display: '-webkit-box',
 | 
			
		||||
  WebkitLineClamp: 3,
 | 
			
		||||
  WebkitBoxOrient: 'vertical',
 | 
			
		||||
  overflow: 'hidden',
 | 
			
		||||
 | 
			
		||||
  ':hover': {
 | 
			
		||||
    cursor: 'pointer',
 | 
			
		||||
    opacity: config.opacity.P500,
 | 
			
		||||
    textDecoration: 'underline',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										77
									
								
								src/app/features/lobby/LobbyHero.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/app/features/lobby/LobbyHero.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,77 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 | 
			
		||||
import { useSpace } from '../../hooks/useSpace';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { RoomAvatar } from '../../components/room-avatar';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
 | 
			
		||||
import * as css from './LobbyHero.css';
 | 
			
		||||
import { PageHero } from '../../components/page';
 | 
			
		||||
import { onEnterOrSpace } from '../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
export function LobbyHero() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const space = useSpace();
 | 
			
		||||
 | 
			
		||||
  const name = useRoomName(space);
 | 
			
		||||
  const topic = useRoomTopic(space);
 | 
			
		||||
  const avatarMxc = useRoomAvatar(space);
 | 
			
		||||
  const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <PageHero
 | 
			
		||||
      icon={
 | 
			
		||||
        <Avatar size="500">
 | 
			
		||||
          <RoomAvatar
 | 
			
		||||
            roomId={space.roomId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            alt={name}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
      title={name}
 | 
			
		||||
      subTitle={
 | 
			
		||||
        topic && (
 | 
			
		||||
          <UseStateProvider initial={false}>
 | 
			
		||||
            {(viewTopic, setViewTopic) => (
 | 
			
		||||
              <>
 | 
			
		||||
                <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
                  <OverlayCenter>
 | 
			
		||||
                    <FocusTrap
 | 
			
		||||
                      focusTrapOptions={{
 | 
			
		||||
                        initialFocus: false,
 | 
			
		||||
                        clickOutsideDeactivates: true,
 | 
			
		||||
                        onDeactivate: () => setViewTopic(false),
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <RoomTopicViewer
 | 
			
		||||
                        name={name}
 | 
			
		||||
                        topic={topic}
 | 
			
		||||
                        requestClose={() => setViewTopic(false)}
 | 
			
		||||
                      />
 | 
			
		||||
                    </FocusTrap>
 | 
			
		||||
                  </OverlayCenter>
 | 
			
		||||
                </Overlay>
 | 
			
		||||
                <Text
 | 
			
		||||
                  as="span"
 | 
			
		||||
                  onClick={() => setViewTopic(true)}
 | 
			
		||||
                  onKeyDown={onEnterOrSpace(() => setViewTopic(true))}
 | 
			
		||||
                  tabIndex={0}
 | 
			
		||||
                  className={css.LobbyHeroTopic}
 | 
			
		||||
                  size="Inherit"
 | 
			
		||||
                  priority="300"
 | 
			
		||||
                >
 | 
			
		||||
                  {topic}
 | 
			
		||||
                </Text>
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </UseStateProvider>
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/features/lobby/RoomItem.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/features/lobby/RoomItem.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const RoomItemCard = style({
 | 
			
		||||
  padding: config.space.S400,
 | 
			
		||||
  borderRadius: 0,
 | 
			
		||||
  position: 'relative',
 | 
			
		||||
  selectors: {
 | 
			
		||||
    '&[data-dragging=true]': {
 | 
			
		||||
      opacity: config.opacity.Disabled,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const RoomProfileTopic = style({
 | 
			
		||||
  cursor: 'pointer',
 | 
			
		||||
  ':hover': {
 | 
			
		||||
    textDecoration: 'underline',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const ErrorNameContainer = style({
 | 
			
		||||
  gap: toRem(2),
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										441
									
								
								src/app/features/lobby/RoomItem.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										441
									
								
								src/app/features/lobby/RoomItem.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,441 @@
 | 
			
		|||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Line,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  as,
 | 
			
		||||
  color,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 | 
			
		||||
import { SequenceCard } from '../../components/sequence-card';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 | 
			
		||||
import { millify } from '../../plugins/millify';
 | 
			
		||||
import {
 | 
			
		||||
  HierarchyRoomSummaryLoader,
 | 
			
		||||
  LocalRoomSummaryLoader,
 | 
			
		||||
} from '../../components/RoomSummaryLoader';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
 | 
			
		||||
import { onEnterOrSpace } from '../../utils/keyboard';
 | 
			
		||||
import { Membership, RoomType } from '../../../types/matrix/room';
 | 
			
		||||
import * as css from './RoomItem.css';
 | 
			
		||||
import * as styleCss from './style.css';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { ErrorCode } from '../../cs-errorcode';
 | 
			
		||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
 | 
			
		||||
import { ItemDraggableTarget, useDraggableItem } from './DnD';
 | 
			
		||||
 | 
			
		||||
type RoomJoinButtonProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  via?: string[];
 | 
			
		||||
};
 | 
			
		||||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
 | 
			
		||||
    useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box shrink="No" gap="200" alignItems="Center">
 | 
			
		||||
      {joinState.status === AsyncStatus.Error && (
 | 
			
		||||
        <TooltipProvider
 | 
			
		||||
          tooltip={
 | 
			
		||||
            <Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text style={{ wordBreak: 'break-word' }} size="T400">
 | 
			
		||||
                  {joinState.error.data?.error || joinState.error.message}
 | 
			
		||||
                </Text>
 | 
			
		||||
                <Text size="T200">{joinState.error.name}</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          {(triggerRef) => (
 | 
			
		||||
            <Icon
 | 
			
		||||
              ref={triggerRef}
 | 
			
		||||
              style={{ color: color.Critical.Main, cursor: 'pointer' }}
 | 
			
		||||
              src={Icons.Warning}
 | 
			
		||||
              size="400"
 | 
			
		||||
              filled
 | 
			
		||||
              tabIndex={0}
 | 
			
		||||
              aria-label={joinState.error.data?.error || joinState.error.message}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </TooltipProvider>
 | 
			
		||||
      )}
 | 
			
		||||
      <Chip
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        size="400"
 | 
			
		||||
        radii="Pill"
 | 
			
		||||
        before={
 | 
			
		||||
          canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="100" />
 | 
			
		||||
        }
 | 
			
		||||
        onClick={join}
 | 
			
		||||
        disabled={!canJoin}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B300">Join</Text>
 | 
			
		||||
      </Chip>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function RoomProfileLoading() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="300">
 | 
			
		||||
      <Avatar className={styleCss.AvatarPlaceholder} />
 | 
			
		||||
      <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(80) }} />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          <Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(40) }} />
 | 
			
		||||
          <Box
 | 
			
		||||
            className={styleCss.LinePlaceholder}
 | 
			
		||||
            shrink="No"
 | 
			
		||||
            style={{
 | 
			
		||||
              maxWidth: toRem(120),
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RoomProfileErrorProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  error: Error;
 | 
			
		||||
  suggested?: boolean;
 | 
			
		||||
  via?: string[];
 | 
			
		||||
};
 | 
			
		||||
function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
 | 
			
		||||
  const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="300">
 | 
			
		||||
      <Avatar>
 | 
			
		||||
        <RoomAvatar
 | 
			
		||||
          roomId={roomId}
 | 
			
		||||
          src={undefined}
 | 
			
		||||
          alt={roomId}
 | 
			
		||||
          renderFallback={() => (
 | 
			
		||||
            <RoomIcon
 | 
			
		||||
              size="300"
 | 
			
		||||
              joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
 | 
			
		||||
              filled
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      </Avatar>
 | 
			
		||||
      <Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          <Text size="H5" truncate>
 | 
			
		||||
            Unknown
 | 
			
		||||
          </Text>
 | 
			
		||||
          {suggested && (
 | 
			
		||||
            <Box shrink="No" alignItems="Center">
 | 
			
		||||
              <Badge variant="Success" fill="Soft" radii="Pill" outlined>
 | 
			
		||||
                <Text size="L400">Suggested</Text>
 | 
			
		||||
              </Badge>
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          {privateRoom && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
 | 
			
		||||
                <Text size="L400">Private Room</Text>
 | 
			
		||||
              </Badge>
 | 
			
		||||
              <Line
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                style={{ height: toRem(12) }}
 | 
			
		||||
                direction="Vertical"
 | 
			
		||||
                size="400"
 | 
			
		||||
              />
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
          <Text size="T200" truncate>
 | 
			
		||||
            {roomId}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RoomProfileProps = {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  topic?: string;
 | 
			
		||||
  avatarUrl?: string;
 | 
			
		||||
  suggested?: boolean;
 | 
			
		||||
  memberCount?: number;
 | 
			
		||||
  joinRule?: JoinRule;
 | 
			
		||||
  options?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
function RoomProfile({
 | 
			
		||||
  roomId,
 | 
			
		||||
  name,
 | 
			
		||||
  topic,
 | 
			
		||||
  avatarUrl,
 | 
			
		||||
  suggested,
 | 
			
		||||
  memberCount,
 | 
			
		||||
  joinRule,
 | 
			
		||||
  options,
 | 
			
		||||
}: RoomProfileProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="300">
 | 
			
		||||
      <Avatar>
 | 
			
		||||
        <RoomAvatar
 | 
			
		||||
          roomId={roomId}
 | 
			
		||||
          src={avatarUrl}
 | 
			
		||||
          alt={name}
 | 
			
		||||
          renderFallback={() => (
 | 
			
		||||
            <RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      </Avatar>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          <Text size="H5" truncate>
 | 
			
		||||
            {name}
 | 
			
		||||
          </Text>
 | 
			
		||||
          {suggested && (
 | 
			
		||||
            <Box shrink="No" alignItems="Center">
 | 
			
		||||
              <Badge variant="Success" fill="Soft" radii="Pill" outlined>
 | 
			
		||||
                <Text size="L400">Suggested</Text>
 | 
			
		||||
              </Badge>
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box gap="200" alignItems="Center">
 | 
			
		||||
          {memberCount && (
 | 
			
		||||
            <Box shrink="No" gap="200">
 | 
			
		||||
              <Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
          {memberCount && topic && (
 | 
			
		||||
            <Line
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              style={{ height: toRem(12) }}
 | 
			
		||||
              direction="Vertical"
 | 
			
		||||
              size="400"
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {topic && (
 | 
			
		||||
            <UseStateProvider initial={false}>
 | 
			
		||||
              {(view, setView) => (
 | 
			
		||||
                <>
 | 
			
		||||
                  <Text
 | 
			
		||||
                    className={css.RoomProfileTopic}
 | 
			
		||||
                    size="T200"
 | 
			
		||||
                    priority="300"
 | 
			
		||||
                    truncate
 | 
			
		||||
                    onClick={() => setView(true)}
 | 
			
		||||
                    onKeyDown={onEnterOrSpace(() => setView(true))}
 | 
			
		||||
                    tabIndex={0}
 | 
			
		||||
                  >
 | 
			
		||||
                    {topic}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Overlay open={view} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
                    <OverlayCenter>
 | 
			
		||||
                      <FocusTrap
 | 
			
		||||
                        focusTrapOptions={{
 | 
			
		||||
                          initialFocus: false,
 | 
			
		||||
                          clickOutsideDeactivates: true,
 | 
			
		||||
                          onDeactivate: () => setView(false),
 | 
			
		||||
                        }}
 | 
			
		||||
                      >
 | 
			
		||||
                        <RoomTopicViewer
 | 
			
		||||
                          name={name}
 | 
			
		||||
                          topic={topic}
 | 
			
		||||
                          requestClose={() => setView(false)}
 | 
			
		||||
                        />
 | 
			
		||||
                      </FocusTrap>
 | 
			
		||||
                    </OverlayCenter>
 | 
			
		||||
                  </Overlay>
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
            </UseStateProvider>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {options}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CallbackOnFoundSpace({
 | 
			
		||||
  roomId,
 | 
			
		||||
  onSpaceFound,
 | 
			
		||||
}: {
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  onSpaceFound: (roomId: string) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    onSpaceFound(roomId);
 | 
			
		||||
  }, [roomId, onSpaceFound]);
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RoomItemCardProps = {
 | 
			
		||||
  item: HierarchyItem;
 | 
			
		||||
  onSpaceFound: (roomId: string) => void;
 | 
			
		||||
  dm?: boolean;
 | 
			
		||||
  firstChild?: boolean;
 | 
			
		||||
  lastChild?: boolean;
 | 
			
		||||
  onOpen: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  options?: ReactNode;
 | 
			
		||||
  before?: ReactNode;
 | 
			
		||||
  after?: ReactNode;
 | 
			
		||||
  onDragging: (item?: HierarchyItem) => void;
 | 
			
		||||
  canReorder: boolean;
 | 
			
		||||
  getRoom: (roomId: string) => Room | undefined;
 | 
			
		||||
};
 | 
			
		||||
export const RoomItemCard = as<'div', RoomItemCardProps>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      item,
 | 
			
		||||
      onSpaceFound,
 | 
			
		||||
      dm,
 | 
			
		||||
      firstChild,
 | 
			
		||||
      lastChild,
 | 
			
		||||
      onOpen,
 | 
			
		||||
      options,
 | 
			
		||||
      before,
 | 
			
		||||
      after,
 | 
			
		||||
      onDragging,
 | 
			
		||||
      canReorder,
 | 
			
		||||
      getRoom,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const { roomId, content } = item;
 | 
			
		||||
    const room = getRoom(roomId);
 | 
			
		||||
    const targetRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const targetHandleRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
    useDraggableItem(item, targetRef, onDragging, targetHandleRef);
 | 
			
		||||
 | 
			
		||||
    const joined = room?.getMyMembership() === Membership.Join;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={css.RoomItemCard}
 | 
			
		||||
        firstChild={firstChild}
 | 
			
		||||
        lastChild={lastChild}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        gap="300"
 | 
			
		||||
        alignItems="Center"
 | 
			
		||||
        {...props}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
      >
 | 
			
		||||
        {before}
 | 
			
		||||
        <Box ref={canReorder ? targetRef : null} grow="Yes">
 | 
			
		||||
          {canReorder && <ItemDraggableTarget ref={targetHandleRef} />}
 | 
			
		||||
          {room ? (
 | 
			
		||||
            <LocalRoomSummaryLoader room={room}>
 | 
			
		||||
              {(localSummary) => (
 | 
			
		||||
                <RoomProfile
 | 
			
		||||
                  roomId={roomId}
 | 
			
		||||
                  name={localSummary.name}
 | 
			
		||||
                  topic={localSummary.topic}
 | 
			
		||||
                  avatarUrl={
 | 
			
		||||
                    dm ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
 | 
			
		||||
                  }
 | 
			
		||||
                  memberCount={localSummary.memberCount}
 | 
			
		||||
                  suggested={content.suggested}
 | 
			
		||||
                  joinRule={localSummary.joinRule}
 | 
			
		||||
                  options={
 | 
			
		||||
                    joined ? (
 | 
			
		||||
                      <Box shrink="No" gap="100" alignItems="Center">
 | 
			
		||||
                        <Chip
 | 
			
		||||
                          data-room-id={roomId}
 | 
			
		||||
                          onClick={onOpen}
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="None"
 | 
			
		||||
                          size="400"
 | 
			
		||||
                          radii="Pill"
 | 
			
		||||
                          aria-label="Open Room"
 | 
			
		||||
                        >
 | 
			
		||||
                          <Icon size="50" src={Icons.ArrowRight} />
 | 
			
		||||
                        </Chip>
 | 
			
		||||
                      </Box>
 | 
			
		||||
                    ) : (
 | 
			
		||||
                      <RoomJoinButton roomId={roomId} via={content.via} />
 | 
			
		||||
                    )
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </LocalRoomSummaryLoader>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <HierarchyRoomSummaryLoader roomId={roomId}>
 | 
			
		||||
              {(summaryState) => (
 | 
			
		||||
                <>
 | 
			
		||||
                  {summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
 | 
			
		||||
                  {summaryState.status === AsyncStatus.Error && (
 | 
			
		||||
                    <RoomProfileError
 | 
			
		||||
                      roomId={roomId}
 | 
			
		||||
                      error={summaryState.error}
 | 
			
		||||
                      suggested={content.suggested}
 | 
			
		||||
                      via={content.via}
 | 
			
		||||
                    />
 | 
			
		||||
                  )}
 | 
			
		||||
                  {summaryState.status === AsyncStatus.Success && (
 | 
			
		||||
                    <>
 | 
			
		||||
                      {summaryState.data.room_type === RoomType.Space && (
 | 
			
		||||
                        <CallbackOnFoundSpace
 | 
			
		||||
                          roomId={summaryState.data.room_id}
 | 
			
		||||
                          onSpaceFound={onSpaceFound}
 | 
			
		||||
                        />
 | 
			
		||||
                      )}
 | 
			
		||||
                      <RoomProfile
 | 
			
		||||
                        roomId={roomId}
 | 
			
		||||
                        name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
 | 
			
		||||
                        topic={summaryState.data.topic}
 | 
			
		||||
                        avatarUrl={
 | 
			
		||||
                          summaryState.data?.avatar_url
 | 
			
		||||
                            ? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
 | 
			
		||||
                              undefined
 | 
			
		||||
                            : undefined
 | 
			
		||||
                        }
 | 
			
		||||
                        memberCount={summaryState.data.num_joined_members}
 | 
			
		||||
                        suggested={content.suggested}
 | 
			
		||||
                        joinRule={summaryState.data.join_rule}
 | 
			
		||||
                        options={<RoomJoinButton roomId={roomId} via={content.via} />}
 | 
			
		||||
                      />
 | 
			
		||||
                    </>
 | 
			
		||||
                  )}
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
            </HierarchyRoomSummaryLoader>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        {options}
 | 
			
		||||
        {after}
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										39
									
								
								src/app/features/lobby/SpaceItem.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/app/features/lobby/SpaceItem.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { color, config, toRem } from 'folds';
 | 
			
		||||
import { recipe } from '@vanilla-extract/recipes';
 | 
			
		||||
 | 
			
		||||
export const SpaceItemCard = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    paddingBottom: config.space.S100,
 | 
			
		||||
    borderBottom: `${config.borderWidth.B300} solid transparent`,
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
    selectors: {
 | 
			
		||||
      '&[data-dragging=true]': {
 | 
			
		||||
        opacity: config.opacity.Disabled,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderBottomColor: color.Surface.ContainerLine,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const HeaderChip = style({
 | 
			
		||||
  paddingLeft: config.space.S200,
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`&[data-ui-before="true"]`]: {
 | 
			
		||||
      paddingLeft: config.space.S100,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export const HeaderChipPlaceholder = style([
 | 
			
		||||
  {
 | 
			
		||||
    borderRadius: config.radii.R400,
 | 
			
		||||
    paddingLeft: config.space.S100,
 | 
			
		||||
    paddingRight: config.space.S300,
 | 
			
		||||
    height: toRem(32),
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue