add username autocomplete feature

This commit is contained in:
Nolan Lawson 2018-03-24 18:04:54 -07:00
parent 5430fdd189
commit 6fc21e40bf
23 changed files with 428 additions and 35 deletions

View File

@ -47,3 +47,21 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
store.set({postingStatus: false})
}
}
export async function insertUsername (realm, username, startIndex, endIndex) {
let oldText = store.getComposeData(realm, 'text')
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, {text: newText})
}
export async function clickSelectedAutosuggestionUsername (realm) {
let selectionStart = store.get('composeSelectionStart')
let searchText = store.get('composeAutosuggestionSearchText')
let selection = store.get('composeAutosuggestionSelected') || 0
let account = store.get('composeAutosuggestionSearchResults')[selection]
let startIndex = selectionStart - searchText.length
let endIndex = selectionStart
await insertUsername(realm, account.acct, startIndex, endIndex)
}

View File

@ -0,0 +1,128 @@
<div class="compose-autosuggest {{shown ? 'shown' : ''}}"
aria-hidden="true" >
<ComposeAutosuggestionList
items="{{searchResults}}"
on:click="onUserSelected(event)"
:selected
/>
</div>
<style>
.compose-autosuggest {
opacity: 0;
pointer-events: none;
transition: opacity 0.1s linear;
min-width: 400px;
max-width: calc(100vw - 20px);
}
.compose-autosuggest.shown {
pointer-events: auto;
opacity: 1
}
@media (max-width: 479px) {
.compose-autosuggest {
/* hack: move this over to the left on mobile so it's easier to see */
transform: translateX(-58px); /* avatar size 48px + 10px padding */
width: calc(100vw - 20px);
}
}
</style>
<script>
import { store } from '../../_store/store'
import { database } from '../../_database/database'
import { insertUsername } from '../../_actions/compose'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { once } from '../../_utils/once'
import ComposeAutosuggestionList from './ComposeAutosuggestionList.html'
const SEARCH_RESULTS_LIMIT = 4
const DATABASE_SEARCH_RESULTS_LIMIT = 30
const MIN_PREFIX_LENGTH = 1
const SEARCH_REGEX = new RegExp(`(?:\\s|^)(@\\S{${MIN_PREFIX_LENGTH},})$`)
export default {
oncreate() {
// perf improves for input responsiveness
this.observe('composeSelectionStart', () => {
scheduleIdleTask(() => {
this.set({composeSelectionStartDeferred: this.get('composeSelectionStart')})
})
})
this.observe('composeFocused', (composeFocused) => {
let updateFocusedState = () => {
scheduleIdleTask(() => {
this.set({composeFocusedDeferred: this.get('composeFocused')})
})
}
// TODO: hack so that when the user clicks the button, and the textarea blurs,
// we don't immediately hide the dropdown which would cause the click to get lost
if (composeFocused) {
updateFocusedState()
} else {
Promise.race([
new Promise(resolve => setTimeout(resolve, 200)),
new Promise(resolve => this.once('userSelected', resolve))
]).then(updateFocusedState)
}
})
this.observe('searchText', async searchText => {
if (!searchText) {
return
}
let results = await this.search(searchText)
this.store.set({
composeAutosuggestionSelected: 0,
composeAutosuggestionSearchText: searchText,
composeAutosuggestionSearchResults: results
})
})
this.observe('shown', shown => {
this.store.set({composeAutosuggestionShown: shown})
})
},
methods: {
once: once,
onUserSelected(account) {
this.fire('userSelected')
let realm = this.get('realm')
let selectionStart = this.store.get('composeSelectionStart')
let searchText = this.store.get('composeAutosuggestionSearchText')
let startIndex = selectionStart - searchText.length
let endIndex = selectionStart
/* no await */ insertUsername(realm, account.acct, startIndex, endIndex)
},
async search(searchText) {
let currentInstance = this.store.get('currentInstance')
let results = await database.searchAccountsByUsername(
currentInstance, searchText.substring(1), DATABASE_SEARCH_RESULTS_LIMIT)
return results.slice(0, SEARCH_RESULTS_LIMIT)
}
},
computed: {
composeSelectionStart: ($composeSelectionStart) => $composeSelectionStart,
composeFocused: ($composeFocused) => $composeFocused,
searchResults: ($composeAutosuggestionSearchResults) => $composeAutosuggestionSearchResults || [],
selected: ($composeAutosuggestionSelected) => $composeAutosuggestionSelected || 0,
searchText: (text, composeSelectionStartDeferred) => {
let selectionStart = composeSelectionStartDeferred || 0
if (!text || selectionStart < MIN_PREFIX_LENGTH) {
return
}
let match = text.substring(0, selectionStart).match(SEARCH_REGEX)
return match && match[1]
},
shown: (composeFocusedDeferred, searchText, searchResults) => {
return !!(composeFocusedDeferred &&
searchText &&
searchResults.length)
}
},
store: () => store,
components: {
ComposeAutosuggestionList
}
}
</script>

