mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +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,
 | 
					  "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": {
 | 
					  "hashRouter": {
 | 
				
			||||||
    "enabled": false,
 | 
					    "enabled": false,
 | 
				
			||||||
    "basename": "/"
 | 
					    "basename": "/"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,9 +9,10 @@
 | 
				
			||||||
  status = 200
 | 
					  status = 200
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
[[redirects]]
 | 
					[[redirects]]
 | 
				
			||||||
  from = "/olm.wasm"
 | 
					  from = "*/olm.wasm"
 | 
				
			||||||
  to = "/olm.wasm"
 | 
					  to = "/olm.wasm"
 | 
				
			||||||
  status = 200
 | 
					  status = 200
 | 
				
			||||||
 | 
					  force = true
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
[[redirects]]
 | 
					[[redirects]]
 | 
				
			||||||
  from = "/pdf.worker.min.js"
 | 
					  from = "/pdf.worker.min.js"
 | 
				
			||||||
| 
						 | 
					@ -31,4 +32,5 @@
 | 
				
			||||||
[[redirects]]
 | 
					[[redirects]]
 | 
				
			||||||
  from = "/*"
 | 
					  from = "/*"
 | 
				
			||||||
  to = "/index.html"
 | 
					  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",
 | 
					      "version": "3.2.0",
 | 
				
			||||||
      "license": "AGPL-3.0-only",
 | 
					      "license": "AGPL-3.0-only",
 | 
				
			||||||
      "dependencies": {
 | 
					      "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",
 | 
					        "@fontsource/inter": "4.5.14",
 | 
				
			||||||
        "@khanacademy/simple-markdown": "0.8.6",
 | 
					        "@khanacademy/simple-markdown": "0.8.6",
 | 
				
			||||||
        "@matrix-org/olm": "3.2.14",
 | 
					        "@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",
 | 
					        "@tippyjs/react": "4.2.6",
 | 
				
			||||||
        "@vanilla-extract/css": "1.9.3",
 | 
					        "@vanilla-extract/css": "1.9.3",
 | 
				
			||||||
        "@vanilla-extract/recipes": "0.3.0",
 | 
					        "@vanilla-extract/recipes": "0.3.0",
 | 
				
			||||||
| 
						 | 
					@ -29,7 +34,7 @@
 | 
				
			||||||
        "file-saver": "2.0.5",
 | 
					        "file-saver": "2.0.5",
 | 
				
			||||||
        "flux": "4.0.3",
 | 
					        "flux": "4.0.3",
 | 
				
			||||||
        "focus-trap-react": "10.0.2",
 | 
					        "focus-trap-react": "10.0.2",
 | 
				
			||||||
        "folds": "1.5.1",
 | 
					        "folds": "2.0.0",
 | 
				
			||||||
        "formik": "2.2.9",
 | 
					        "formik": "2.2.9",
 | 
				
			||||||
        "html-dom-parser": "4.0.0",
 | 
					        "html-dom-parser": "4.0.0",
 | 
				
			||||||
        "html-react-parser": "4.2.0",
 | 
					        "html-react-parser": "4.2.0",
 | 
				
			||||||
| 
						 | 
					@ -111,6 +116,34 @@
 | 
				
			||||||
        "node": ">=6.0.0"
 | 
					        "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": {
 | 
					    "node_modules/@babel/code-frame": {
 | 
				
			||||||
      "version": "7.23.4",
 | 
					      "version": "7.23.4",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
 | 
				
			||||||
| 
						 | 
					@ -3093,25 +3126,75 @@
 | 
				
			||||||
        "@swc/counter": "^0.1.3"
 | 
					        "@swc/counter": "^0.1.3"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "node_modules/@tanstack/react-virtual": {
 | 
					    "node_modules/@tanstack/query-core": {
 | 
				
			||||||
      "version": "3.0.0-beta.54",
 | 
					      "version": "5.24.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.54.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz",
 | 
				
			||||||
      "integrity": "sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==",
 | 
					      "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": {
 | 
					      "dependencies": {
 | 
				
			||||||
        "@tanstack/virtual-core": "3.0.0-beta.54"
 | 
					        "@tanstack/query-core": "5.24.1"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "funding": {
 | 
					      "funding": {
 | 
				
			||||||
        "type": "github",
 | 
					        "type": "github",
 | 
				
			||||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
					        "url": "https://github.com/sponsors/tannerlinsley"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "peerDependencies": {
 | 
					      "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": {
 | 
					    "node_modules/@tanstack/virtual-core": {
 | 
				
			||||||
      "version": "3.0.0-beta.54",
 | 
					      "version": "3.2.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.54.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
 | 
				
			||||||
      "integrity": "sha512-jtkwqdP2rY2iCCDVAFuaNBH3fiEi29aTn2RhtIoky8DTTiCdc48plpHHreLwmv1PICJ4AJUUESaq3xa8fZH8+g==",
 | 
					      "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
 | 
				
			||||||
      "funding": {
 | 
					      "funding": {
 | 
				
			||||||
        "type": "github",
 | 
					        "type": "github",
 | 
				
			||||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
					        "url": "https://github.com/sponsors/tannerlinsley"
 | 
				
			||||||
| 
						 | 
					@ -4065,6 +4148,11 @@
 | 
				
			||||||
        "node": ">=8"
 | 
					        "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": {
 | 
					    "node_modules/blurhash": {
 | 
				
			||||||
      "version": "2.0.4",
 | 
					      "version": "2.0.4",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz",
 | 
				
			||||||
| 
						 | 
					@ -5665,9 +5753,9 @@
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "node_modules/folds": {
 | 
					    "node_modules/folds": {
 | 
				
			||||||
      "version": "1.5.1",
 | 
					      "version": "2.0.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/folds/-/folds-2.0.0.tgz",
 | 
				
			||||||
      "integrity": "sha512-2QxyA+FRKjPKXDTMDoD7NmOUiReWrKYO0Msg44QqlzTkTrRVEzJgyPIfC/Ia4/u0ByQpk6dbq8UQxomKmneJ/g==",
 | 
					      "integrity": "sha512-lKv31vij4GEpEzGKWk5c3ar78fMZ9Di5n1XFR14Z2wnnpqhiiM5JTIzr127Gk5dOfy4mJkjnv/ZfMZvM2k+OQg==",
 | 
				
			||||||
      "peerDependencies": {
 | 
					      "peerDependencies": {
 | 
				
			||||||
        "@vanilla-extract/css": "^1.9.2",
 | 
					        "@vanilla-extract/css": "^1.9.2",
 | 
				
			||||||
        "@vanilla-extract/recipes": "^0.3.0",
 | 
					        "@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": {
 | 
					    "node_modules/react": {
 | 
				
			||||||
      "version": "18.2.0",
 | 
					      "version": "18.2.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,10 +20,15 @@
 | 
				
			||||||
  "author": "Ajay Bura",
 | 
					  "author": "Ajay Bura",
 | 
				
			||||||
  "license": "AGPL-3.0-only",
 | 
					  "license": "AGPL-3.0-only",
 | 
				
			||||||
  "dependencies": {
 | 
					  "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",
 | 
					    "@fontsource/inter": "4.5.14",
 | 
				
			||||||
    "@khanacademy/simple-markdown": "0.8.6",
 | 
					    "@khanacademy/simple-markdown": "0.8.6",
 | 
				
			||||||
    "@matrix-org/olm": "3.2.14",
 | 
					    "@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",
 | 
					    "@tippyjs/react": "4.2.6",
 | 
				
			||||||
    "@vanilla-extract/css": "1.9.3",
 | 
					    "@vanilla-extract/css": "1.9.3",
 | 
				
			||||||
    "@vanilla-extract/recipes": "0.3.0",
 | 
					    "@vanilla-extract/recipes": "0.3.0",
 | 
				
			||||||
| 
						 | 
					@ -40,7 +45,7 @@
 | 
				
			||||||
    "file-saver": "2.0.5",
 | 
					    "file-saver": "2.0.5",
 | 
				
			||||||
    "flux": "4.0.3",
 | 
					    "flux": "4.0.3",
 | 
				
			||||||
    "focus-trap-react": "10.0.2",
 | 
					    "focus-trap-react": "10.0.2",
 | 
				
			||||||
    "folds": "1.5.1",
 | 
					    "folds": "2.0.0",
 | 
				
			||||||
    "formik": "2.2.9",
 | 
					    "formik": "2.2.9",
 | 
				
			||||||
    "html-dom-parser": "4.0.0",
 | 
					    "html-dom-parser": "4.0.0",
 | 
				
			||||||
    "html-react-parser": "4.2.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 no-param-reassign */
 | 
				
			||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
 | 
					/* 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 classNames from 'classnames';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
| 
						 | 
					@ -13,6 +13,7 @@ import {
 | 
				
			||||||
  Input,
 | 
					  Input,
 | 
				
			||||||
  Menu,
 | 
					  Menu,
 | 
				
			||||||
  PopOut,
 | 
					  PopOut,
 | 
				
			||||||
 | 
					  RectCords,
 | 
				
			||||||
  Scroll,
 | 
					  Scroll,
 | 
				
			||||||
  Spinner,
 | 
					  Spinner,
 | 
				
			||||||
  Text,
 | 
					  Text,
 | 
				
			||||||
| 
						 | 
					@ -48,7 +49,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
				
			||||||
    const isError =
 | 
					    const isError =
 | 
				
			||||||
      pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
 | 
					      pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
 | 
				
			||||||
    const [pageNo, setPageNo] = useState(1);
 | 
					    const [pageNo, setPageNo] = useState(1);
 | 
				
			||||||
    const [openJump, setOpenJump] = useState(false);
 | 
					    const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useEffect(() => {
 | 
					    useEffect(() => {
 | 
				
			||||||
      loadPdfJS();
 | 
					      loadPdfJS();
 | 
				
			||||||
| 
						 | 
					@ -86,7 +87,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
				
			||||||
      if (!jumpInput) return;
 | 
					      if (!jumpInput) return;
 | 
				
			||||||
      const jumpTo = parseInt(jumpInput.value, 10);
 | 
					      const jumpTo = parseInt(jumpInput.value, 10);
 | 
				
			||||||
      setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
 | 
					      setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
 | 
				
			||||||
      setOpenJump(false);
 | 
					      setJumpAnchor(undefined);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handlePrevPage = () => {
 | 
					    const handlePrevPage = () => {
 | 
				
			||||||
| 
						 | 
					@ -98,6 +99,10 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
				
			||||||
      setPageNo((n) => Math.min(n + 1, docState.data.numPages));
 | 
					      setPageNo((n) => Math.min(n + 1, docState.data.numPages));
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
				
			||||||
 | 
					      setJumpAnchor(evt.currentTarget.getBoundingClientRect());
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
 | 
					      <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
 | 
				
			||||||
        <Header className={css.PdfViewerHeader} size="400">
 | 
					        <Header className={css.PdfViewerHeader} size="400">
 | 
				
			||||||
| 
						 | 
					@ -187,14 +192,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
				
			||||||
            </Chip>
 | 
					            </Chip>
 | 
				
			||||||
            <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
 | 
					            <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
 | 
				
			||||||
              <PopOut
 | 
					              <PopOut
 | 
				
			||||||
                open={openJump}
 | 
					                anchor={jumpAnchor}
 | 
				
			||||||
                align="Center"
 | 
					                align="Center"
 | 
				
			||||||
                position="Top"
 | 
					                position="Top"
 | 
				
			||||||
                content={
 | 
					                content={
 | 
				
			||||||
                  <FocusTrap
 | 
					                  <FocusTrap
 | 
				
			||||||
                    focusTrapOptions={{
 | 
					                    focusTrapOptions={{
 | 
				
			||||||
                      initialFocus: false,
 | 
					                      initialFocus: false,
 | 
				
			||||||
                      onDeactivate: () => setOpenJump(false),
 | 
					                      onDeactivate: () => setJumpAnchor(undefined),
 | 
				
			||||||
                      clickOutsideDeactivates: true,
 | 
					                      clickOutsideDeactivates: true,
 | 
				
			||||||
                    }}
 | 
					                    }}
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
| 
						 | 
					@ -227,17 +232,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
 | 
				
			||||||
                  </FocusTrap>
 | 
					                  </FocusTrap>
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                {(anchorRef) => (
 | 
					                <Chip
 | 
				
			||||||
                  <Chip
 | 
					                  onClick={handleOpenJump}
 | 
				
			||||||
                    onClick={() => setOpenJump(!openJump)}
 | 
					                  variant="SurfaceVariant"
 | 
				
			||||||
                    ref={anchorRef}
 | 
					                  radii="300"
 | 
				
			||||||
                    variant="SurfaceVariant"
 | 
					                  aria-pressed={jumpAnchor !== undefined}
 | 
				
			||||||
                    radii="300"
 | 
					                >
 | 
				
			||||||
                    aria-pressed={openJump}
 | 
					                  <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
 | 
				
			||||||
                  >
 | 
					                </Chip>
 | 
				
			||||||
                    <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
 | 
					 | 
				
			||||||
                  </Chip>
 | 
					 | 
				
			||||||
                )}
 | 
					 | 
				
			||||||
              </PopOut>
 | 
					              </PopOut>
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
            <Chip
 | 
					            <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 { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
				
			||||||
import { SpecVersions, specVersions } from '../cs-api';
 | 
					import { SpecVersions, specVersions } from '../cs-api';
 | 
				
			||||||
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SpecVersionsLoaderProps = {
 | 
					type SpecVersionsLoaderProps = {
 | 
				
			||||||
 | 
					  baseUrl: string;
 | 
				
			||||||
  fallback?: () => ReactNode;
 | 
					  fallback?: () => ReactNode;
 | 
				
			||||||
  error?: (err: unknown) => ReactNode;
 | 
					  error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
 | 
				
			||||||
  children: (versions: SpecVersions) => ReactNode;
 | 
					  children: (versions: SpecVersions) => ReactNode;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
 | 
					export function SpecVersionsLoader({
 | 
				
			||||||
  const autoDiscoveryInfo = useAutoDiscoveryInfo();
 | 
					  baseUrl,
 | 
				
			||||||
  const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
 | 
					  fallback,
 | 
				
			||||||
 | 
					  error,
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					}: SpecVersionsLoaderProps) {
 | 
				
			||||||
  const [state, load] = useAsyncCallback(
 | 
					  const [state, load] = useAsyncCallback(
 | 
				
			||||||
    useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
 | 
					    useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					  const [ignoreError, setIgnoreError] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const ignoreCallback = useCallback(() => setIgnoreError(true), []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    load();
 | 
					    load();
 | 
				
			||||||
| 
						 | 
					@ -24,9 +29,15 @@ export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLo
 | 
				
			||||||
    return fallback?.();
 | 
					    return fallback?.();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (state.status === AsyncStatus.Error) {
 | 
					  if (!ignoreError && state.status === AsyncStatus.Error) {
 | 
				
			||||||
    return error?.(state.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,
 | 
					  Line,
 | 
				
			||||||
  Menu,
 | 
					  Menu,
 | 
				
			||||||
  PopOut,
 | 
					  PopOut,
 | 
				
			||||||
 | 
					  RectCords,
 | 
				
			||||||
  Scroll,
 | 
					  Scroll,
 | 
				
			||||||
  Text,
 | 
					  Text,
 | 
				
			||||||
  Tooltip,
 | 
					  Tooltip,
 | 
				
			||||||
  TooltipProvider,
 | 
					  TooltipProvider,
 | 
				
			||||||
  toRem,
 | 
					  toRem,
 | 
				
			||||||
} from 'folds';
 | 
					} from 'folds';
 | 
				
			||||||
import React, { ReactNode, useState } from 'react';
 | 
					import React, { MouseEventHandler, ReactNode, useState } from 'react';
 | 
				
			||||||
import { ReactEditor, useSlate } from 'slate-react';
 | 
					import { ReactEditor, useSlate } from 'slate-react';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  headingLevel,
 | 
					  headingLevel,
 | 
				
			||||||
| 
						 | 
					@ -119,26 +120,33 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
 | 
				
			||||||
export function HeadingBlockButton() {
 | 
					export function HeadingBlockButton() {
 | 
				
			||||||
  const editor = useSlate();
 | 
					  const editor = useSlate();
 | 
				
			||||||
  const level = headingLevel(editor);
 | 
					  const level = headingLevel(editor);
 | 
				
			||||||
  const [open, setOpen] = useState(false);
 | 
					  const [anchor, setAnchor] = useState<RectCords>();
 | 
				
			||||||
  const isActive = isBlockActive(editor, BlockType.Heading);
 | 
					  const isActive = isBlockActive(editor, BlockType.Heading);
 | 
				
			||||||
  const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
 | 
					  const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleMenuSelect = (selectedLevel: HeadingLevel) => {
 | 
					  const handleMenuSelect = (selectedLevel: HeadingLevel) => {
 | 
				
			||||||
    setOpen(false);
 | 
					    setAnchor(undefined);
 | 
				
			||||||
    toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
 | 
					    toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
 | 
				
			||||||
    ReactEditor.focus(editor);
 | 
					    ReactEditor.focus(editor);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
				
			||||||
 | 
					    if (isActive) {
 | 
				
			||||||
 | 
					      toggleBlock(editor, BlockType.Heading);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    setAnchor(evt.currentTarget.getBoundingClientRect());
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <PopOut
 | 
					    <PopOut
 | 
				
			||||||
      open={open}
 | 
					      anchor={anchor}
 | 
				
			||||||
      offset={5}
 | 
					      offset={5}
 | 
				
			||||||
      position="Top"
 | 
					      position="Top"
 | 
				
			||||||
      content={
 | 
					      content={
 | 
				
			||||||
        <FocusTrap
 | 
					        <FocusTrap
 | 
				
			||||||
          focusTrapOptions={{
 | 
					          focusTrapOptions={{
 | 
				
			||||||
            initialFocus: false,
 | 
					            initialFocus: false,
 | 
				
			||||||
            onDeactivate: () => setOpen(false),
 | 
					            onDeactivate: () => setAnchor(undefined),
 | 
				
			||||||
            clickOutsideDeactivates: true,
 | 
					            clickOutsideDeactivates: true,
 | 
				
			||||||
            isKeyForward: (evt: KeyboardEvent) =>
 | 
					            isKeyForward: (evt: KeyboardEvent) =>
 | 
				
			||||||
              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
					              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
				
			||||||
| 
						 | 
					@ -197,20 +205,17 @@ export function HeadingBlockButton() {
 | 
				
			||||||
        </FocusTrap>
 | 
					        </FocusTrap>
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      {(ref) => (
 | 
					      <IconButton
 | 
				
			||||||
        <IconButton
 | 
					        style={{ width: 'unset' }}
 | 
				
			||||||
          style={{ width: 'unset' }}
 | 
					        variant="SurfaceVariant"
 | 
				
			||||||
          ref={ref}
 | 
					        onClick={handleMenuOpen}
 | 
				
			||||||
          variant="SurfaceVariant"
 | 
					        aria-pressed={isActive}
 | 
				
			||||||
          onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
 | 
					        size="400"
 | 
				
			||||||
          aria-pressed={isActive}
 | 
					        radii="300"
 | 
				
			||||||
          size="400"
 | 
					      >
 | 
				
			||||||
          radii="300"
 | 
					        <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
 | 
				
			||||||
        >
 | 
					        <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
 | 
				
			||||||
          <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
 | 
					      </IconButton>
 | 
				
			||||||
          <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
 | 
					 | 
				
			||||||
        </IconButton>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </PopOut>
 | 
					    </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 { Editor } from 'slate';
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
 | 
					import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
 | 
				
			||||||
import { MatrixClient } from 'matrix-js-sdk';
 | 
					import { JoinRule, MatrixClient } from 'matrix-js-sdk';
 | 
				
			||||||
 | 
					import { useAtomValue } from 'jotai';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
					import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
				
			||||||
import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
 | 
					import { getDirectRoomAvatarUrl } from '../../../utils/room';
 | 
				
			||||||
import { roomIdByActivity } from '../../../../util/sort';
 | 
					 | 
				
			||||||
import initMatrix from '../../../../client/initMatrix';
 | 
					 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
					import { AutocompleteQuery } from './autocompleteQuery';
 | 
				
			||||||
import { AutocompleteMenu } from './AutocompleteMenu';
 | 
					import { AutocompleteMenu } from './AutocompleteMenu';
 | 
				
			||||||
| 
						 | 
					@ -14,6 +13,10 @@ import { getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
				
			||||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 | 
					import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 | 
				
			||||||
import { onTabPress } from '../../../utils/keyboard';
 | 
					import { onTabPress } from '../../../utils/keyboard';
 | 
				
			||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
					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;
 | 
					type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -74,15 +77,12 @@ export function RoomMentionAutocomplete({
 | 
				
			||||||
  requestClose,
 | 
					  requestClose,
 | 
				
			||||||
}: RoomMentionAutocompleteProps) {
 | 
					}: RoomMentionAutocompleteProps) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
 | 
					  const mDirects = useAtomValue(mDirectAtom);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const allRoomId: string[] = useMemo(() => {
 | 
					  const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
 | 
				
			||||||
    const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
 | 
					 | 
				
			||||||
    return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [result, search, resetSearch] = useAsyncSearch(
 | 
					  const [result, search, resetSearch] = useAsyncSearch(
 | 
				
			||||||
    allRoomId,
 | 
					    allRooms,
 | 
				
			||||||
    useCallback(
 | 
					    useCallback(
 | 
				
			||||||
      (rId) => {
 | 
					      (rId) => {
 | 
				
			||||||
        const r = mx.getRoom(rId);
 | 
					        const r = mx.getRoom(rId);
 | 
				
			||||||
| 
						 | 
					@ -96,7 +96,7 @@ export function RoomMentionAutocomplete({
 | 
				
			||||||
    SEARCH_OPTIONS
 | 
					    SEARCH_OPTIONS
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
 | 
					  const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (query.text) search(query.text);
 | 
					    if (query.text) search(query.text);
 | 
				
			||||||
| 
						 | 
					@ -136,9 +136,7 @@ export function RoomMentionAutocomplete({
 | 
				
			||||||
        autoCompleteRoomIds.map((rId) => {
 | 
					        autoCompleteRoomIds.map((rId) => {
 | 
				
			||||||
          const room = mx.getRoom(rId);
 | 
					          const room = mx.getRoom(rId);
 | 
				
			||||||
          if (!room) return null;
 | 
					          if (!room) return null;
 | 
				
			||||||
          const dm = dms.has(room.roomId);
 | 
					          const dm = mDirects.has(room.roomId);
 | 
				
			||||||
          const avatarUrl = getRoomAvatarUrl(mx, room);
 | 
					 | 
				
			||||||
          const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
 | 
					          const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -158,17 +156,21 @@ export function RoomMentionAutocomplete({
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
              before={
 | 
					              before={
 | 
				
			||||||
                <Avatar size="200">
 | 
					                <Avatar size="200">
 | 
				
			||||||
                  {iconSrc && <Icon src={iconSrc} size="100" />}
 | 
					                  {dm ? (
 | 
				
			||||||
                  {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
 | 
					                    <RoomAvatar
 | 
				
			||||||
                  {!avatarUrl && !iconSrc && (
 | 
					                      roomId={room.roomId}
 | 
				
			||||||
                    <AvatarFallback
 | 
					                      src={getDirectRoomAvatarUrl(mx, room)}
 | 
				
			||||||
                      style={{
 | 
					                      alt={room.name}
 | 
				
			||||||
                        backgroundColor: color.Secondary.Container,
 | 
					                      renderFallback={() => (
 | 
				
			||||||
                        color: color.Secondary.OnContainer,
 | 
					                        <RoomIcon
 | 
				
			||||||
                      }}
 | 
					                          size="50"
 | 
				
			||||||
                    >
 | 
					                          joinRule={room.getJoinRule() ?? JoinRule.Restricted}
 | 
				
			||||||
                      <Text size="H6">{room.name[0]}</Text>
 | 
					                          filled
 | 
				
			||||||
                    </AvatarFallback>
 | 
					                        />
 | 
				
			||||||
 | 
					                      )}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                  ) : (
 | 
				
			||||||
 | 
					                    <RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
 | 
				
			||||||
                  )}
 | 
					                  )}
 | 
				
			||||||
                </Avatar>
 | 
					                </Avatar>
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
 | 
					import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
 | 
				
			||||||
import { Editor } from 'slate';
 | 
					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 { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
					import { AutocompleteQuery } from './autocompleteQuery';
 | 
				
			||||||
| 
						 | 
					@ -17,6 +17,7 @@ import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
				
			||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
					import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
				
			||||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
					import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
				
			||||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
 | 
					import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
 | 
				
			||||||
 | 
					import { UserAvatar } from '../../user-avatar';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 | 
					type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,12 +27,10 @@ const userIdFromQueryText = (mx: MatrixClient, text: string) =>
 | 
				
			||||||
    : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
					    : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function UnknownMentionItem({
 | 
					function UnknownMentionItem({
 | 
				
			||||||
  query,
 | 
					 | 
				
			||||||
  userId,
 | 
					  userId,
 | 
				
			||||||
  name,
 | 
					  name,
 | 
				
			||||||
  handleAutocomplete,
 | 
					  handleAutocomplete,
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  query: AutocompleteQuery<string>;
 | 
					 | 
				
			||||||
  userId: string;
 | 
					  userId: string;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  handleAutocomplete: MentionAutoCompleteHandler;
 | 
					  handleAutocomplete: MentionAutoCompleteHandler;
 | 
				
			||||||
| 
						 | 
					@ -46,14 +45,10 @@ function UnknownMentionItem({
 | 
				
			||||||
      onClick={() => handleAutocomplete(userId, name)}
 | 
					      onClick={() => handleAutocomplete(userId, name)}
 | 
				
			||||||
      before={
 | 
					      before={
 | 
				
			||||||
        <Avatar size="200">
 | 
					        <Avatar size="200">
 | 
				
			||||||
          <AvatarFallback
 | 
					          <UserAvatar
 | 
				
			||||||
            style={{
 | 
					            userId={userId}
 | 
				
			||||||
              backgroundColor: color.Secondary.Container,
 | 
					            renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
				
			||||||
              color: color.Secondary.OnContainer,
 | 
					          />
 | 
				
			||||||
            }}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <Text size="H6">{query.text[0]}</Text>
 | 
					 | 
				
			||||||
          </AvatarFallback>
 | 
					 | 
				
			||||||
        </Avatar>
 | 
					        </Avatar>
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
| 
						 | 
					@ -135,7 +130,6 @@ export function UserMentionAutocomplete({
 | 
				
			||||||
    <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
 | 
					    <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
 | 
				
			||||||
      {query.text === 'room' && (
 | 
					      {query.text === 'room' && (
 | 
				
			||||||
        <UnknownMentionItem
 | 
					        <UnknownMentionItem
 | 
				
			||||||
          query={query}
 | 
					 | 
				
			||||||
          userId={roomAliasOrId}
 | 
					          userId={roomAliasOrId}
 | 
				
			||||||
          name="@room"
 | 
					          name="@room"
 | 
				
			||||||
          handleAutocomplete={handleAutocomplete}
 | 
					          handleAutocomplete={handleAutocomplete}
 | 
				
			||||||
| 
						 | 
					@ -143,7 +137,6 @@ export function UserMentionAutocomplete({
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
      {autoCompleteMembers.length === 0 ? (
 | 
					      {autoCompleteMembers.length === 0 ? (
 | 
				
			||||||
        <UnknownMentionItem
 | 
					        <UnknownMentionItem
 | 
				
			||||||
          query={query}
 | 
					 | 
				
			||||||
          userId={userIdFromQueryText(mx, query.text)}
 | 
					          userId={userIdFromQueryText(mx, query.text)}
 | 
				
			||||||
          name={userIdFromQueryText(mx, query.text)}
 | 
					          name={userIdFromQueryText(mx, query.text)}
 | 
				
			||||||
          handleAutocomplete={handleAutocomplete}
 | 
					          handleAutocomplete={handleAutocomplete}
 | 
				
			||||||
| 
						 | 
					@ -167,18 +160,12 @@ export function UserMentionAutocomplete({
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
              before={
 | 
					              before={
 | 
				
			||||||
                <Avatar size="200">
 | 
					                <Avatar size="200">
 | 
				
			||||||
                  {avatarUrl ? (
 | 
					                  <UserAvatar
 | 
				
			||||||
                    <AvatarImage src={avatarUrl} alt={getName(roomMember)} />
 | 
					                    userId={roomMember.userId}
 | 
				
			||||||
                  ) : (
 | 
					                    src={avatarUrl ?? undefined}
 | 
				
			||||||
                    <AvatarFallback
 | 
					                    alt={getName(roomMember)}
 | 
				
			||||||
                      style={{
 | 
					                    renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
				
			||||||
                        backgroundColor: color.Secondary.Container,
 | 
					                  />
 | 
				
			||||||
                        color: color.Secondary.OnContainer,
 | 
					 | 
				
			||||||
                      }}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                      <Text size="H6">{getName(roomMember)[0]}</Text>
 | 
					 | 
				
			||||||
                    </AvatarFallback>
 | 
					 | 
				
			||||||
                  )}
 | 
					 | 
				
			||||||
                </Avatar>
 | 
					                </Avatar>
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,8 +2,6 @@ import React from 'react';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Avatar,
 | 
					  Avatar,
 | 
				
			||||||
  AvatarFallback,
 | 
					 | 
				
			||||||
  AvatarImage,
 | 
					 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Header,
 | 
					  Header,
 | 
				
			||||||
  Icon,
 | 
					  Icon,
 | 
				
			||||||
| 
						 | 
					@ -21,8 +19,8 @@ import { getMemberDisplayName } from '../../utils/room';
 | 
				
			||||||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
					import { getMxIdLocalPart } from '../../utils/matrix';
 | 
				
			||||||
import * as css from './EventReaders.css';
 | 
					import * as css from './EventReaders.css';
 | 
				
			||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import colorMXID from '../../../util/colorMXID';
 | 
					 | 
				
			||||||
import { openProfileViewer } from '../../../client/action/navigation';
 | 
					import { openProfileViewer } from '../../../client/action/navigation';
 | 
				
			||||||
 | 
					import { UserAvatar } from '../user-avatar';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type EventReadersProps = {
 | 
					export type EventReadersProps = {
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
| 
						 | 
					@ -72,18 +70,12 @@ export const EventReaders = as<'div', EventReadersProps>(
 | 
				
			||||||
                    }}
 | 
					                    }}
 | 
				
			||||||
                    before={
 | 
					                    before={
 | 
				
			||||||
                      <Avatar size="200">
 | 
					                      <Avatar size="200">
 | 
				
			||||||
                        {avatarUrl ? (
 | 
					                        <UserAvatar
 | 
				
			||||||
                          <AvatarImage src={avatarUrl} />
 | 
					                          userId={readerId}
 | 
				
			||||||
                        ) : (
 | 
					                          src={avatarUrl ?? undefined}
 | 
				
			||||||
                          <AvatarFallback
 | 
					                          alt={name}
 | 
				
			||||||
                            style={{
 | 
					                          renderFallback={() => <Icon size="50" src={Icons.User} filled />}
 | 
				
			||||||
                              background: colorMXID(readerId),
 | 
					                        />
 | 
				
			||||||
                              color: 'white',
 | 
					 | 
				
			||||||
                            }}
 | 
					 | 
				
			||||||
                          >
 | 
					 | 
				
			||||||
                            <Text size="H6">{name[0]}</Text>
 | 
					 | 
				
			||||||
                          </AvatarFallback>
 | 
					 | 
				
			||||||
                        )}
 | 
					 | 
				
			||||||
                      </Avatar>
 | 
					                      </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 { Badge, Box, Text, as, toRem } from 'folds';
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import { mimeTypeToExt } from '../../../utils/mimeTypes';
 | 
					import { mimeTypeToExt } from '../../utils/mimeTypes';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const badgeStyles = { maxWidth: toRem(100) };
 | 
					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 { style } from '@vanilla-extract/css';
 | 
				
			||||||
import { config, toRem } from 'folds';
 | 
					import { config, toRem } from 'folds';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ReplyBend = style({
 | 
				
			||||||
 | 
					  flexShrink: 0,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Reply = style({
 | 
					export const Reply = style({
 | 
				
			||||||
  padding: `0 ${config.space.S100}`,
 | 
					 | 
				
			||||||
  marginBottom: toRem(1),
 | 
					  marginBottom: toRem(1),
 | 
				
			||||||
  cursor: 'pointer',
 | 
					 | 
				
			||||||
  minWidth: 0,
 | 
					  minWidth: 0,
 | 
				
			||||||
  maxWidth: '100%',
 | 
					  maxWidth: '100%',
 | 
				
			||||||
  minHeight: config.lineHeight.T300,
 | 
					  minHeight: config.lineHeight.T300,
 | 
				
			||||||
 | 
					  selectors: {
 | 
				
			||||||
 | 
					    'button&': {
 | 
				
			||||||
 | 
					      cursor: 'pointer',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ReplyContent = style({
 | 
					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 { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
 | 
				
			||||||
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
 | 
					import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
 | 
				
			||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 | 
					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 to from 'await-to-js';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import colorMXID from '../../../util/colorMXID';
 | 
					import colorMXID from '../../../util/colorMXID';
 | 
				
			||||||
| 
						 | 
					@ -10,94 +10,105 @@ import { getMxIdLocalPart } from '../../utils/matrix';
 | 
				
			||||||
import { LinePlaceholder } from './placeholder';
 | 
					import { LinePlaceholder } from './placeholder';
 | 
				
			||||||
import { randomNumberBetween } from '../../utils/common';
 | 
					import { randomNumberBetween } from '../../utils/common';
 | 
				
			||||||
import * as css from './Reply.css';
 | 
					import * as css from './Reply.css';
 | 
				
			||||||
import {
 | 
					import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
 | 
				
			||||||
  MessageBadEncryptedContent,
 | 
					import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
 | 
				
			||||||
  MessageDeletedContent,
 | 
					
 | 
				
			||||||
  MessageFailedContent,
 | 
					type ReplyLayoutProps = {
 | 
				
			||||||
} from './MessageContentFallback';
 | 
					  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 = {
 | 
					type ReplyProps = {
 | 
				
			||||||
  mx: MatrixClient;
 | 
					  mx: MatrixClient;
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
  timelineSet: EventTimelineSet;
 | 
					  timelineSet?: EventTimelineSet;
 | 
				
			||||||
  eventId: string;
 | 
					  eventId: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Reply = as<'div', ReplyProps>(
 | 
					export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => {
 | 
				
			||||||
  ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
 | 
					  const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
 | 
				
			||||||
    const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
 | 
					    timelineSet?.findEventById(eventId)
 | 
				
			||||||
      timelineSet.findEventById(eventId)
 | 
					  );
 | 
				
			||||||
    );
 | 
					  const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { body } = replyEvent?.getContent() ?? {};
 | 
					  const { body } = replyEvent?.getContent() ?? {};
 | 
				
			||||||
    const sender = replyEvent?.getSender();
 | 
					  const sender = replyEvent?.getSender();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const fallbackBody = replyEvent?.isRedacted() ? (
 | 
					  const fallbackBody = replyEvent?.isRedacted() ? (
 | 
				
			||||||
      <MessageDeletedContent />
 | 
					    <MessageDeletedContent />
 | 
				
			||||||
    ) : (
 | 
					  ) : (
 | 
				
			||||||
      <MessageFailedContent />
 | 
					    <MessageFailedContent />
 | 
				
			||||||
    );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
      let disposed = false;
 | 
					    let disposed = false;
 | 
				
			||||||
      const loadEvent = async () => {
 | 
					    const loadEvent = async () => {
 | 
				
			||||||
        const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
 | 
					      const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
 | 
				
			||||||
        const mEvent = new MatrixEvent(evt);
 | 
					      const mEvent = new MatrixEvent(evt);
 | 
				
			||||||
        if (disposed) return;
 | 
					      if (disposed) return;
 | 
				
			||||||
        if (err) {
 | 
					      if (err) {
 | 
				
			||||||
          setReplyEvent(null);
 | 
					        setReplyEvent(null);
 | 
				
			||||||
          return;
 | 
					        return;
 | 
				
			||||||
        }
 | 
					      }
 | 
				
			||||||
        if (mEvent.isEncrypted() && mx.getCrypto()) {
 | 
					      if (mEvent.isEncrypted() && mx.getCrypto()) {
 | 
				
			||||||
          await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
 | 
					        await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
 | 
				
			||||||
        }
 | 
					      }
 | 
				
			||||||
        setReplyEvent(mEvent);
 | 
					      setReplyEvent(mEvent);
 | 
				
			||||||
      };
 | 
					    };
 | 
				
			||||||
      if (replyEvent === undefined) loadEvent();
 | 
					    if (replyEvent === undefined) loadEvent();
 | 
				
			||||||
      return () => {
 | 
					    return () => {
 | 
				
			||||||
        disposed = true;
 | 
					      disposed = true;
 | 
				
			||||||
      };
 | 
					    };
 | 
				
			||||||
    }, [replyEvent, mx, room, eventId]);
 | 
					  }, [replyEvent, mx, room, eventId]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
 | 
					  const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
 | 
				
			||||||
    const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
 | 
					  const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					  return (
 | 
				
			||||||
      <Box
 | 
					    <ReplyLayout
 | 
				
			||||||
        className={classNames(css.Reply, className)}
 | 
					      userColor={sender ? colorMXID(sender) : undefined}
 | 
				
			||||||
        alignItems="Center"
 | 
					      username={
 | 
				
			||||||
        gap="100"
 | 
					        sender && (
 | 
				
			||||||
        {...props}
 | 
					          <Text size="T300" truncate>
 | 
				
			||||||
        ref={ref}
 | 
					            <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
 | 
				
			||||||
      >
 | 
					          </Text>
 | 
				
			||||||
        <Box
 | 
					        )
 | 
				
			||||||
          style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
 | 
					      }
 | 
				
			||||||
          alignItems="Center"
 | 
					      {...props}
 | 
				
			||||||
          shrink="No"
 | 
					      ref={ref}
 | 
				
			||||||
        >
 | 
					    >
 | 
				
			||||||
          <Icon src={Icons.ReplyArrow} size="50" />
 | 
					      {replyEvent !== undefined ? (
 | 
				
			||||||
          {sender && (
 | 
					        <Text size="T300" truncate>
 | 
				
			||||||
            <Text size="T300" truncate>
 | 
					          {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
 | 
				
			||||||
              {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
 | 
					        </Text>
 | 
				
			||||||
            </Text>
 | 
					      ) : (
 | 
				
			||||||
          )}
 | 
					        <LinePlaceholder
 | 
				
			||||||
        </Box>
 | 
					          style={{
 | 
				
			||||||
        <Box grow="Yes" className={css.ReplyContent}>
 | 
					            backgroundColor: color.SurfaceVariant.ContainerActive,
 | 
				
			||||||
          {replyEvent !== undefined ? (
 | 
					            maxWidth: toRem(placeholderWidth),
 | 
				
			||||||
            <Text className={css.ReplyContentText} size="T300" truncate>
 | 
					            width: '100%',
 | 
				
			||||||
              {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
 | 
					          }}
 | 
				
			||||||
            </Text>
 | 
					        />
 | 
				
			||||||
          ) : (
 | 
					      )}
 | 
				
			||||||
            <LinePlaceholder
 | 
					    </ReplyLayout>
 | 
				
			||||||
              style={{
 | 
					  );
 | 
				
			||||||
                backgroundColor: color.SurfaceVariant.ContainerActive,
 | 
					});
 | 
				
			||||||
                maxWidth: toRem(randomNumberBetween(40, 400)),
 | 
					 | 
				
			||||||
                width: '100%',
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </Box>
 | 
					 | 
				
			||||||
      </Box>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React, { ComponentProps } from 'react';
 | 
				
			||||||
import { Text, as } from 'folds';
 | 
					import { Text, as } from 'folds';
 | 
				
			||||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
 | 
					import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,21 +7,23 @@ export type TimeProps = {
 | 
				
			||||||
  ts: number;
 | 
					  ts: number;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
 | 
					export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
 | 
				
			||||||
  let time = '';
 | 
					  ({ compact, ts, ...props }, ref) => {
 | 
				
			||||||
  if (compact) {
 | 
					    let time = '';
 | 
				
			||||||
    time = timeHourMinute(ts);
 | 
					    if (compact) {
 | 
				
			||||||
  } else if (today(ts)) {
 | 
					      time = timeHourMinute(ts);
 | 
				
			||||||
    time = timeHourMinute(ts);
 | 
					    } else if (today(ts)) {
 | 
				
			||||||
  } else if (yesterday(ts)) {
 | 
					      time = timeHourMinute(ts);
 | 
				
			||||||
    time = `Yesterday ${timeHourMinute(ts)}`;
 | 
					    } else if (yesterday(ts)) {
 | 
				
			||||||
  } else {
 | 
					      time = `Yesterday ${timeHourMinute(ts)}`;
 | 
				
			||||||
    time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
 | 
					    } else {
 | 
				
			||||||
  }
 | 
					      time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					    return (
 | 
				
			||||||
    <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
 | 
					      <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
 | 
				
			||||||
      {time}
 | 
					        {time}
 | 
				
			||||||
    </Text>
 | 
					      </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 { Box, Icon, IconSrc } from 'folds';
 | 
				
			||||||
import React, { ReactNode } from 'react';
 | 
					import React, { ReactNode } from 'react';
 | 
				
			||||||
import { CompactLayout, ModernLayout } from '../../../components/message';
 | 
					import { CompactLayout, ModernLayout } from '..';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type EventContentProps = {
 | 
					export type EventContentProps = {
 | 
				
			||||||
  messageLayout: number;
 | 
					  messageLayout: number;
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
import React, { useCallback, useState } from 'react';
 | 
					import React, { ReactNode, useCallback, useState } from 'react';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Button,
 | 
					  Button,
 | 
				
			||||||
| 
						 | 
					@ -22,23 +22,13 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { getFileSrcUrl, getSrcFile } from './util';
 | 
					import { getFileSrcUrl, getSrcFile } from './util';
 | 
				
			||||||
import { bytesToSize } from '../../../utils/common';
 | 
					import { bytesToSize } from '../../../utils/common';
 | 
				
			||||||
import { TextViewer } from '../../../components/text-viewer';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  READABLE_EXT_TO_MIME_TYPE,
 | 
					  READABLE_EXT_TO_MIME_TYPE,
 | 
				
			||||||
  READABLE_TEXT_MIME_TYPES,
 | 
					  READABLE_TEXT_MIME_TYPES,
 | 
				
			||||||
  getFileNameExt,
 | 
					  getFileNameExt,
 | 
				
			||||||
  mimeTypeToExt,
 | 
					  mimeTypeToExt,
 | 
				
			||||||
} from '../../../utils/mimeTypes';
 | 
					} from '../../../utils/mimeTypes';
 | 
				
			||||||
import { PdfViewer } from '../../../components/Pdf-viewer';
 | 
					import * as css from './style.css';
 | 
				
			||||||
import * as css from './styles.css';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type FileContentProps = {
 | 
					 | 
				
			||||||
  body: string;
 | 
					 | 
				
			||||||
  mimeType: string;
 | 
					 | 
				
			||||||
  url: string;
 | 
					 | 
				
			||||||
  info: IFileInfo;
 | 
					 | 
				
			||||||
  encInfo?: EncryptedAttachmentInfo;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const renderErrorButton = (retry: () => void, text: string) => (
 | 
					const renderErrorButton = (retry: () => void, text: string) => (
 | 
				
			||||||
  <TooltipProvider
 | 
					  <TooltipProvider
 | 
				
			||||||
| 
						 | 
					@ -69,7 +59,20 @@ const renderErrorButton = (retry: () => void, text: string) => (
 | 
				
			||||||
  </TooltipProvider>
 | 
					  </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 mx = useMatrixClient();
 | 
				
			||||||
  const [textViewer, setTextViewer] = useState(false);
 | 
					  const [textViewer, setTextViewer] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -105,16 +108,14 @@ function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, '
 | 
				
			||||||
                size="500"
 | 
					                size="500"
 | 
				
			||||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <TextViewer
 | 
					                {renderViewer({
 | 
				
			||||||
                  name={body}
 | 
					                  name: body,
 | 
				
			||||||
                  text={textState.data}
 | 
					                  text: textState.data,
 | 
				
			||||||
                  langName={
 | 
					                  langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
 | 
				
			||||||
                    READABLE_TEXT_MIME_TYPES.includes(mimeType)
 | 
					                    ? mimeTypeToExt(mimeType)
 | 
				
			||||||
                      ? mimeTypeToExt(mimeType)
 | 
					                    : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
 | 
				
			||||||
                      : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
 | 
					                  requestClose: () => setTextViewer(false),
 | 
				
			||||||
                  }
 | 
					                })}
 | 
				
			||||||
                  requestClose={() => setTextViewer(false)}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              </Modal>
 | 
					              </Modal>
 | 
				
			||||||
            </FocusTrap>
 | 
					            </FocusTrap>
 | 
				
			||||||
          </OverlayCenter>
 | 
					          </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 mx = useMatrixClient();
 | 
				
			||||||
  const [pdfViewer, setPdfViewer] = useState(false);
 | 
					  const [pdfViewer, setPdfViewer] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -178,11 +191,11 @@ function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'i
 | 
				
			||||||
                size="500"
 | 
					                size="500"
 | 
				
			||||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <PdfViewer
 | 
					                {renderViewer({
 | 
				
			||||||
                  name={body}
 | 
					                  name: body,
 | 
				
			||||||
                  src={pdfState.data}
 | 
					                  src: pdfState.data,
 | 
				
			||||||
                  requestClose={() => setPdfViewer(false)}
 | 
					                  requestClose: () => setPdfViewer(false),
 | 
				
			||||||
                />
 | 
					                })}
 | 
				
			||||||
              </Modal>
 | 
					              </Modal>
 | 
				
			||||||
            </FocusTrap>
 | 
					            </FocusTrap>
 | 
				
			||||||
          </OverlayCenter>
 | 
					          </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 mx = useMatrixClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [downloadState, download] = useAsyncCallback(
 | 
					  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>(
 | 
					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}>
 | 
					    <Box direction="Column" gap="300" {...props} ref={ref}>
 | 
				
			||||||
      {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
 | 
					      {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
 | 
				
			||||||
        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
 | 
					        READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
 | 
				
			||||||
        <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
 | 
					        renderAsTextFile()}
 | 
				
			||||||
      )}
 | 
					      {mimeType === 'application/pdf' && renderAsPdfFile()}
 | 
				
			||||||
      {mimeType === 'application/pdf' && (
 | 
					      {children}
 | 
				
			||||||
        <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
 | 
					 | 
				
			||||||
    </Box>
 | 
					    </Box>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
					import React, { ReactNode, useCallback, useEffect, useState } from 'react';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Badge,
 | 
					  Badge,
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
| 
						 | 
					@ -23,12 +23,24 @@ import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/ma
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { getFileSrcUrl } from './util';
 | 
					import { getFileSrcUrl } from './util';
 | 
				
			||||||
import { Image } from '../../../components/media';
 | 
					import * as css from './style.css';
 | 
				
			||||||
import * as css from './styles.css';
 | 
					 | 
				
			||||||
import { bytesToSize } from '../../../utils/common';
 | 
					import { bytesToSize } from '../../../utils/common';
 | 
				
			||||||
import { ImageViewer } from '../../../components/image-viewer';
 | 
					 | 
				
			||||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
 | 
					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 = {
 | 
					export type ImageContentProps = {
 | 
				
			||||||
  body: string;
 | 
					  body: string;
 | 
				
			||||||
  mimeType?: string;
 | 
					  mimeType?: string;
 | 
				
			||||||
| 
						 | 
					@ -36,9 +48,25 @@ export type ImageContentProps = {
 | 
				
			||||||
  info?: IImageInfo;
 | 
					  info?: IImageInfo;
 | 
				
			||||||
  encInfo?: EncryptedAttachmentInfo;
 | 
					  encInfo?: EncryptedAttachmentInfo;
 | 
				
			||||||
  autoPlay?: boolean;
 | 
					  autoPlay?: boolean;
 | 
				
			||||||
 | 
					  renderViewer: (props: RenderViewerProps) => ReactNode;
 | 
				
			||||||
 | 
					  renderImage: (props: RenderImageProps) => ReactNode;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export const ImageContent = as<'div', ImageContentProps>(
 | 
					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 mx = useMatrixClient();
 | 
				
			||||||
    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
					    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -87,11 +115,11 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
				
			||||||
                  size="500"
 | 
					                  size="500"
 | 
				
			||||||
                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <ImageViewer
 | 
					                  {renderViewer({
 | 
				
			||||||
                    src={srcState.data}
 | 
					                    src: srcState.data,
 | 
				
			||||||
                    alt={body}
 | 
					                    alt: body,
 | 
				
			||||||
                    requestClose={() => setViewer(false)}
 | 
					                    requestClose: () => setViewer(false),
 | 
				
			||||||
                  />
 | 
					                  })}
 | 
				
			||||||
                </Modal>
 | 
					                </Modal>
 | 
				
			||||||
              </FocusTrap>
 | 
					              </FocusTrap>
 | 
				
			||||||
            </OverlayCenter>
 | 
					            </OverlayCenter>
 | 
				
			||||||
| 
						 | 
					@ -122,16 +150,15 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {srcState.status === AsyncStatus.Success && (
 | 
					        {srcState.status === AsyncStatus.Success && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer}>
 | 
					          <Box className={css.AbsoluteContainer}>
 | 
				
			||||||
            <Image
 | 
					            {renderImage({
 | 
				
			||||||
              alt={body}
 | 
					              alt: body,
 | 
				
			||||||
              title={body}
 | 
					              title: body,
 | 
				
			||||||
              src={srcState.data}
 | 
					              src: srcState.data,
 | 
				
			||||||
              loading="lazy"
 | 
					              onLoad: handleLoad,
 | 
				
			||||||
              onLoad={handleLoad}
 | 
					              onError: handleError,
 | 
				
			||||||
              onError={handleError}
 | 
					              onClick: () => setViewer(true),
 | 
				
			||||||
              onClick={() => setViewer(true)}
 | 
					              tabIndex: 0,
 | 
				
			||||||
              tabIndex={0}
 | 
					            })}
 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
					        {(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 {
 | 
					import {
 | 
				
			||||||
  Badge,
 | 
					  Badge,
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
| 
						 | 
					@ -19,25 +19,47 @@ import {
 | 
				
			||||||
  IVideoInfo,
 | 
					  IVideoInfo,
 | 
				
			||||||
  MATRIX_BLUR_HASH_PROPERTY_NAME,
 | 
					  MATRIX_BLUR_HASH_PROPERTY_NAME,
 | 
				
			||||||
} from '../../../../types/matrix/common';
 | 
					} from '../../../../types/matrix/common';
 | 
				
			||||||
import * as css from './styles.css';
 | 
					import * as css from './style.css';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
				
			||||||
import { getFileSrcUrl } from './util';
 | 
					import { getFileSrcUrl } from './util';
 | 
				
			||||||
import { Image, Video } from '../../../components/media';
 | 
					 | 
				
			||||||
import { bytesToSize } from '../../../../util/common';
 | 
					import { bytesToSize } from '../../../../util/common';
 | 
				
			||||||
import { millisecondsToMinutesAndSeconds } from '../../../utils/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;
 | 
					  body: string;
 | 
				
			||||||
  mimeType: string;
 | 
					  mimeType: string;
 | 
				
			||||||
  url: string;
 | 
					  url: string;
 | 
				
			||||||
  info: IVideoInfo & IThumbnailContent;
 | 
					  info: IVideoInfo & IThumbnailContent;
 | 
				
			||||||
  encInfo?: EncryptedAttachmentInfo;
 | 
					  encInfo?: EncryptedAttachmentInfo;
 | 
				
			||||||
  autoPlay?: boolean;
 | 
					  autoPlay?: boolean;
 | 
				
			||||||
  loadThumbnail?: boolean;
 | 
					  renderThumbnail?: () => ReactNode;
 | 
				
			||||||
 | 
					  renderVideo: (props: RenderVideoProps) => ReactNode;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export const VideoContent = as<'div', VideoContentProps>(
 | 
					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 mx = useMatrixClient();
 | 
				
			||||||
    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
					    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -50,20 +72,6 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
        [mx, url, mimeType, encInfo]
 | 
					        [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 = () => {
 | 
					    const handleLoad = () => {
 | 
				
			||||||
      setLoad(true);
 | 
					      setLoad(true);
 | 
				
			||||||
| 
						 | 
					@ -81,9 +89,6 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
    useEffect(() => {
 | 
					    useEffect(() => {
 | 
				
			||||||
      if (autoPlay) loadSrc();
 | 
					      if (autoPlay) loadSrc();
 | 
				
			||||||
    }, [autoPlay, loadSrc]);
 | 
					    }, [autoPlay, loadSrc]);
 | 
				
			||||||
    useEffect(() => {
 | 
					 | 
				
			||||||
      if (loadThumbnail) loadThumbSrc();
 | 
					 | 
				
			||||||
    }, [loadThumbnail, loadThumbSrc]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
 | 
					      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
 | 
				
			||||||
| 
						 | 
					@ -96,9 +101,9 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
            punch={1}
 | 
					            punch={1}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {thumbSrcState.status === AsyncStatus.Success && !load && (
 | 
					        {renderThumbnail && !load && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
					          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
				
			||||||
            <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
 | 
					            {renderThumbnail()}
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {!autoPlay && srcState.status === AsyncStatus.Idle && (
 | 
					        {!autoPlay && srcState.status === AsyncStatus.Idle && (
 | 
				
			||||||
| 
						 | 
					@ -117,14 +122,14 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {srcState.status === AsyncStatus.Success && (
 | 
					        {srcState.status === AsyncStatus.Success && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer}>
 | 
					          <Box className={css.AbsoluteContainer}>
 | 
				
			||||||
            <Video
 | 
					            {renderVideo({
 | 
				
			||||||
              title={body}
 | 
					              title: body,
 | 
				
			||||||
              src={srcState.data}
 | 
					              src: srcState.data,
 | 
				
			||||||
              onLoadedMetadata={handleLoad}
 | 
					              onLoadedMetadata: handleLoad,
 | 
				
			||||||
              onError={handleError}
 | 
					              onError: handleError,
 | 
				
			||||||
              autoPlay
 | 
					              autoPlay: true,
 | 
				
			||||||
              controls
 | 
					              controls: true,
 | 
				
			||||||
            />
 | 
					            })}
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
					        {(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 './Reaction';
 | 
				
			||||||
export * from './attachment';
 | 
					export * from './attachment';
 | 
				
			||||||
export * from './Reply';
 | 
					export * from './Reply';
 | 
				
			||||||
export * from './MessageContentFallback';
 | 
					export * from './content';
 | 
				
			||||||
export * from './Time';
 | 
					export * from './Time';
 | 
				
			||||||
 | 
					export * from './MsgTypeRenderers';
 | 
				
			||||||
 | 
					export * from './FileHeader';
 | 
				
			||||||
 | 
					export * from './RenderBody';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -61,6 +61,7 @@ const highlightAnime = keyframes({
 | 
				
			||||||
const HighlightVariant = styleVariants({
 | 
					const HighlightVariant = styleVariants({
 | 
				
			||||||
  true: {
 | 
					  true: {
 | 
				
			||||||
    animation: `${highlightAnime} 2000ms ease-in-out`,
 | 
					    animation: `${highlightAnime} 2000ms ease-in-out`,
 | 
				
			||||||
 | 
					    animationIterationCount: 'infinite',
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -143,12 +144,14 @@ export const BubbleContent = style({
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Username = style({
 | 
					export const Username = style({
 | 
				
			||||||
  cursor: 'pointer',
 | 
					 | 
				
			||||||
  overflow: 'hidden',
 | 
					  overflow: 'hidden',
 | 
				
			||||||
  whiteSpace: 'nowrap',
 | 
					  whiteSpace: 'nowrap',
 | 
				
			||||||
  textOverflow: 'ellipsis',
 | 
					  textOverflow: 'ellipsis',
 | 
				
			||||||
  selectors: {
 | 
					  selectors: {
 | 
				
			||||||
    '&:hover, &:focus-visible': {
 | 
					    'button&': {
 | 
				
			||||||
 | 
					      cursor: 'pointer',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    'button&:hover, button&:focus-visible': {
 | 
				
			||||||
      textDecoration: 'underline',
 | 
					      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 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 { Room } from 'matrix-js-sdk';
 | 
				
			||||||
import { openInviteUser, selectRoom } from '../../../client/action/navigation';
 | 
					import { useAtomValue } from 'jotai';
 | 
				
			||||||
import { useStateEvent } from '../../hooks/useStateEvent';
 | 
					import { openInviteUser } from '../../../client/action/navigation';
 | 
				
			||||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
 | 
					import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
 | 
				
			||||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
 | 
					import { getMemberDisplayName, getStateEvent } from '../../utils/room';
 | 
				
			||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { getMxIdLocalPart } from '../../utils/matrix';
 | 
					import { getMxIdLocalPart } from '../../utils/matrix';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
				
			||||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
 | 
					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 = {
 | 
					export type RoomIntroProps = {
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
| 
						 | 
					@ -16,21 +21,21 @@ export type RoomIntroProps = {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
 | 
					export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const createEvent = getStateEvent(room, StateEvent.RoomCreate);
 | 
					  const { navigateRoom } = useRoomNavigate();
 | 
				
			||||||
  const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
 | 
					  const mDirects = useAtomValue(mDirectAtom);
 | 
				
			||||||
  const nameEvent = useStateEvent(room, StateEvent.RoomName);
 | 
					 | 
				
			||||||
  const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
 | 
					 | 
				
			||||||
  const createContent = createEvent?.getContent<IRoomCreateContent>();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 ts = createEvent?.getTs();
 | 
				
			||||||
  const creatorId = createEvent?.getSender();
 | 
					  const creatorId = createEvent?.getSender();
 | 
				
			||||||
  const creatorName =
 | 
					  const creatorName =
 | 
				
			||||||
    creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
 | 
					    creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
 | 
				
			||||||
  const prevRoomId = createContent?.predecessor?.room_id;
 | 
					  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(
 | 
					  const [prevRoomState, joinPrevRoom] = useAsyncCallback(
 | 
				
			||||||
    useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
 | 
					    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 direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
 | 
				
			||||||
      <Box>
 | 
					      <Box>
 | 
				
			||||||
        <Avatar size="500">
 | 
					        <Avatar size="500">
 | 
				
			||||||
          {avatarHttpUrl ? (
 | 
					          <RoomAvatar
 | 
				
			||||||
            <AvatarImage src={avatarHttpUrl} alt={name} />
 | 
					            roomId={room.roomId}
 | 
				
			||||||
          ) : (
 | 
					            src={avatarHttpUrl ?? undefined}
 | 
				
			||||||
            <AvatarFallback
 | 
					            alt={name}
 | 
				
			||||||
              style={{
 | 
					            renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
 | 
				
			||||||
                backgroundColor: color.SurfaceVariant.Container,
 | 
					          />
 | 
				
			||||||
                color: color.SurfaceVariant.OnContainer,
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              <Text size="H2">{name[0]}</Text>
 | 
					 | 
				
			||||||
            </AvatarFallback>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </Avatar>
 | 
					        </Avatar>
 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
      <Box direction="Column" gap="300">
 | 
					      <Box direction="Column" gap="300">
 | 
				
			||||||
| 
						 | 
					@ -82,7 +81,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
 | 
				
			||||||
          {typeof prevRoomId === 'string' &&
 | 
					          {typeof prevRoomId === 'string' &&
 | 
				
			||||||
            (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
 | 
					            (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
 | 
				
			||||||
              <Button
 | 
					              <Button
 | 
				
			||||||
                onClick={() => selectRoom(prevRoomId)}
 | 
					                onClick={() => navigateRoom(prevRoomId)}
 | 
				
			||||||
                variant="Success"
 | 
					                variant="Success"
 | 
				
			||||||
                size="300"
 | 
					                size="300"
 | 
				
			||||||
                fill="Soft"
 | 
					                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 { 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([
 | 
					export const Sidebar = style([
 | 
				
			||||||
  DefaultReset,
 | 
					  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;
 | 
					const PUSH_X = 2;
 | 
				
			||||||
export const SidebarAvatarBox = recipe({
 | 
					export const SidebarItem = recipe({
 | 
				
			||||||
  base: [
 | 
					  base: [
 | 
				
			||||||
    DefaultReset,
 | 
					    DefaultReset,
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					      minWidth: toRem(42),
 | 
				
			||||||
      display: 'flex',
 | 
					      display: 'flex',
 | 
				
			||||||
      alignItems: 'center',
 | 
					      alignItems: 'center',
 | 
				
			||||||
 | 
					      justifyContent: 'center',
 | 
				
			||||||
      position: 'relative',
 | 
					      position: 'relative',
 | 
				
			||||||
      transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
 | 
					      transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -59,6 +96,8 @@ export const SidebarAvatarBox = recipe({
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    Disabled,
 | 
				
			||||||
 | 
					    DropTarget,
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  variants: {
 | 
					  variants: {
 | 
				
			||||||
    active: {
 | 
					    active: {
 | 
				
			||||||
| 
						 | 
					@ -76,26 +115,27 @@ export const SidebarAvatarBox = recipe({
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					export type SidebarItemVariants = RecipeVariants<typeof SidebarItem>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
 | 
					export const SidebarItemBadge = recipe({
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const SidebarBadgeBox = recipe({
 | 
					 | 
				
			||||||
  base: [
 | 
					  base: [
 | 
				
			||||||
    DefaultReset,
 | 
					    DefaultReset,
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					      pointerEvents: 'none',
 | 
				
			||||||
      position: 'absolute',
 | 
					      position: 'absolute',
 | 
				
			||||||
      zIndex: 1,
 | 
					      zIndex: 1,
 | 
				
			||||||
 | 
					      lineHeight: 0,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  variants: {
 | 
					  variants: {
 | 
				
			||||||
    hasCount: {
 | 
					    hasCount: {
 | 
				
			||||||
      true: {
 | 
					      true: {
 | 
				
			||||||
        top: toRem(-6),
 | 
					        top: toRem(-6),
 | 
				
			||||||
        right: toRem(-6),
 | 
					        left: toRem(-6),
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      false: {
 | 
					      false: {
 | 
				
			||||||
        top: toRem(-2),
 | 
					        top: toRem(-2),
 | 
				
			||||||
        right: toRem(-2),
 | 
					        left: toRem(-2),
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
| 
						 | 
					@ -103,9 +143,107 @@ export const SidebarBadgeBox = recipe({
 | 
				
			||||||
    hasCount: false,
 | 
					    hasCount: false,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					export type SidebarItemBadgeVariants = RecipeVariants<typeof SidebarItemBadge>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
 | 
					export const SidebarAvatar = recipe({
 | 
				
			||||||
 | 
					  base: [
 | 
				
			||||||
export const SidebarBadgeOutline = style({
 | 
					    {
 | 
				
			||||||
  boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
 | 
					      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 React, { ReactNode } from 'react';
 | 
				
			||||||
import { Box, Scroll } from 'folds';
 | 
					import { Box } from 'folds';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SidebarContentProps = {
 | 
					type SidebarContentProps = {
 | 
				
			||||||
  scrollable: ReactNode;
 | 
					  scrollable: ReactNode;
 | 
				
			||||||
| 
						 | 
					@ -9,9 +9,7 @@ export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      <Box direction="Column" grow="Yes">
 | 
					      <Box direction="Column" grow="Yes">
 | 
				
			||||||
        <Scroll variant="Background" size="0">
 | 
					        {scrollable}
 | 
				
			||||||
          {scrollable}
 | 
					 | 
				
			||||||
        </Scroll>
 | 
					 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
      <Box direction="Column" shrink="No">
 | 
					      <Box direction="Column" shrink="No">
 | 
				
			||||||
        {sticky}
 | 
					        {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 './Sidebar';
 | 
				
			||||||
export * from './SidebarAvatar';
 | 
					export * from './SidebarItem';
 | 
				
			||||||
export * from './SidebarContent';
 | 
					export * from './SidebarContent';
 | 
				
			||||||
export * from './SidebarStack';
 | 
					export * from './SidebarStack';
 | 
				
			||||||
export * from './SidebarStackSeparator';
 | 
					export * from './SidebarStackSeparator';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,10 +17,14 @@ export const TypingDot = recipe({
 | 
				
			||||||
      backgroundColor: 'currentColor',
 | 
					      backgroundColor: 'currentColor',
 | 
				
			||||||
      borderRadius: '50%',
 | 
					      borderRadius: '50%',
 | 
				
			||||||
      transform: 'translateY(15%)',
 | 
					      transform: 'translateY(15%)',
 | 
				
			||||||
      animation: `${TypingDotAnime} 0.6s infinite alternate`,
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  variants: {
 | 
					  variants: {
 | 
				
			||||||
 | 
					    animated: {
 | 
				
			||||||
 | 
					      true: {
 | 
				
			||||||
 | 
					        animation: `${TypingDotAnime} 0.6s infinite alternate`,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    size: {
 | 
					    size: {
 | 
				
			||||||
      '300': {
 | 
					      '300': {
 | 
				
			||||||
        width: toRem(4),
 | 
					        width: toRem(4),
 | 
				
			||||||
| 
						 | 
					@ -45,5 +49,6 @@ export const TypingDot = recipe({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  defaultVariants: {
 | 
					  defaultVariants: {
 | 
				
			||||||
    size: '400',
 | 
					    size: '400',
 | 
				
			||||||
 | 
					    animated: true,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,19 +4,22 @@ import * as css from './TypingIndicator.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type TypingIndicatorProps = {
 | 
					export type TypingIndicatorProps = {
 | 
				
			||||||
  size?: '300' | '400';
 | 
					  size?: '300' | '400';
 | 
				
			||||||
 | 
					  disableAnimation?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
 | 
					export const TypingIndicator = as<'div', TypingIndicatorProps>(
 | 
				
			||||||
  <Box
 | 
					  ({ size, disableAnimation, style, ...props }, ref) => (
 | 
				
			||||||
    as="span"
 | 
					    <Box
 | 
				
			||||||
    alignItems="Center"
 | 
					      as="span"
 | 
				
			||||||
    shrink="No"
 | 
					      alignItems="Center"
 | 
				
			||||||
    style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
 | 
					      shrink="No"
 | 
				
			||||||
    {...props}
 | 
					      style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
 | 
				
			||||||
    ref={ref}
 | 
					      {...props}
 | 
				
			||||||
  >
 | 
					      ref={ref}
 | 
				
			||||||
    <span className={css.TypingDot({ size, index: '0' })} />
 | 
					    >
 | 
				
			||||||
    <span className={css.TypingDot({ size, index: '1' })} />
 | 
					      <span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
 | 
				
			||||||
    <span className={css.TypingDot({ size, index: '2' })} />
 | 
					      <span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
 | 
				
			||||||
  </Box>
 | 
					      <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 React, { useCallback, useEffect, useRef, useState } from 'react';
 | 
				
			||||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
 | 
					import { IPreviewUrlResponse } from 'matrix-js-sdk';
 | 
				
			||||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
 | 
					import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import {
 | 
					import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
 | 
				
			||||||
  UrlPreview,
 | 
					 | 
				
			||||||
  UrlPreviewContent,
 | 
					 | 
				
			||||||
  UrlPreviewDescription,
 | 
					 | 
				
			||||||
  UrlPreviewImg,
 | 
					 | 
				
			||||||
} from '../../../components/url-preview';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  getIntersectionObserverEntry,
 | 
					  getIntersectionObserverEntry,
 | 
				
			||||||
  useIntersectionObserver,
 | 
					  useIntersectionObserver,
 | 
				
			||||||
} from '../../../hooks/useIntersectionObserver';
 | 
					} from '../../hooks/useIntersectionObserver';
 | 
				
			||||||
import * as css from './styles.css';
 | 
					import * as css from './UrlPreviewCard.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const linkStyles = { color: color.Success.Main };
 | 
					const linkStyles = { color: color.Success.Main };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1 +1,2 @@
 | 
				
			||||||
export * from './UrlPreview';
 | 
					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,
 | 
					  request: typeof fetch,
 | 
				
			||||||
  baseUrl: string
 | 
					  baseUrl: string
 | 
				
			||||||
): Promise<SpecVersions> => {
 | 
					): 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;
 | 
					  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