add username autocomplete feature
This commit is contained in:
		
							parent
							
								
									5430fdd189
								
							
						
					
					
						commit
						6fc21e40bf
					
				
					 23 changed files with 428 additions and 35 deletions
				
			
		| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										128
									
								
								routes/_components/compose/ComposeAutosuggest.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								routes/_components/compose/ComposeAutosuggest.html
									
										
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										90
									
								
								routes/_components/compose/ComposeAutosuggestionList.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								routes/_components/compose/ComposeAutosuggestionList.html
									
										
									
									
									
										Normal 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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()" />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
  })
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,3 +45,14 @@ export function createPinnedStatusKeyRange (accountId) {
 | 
			
		|||
    accountId + '\u0000\uffff'
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//
 | 
			
		||||
// accounts
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
export function createAccountUsernamePrefixKeyRange (accountUsernamePrefix) {
 | 
			
		||||
  return IDBKeyRange.bound(
 | 
			
		||||
    accountUsernamePrefix,
 | 
			
		||||
    accountUsernamePrefix + '\uffff'
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
									
								
							
							
						
						
									
										8
									
								
								routes/_utils/once.js
									
										
									
									
									
										Normal 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)
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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%);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue