import * as React from "react"; import * as Constants from "~/common/constants"; import * as SVG from "~/common/svg"; import * as Actions from "~/common/actions"; import * as Strings from "~/common/strings"; import * as Window from "~/common/window"; import * as Validations from "~/common/validations"; import * as Logging from "~/common/logging"; import MiniSearch from "minisearch"; import SlateMediaObjectPreview from "~/components/core/SlateMediaObjectPreview"; import { Link } from "~/components/core/Link"; import { css } from "@emotion/react"; import { LoaderSpinner } from "~/components/system/components/Loaders"; import { Boundary } from "~/components/system/components/fragments/Boundary"; import { PopoverNavigation } from "~/components/system/components/PopoverNavigation"; import { FileTypeIcon } from "~/components/core/FileTypeIcon"; import { useIntercom } from "react-use-intercom"; import ProfilePhoto from "~/components/core/ProfilePhoto"; const STYLES_MOBILE_HIDDEN = css` @media (max-width: ${Constants.sizes.mobile}px) { display: none; } `; const STYLES_BACKGROUND = css` position: fixed; left: 0; right: 0; bottom: 0; top: 0; width: 100%; height: 100%; background-color: rgba(223, 223, 223, 0.3); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px); z-index: ${Constants.zindex.modal}; `; const STYLES_ICON_SQUARE = css` height: 48px; width: 48px; border-radius: 4px; border: 1px solid ${Constants.semantic.borderGrayLight}; background-color: ${Constants.system.white}; color: #bfbfbf; display: flex; align-items: center; justify-content: center; `; const STYLES_CONTAINER = css` width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; `; const STYLES_MODAL = css` position: relative; display: inline-flex; padding: 24px; border-radius: 4px; background-color: ${Constants.system.white}; box-shadow: 0 12px 48px 0px rgba(178, 178, 178, 0.3); width: 95vw; max-width: 800px; height: 85vh; max-height: 504px; `; const STYLES_PROFILE_PREVIEW = css` background-color: ${Constants.semantic.bgLight}; background-size: cover; background-position: 50% 50%; height: 48px; width: 48px; border-radius: 4px; `; const UserEntry = ({ user }) => { return (
{user.name ? (
{user.name}
@{user.username}
) : (
@{user.username}
)}
); }; const STYLES_PROFILE_IMAGE = css` background-color: ${Constants.semantic.bgLight}; background-size: cover; background-position: 50% 50%; flex-shrink: 0; height: 182px; width: 182px; margin-bottom: 12px; border-radius: 8px; `; const UserPreview = ({ user }) => { return (
{user.name ?
{user.name}
: null}
@{user.username}
{user.data.slates ? (
{user.data.slates.length} Collection{user.data.slates.length === 1 ? "" : "s"}
) : null}
); }; const STYLES_ENTRY = css` padding: 4px 0px; `; const STYLES_ENTRY_CONTAINER = css` display: grid; grid-template-columns: 62px minmax(0, 1fr); flex-direction: row; align-items: center; `; const STYLES_TEXT_ROWS = css` display: flex; flex-direction: column; `; const STYLES_TITLE = css` font-family: ${Constants.font.medium}; font-size: ${Constants.typescale.lvl1}; color: ${Constants.semantic.textGray}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-wrap: break-word; word-break: break-all; `; const STYLES_SUBTITLE = css` font-size: ${Constants.typescale.lvlN1}; color: ${Constants.semantic.textGrayLight}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-wrap: break-word; word-break: break-all; `; const SlateEntry = ({ slate, user }) => { return (
{slate.name || slate.slatename}
{user ?
{user.name || `@${user.username}`}
: null}
); }; const STYLES_PREVIEW_IMAGE = css` margin: 0 auto; height: 182px; width: 182px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; `; const STYLES_PREVIEW_TEXT = css` font-family: ${Constants.font.medium}; font-size: ${Constants.typescale.lvlN1}; color: ${Constants.semantic.textGray}; margin: 4px 16px; word-break: break-word; `; const STYLES_EMPTY_SLATE_PREVIEW = css` height: 182px; width: 182px; display: flex; align-items: center; justify-content: center; border: 1px solid ${Constants.semantic.borderGrayLight}; `; const SlatePreview = ({ slate, user }) => { let preview = slate.coverImage; return (
{preview ? ( ) : (
)}
{user ? (
Created by: {user.name || `@${user.username}`}
) : null} {slate.objects && (
{slate.objects.length} File{slate.objects.length === 1 ? "" : "s"}
)}
); }; const FileEntry = ({ file }) => { return (
{file.name || file.filename}
{Strings.getFileExtension(file.filename)}
); }; const FilePreview = ({ file, slate, user, viewerId }) => { return (
{user ?
Owner: {user.name || `@${user.username}`}
: null} {slate ? (
Collection: {slate.name || slate.slatename}
) : user?.id === viewerId ? (
In your files
) : null}
); }; const STYLES_DROPDOWN_CONTAINER = css` box-sizing: border-box; z-index: ${Constants.zindex.modal}; position: relative; width: 100%; `; const STYLES_DROPDOWN = css` box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; width: 50%; scrollbar-width: none; padding-bottom: 8px; height: calc(100% - 144px); overflow-y: scroll; ::-webkit-scrollbar { display: none; } @media (max-width: ${Constants.sizes.mobile}px) { width: 100%; } `; const STYLES_DROPDOWN_ITEM = css` box-sizing: border-box; margin-bottom: 8px; padding: 0 4px; border-radius: 4px; cursor: pointer; position: relative; `; const STYLES_INPUT = css` font-family: ${Constants.font.medium}; -webkit-appearance: none; width: 100%; height: 56px; background: ${Constants.semantic.bgLight}; color: ${Constants.semantic.textGray}; display: flex; font-size: 14px; align-items: center; justify-content: flex-start; outline: 0; border: 0; box-sizing: border-box; transition: 200ms ease all; padding: 0 40px 0 0; text-overflow: ellipsis; white-space: nowrap; border-radius: 0 2px 2px 0; margin-bottom: 8px; letter-spacing: -0.1px; ::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ color: ${Constants.semantic.textGrayLight}; opacity: 1; /* Firefox */ } :-ms-input-placeholder { /* Internet Explorer 10-11 */ color: ${Constants.semantic.textGrayLight}; } ::-ms-input-placeholder { /* Microsoft Edge */ color: ${Constants.semantic.textGrayLight}; } `; const STYLES_LOADER = css` width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; `; const STYLES_RETURN = css` position: absolute; right: 16px; top: 20px; color: ${Constants.semantic.textGrayLight}; font-size: ${Constants.typescale.lvlN1}; display: flex; align-items: center; `; const STYLES_FILTER_BUTTON = css` padding: 11px; border-radius: 4px; border: 1px solid ${Constants.semantic.borderGrayLight}; color: ${Constants.semantic.textGray}; margin-right: 8px; display: flex; align-items: center; font-size: ${Constants.typescale.lvlN1}; cursor: pointer; letter-spacing: -0.1px; `; const STYLES_PREVIEW_PANEL = css` width: 50%; height: calc(100% - 120px); position: absolute; bottom: 0px; right: 0px; display: flex; align-items: center; justify-content: center; text-align: center; cursor: pointer; @media (max-width: ${Constants.sizes.mobile}px) { display: none; } `; const STYLES_INLINE_TAG_CONTAINER = css` height: 56px; background: ${Constants.semantic.bgLight}; display: flex; align-items: center; margin-bottom: 8px; padding: 8px; border-radius: 2px 0 0 2px; `; const STYLES_INLINE_TAG = css` font-family: ${Constants.font.medium}; color: ${Constants.semantic.textGray}; font-size: 14px; display: flex; align-items: center; background: ${Constants.system.white}; height: 100%; padding: 0px 10px; letter-spacing: -0.1px; `; const STYLES_DISMISS_BOX = css` position: absolute; right: 12px; top: 16px; padding: 2px; cursor: pointer; color: ${Constants.semantic.textGray}; outline: 0; `; const getHref = (result) => { if (result.type === "SLATE") { return `/$/slate/${result.data.slate.id}`; } else if (result.type === "USER") { return `/$/user/${result.data.user.id}`; } else if (result.type === "FILE") { return `/$/user/${result.data.user.id}?cid=${result.data.file.cid}`; } else if (result.type === "DATA_FILE") { return `/_/data?cid=${result.data.file.cid}`; } else { Logging.error("Get href failed because result was:", result); } }; export class SearchModal extends React.Component { _input; _optionRoot; initialized = false; state = { modal: false, loading: true, defaultResults: [], results: [], inputValue: "", typeFilter: null, scopeFilter: null, selectedIndex: 0, }; componentDidMount = () => { window.addEventListener("show-search", this._handleShow); window.addEventListener("hide-search", this._handleHide); }; componentWillUnmount = () => { window.removeEventListener("show-search", this._handleShow); window.removeEventListener("hide-search", this._handleHide); this._handleHide(); }; _handleShow = async (e) => { this.setState({ modal: true, inputValue: e.detail.initialValue }); await this.fillLocalDirectory(); this.setState({ loading: false }); if (!this.initialized) { await this.initializeSearch(); } this._input.select(); window.addEventListener("keydown", this._handleDocumentKeydown); }; _handleHide = () => { window.removeEventListener("keydown", this._handleDocumentKeydown); this.setState({ modal: false }); }; initializeSearch = async () => { this.debounceInstance = Window.debounce(() => { this._handleSearch(); }, 500); let defaultResults = this.props.viewer ? this.props.viewer.slates : []; defaultResults = defaultResults.map((slate) => { return { id: slate.id, type: "SLATE", data: { slate }, component: , preview: , href: `/$/slate/${slate.id}`, }; }); this.setState({ defaultResults }); let networkIds = []; let slateIds = []; if (this.props.viewer?.subscriptions) { for (let sub of this.props.viewer.subscriptions) { if (sub.target_user_id) { networkIds.push(sub.target_user_id); } else if (sub.target_slate_id) { slateIds.push(sub.target_slate_id); } } } this.networkIds = networkIds; this.slateIds = slateIds; }; fillLocalDirectory = () => { if (!this.props.viewer) { return; } this.localSearch = new MiniSearch({ fields: ["name", "title"], storeFields: ["type", "data", "id"], extractField: (entry, fieldName) => { return fieldName.split(".").reduce((doc, key) => doc && doc[key], entry); }, searchOptions: { fuzzy: 0.15, }, }); let files = this.props.viewer.library.map((file, i) => { return { ...file, type: "DATA_FILE", }; }); this.localSearch.addAll(files); let privateSlates = this.props.viewer.slates.filter((slate) => !slate.isPublic); let privateFiles = []; for (let slate of privateSlates) { privateFiles.push( ...slate.objects.map((file, i) => { return { ...file, type: "FILE", }; }) ); } privateSlates = privateSlates.map((slate) => { return { ...slate, type: "SLATE", }; }); this.localSearch.addAll(privateSlates); this.localSearch.addAll(privateFiles); }; _handleDocumentKeydown = (e) => { let results; if (this.state.results && this.state.results.length) { results = this.state.results; } else if (!this.state.inputValue || !this.state.inputValue.length) { results = this.state.defaultResults; } else { return; } if (e.keyCode === 27) { this._handleHide(); e.preventDefault(); } else if (e.keyCode === 8) { if (this._input.selectionStart === 0) { this.setState({ typeFilter: null }); } } else if (e.keyCode === 9) { this._handleHide(); } else if (e.keyCode === 40) { if (results.length) { let index; if (this.state.selectedIndex < results.length - 1) { index = this.state.selectedIndex + 1; } else { index = 0; } let listElem = this._optionRoot.children[index]; let elemRect = listElem.getBoundingClientRect(); let rootRect = this._optionRoot.getBoundingClientRect(); if (elemRect.bottom > rootRect.bottom) { this._optionRoot.scrollTop = listElem.offsetTop + listElem.offsetHeight - this._optionRoot.offsetHeight - this._optionRoot.offsetTop; } else if (elemRect.top < rootRect.top) { this._optionRoot.scrollTop = listElem.offsetTop - this._optionRoot.offsetTop; } this.setState({ selectedIndex: index }); } e.preventDefault(); } else if (e.keyCode === 38) { if (results.length) { let index; if (this.state.selectedIndex > 0) { index = this.state.selectedIndex - 1; } else { index = results.length - 1; } let listElem = this._optionRoot.children[index]; let elemRect = listElem.getBoundingClientRect(); let rootRect = this._optionRoot.getBoundingClientRect(); if (elemRect.top < rootRect.top) { this._optionRoot.scrollTop = listElem.offsetTop - this._optionRoot.offsetTop; } else if (elemRect.bottom > rootRect.bottom) { this._optionRoot.scrollTop = listElem.offsetTop + listElem.offsetHeight - this._optionRoot.offsetHeight - this._optionRoot.offsetTop; } this.setState({ selectedIndex: index }); } e.preventDefault(); } else if (e.keyCode === 13) { if (results.length > this.state.selectedIndex && this.state.selectedIndex >= 0) { let href = results[this.state.selectedIndex].href; this.props.onAction({ type: "NAVIGATE", href }); } e.preventDefault(); } }; _handleChange = (e) => { this.debounceInstance(); this.setState({ inputValue: e.target.value }); }; _handleSearch = async (refilter = false) => { let searchResults = []; let results = []; let ids = new Set(); if (this.state.typeFilter !== "USER" && this.props.viewer) { let filter; if (this.state.typeFilter === "FILE") { filter = { filter: (result) => { return result.type === "FILE" || result.type === "DATA_FILE"; }, }; } else if (this.state.typeFilter === "SLATE") { filter = { filter: (result) => result.type === "SLATE", }; } if (filter) { searchResults.push(this.localSearch.search(this.state.inputValue, filter)); } else { searchResults.push(this.localSearch.search(this.state.inputValue)); } for (let result of searchResults) { ids.add(result.id); } let autofill = this.localSearch.autoSuggest(this.state.inputValue); let count = 0; for (let i = 0; i < autofill.length; i++) { if (count >= 15) break; let results; if (filter) { results = this.localSearch.search(autofill[i].suggestion, filter); } else { results = this.localSearch.search(autofill[i].suggestion); } if (results && results.length && !ids.has(results[0].id)) { count += 1; ids.add(results[0].id); searchResults.push(results[0]); } } for (let item of searchResults) { if (item.type === "SLATE") { results.push({ id: slate.id, type: item.type, data: { slate: item }, component: , preview: , }); } else if (item.type === "FILE" || item.type === "DATA_FILE") { results.push({ id: item.data.file.id, type: item.type, data: { file: item }, component: , preview: ( ), }); } } } let res; if (!refilter) { let response = await Actions.search({ query: this.state.inputValue, type: this.state.typeFilter, }); this.setState({ unfilteredResults: response?.data?.results || [] }); res = response?.data?.results || []; } else { res = this.state.unfilteredResults; } searchResults = this.processResults(res); for (let res of searchResults) { if (res.type === "USER") { let id = res.user?.id; if (!id || ids.has(id)) continue; ids.add(id); results.push({ id, type: res.type, href: res.href, data: res, component: , preview: , }); } else if (res.type === "SLATE") { let id = res.slate?.id; if (!id || ids.has(id)) continue; ids.add(id); results.push({ id, type: res.type, href: res.href, data: res, component: , preview: , }); } else if (res.type === "FILE") { let id = res.file?.id; if (!id || ids.has(id)) continue; ids.add(id); results.push({ id, type: res.type, href: res.href, data: res, component: , preview: ( ), }); } } results = results.map((res) => { return { ...res, href: getHref(res) }; }); this.setState({ results, selectedIndex: 0 }); if (this._optionRoot) { this._optionRoot.scrollTop = 0; } }; processResults = (searchResults) => { if (!this.state.viewer) { return searchResults; } let results = searchResults; if (this.state.scopeFilter === "MY") { results = results.filter((res) => { if (res.ownerId !== this.props.viewer.id) return false; return true; }); } else if (this.state.scopeFilter === "NETWORK" && this.networkIds && this.networkIds.length) { results = results.filter((res) => { if ( (res.type === "USER" && this.networkIds.includes(res.id)) || (res.type === "SLATE" && this.slateIds.includes(res.id)) || this.networkIds.includes(res.ownerId) ) { return true; } return false; }); } if (this.state.scopeFilter !== "MY") { results = results.sort((a, b) => { if (this.props.viewer.id && this.state.scopeFilter !== "MY") { if (a.ownerId === this.props.viewer.id && b.ownerId !== this.props.viewer.id) { return -1; } else if (a.ownerId !== this.props.viewer.id && b.ownerId === this.props.viewer.id) { return 1; } } if ( this.networkIds && this.state.scopeFilter !== "NETWORK" && this.state.scopeFilter !== "MY" ) { let aInNetwork = (a.type === "USER" && this.networkIds.includes(a.user.id)) || (a.type === "SLATE" && this.slateIds.includes(a.slate.id)) || this.networkIds.includes(a.ownerId); let bInNetwork = (b.type === "USER" && this.networkIds.includes(b.user.id)) || (b.type === "SLATE" && this.slateIds.includes(b.slate.id)) || this.networkIds.includes(b.ownerId); if (aInNetwork && !bInNetwork) { return -1; } else if (!aInNetwork && bInNetwork) { return 1; } } return 0; }); } return results; }; // _handleSelect = async (res) => { // if (res.type === "SLATE") { // this.props.onAction({ // type: "NAVIGATE", // value: "NAV_SLATE", // data: res.data.slate, // }); // } else if (res.type === "USER") { // this.props.onAction({ // type: "NAVIGATE", // value: "NAV_PROFILE", // data: res.data.user, // }); // } else if (res.type === "DATA_FILE" || res.data.file.ownerId === this.props.viewer?.id) { // await this.props.onAction({ // type: "NAVIGATE", // value: "NAV_DATA", // fileId: res.data.file.id, // }); // } else if (res.type === "FILE") { // await this.props.onAction({ // type: "NAVIGATE", // value: "NAV_PROFILE", // data: res.data.user, // fileId: res.data.file.id, // }); // } // this._handleHide(); // }; _handleRedirect = async (destination) => { // if (destination === "FMU") { // let isProd = window.location.hostname.includes("slate.host"); // this._handleSelect({ // type: "FILE", // data: { // file: { id: "rick-roll" }, // slate: { // id: isProd // ? "01edcede-53c9-46b3-ac63-8f8479e10bcf" // : "60d199e7-6bf5-4994-94e8-b17547c64449", // data: { // objects: [ // { // id: "rick-roll", // url: // "https://slate.textile.io/ipfs/bafybeifcxjvbad4lgpnbwff2dafufmnlylylmku4qoqtlkwgidupwi6f3a", // ownerId: "owner", // name: "Never gonna give you up", // title: "never-gonna-give-you-up.mp4", // type: "video/mp4", // }, // ], // }, // ownerId: "owner", // }, // }, // }); // } this.props.onAction({ type: "SIDEBAR", value: destination, }); this._handleHide(); }; _handleFilterType = async (type) => { if (this._input) { this._input.focus(); } this.setState({ typeFilter: this.state.typeFilter === type ? null : type }, () => { this._handleSearch(); }); }; _handleFilterScope = async (scope) => { if (this._input) { this._input.focus(); } this.setState({ scopeFilter: scope, filterTooltip: false }); if (this.state.inputValue) { this._handleSearch(true); } }; _handleClearAll = () => { if (!this.state.inputValue || !this.state.inputValue.length) { this._handleHide(); } if (this._optionRoot) { this._optionRoot.scrollTop = 0; } if (this._input) { this._input.focus(); } this.setState({ inputValue: "", results: [], selectedIndex: 0, scopeFilter: null, typeFilter: null, }); }; _handleSelectIndex = (i) => { if (this.state.selectedIndex === i || this.props.isMobile) { this._handleHide(); } }; render() { let selectedIndex = this.state.selectedIndex; let results = this.state.inputValue && this.state.inputValue.length ? this.state.results : this.state.defaultResults; const filterDropdown = this.props.viewer ? (
this.setState({ filterTooltip: !this.state.filterTooltip })} >
{this.state.filterTooltip ? ( this.setState({ filterTooltip: false })} > All ), onClick: () => this._handleFilterScope(null), }, { text: ( My stuff ), onClick: () => this._handleFilterScope("MY"), }, { text: ( My network ), onClick: () => this._handleFilterScope("NETWORK"), }, ], ]} /> ) : null}
) : null; return (
{this.state.loading ? (
) : (
{this.state.typeFilter ? (
{this.state.typeFilter === "SLATE" ? "Collections:" : this.state.typeFilter === "USER" ? "Users:" : "Files:"}
) : null}
{ this._input = c; }} />
this._handleFilterType("SLATE")} > Search collections
this._handleFilterType("USER")} > Search users
this._handleFilterType("FILE")} > Search files
{filterDropdown}
{ this._optionRoot = c; }} css={STYLES_DROPDOWN} > {results.map((each, i) => ( this._handleSelectIndex(i)} >
{ // selectedIndex === i || this.props.isMobile // ? this._handleSelect(each) // : this.setState({ selectedIndex: i }); // }} onClick={() => this.setState({ selectedIndex: i })} > {each.component} {selectedIndex === i ? (
{" "} Return
) : null}
))}
{results?.length && selectedIndex < results.length && selectedIndex >= 0 ? (
{ // if (selectedIndex >= 0 && selectedIndex < results.length) { // this._handleSelect(results[selectedIndex]); // } // }} > {results[selectedIndex].preview}
) : null}
)}
); } }