View File

@ -0,0 +1,90 @@
<ul class="generic-user-list">
{{#each items as account, i @id}}
<li class="generic-user-list-item">
<button class="generic-user-list-button {{i === selected ? 'selected' : ''}}"
tabindex="0"
on:click="fire('click', account)">
<div class="generic-user-list-grid">
<Avatar
className="generic-user-list-item-avatar"
size="small"
:account
/>
<span class="generic-user-list-display-name">
{{account.display_name || account.acct}}
</span>
<span class="generic-user-list-username">
{{'@' + account.acct}}
</span>
</div>
</button>
</li>
{{/each}}
</ul>
<style>
.generic-user-list {
list-style: none;
width: 100%;
border-radius: 2px;
box-sizing: border-box;
border: 1px solid var(--compose-autosuggest-outline);
}
.generic-user-list-item {
border-bottom: 1px solid var(--compose-autosuggest-outline);
display: flex;
}
.generic-user-list-item:last-child {
border-bottom: none;
}
.generic-user-list-button {
padding: 10px;
background: var(--settings-list-item-bg);
border: none;
margin: 0;
flex: 1;
}
.generic-user-list-grid {
display: grid;
width: 100%;
grid-template-areas: "avatar display-name"
"avatar username";
grid-template-columns: min-content 1fr;
grid-column-gap: 10px;
grid-row-gap: 5px;
}
:global(.generic-user-list-item-avatar) {
grid-area: avatar;
}
.generic-user-list-display-name {
grid-area: display-name;
font-size: 1.1em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
}
.generic-user-list-username {
grid-area: username;
font-size: 1em;
color: var(--deemphasized-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.generic-user-list-button:hover, .generic-user-list-button.selected {
background: var(--compose-autosuggest-item-hover);
}
.generic-user-list-button:active {
background: var(--compose-autosuggest-item-active);
}
</style>
<script>
import Avatar from '../Avatar.html'
export default {
components: {
Avatar
}
}
</script>

View File

@ -8,7 +8,7 @@
{{/if}}
<ComposeInput :realm :text :autoFocus />
<ComposeLengthGauge :length :overLimit />
<ComposeToolbar :realm :postPrivacy :media :contentWarningShown />
<ComposeToolbar :realm :postPrivacy :media :contentWarningShown :text />
<ComposeLengthIndicator :length :overLimit />
<ComposeMedia :realm :media />
<ComposeButton :length :overLimit on:click="onClickPostButton()" />

View File

@ -4,6 +4,9 @@
ref:textarea
bind:value=rawText
on:blur="onBlur()"
on:focus="onFocus()"
on:selectionChange="onSelectionChange(event)"
on:keydown="onKeydown(event)"
></textarea>
<style>
.compose-box-input {
@ -29,6 +32,8 @@
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import debounce from 'lodash/debounce'
import { mark, stop } from '../../_utils/marks'
import { selectionChange } from '../../_utils/events'
import { clickSelectedAutosuggestionUsername } from '../../_actions/compose'
export default {
oncreate() {
@ -82,7 +87,53 @@
stop('autosize.destroy()')
},
onBlur() {
this.store.set({composeSelectionStart: this.refs.textarea.selectionStart})
this.store.set({composeFocused: false})
},
onFocus() {
this.store.set({composeFocused: true})
},
onSelectionChange(selectionStart) {
this.store.set({composeSelectionStart: selectionStart})
},
onKeydown(e) {
let { keyCode } = e
switch (keyCode) {
case 9: // tab
case 13: //enter
this.clickSelectedAutosuggestion(e)
break
case 38: // up
this.incrementAutosuggestSelected(-1, e)
break
case 40: // down
this.incrementAutosuggestSelected(1, e)
break
default:
}
},
clickSelectedAutosuggestion(event) {
let autosuggestionShown = this.store.get('composeAutosuggestionShown')
if (!autosuggestionShown) {
return
}
clickSelectedAutosuggestionUsername(this.get('realm'))
event.preventDefault()
},
incrementAutosuggestSelected(increment, event) {
let autosuggestionShown = this.store.get('composeAutosuggestionShown')
if (!autosuggestionShown) {
return
}
let selected = this.store.get('composeAutosuggestionSelected') || 0
let searchResults = this.store.get('composeAutosuggestionSearchResults') || []
selected += increment
if (selected >= 0) {
selected = selected % searchResults.length
} else {
selected = searchResults.length + selected
}
this.store.set({composeAutosuggestionSelected: selected})
event.preventDefault()
}
},
store: () => store,
@ -91,6 +142,9 @@
}),
computed: {
postedStatusForRealm: ($postedStatusForRealm) => $postedStatusForRealm
},
events: {
selectionChange
}
}
</script>

View File

@ -1,41 +1,55 @@
<div class="compose-box-toolbar">
<IconButton
label="Insert emoji"
href="#fa-smile"
on:click="onEmojiClick()"
/>
<IconButton
className="{{$uploadingMedia ? 'spin' : ''}}"
label="Add media"
href="{{$uploadingMedia ? '#fa-spinner' : '#fa-camera'}}"
on:click="onMediaClick()"
disabled="{{$uploadingMedia || (media.length === 4)}}"
/>
<IconButton
label="Adjust privacy (currently {{postPrivacy.label}})"
href="{{postPrivacy.icon}}"
on:click="onPostPrivacyClick()"
/>
<IconButton
label="{{contentWarningShown ? 'Remove content warning' : 'Add content warning'}}"
href="#fa-exclamation-triangle"
on:click="onContentWarningClick()"
pressable="true"
pressed="{{contentWarningShown}}"
/>
<div class="compose-box-toolbar-items">
<IconButton
label="Insert emoji"
href="#fa-smile"
on:click="onEmojiClick()"
/>
<IconButton
className="{{$uploadingMedia ? 'spin' : ''}}"
label="Add media"
href="{{$uploadingMedia ? '#fa-spinner' : '#fa-camera'}}"
on:click="onMediaClick()"
disabled="{{$uploadingMedia || (media.length === 4)}}"
/>
<IconButton
label="Adjust privacy (currently {{postPrivacy.label}})"
href="{{postPrivacy.icon}}"
on:click="onPostPrivacyClick()"
/>
<IconButton
label="{{contentWarningShown ? 'Remove content warning' : 'Add content warning'}}"
href="#fa-exclamation-triangle"
on:click="onContentWarningClick()"
pressable="true"
pressed="{{contentWarningShown}}"
/>
</div>
<input ref:input
on:change="onFileChange(event)"
style="display: none;"
type="file"
accept=".jpg,.jpeg,.png,.gif,.webm,.mp4,.m4v,image/jpeg,image/png,image/gif,video/webm,video/mp4">
<div class="compose-autosuggest-wrapper">
<ComposeAutosuggest :realm :text />
</div>
</div>
<style>
.compose-box-toolbar {
grid-area: toolbar;
position: relative;
align-self: center;
}
.compose-box-toolbar-items {
display: flex;
align-items: center;
}
.compose-autosuggest-wrapper {
position: absolute;
left: 5px;
top: 0;
z-index: 90;
}
</style>
<script>
import IconButton from '../IconButton.html'
@ -44,6 +58,7 @@
import { importDialogs } from '../../_utils/asyncModules'
import { doMediaUpload } from '../../_actions/media'
import { toggleContentWarningShown } from '../../_actions/contentWarnings'
import ComposeAutosuggest from './ComposeAutosuggest.html'
export default {
oncreate() {
@ -58,7 +73,8 @@
}
},
components: {
IconButton
IconButton,
ComposeAutosuggest
},
store: () => store,
methods: {

View File

@ -1,6 +1,10 @@
import { ACCOUNTS_STORE, RELATIONSHIPS_STORE } from './constants'
import {
ACCOUNTS_STORE, RELATIONSHIPS_STORE, USERNAME_LOWERCASE
} from './constants'
import { accountsCache, relationshipsCache } from './cache'
import { cloneForStorage, getGenericEntityWithId, setGenericEntityWithId } from './helpers'
import { dbPromise, getDatabase } from './databaseLifecycle'
import { createAccountUsernamePrefixKeyRange } from './keys'
export async function getAccount (instanceName, accountId) {
return getGenericEntityWithId(ACCOUNTS_STORE, accountsCache, instanceName, accountId)
@ -17,3 +21,25 @@ export async function getRelationship (instanceName, accountId) {
export async function setRelationship (instanceName, relationship) {
return setGenericEntityWithId(RELATIONSHIPS_STORE, relationshipsCache, instanceName, cloneForStorage(relationship))
}
export async function searchAccountsByUsername (instanceName, usernamePrefix, limit = 20) {
const db = await getDatabase(instanceName)
return dbPromise(db, ACCOUNTS_STORE, 'readonly', (accountsStore, callback) => {
let keyRange = createAccountUsernamePrefixKeyRange(usernamePrefix.toLowerCase())
accountsStore.index(USERNAME_LOWERCASE).getAll(keyRange, limit).onsuccess = e => {
let results = e.target.result
results = results.sort((a, b) => {
// accounts you're following go first
if (a.following !== b.following) {
return a.following ? -1 : 1
}
// after that, just sort by username
if (a[USERNAME_LOWERCASE] !== b[USERNAME_LOWERCASE]) {
return a[USERNAME_LOWERCASE] < b[USERNAME_LOWERCASE] ? -1 : 1
}
return 0 // eslint-disable-line
})
callback(results)
}
})
}

View File

@ -12,3 +12,4 @@ export const TIMESTAMP = '__pinafore_ts'
export const ACCOUNT_ID = '__pinafore_acct_id'
export const STATUS_ID = '__pinafore_status_id'
export const REBLOG_ID = '__pinafore_reblog_id'
export const USERNAME_LOWERCASE = '__pinafore_acct_lc'

View File

@ -10,7 +10,8 @@ import {
TIMESTAMP,
REBLOG_ID,
THREADS_STORE,
STATUS_ID
STATUS_ID,
USERNAME_LOWERCASE
} from './constants'
import forEach from 'lodash/forEach'
@ -18,7 +19,9 @@ import forEach from 'lodash/forEach'
const openReqs = {}
const databaseCache = {}
const DB_VERSION = 9
const DB_VERSION_INITIAL = 9
const DB_VERSION_SEARCH_ACCOUNTS = 10
const DB_VERSION_CURRENT = 10
export function getDatabase (instanceName) {
if (!instanceName) {
@ -29,7 +32,7 @@ export function getDatabase (instanceName) {
}
databaseCache[instanceName] = new Promise((resolve, reject) => {
let req = indexedDB.open(instanceName, DB_VERSION)
let req = indexedDB.open(instanceName, DB_VERSION_CURRENT)
openReqs[instanceName] = req
req.onerror = reject
req.onblocked = () => {
@ -37,6 +40,7 @@ export function getDatabase (instanceName) {
}
req.onupgradeneeded = (e) => {
let db = req.result
let tx = e.currentTarget.transaction
function createObjectStore (name, init, indexes) {
let store = init
@ -49,7 +53,7 @@ export function getDatabase (instanceName) {
}
}
if (e.oldVersion < DB_VERSION) {
if (e.oldVersion < DB_VERSION_INITIAL) {
createObjectStore(STATUSES_STORE, {keyPath: 'id'}, {
[TIMESTAMP]: TIMESTAMP,
[REBLOG_ID]: REBLOG_ID
@ -78,6 +82,10 @@ export function getDatabase (instanceName) {
})
createObjectStore(META_STORE)
}
if (e.oldVersion < DB_VERSION_SEARCH_ACCOUNTS) {
tx.objectStore(ACCOUNTS_STORE)
.createIndex(USERNAME_LOWERCASE, USERNAME_LOWERCASE)
}
}
req.onsuccess = () => resolve(req.result)
})

View File

@ -1,6 +1,6 @@
import { dbPromise, getDatabase } from './databaseLifecycle'
import { getInCache, hasInCache, setInCache } from './cache'
import { ACCOUNT_ID, REBLOG_ID, STATUS_ID, TIMESTAMP } from './constants'
import { ACCOUNT_ID, REBLOG_ID, STATUS_ID, TIMESTAMP, USERNAME_LOWERCASE } from './constants'
export async function getGenericEntityWithId (store, cache, instanceName, id) {
if (hasInCache(cache, instanceName, id)) {
@ -41,6 +41,10 @@ export function cloneForStorage (obj) {
case 'reblog':
res[REBLOG_ID] = value.id
break
case 'acct':
res[key] = value
res[USERNAME_LOWERCASE] = value.toLowerCase()
break
default:
res[key] = value
break

View File

@ -45,3 +45,14 @@ export function createPinnedStatusKeyRange (accountId) {
accountId + '\u0000\uffff'
)
}
//
// accounts
//
export function createAccountUsernamePrefixKeyRange (accountUsernamePrefix) {
return IDBKeyRange.bound(
accountUsernamePrefix,
accountUsernamePrefix + '\uffff'
)
}

View File

@ -52,3 +52,20 @@ export function blurWithCapture (node, callback) {
}
}
}
export function selectionChange (node, callback) {
let events = ['keyup', 'click', 'focus', 'blur']
let listener = () => {
callback(node.selectionStart)
}
for (let event of events) {
node.addEventListener(event, listener)
}
return {
teardown () {
for (let event of events) {
node.removeEventListener(event, listener)
}
}
}
}

8
routes/_utils/once.js Normal file
View File

@ -0,0 +1,8 @@
// svelte helper to add a .once() method similar to .on, but only fires once
export function once (eventName, callback) {
let listener = this.on(eventName, eventValue => {
listener.cancel()
callback(eventValue)
})
}

View File

@ -74,4 +74,8 @@
--muted-modal-bg: transparent;
--muted-modal-focus: #999;
--muted-modal-hover: rgba(255, 255, 255, 0.2);
--compose-autosuggest-item-hover: $compose-background;
--compose-autosuggest-item-active: darken($compose-background, 5%);
--compose-autosuggest-outline: lighten($focus-outline, 5%);
}

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 30%);
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 15%);
$compose-background: lighten($main-theme-color, 17%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 30%);
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 15%);
$compose-background: lighten($main-theme-color, 17%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 30%);
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 30%);
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 30%);
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";

View File

@ -8,6 +8,7 @@ $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 50%);
$compose-background: lighten($main-theme-color, 52%);
@import "_base.scss";

View File

@ -16,9 +16,9 @@
<style>
/* auto-generated w/ build-sass.js */
body{--button-primary-bg:#6081e6;--button-primary-text:#fff;--button-primary-border:#132c76;--button-primary-bg-active:#456ce2;--button-primary-bg-hover:#6988e7;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#4169e1;--main-bg:#fff;--body-bg:#e8edfb;--body-text-color:#333;--main-border:#dadada;--svg-fill:#4169e1;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#4169e1;--nav-border:#214cce;--nav-a-border:#4169e1;--nav-a-selected-border:#fff;--nav-a-selected-bg:#6d8ce8;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#839deb;--nav-a-bg-hover:#577ae4;--nav-a-border-hover:#4169e1;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#90a8ee;--action-button-fill-color-hover:#a2b6f0;--action-button-fill-color-active:#577ae4;--action-button-fill-color-pressed:#2351dc;--action-button-fill-color-pressed-hover:#3862e0;--action-button-fill-color-pressed-active:#1d44b8;--settings-list-item-bg:#fff;--settings-list-item-text:#4169e1;--settings-list-item-text-hover:#4169e1;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--mask-opaque-bg:rgba(51,51,51,0.8);--loading-bg:#ededed;--deemphasized-text-color:#666;--focus-outline:#c5d1f6;--very-deemphasized-link-color:rgba(65,105,225,0.6);--very-deemphasized-text-color:rgba(102,102,102,0.6);--status-direct-background:#d2dcf8;--main-theme-color:#4169e1;--warning-color:#e01f19;--alt-input-bg:rgba(255,255,255,0.7);--muted-modal-bg:transparent;--muted-modal-focus:#999;--muted-modal-hover:rgba(255,255,255,0.2)}
body{--button-primary-bg:#6081e6;--button-primary-text:#fff;--button-primary-border:#132c76;--button-primary-bg-active:#456ce2;--button-primary-bg-hover:#6988e7;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#4169e1;--main-bg:#fff;--body-bg:#e8edfb;--body-text-color:#333;--main-border:#dadada;--svg-fill:#4169e1;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#4169e1;--nav-border:#214cce;--nav-a-border:#4169e1;--nav-a-selected-border:#fff;--nav-a-selected-bg:#6d8ce8;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#839deb;--nav-a-bg-hover:#577ae4;--nav-a-border-hover:#4169e1;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#90a8ee;--action-button-fill-color-hover:#a2b6f0;--action-button-fill-color-active:#577ae4;--action-button-fill-color-pressed:#2351dc;--action-button-fill-color-pressed-hover:#3862e0;--action-button-fill-color-pressed-active:#1d44b8;--settings-list-item-bg:#fff;--settings-list-item-text:#4169e1;--settings-list-item-text-hover:#4169e1;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--mask-opaque-bg:rgba(51,51,51,0.8);--loading-bg:#ededed;--deemphasized-text-color:#666;--focus-outline:#c5d1f6;--very-deemphasized-link-color:rgba(65,105,225,0.6);--very-deemphasized-text-color:rgba(102,102,102,0.6);--status-direct-background:#d2dcf8;--main-theme-color:#4169e1;--warning-color:#e01f19;--alt-input-bg:rgba(255,255,255,0.7);--muted-modal-bg:transparent;--muted-modal-focus:#999;--muted-modal-hover:rgba(255,255,255,0.2);--compose-autosuggest-item-hover:#ced8f7;--compose-autosuggest-item-active:#b8c7f4;--compose-autosuggest-outline:#dbe3f9}
body{margin:0;font-family:system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue;font-size:14px;line-height:1.4;color:var(--body-text-color);background:var(--body-bg);position:fixed;left:0;right:0;bottom:0;top:0}.container{overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;will-change:transform;position:absolute;top:72px;left:0;right:0;bottom:0}@media (max-width: 767px){.container{top:62px}}main{position:relative;width:602px;max-width:100vw;padding:0;box-sizing:border-box;margin:30px auto 15px;background:var(--main-bg);border:1px solid var(--main-border);border-radius:1px;min-height:70vh}@media (max-width: 767px){main{margin:5px auto 15px}}footer{width:602px;max-width:100vw;box-sizing:border-box;margin:20px auto;border-radius:1px;background:var(--main-bg);font-size:0.9em;padding:20px;border:1px solid var(--main-border)}h1,h2,h3,h4,h5,h6{margin:0 0 0.5em 0;font-weight:400;line-height:1.2}h1{font-size:2em}a{color:var(--anchor-text);text-decoration:none}a:visited{color:var(--anchor-text)}a:hover{text-decoration:underline}input{border:1px solid var(--input-border);padding:5px;box-sizing:border-box}button,.button{font-size:1.2em;background:var(--button-bg);border-radius:2px;padding:10px 15px;border:1px solid var(--button-border);cursor:pointer;color:var(--button-text)}button:hover,.button:hover{background:var(--button-bg-hover);text-decoration:none}button:active,.button:active{background:var(--button-bg-active)}button[disabled],.button[disabled]{opacity:0.35;pointer-events:none;cursor:not-allowed}button.primary,.button.primary{border:1px solid var(--button-primary-border);background:var(--button-primary-bg);color:var(--button-primary-text)}button.primary:hover,.button.primary:hover{background:var(--button-primary-bg-hover)}button.primary:active,.button.primary:active{background:var(--button-primary-bg-active)}p,label,input{font-size:1.3em}ul,li,p{padding:0;margin:0}.hidden{opacity:0}*:focus{outline:2px solid var(--focus-outline)}button::-moz-focus-inner{border:0}input:required,input:invalid{box-shadow:none}textarea{font-family:inherit;font-size:inherit;box-sizing:border-box}@keyframes spin{0%{transform:rotate(0deg)}50%{transform:rotate(180deg)}100%{transform:rotate(360deg)}}.spin{animation:spin 2s infinite linear}.ellipsis::after{content:"\2026"}
body.offline,body.theme-hotpants.offline,body.theme-majesty.offline,body.theme-oaken.offline,body.theme-scarlet.offline,body.theme-seafoam.offline,body.theme-gecko.offline{--button-primary-bg:#ababab;--button-primary-text:#fff;--button-primary-border:#4d4d4d;--button-primary-bg-active:#9c9c9c;--button-primary-bg-hover:#b0b0b0;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#999;--main-bg:#fff;--body-bg:#fafafa;--body-text-color:#333;--main-border:#dadada;--svg-fill:#999;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#999;--nav-border:gray;--nav-a-border:#999;--nav-a-selected-border:#fff;--nav-a-selected-bg:#b3b3b3;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#bfbfbf;--nav-a-bg-hover:#a6a6a6;--nav-a-border-hover:#999;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#c7c7c7;--action-button-fill-color-hover:#d1d1d1;--action-button-fill-color-active:#a6a6a6;--action-button-fill-color-pressed:#878787;--action-button-fill-color-pressed-hover:#949494;--action-button-fill-color-pressed-active:#737373;--settings-list-item-bg:#fff;--settings-list-item-text:#999;--settings-list-item-text-hover:#999;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--mask-opaque-bg:rgba(51,51,51,0.8);--loading-bg:#ededed;--deemphasized-text-color:#666;--focus-outline:#bfbfbf;--very-deemphasized-link-color:rgba(153,153,153,0.6);--very-deemphasized-text-color:rgba(102,102,102,0.6);--status-direct-background:#ededed;--main-theme-color:#999;--warning-color:#e01f19;--alt-input-bg:rgba(255,255,255,0.7);--muted-modal-bg:transparent;--muted-modal-focus:#999;--muted-modal-hover:rgba(255,255,255,0.2)}
body.offline,body.theme-hotpants.offline,body.theme-majesty.offline,body.theme-oaken.offline,body.theme-scarlet.offline,body.theme-seafoam.offline,body.theme-gecko.offline{--button-primary-bg:#ababab;--button-primary-text:#fff;--button-primary-border:#4d4d4d;--button-primary-bg-active:#9c9c9c;--button-primary-bg-hover:#b0b0b0;--button-bg:#e6e6e6;--button-text:#333;--button-border:#a7a7a7;--button-bg-active:#bfbfbf;--button-bg-hover:#f2f2f2;--input-border:#dadada;--anchor-text:#999;--main-bg:#fff;--body-bg:#fafafa;--body-text-color:#333;--main-border:#dadada;--svg-fill:#999;--form-bg:#f7f7f7;--form-border:#c1c1c1;--nav-bg:#999;--nav-border:gray;--nav-a-border:#999;--nav-a-selected-border:#fff;--nav-a-selected-bg:#b3b3b3;--nav-svg-fill:#fff;--nav-text-color:#fff;--nav-a-selected-border-hover:#fff;--nav-a-selected-bg-hover:#bfbfbf;--nav-a-bg-hover:#a6a6a6;--nav-a-border-hover:#999;--nav-svg-fill-hover:#fff;--nav-text-color-hover:#fff;--action-button-fill-color:#c7c7c7;--action-button-fill-color-hover:#d1d1d1;--action-button-fill-color-active:#a6a6a6;--action-button-fill-color-pressed:#878787;--action-button-fill-color-pressed-hover:#949494;--action-button-fill-color-pressed-active:#737373;--settings-list-item-bg:#fff;--settings-list-item-text:#999;--settings-list-item-text-hover:#999;--settings-list-item-border:#dadada;--settings-list-item-bg-active:#e6e6e6;--settings-list-item-bg-hover:#fafafa;--toast-bg:#333;--toast-border:#fafafa;--toast-text:#fff;--mask-bg:#333;--mask-svg-fill:#fff;--mask-opaque-bg:rgba(51,51,51,0.8);--loading-bg:#ededed;--deemphasized-text-color:#666;--focus-outline:#bfbfbf;--very-deemphasized-link-color:rgba(153,153,153,0.6);--very-deemphasized-text-color:rgba(102,102,102,0.6);--status-direct-background:#ededed;--main-theme-color:#999;--warning-color:#e01f19;--alt-input-bg:rgba(255,255,255,0.7);--muted-modal-bg:transparent;--muted-modal-focus:#999;--muted-modal-hover:rgba(255,255,255,0.2);--compose-autosuggest-item-hover:#c4c4c4;--compose-autosuggest-item-active:#b8b8b8;--compose-autosuggest-outline:#ccc}
</style>