Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve search box by adding autofocus (Desktop) / ability to clear search when expected #46

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/res/reset.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 64 additions & 29 deletions web/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html, render, Component } from "../lib/htm/preact.js"
import { Spinner } from "./spinner.js"
import { SearchBox } from "./search-box.js"
import { shouldAutofocusSearchBar, shouldDisplayAutofocusSearchBar, SearchBox } from "./search-box.js"
import { checkMobileSafari } from "./user-agent-detect.js"
import * as widgetAPI from "./widget-api.js"
import * as frequent from "./frequently-used.js"

Expand All @@ -35,26 +36,33 @@ const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${

// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
// This is also used to fix scrolling to sections on Element iOS
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/)
const isMobileSafari = checkMobileSafari()

// We need to detect iOS webkit / Android because autofocusing a field does not open
// the device keyboard by design, making the option obsolete
const shouldAutofocusOption = shouldAutofocusSearchBar()
const displayAutofocusOption = shouldDisplayAutofocusSearchBar()

const supportedThemes = ["light", "dark", "black"]

const defaultState = {
packs: [],
filtering: {
searchTerm: "",
packs: [],
},
loading: true,
error: null,
}

const defaultSearchState = {
searchTerm: null,
filteredPacks: null
}

class App extends Component {
constructor(props) {
super(props)
this.defaultTheme = params.get("theme")
this.state = {
packs: defaultState.packs,
loading: true,
error: null,
...defaultState,
...defaultSearchState,
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
frequentlyUsed: {
Expand All @@ -63,7 +71,6 @@ class App extends Component {
stickerIDs: frequent.get(),
stickers: [],
},
filtering: defaultState.filtering,
}
if (!supportedThemes.includes(this.state.theme)) {
this.state.theme = "light"
Expand All @@ -77,6 +84,7 @@ class App extends Component {
this.packListRef = null
this.navRef = null
this.searchStickers = this.searchStickers.bind(this)
this.resetSearch = this.resetSearch.bind(this)
this.sendSticker = this.sendSticker.bind(this)
this.navScroll = this.navScroll.bind(this)
this.reloadPacks = this.reloadPacks.bind(this)
Expand All @@ -101,28 +109,33 @@ class App extends Component {
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
}

searchStickers(e) {
// Search

resetSearch() {
this.setState({ ...defaultSearchState })
}

searchStickers(searchTerm) {
const sanitizeString = s => s.toLowerCase().trim()
const searchTerm = sanitizeString(e.target.value)
const sanitizedSearch = sanitizeString(searchTerm)

const allPacks = [this.state.frequentlyUsed, ...this.state.packs]
const packsWithFilteredStickers = allPacks.map(pack => ({
...pack,
stickers: pack.stickers.filter(sticker =>
sanitizeString(sticker.body).includes(searchTerm) ||
sanitizeString(sticker.id).includes(searchTerm)
sanitizeString(sticker.body).includes(sanitizedSearch) ||
sanitizeString(sticker.id).includes(sanitizedSearch)
),
}))
const filteredPacks = packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length)

this.setState({
filtering: {
...this.state.filtering,
searchTerm,
packs: packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length),
},
})
this.setState({ searchTerm, filteredPacks })
}

// End search

// Settings

setStickersPerRow(val) {
localStorage.mauStickersPerRow = val
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
Expand All @@ -142,13 +155,17 @@ class App extends Component {
}
}

setAutofocusSearchBar(checked) {
localStorage.mauAutofocusSearchBar = checked
}

// End settings

reloadPacks() {
this.imageObserver.disconnect()
this.sectionObserver.disconnect()
this.setState({
packs: defaultState.packs,
filtering: defaultState.filtering,
})
this.setState({ packs: defaultState.packs })
this.resetSearch()
this._loadPacks(true)
}

Expand Down Expand Up @@ -215,6 +232,9 @@ class App extends Component {
for (const entry of intersections) {
const packID = entry.target.getAttribute("data-pack-id")
const navElement = document.getElementById(`nav-${packID}`)
if (!navElement) {
continue
}
if (entry.isIntersecting) {
navElement.classList.add("visible")
const bb = navElement.getBoundingClientRect()
Expand Down Expand Up @@ -258,6 +278,7 @@ class App extends Component {
const sticker = this.stickersByID.get(id)
frequent.add(id)
this.updateFrequentlyUsed()
this.resetSearch()
widgetAPI.sendSticker(sticker)
}

Expand All @@ -267,8 +288,10 @@ class App extends Component {

render() {
const theme = `theme-${this.state.theme}`
const filterActive = !!this.state.filtering.searchTerm
const packs = filterActive ? this.state.filtering.packs : [this.state.frequentlyUsed, ...this.state.packs]

const filterActive = !!this.state.filteredPacks
const packs = filterActive ? this.state.filteredPacks : [this.state.frequentlyUsed, ...this.state.packs]
const noPacksForSearch = filterActive && packs.length === 0

if (this.state.loading) {
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`
Expand All @@ -287,9 +310,13 @@ class App extends Component {
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
</nav>
<${SearchBox} onKeyUp=${this.searchStickers} />
<${SearchBox}
value=${this.state.searchTerm}
onSearch=${this.searchStickers}
onReset=${this.resetSearch}
/>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
${filterActive && packs.length === 0 ? html`<div class="search-empty"><h1>No stickers match your search</h1></div>` : null}
${noPacksForSearch ? html`<div class="search-empty"><h1>No stickers match your search</h1></div>` : null}
${packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)}
<${Settings} app=${this}/>
</div>
Expand Down Expand Up @@ -317,6 +344,14 @@ const Settings = ({ app }) => html`
<option value="black">Black</option>
</select>
</div>
${displayAutofocusOption ? html`<div>
Autofocus search bar:
<input
type="checkbox"
checked=${shouldAutofocusOption}
onChange=${evt => app.setAutofocusSearchBar(evt.target.checked)}
/>
</div>` : null}
</div>
</section>
`
Expand Down
83 changes: 73 additions & 10 deletions web/src/search-box.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,77 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"

export const SearchBox = ({ onKeyUp, placeholder = 'Find stickers' }) => {
const component = html`
<div class="search-box">
<input type="text" placeholder=${placeholder} onKeyUp=${onKeyUp} />
<span class="icon icon-search" />
</div>
`
return component
import { html, Component } from "../lib/htm/preact.js"
import { checkMobileSafari, checkAndroid } from "./user-agent-detect.js"

export function shouldDisplayAutofocusSearchBar() {
return !checkMobileSafari() && !checkAndroid()
}

export function shouldAutofocusSearchBar() {
return localStorage.mauAutofocusSearchBar === 'true' && shouldDisplayAutofocusSearchBar()
}

export function focusSearchBar() {
const inputInWebView = document.querySelector('.search-box input')
if (inputInWebView && shouldAutofocusSearchBar()) {
inputInWebView.focus()
}
}

export class SearchBox extends Component {
constructor(props) {
super(props)

this.autofocus = shouldAutofocusSearchBar()
this.value = props.value
this.onSearch = props.onSearch
this.onReset = props.onReset

this.search = this.search.bind(this)
this.clearSearch = this.clearSearch.bind(this)
}

componentDidMount() {
focusSearchBar()
}

componentWillReceiveProps(props) {
this.value = props.value
}

search(e) {
if (e.key === "Escape") {
this.clearSearch()
return
}
this.onSearch(e.target.value)
}

clearSearch() {
this.onReset()
}

render() {
const isEmpty = !this.value

const className = `icon-display ${isEmpty ? null : 'reset-click-zone'}`
const title = isEmpty ? null : 'Click to reset'
const onClick = isEmpty ? null : this.clearSearch
const iconToDisplay = `icon-${isEmpty ? 'search' : 'reset'}`

return html`
<div class="search-box">
<input
placeholder="Find stickers …"
value=${this.value}
onKeyUp=${this.search}
autoFocus=${this.autofocus}
/>
<div class=${className} title=${title} onClick=${onClick}>
<span class="icon ${iconToDisplay}" />
</div>
</div>
`
}
}
18 changes: 18 additions & 0 deletions web/src/user-agent-detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const getUserAgent = () => navigator.userAgent || navigator.vendor || window.opera

export const checkiOSDevice = () => {
const agent = getUserAgent()
return agent.match(/(iPod|iPhone|iPad)/)
}

export const checkMobileSafari = () => {
const agent = getUserAgent()
return agent.match(/(iPod|iPhone|iPad)/) && agent.match(/AppleWebKit/)
}

export const checkAndroid = () => {
const agent = getUserAgent()
return agent.match(/android/i)
}

export const checkMobileDevice = () => checkiOSDevice() || checkAndroid()
10 changes: 7 additions & 3 deletions web/src/widget-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { focusSearchBar } from "./search-box.js"

let widgetId = null

window.onmessage = event => {
Expand All @@ -33,10 +35,12 @@ window.onmessage = event => {
widgetId = request.widgetId
}

let response
let response = {}

if (request.action === "visibility") {
response = {}
if (request.action === "visibility") { // visibility of the widget changed
if (request.visible) {
focusSearchBar() // we have to re-focus the search bar when appropriate
}
} else if (request.action === "capabilities") {
response = { capabilities: ["m.sticker"] }
} else {
Expand Down
2 changes: 1 addition & 1 deletion web/style/index.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading