code:
$main-theme-color: royalblue;
$body-bg-color: lighten($main-theme-color, 38%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$scrollbar-face: #{lighten($main-theme-color, 18%)} !default;
$scrollbar-face-hover: #{lighten($main-theme-color, 20%)} !default;
$scrollbar-face-active: #{lighten($main-theme-color, 15%)} !default;
$scrollbar-track: #{darken($body-bg-color, 3%)} !default;

@import "_scrollbars.scss";

repo_name: RayoGundead/pinny

license: mit


code:
$main-theme-color: #999999;
$body-bg-color: lighten($main-theme-color, 38%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
:root {
  --scrollbar-face-color: #{$scrollbar-face};
  --scrollbar-track-color: #{$scrollbar-track};
  --scrollbar-border-radius: 0;
  --scrollbar-face-color-hover: #{$scrollbar-face-hover};
  --scrollbar-face-color-active: #{$scrollbar-face-active};
  --scrollbar-width: calc(20px + .25em);
  --scrollbar-height: calc(20px + .25em);
  --scrollbar-background-color: transparent;
}


code:
$main-theme-color: #08439b;
$body-bg-color: #05285c;
$anchor-color: #eeae00;
$main-text-color: #e0e0e0;
$main-bg-color: #08439b;
$border-color: lighten($main-bg-color, 16%);
$secondary-text-color: darken($main-text-color, 5%);
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-bg-color, 16%);
$compose-background: lighten($main-theme-color, 32%);

@import "_base.scss";
@import "_dark.scss";
@import "_dark_scrollbars.scss";

:root {
  --settings-list-item-text: #{$main-text-color};
  --settings-list-item-text-hover: #{$main-text-color};

  --action-button-fill-color: #{lighten($main-theme-color, 30%)};
  --action-button-fill-color-hover: #{lighten($main-theme-color, 35%)};
  --action-button-fill-color-active: #{lighten($main-theme-color, 40%)};

  --action-button-fill-color-pressed: #{$anchor-color};
  --action-button-fill-color-pressed-hover: #{darken($anchor-color, 2%)};
  --action-button-fill-color-pressed-active: #{darken($anchor-color, 15%)};

  --svg-fill: #{$secondary-text-color};

  --nav-svg-fill: #{$main-text-color};
  --nav-text-color: #{$main-text-color};
  --nav-svg-fill-hover: #{$main-text-color};
  --nav-text-color-hover: #{$main-text-color};
  --nav-a-selected-border: #{$anchor-color};
  --nav-a-selected-border-hover: #{$anchor-color};
}


code:
$main-theme-color: #4ab92f;
$body-bg-color: lighten($main-theme-color, 45%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$main-theme-color: #4aaa2f;
//$main-theme-color: #4ab92f;
$main-bg-color: #282828;
$body-bg-color: darken($main-bg-color, 5%);
$anchor-color: $main-theme-color;
$main-text-color: #FFF;
$border-color: lighten($body-bg-color, 10%);
$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";
@import "_dark.scss";
@import "_dark_navbar.scss";
@import "_dark_scrollbars.scss";


code:
$main-theme-color: hotpink;
$body-bg-color: lighten($main-theme-color, 25%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 15%);
$compose-background: lighten($main-theme-color, 17%);

// override scrollbar color to improve contrast
$scrollbar-face: #{lighten($main-theme-color, 12%)};
$scrollbar-face-hover: #{lighten($main-theme-color, 14%)};
$scrollbar-face-active: #{lighten($main-theme-color, 10%)};

@import "_base.scss";
@import "_light_scrollbars.scss";


code:
$main-theme-color: blueviolet;
$body-bg-color: lighten($main-theme-color, 40%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$main-theme-color: #313543;
$body-bg-color: #17191F;
$main-bg-color: #282C37;
$anchor-color: #d9e1e8;
$main-text-color: #ffffff;
$border-color: #393f4f;
$secondary-text-color: #f6f6ff;
$toast-border: $main-theme-color;
$toast-bg: lighten($body-bg-color, 4%);
$focus-outline: darken($main-theme-color, 10%);
$compose-background: darken($main-theme-color, 12%);

@import "_base.scss";
@import "_dark.scss";
@import "_dark_scrollbars.scss";

:root {
  --settings-list-item-text: #9baec8;
  --settings-list-item-text-hover: #{$main-text-color};

  --compose-button-halo: transparent;
  --button-primary-bg: #2b90d9;
  --button-primary-bg-hover: #56a7e1;
  --button-primary-border: transparent;

  --action-button-fill-color-pressed: #2b90d9;
  --action-button-fill-color-pressed-hover: #2b90d9;
}


code:
$main-theme-color: saddlebrown;
$body-bg-color: lighten($main-theme-color, 60%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$main-theme-color: #5263af;
$body-bg-color: #0f1427;
$main-bg-color: #212433;
$anchor-color: lighten($main-theme-color, 20%);
$main-text-color: #fafaff;
$border-color: lighten($main-bg-color, 16%);
$secondary-text-color: #f6f6ff;
$toast-border: $main-theme-color;
$toast-bg: lighten($body-bg-color, 4%);
$focus-outline: darken($main-theme-color, 10%);
$compose-background: darken($main-theme-color, 12%);

@import "_base.scss";
@import "_dark.scss";
@import "_dark_scrollbars.scss";


code:
$main-theme-color: rgb( 16,61,80);
$body-bg-color: #03131b;
$main-bg-color: #000;
$anchor-color: lighten($main-theme-color, 20%);
$main-text-color: #fafaff;
$border-color: $body-bg-color;
$secondary-text-color: #f6f6ff;
$toast-border: lighten($body-bg-color, 4%);
$toast-bg: lighten($body-bg-color, 4%);
$focus-outline: lighten($main-theme-color, 10%);
$compose-background: darken($main-theme-color, 12%);

@import "_base.scss";
@import "_dark.scss";
@import "_dark_scrollbars.scss";

:root {
  --settings-list-item-text: #{$anchor-color};
  --settings-list-item-text-hover: #{$anchor-color};
  --settings-list-item-bg-active: #{darken($body-bg-color, 10%)};
  --settings-list-item-bg-hover: #{darken($body-bg-color, 2%)};

  --button-bg: #{lighten($body-bg-color, 5%)};
  --button-border: #{$border-color};
  --button-bg-hover: #{lighten($body-bg-color, 10%)};
  --button-bg-active: #{lighten($body-bg-color, 15%)};

  --form-bg: #{$body-bg-color};
  --form-border: #{darken($border-color, 10%)};

  --action-button-fill-color: #{lighten($main-theme-color, 5%)};
  --action-button-fill-color-hover: #{lighten($main-theme-color, 12%)};
  --action-button-fill-color-active: #{darken($main-theme-color, 15%)};
  --action-button-fill-color-pressed: #{lighten($main-theme-color, 20%)};
  --action-button-fill-color-pressed-hover: #{lighten($main-theme-color, 24%)};
  --action-button-fill-color-pressed-active: #{lighten($main-theme-color, 7%)};
}


code:
$main-theme-color: #e04e41;
//$main-theme-color: #e04e41;
$main-bg-color: #28252E;
$body-bg-color: darken($main-bg-color, 5%);
$anchor-color: $main-theme-color;
$main-text-color: #FFF;
$border-color: lighten($body-bg-color, 10%);
$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";
@import "_dark.scss";
@import "_dark_navbar.scss";
@import "_dark_scrollbars.scss";


code:
$main-theme-color: hotpink;
//$main-theme-color: hotpink;
$main-bg-color: #28252E;
$body-bg-color: darken($main-bg-color, 5%);
$anchor-color: $main-theme-color;
$main-text-color: #FFF;
$border-color: lighten($body-bg-color, 10%);
$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";
@import "_dark.scss";
@import "_dark_navbar.scss";
@import "_dark_scrollbars.scss";


code:
$main-theme-color: #e04e41;
$body-bg-color: lighten($main-theme-color, 38%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$main-theme-color: #177380;
$body-bg-color: lighten($main-theme-color, 60%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$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";
@import "_light_scrollbars.scss";


code:
$main-theme-color: #ae91e8;
//$main-theme-color: #8686CB;
$main-bg-color: #28252E;
$body-bg-color: darken($main-bg-color, 5%);
$anchor-color: $main-theme-color;
$main-text-color: #FFF;
$border-color: lighten($body-bg-color, 10%);
$secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 50%);
$compose-background: lighten($main-theme-color, 52%);

// override scrollbar color to make it a bit darker
$scrollbar-face: #{lighten($main-theme-color, 3%)};
$scrollbar-face-hover: #{lighten($main-theme-color, 5%)};
$scrollbar-face-active: #{lighten($main-theme-color, 1%)};

@import "_base.scss";
@import "_dark.scss";
@import "_dark_navbar.scss";
@import "_dark_scrollbars.scss";


code:
// This is the Sapper server, which we only run during `sapper export`.

import * as sapper from '../__sapper__/server.js'
import express from 'express'

const { PORT = 4002 } = process.env
const app = express()

app.use(express.static('static'))
app.use(sapper.middleware())

app.listen(PORT, () => console.log(`listening on port ${PORT}`))

// Handle SIGINT (source: https://git.io/vhJgF)
process.on('SIGINT', () => process.exit(0))


code:
// TODO: this is a hacky solution to generating checksums for Sapper's inline scripts.
// I determined this by running `sapper export` and then checking all unique inline scripts.
// Really, this ought to be built into Sapper somehow.

import crypto from 'crypto'

const baseScripts = [
  `__SAPPER__={baseUrl:"",preloaded:[{},{}]};`,
  `__SAPPER__={baseUrl:"",preloaded:[{}]};`,
  `__SAPPER__={baseUrl:"",preloaded:[{},null,null,{}]};`,
  `__SAPPER__={baseUrl:"",preloaded:[{},null,{}]};`
]

const scriptsWithSW = baseScripts.map(script => (
  `${script}if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js');`)
)

// sapper adds service worker usually, but it seems inconsistent in dev mode especially
const scripts = [].concat(baseScripts).concat(scriptsWithSW)

export const sapperInlineScriptChecksums = scripts.map(script => {
  return crypto.createHash('sha256').update(script).digest('base64')
})


code:
import {
  assets as __assets__,
  shell as __shell__,
  routes as __routes__
} from '../__sapper__/service-worker.js'

import {
  get,
  post
} from './routes/_utils/ajax'

const timestamp = process.env.SAPPER_TIMESTAMP
const ASSETS = `assets_${timestamp}`
const WEBPACK_ASSETS = `webpack_assets_${timestamp}`

// `static` is an array of everything in the `static` directory
const assets = __assets__
  .map(file => file.startsWith('/') ? file : `/${file}`)
  .filter(filename => !filename.startsWith('/apple-icon'))
  .filter(filename => !filename.endsWith('.map'))
  .filter(filename => filename !== '/robots.txt')

// `shell` is an array of all the files generated by webpack
// also contains '/index.html' for some reason
const webpackAssets = __shell__
  .filter(filename => !filename.endsWith('.map')) // don't bother with sourcemaps

// `routes` is an array of `{ pattern: RegExp }` objects that
// match the pages in your src
const routes = __routes__

self.addEventListener('install', event => {
  event.waitUntil((async () => {
    await Promise.all([
      caches.open(WEBPACK_ASSETS).then(cache => cache.addAll(webpackAssets)),
      caches.open(ASSETS).then(cache => cache.addAll(assets))
    ])
    // We shouldn't have to do this, but the previous page could be an old one,
    // which would not send us a postMessage to skipWaiting().
    // See https://github.com/nolanlawson/pinafore/issues/1243
    self.skipWaiting()
  })())
})

self.addEventListener('activate', event => {
  event.waitUntil((async () => {
    let keys = await caches.keys()

    // delete old asset/ondemand caches
    for (let key of keys) {
      if (key !== ASSETS &&
        !key.startsWith('webpack_assets_')) {
        await caches.delete(key)
      }
    }

    // for webpack static, keep the two latest builds because we may need
    // them when the service worker has installed but the page has not
    // yet reloaded (e.g. when it gives the toast saying "please reload"
    // but then you don't refresh and instead load an async chunk)
    let webpackKeysToDelete = keys
      .filter(key => key.startsWith('webpack_assets_'))
      .sort((a, b) => {
        let aTimestamp = parseInt(a.substring(15), 10)
        let bTimestamp = parseInt(b.substring(15), 10)
        return bTimestamp < aTimestamp ? -1 : 1
      })
      .slice(2)

    for (let key of webpackKeysToDelete) {
      await caches.delete(key)
    }

    await self.clients.claim()
  })())
})

self.addEventListener('fetch', event => {
  const req = event.request
  const url = new URL(req.url)

  // don't try to handle e.g. data: URIs
  if (!url.protocol.startsWith('http')) {
    return
  }

  event.respondWith((async () => {
    let sameOrigin = url.origin === self.origin

    if (sameOrigin) {
      // always serve webpack-generated resources and
      // static from the cache if possible
      let response = await caches.match(req)
      if (response) {
        return response
      }
      // for routes, serve the /service-worker-index.html file from the most recent
      // static cache
      if (routes.find(route => route.pattern.test(url.pathname))) {
        let response = await caches.match('/service-worker-index.html')
        if (response) {
          return response
        }
      }
    }

    // for everything else, go network-only
    return fetch(req)
  })())
})

self.addEventListener('push', event => {
  event.waitUntil((async () => {
    const data = event.data.json()
    const { origin } = new URL(data.icon)

    try {
      const notification = await get(`${origin}/api/v1/notifications/${data.notification_id}`, {
        'Authorization': `Bearer ${data.access_token}`
      }, { timeout: 2000 })

      await showRichNotification(data, notification)
    } catch (e) {
      await showSimpleNotification(data)
    }
  })())
})

async function showSimpleNotification (data) {
  await self.registration.showNotification(data.title, {
    icon: data.icon,
    body: data.body
  })
}

async function showRichNotification (data, notification) {
  const { icon, body } = data
  const tag = notification.id
  const { origin } = self.location
  const badge = '/icon-push-badge.png'

  switch (notification.type) {
    case 'follow': {
      await self.registration.showNotification(data.title, {
        badge,
        icon,
        body,
        tag,
        data: {
          url: `${origin}/accounts/${notification.account.id}`
        }
      })
      break
    }
    case 'reblog':
    case 'favourite':
    case 'poll':
      await self.registration.showNotification(data.title, {
        badge,
        icon,
        body,
        tag,
        data: {
          url: `${origin}/statuses/${notification.status.id}`
        }
      })
      break
    case 'mention':
      const isPublic = ['public', 'unlisted'].includes(notification.status.visibility)
      const actions = [
        isPublic && {
          action: 'reblog',
          icon: '/icon-push-fa-retweet.png', // generated manually from font-awesome-svg
          title: 'Boost'
        },
        {
          action: 'favourite',
          icon: '/icon-push-fa-star.png', // generated manually from font-awesome-svg
          title: 'Favorite'
        }
      ].filter(Boolean)

      await self.registration.showNotification(data.title, {
        badge,
        icon,
        body,
        tag,
        data: {
          instance: new URL(data.icon).origin,
          status_id: notification.status.id,
          access_token: data.access_token,
          url: `${origin}/statuses/${notification.status.id}`
        },
        actions
      })
      break
  }
}

const cloneNotification = notification => {
  const clone = {}

  for (let k in notification) {
    // deliberately not doing a hasOwnProperty check, but skipping
    // functions and null props like onclick and onshow and showTrigger
    if (typeof notification[k] !== 'function' && notification[k] !== null) {
      clone[k] = notification[k]
    }
  }

  return clone
}

const updateNotificationWithoutAction = (notification, action) => {
  const newNotification = cloneNotification(notification)

  newNotification.actions = newNotification.actions.filter(item => item.action !== action)

  return self.registration.showNotification(newNotification.title, newNotification)
}

self.addEventListener('notificationclick', event => {
  event.waitUntil((async () => {
    switch (event.action) {
      case 'reblog': {
        const url = `${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/reblog`
        await post(url, null, {
          'Authorization': `Bearer ${event.notification.data.access_token}`
        })
        await updateNotificationWithoutAction(event.notification, 'reblog')
        break
      }
      case 'favourite': {
        const url = `${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/favourite`
        await post(url, null, {
          'Authorization': `Bearer ${event.notification.data.access_token}`
        })
        await updateNotificationWithoutAction(event.notification, 'favourite')
        break
      }
      default: {
        await self.clients.openWindow(event.notification.data.url)
        await event.notification.close()
        break
      }
    }
  })())
})

self.addEventListener('message', (event) => {
  switch (event.data) {
    case 'skip-waiting':
      self.skipWaiting()
      break
  }
})


code:
Copied from https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG

Version used: 1.2.2


Pinafore for Mastodon

background_color: #ffffff

short_name: Pinafore

description: Alternative web client for Mastodon, focused on speed and simplicity.

display: standalone

start_url: /?pwa=true

action: /share

method: GET

enctype: application/x-www-form-urlencoded

title: title

text: text

url: url

type: image/png


code:
export function base64StringToBlob (base64, type) {
  function binaryStringToArrayBuffer (binary) {
    let length = binary.length
    let buf = new ArrayBuffer(length)
    let arr = new Uint8Array(buf)
    let i = -1
    while (++i < length) {
      arr[i] = binary.charCodeAt(i)
    }
    return buf
  }

  let parts = [binaryStringToArrayBuffer(atob(base64))]
  return type ? new Blob(parts, { type: type }) : new Blob(parts)
}


code:
import { times } from './utils'

export const homeTimeline = [
  { content: 'pinned toot 1' },
  { content: 'notification of unlisted message' },
  { content: 'notification of followers-only message' },
  { content: 'this is unlisted' },
  { content: 'this is followers-only' },
  { spoiler: 'kitten CW' },
  { content: 'secret video' },
  { content: "here's a video" },
  { spoiler: 'CW' },
  { content: "here's a secret animated kitten gif" },
  { content: "here's an animated kitten gif" },
  { content: "here's 2 kitten photos" },
  { content: "here's a secret kitten" },
  { content: "here's a kitten" },
  { content: 'hello admin' },
  { content: 'hello foobar' },
  { content: 'hello world' }
].concat(times(30, i => ({ content: (30 - i).toString() })))

export const localTimeline = [
  { spoiler: 'kitten CW' },
  { content: 'secret video' },
  { content: "here's a video" },
  { spoiler: 'CW' },
  { content: "here's a secret animated kitten gif" },
  { content: "here's an animated kitten gif" },
  { content: "here's 2 kitten photos" },
  { content: "here's a secret kitten" },
  { content: "here's a kitten" },
  { content: 'hello world' }
].concat(times(30, i => ({ content: (30 - i).toString() })))

export const notifications = [
  { favoritedBy: 'admin' },
  { rebloggedBy: 'admin' },
  { content: 'notification of unlisted message' },
  { content: 'notification of followers-only message' },
  { content: 'notification of direct message' },
  { followedBy: 'quux' },
  { content: 'hello foobar' },
  { followedBy: 'admin' }
]

export const notificationsMentions = [
  { content: 'notification of unlisted message' },
  { content: 'notification of followers-only message' },
  { content: 'notification of direct message' },
  { content: 'hello foobar' }
]

export const favorites = [
  { content: 'notification of direct message' },
  { content: 'notification of followers-only message' },
  { content: 'notification of unlisted message' },
  { content: 'pinned toot 1' }
]

export const directMessages = [
  { content: 'notification of direct message' },
  { content: 'direct' }
]

export const quuxStatuses = [
  { content: 'pinned toot 2' },
  { content: 'pinned toot 1' }
].concat(times(25, i => ({ content: `unlisted thread ${25 - i}` })))

export const quuxThread = times(25, i => ({ content: `unlisted thread ${i + 1}` }))

export const bazThreadRelativeTo2B2 = [
  { content: 'thread 1' },
  { content: 'thread 2' },
  { content: 'thread 2b' },
  { content: 'thread 2b2' },
  { content: 'thread 2b2a' }
]

export const bazThreadRelativeTo2b = [
  { content: 'thread 1' },
  { content: 'thread 2' },
  { content: 'thread 2b' },
  { content: 'thread 2b1' },
  { content: 'thread 2b2' },
  { content: 'thread 2b2a' }
]

export const bazThreadRelativeTo2 = [
  { content: 'thread 1' },
  { content: 'thread 2' },
  { content: 'thread 2a' },
  { content: 'thread 2b' },
  { content: 'thread 2b1' },
  { content: 'thread 2b2' },
  { content: 'thread 2b2a' },
  { content: 'thread 2c' }
]


code:
import fs from 'fs'
import path from 'path'

export const kitten1 = {
  data: fs.readFileSync(path.join(__dirname, './images/kitten1.jpg')).toString('base64'),
  name: 'kitten1.jpg'
}

export const kitten2 = {
  data: fs.readFileSync(path.join(__dirname, './images/kitten2.jpg')).toString('base64'),
  name: 'kitten2.jpg'
}

export const kitten3 = {
  data: fs.readFileSync(path.join(__dirname, './images/kitten3.jpg')).toString('base64'),
  name: 'kitten3.jpg'
}

export const kitten4 = {
  data: fs.readFileSync(path.join(__dirname, './images/kitten4.jpg')).toString('base64'),
  name: 'kitten4.jpg'
}


code:
import indexedDB from 'fake-indexeddb'
import IDBKeyRange from 'fake-indexeddb/build/FDBKeyRange'

global.indexedDB = indexedDB
global.IDBKeyRange = IDBKeyRange


code:
import { getUrl } from './utils'
import { users } from './users'

// quick login using a secret page and a known access token (makes tests run faster)
async function login (t, user) {
  await t.navigateTo(`/settings/quick-login?instanceName=localhost:3000&accessToken=${user.accessToken}`)
    .expect(getUrl()).eql('http://localhost:4002/', { timeout: 30000 })
}

// roles appear not to be working anymore :(
// export const foobarRole = Role('http://localhost:4002/settings/instances/add', async t => {
//   await login(t, users.foobar.email, users.foobar.password)
// })
//
// export const lockedAccountRole = Role('http://localhost:4002/settings/instances/add', async t => {
//   await login(t, users.LockedAccount.email, users.LockedAccount.password)
// })

export async function loginAsFoobar (t) {
  await login(t, users.foobar)
}

export async function loginAsLockedAccount (t) {
  await login(t, users.LockedAccount)
}


code:
import { favoriteStatus } from '../src/routes/_api/favorite'
import fetch from 'node-fetch'
import FileApi from 'file-api'
import { users } from './users'
import { postStatus } from '../src/routes/_api/statuses'
import { deleteStatus } from '../src/routes/_api/delete'
import { authorizeFollowRequest, getFollowRequests } from '../src/routes/_actions/followRequests'
import { followAccount, unfollowAccount } from '../src/routes/_api/follow'
import { updateCredentials } from '../src/routes/_api/updateCredentials'
import { reblogStatus } from '../src/routes/_api/reblog'
import { submitMedia } from './submitMedia'
import { voteOnPoll } from '../src/routes/_api/polls'
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'

global.fetch = fetch
global.File = FileApi.File
global.FormData = FileApi.FormData

const instanceName = 'localhost:3000'

export async function favoriteStatusAs (username, statusId) {
  return favoriteStatus(instanceName, users[username].accessToken, statusId)
}

export async function reblogStatusAs (username, statusId) {
  return reblogStatus(instanceName, users[username].accessToken, statusId)
}

export async function postAs (username, text) {
  return postStatus(instanceName, users[username].accessToken, text,
    null, null, false, null, 'public')
}

export async function postWithSpoilerAndPrivacyAs (username, text, spoiler, privacy) {
  return postStatus(instanceName, users[username].accessToken, text,
    null, null, true, spoiler, privacy)
}

export async function postEmptyStatusWithMediaAs (username, filename, alt) {
  let mediaResponse = await submitMedia(users[username].accessToken, filename, alt)
  return postStatus(instanceName, users[username].accessToken, '',
    null, [mediaResponse.id], false, null, 'public')
}

export async function postReplyAs (username, text, inReplyTo) {
  return postStatus(instanceName, users[username].accessToken, text,
    inReplyTo, null, false, null, 'public')
}

export async function deleteAs (username, statusId) {
  return deleteStatus(instanceName, users[username].accessToken, statusId)
}

export async function getFollowRequestsAs (username) {
  return getFollowRequests(instanceName, users[username].accessToken)
}

export async function authorizeFollowRequestAs (username, id) {
  return authorizeFollowRequest(instanceName, users[username].accessToken, id)
}

export async function followAs (username, userToFollow) {
  return followAccount(instanceName, users[username].accessToken, users[userToFollow].id)
}

export async function unfollowAs (username, userToFollow) {
  return unfollowAccount(instanceName, users[username].accessToken, users[userToFollow].id)
}

export async function updateUserDisplayNameAs (username, displayName) {
  return updateCredentials(instanceName, users[username].accessToken, { display_name: displayName })
}

export async function createPollAs (username, content, options, multiple) {
  return postStatus(instanceName, users[username].accessToken, content, null, null, false, null, 'public', {
    options,
    multiple,
    expires_in: POLL_EXPIRY_DEFAULT
  })
}

export async function voteOnPollAs (username, pollId, choices) {
  return voteOnPoll(instanceName, users[username].accessToken, pollId, choices.map(_ => _.toString()))
}


code:
import { Selector as $ } from 'testcafe'
import { getUrl, logInToInstanceLink, settingsButton } from '../utils'

fixture`001-basic-spec.js`
  .page`http://localhost:4002`

test('has the correct <h1>', async t => {
  await t
    .expect($('.main-content h1').innerText).eql('Pinafore')
})

test('navigates to about', async t => {
  await t
    .click(settingsButton)
    .expect(getUrl()).contains('/settings')
    .click('a[href="/settings/about"]')
    .expect(getUrl()).contains('/about')
    .expect($('.main-content h1').innerText).eql('About Pinafore')
})

test('navigates to /settings/instances/add', async t => {
  await t.click(logInToInstanceLink)
    .expect(getUrl()).contains('/settings/instances/add')
})

test('navigates to settings/instances', async t => {
  await t.click(settingsButton)
    .expect(getUrl()).contains('/settings')
    .click($('a').withText('Instances'))
    .expect(getUrl()).contains('/settings/instances')
    .expect($('.main-content').innerText)
    .contains("You're not logged in to any instances")
})


code:
import { Selector as $ } from 'testcafe'
import {
  addInstanceButton,
  authorizeInput, confirmationDialogOKButton,
  emailInput,
  formError,
  getFirstVisibleStatus, getNthStatus, getOpacity,
  getUrl,
  homeNavButton,
  instanceInput,
  logInToInstanceLink,
  mastodonLogInButton,
  passwordInput, reload,
  settingsButton,
  sleep
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`002-login-spec.js`
  .page`http://localhost:4002`

function manualLogin (t, username, password) {
  return t.click(logInToInstanceLink)
    .expect(getUrl()).contains('/settings/instances/add')
    .typeText(instanceInput, 'localhost:3000')
    .click(addInstanceButton)
    .expect(getUrl()).eql('http://localhost:3000/auth/sign_in', { timeout: 30000 })
    .typeText(emailInput, username, { paste: true })
    .typeText(passwordInput, password, { paste: true })
    .click(mastodonLogInButton)
    .expect(getUrl()).contains('/oauth/authorize')
    .click(authorizeInput)
    .expect(getUrl()).eql('http://localhost:4002/')
}

test('Cannot log in to a fake instance', async t => {
  await sleep(500)
  await t.click(logInToInstanceLink)
    .expect(getUrl()).contains('/settings/instances/add')
    .typeText(instanceInput, 'fake.nolanlawson.com', { paste: true })
    .click(addInstanceButton)
    .expect(formError.exists).ok()
    .expect(formError.innerText).contains('Is this a valid Mastodon instance?')
    .typeText(instanceInput, '.biz', { paste: true })
    .expect(formError.exists).notOk()
    .typeText(instanceInput, 'fake.nolanlawson.com', { paste: true, replace: true })
    .expect(formError.exists).ok()
    .expect(formError.innerText).contains('Is this a valid Mastodon instance?')
})

test('Logs in and logs out of localhost:3000', async t => {
  await sleep(500)
  await manualLogin(t, 'foobar@localhost:3000', 'foobarfoobar')
    .expect(getUrl()).eql('http://localhost:4002/')
    .hover(getFirstVisibleStatus())
    .expect($('article.status-article').exists).ok()
    .click(settingsButton)
    .click($('a').withText('Instances'))
    .click($('a').withText('localhost:3000'))
    .expect(getUrl()).contains('/settings/instances/localhost:3000')
    .expect($('.instance-name-h1').innerText).eql('localhost:3000')
    .expect($('.acct-handle').innerText).eql('@foobar')
    .expect($('.acct-display-name').innerText).eql('foobar')
    .click($('button').withText('Log out'))
    .click(confirmationDialogOKButton)
    .expect($('.main-content').innerText).contains("You're not logged in to any instances")
    .click(homeNavButton)
    // check that the "hidden from SSR" content is visible
    .expect(getOpacity('.hidden-from-ssr')()).eql('1')
    .navigateTo('/')
    .expect(getOpacity('.hidden-from-ssr')()).eql('1')
  await reload()
  await t
    .expect(getOpacity('.hidden-from-ssr')()).eql('1')
})

test('Logs in, refreshes, then logs out', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .hover(getNthStatus(1))
  await reload()
  await t
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .hover(getNthStatus(1))
    .click(settingsButton)
    .click($('a').withText('Instances'))
    .click($('a').withText('localhost:3000'))
    .expect(getUrl()).contains('/settings/instances/localhost:3000')
    .expect($('.instance-name-h1').innerText).eql('localhost:3000')
    .expect($('.acct-handle').innerText).eql('@foobar')
    .expect($('.acct-display-name').innerText).eql('foobar')
    .click($('button').withText('Log out'))
    .click(confirmationDialogOKButton)
    .expect($('.main-content').innerText).contains("You're not logged in to any instances")
    .click(homeNavButton)
    .expect(getOpacity('.hidden-from-ssr')()).eql('1')
})


code:
import { Selector as $ } from 'testcafe'
import {
  communityNavButton,
  getFirstVisibleStatus, getNthStatus, getUrl, localTimelineNavButton, notificationsNavButton,
  validateTimeline
} from '../utils'
import { homeTimeline, notifications, localTimeline, favorites, directMessages } from '../fixtures'
import { loginAsFoobar } from '../roles'

fixture`003-basic-timeline-spec.js`
  .page`http://localhost:4002`

test('Shows the home timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .hover(getNthStatus(1))
    .expect(getFirstVisibleStatus().exists).ok()
    .expect(getFirstVisibleStatus().hasAttribute('aria-setsize')).ok()
    .expect(getFirstVisibleStatus().getAttribute('aria-posinset')).eql('1')

  await validateTimeline(t, homeTimeline)

  await t.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('47')
})

test('Shows notifications', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .click(notificationsNavButton)
    .expect(getUrl()).contains('/notifications')

  await validateTimeline(t, notifications)
})

test('Shows the local timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .click(localTimelineNavButton)
    .expect(getUrl()).contains('/local')

  await validateTimeline(t, localTimeline)
})

test('Shows the federated timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .click(communityNavButton)
    .expect(getUrl()).contains('/community')
    .click($('a').withText('Federated'))
    .expect(getUrl()).contains('/federated')

  await validateTimeline(t, localTimeline) // local is same as federated in this case
})

test('Shows favorites', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .click(communityNavButton)
    .expect(getUrl()).contains('/community')
    .click($('a').withText('Favorites'))
    .expect(getUrl()).contains('/favorites')

  await validateTimeline(t, favorites)
})

test('Shows direct messages', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok({ timeout: 30000 })
    .click(communityNavButton)
    .click($('a').withText('Direct messages'))
    .expect(getUrl()).contains('/direct')

  await validateTimeline(t, directMessages)
})


code:
import { Selector as $ } from 'testcafe'
import { communityNavButton, getNthPinnedStatus, getUrl } from '../utils'
import { loginAsFoobar } from '../roles'

fixture`004-pinned-statuses.js`
  .page`http://localhost:4002`

test("shows a user's pinned statuses", async t => {
  await loginAsFoobar(t)
  await t
    .click(communityNavButton)
    .expect(getUrl()).contains('/community')
    .click($('a[href="/pinned"]'))
    .expect(getUrl()).contains('/pinned')
    .expect($('.status-article').getAttribute('aria-posinset')).eql('1')
    .expect($('.status-article').getAttribute('aria-setsize')).eql('1')
    .expect($('.status-article .status-content').innerText).contains('this is unlisted')
})

test("shows pinned statuses on a user's account page", async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/accounts/2')
    .expect(getNthPinnedStatus(1).getAttribute('aria-posinset')).eql('1')
    .expect(getNthPinnedStatus(1).getAttribute('aria-setsize')).eql('1')
    .expect(getNthPinnedStatus(1).innerText).contains('this is unlisted')
})

test("shows pinned statuses on a user's account page 2", async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/accounts/3')
    .expect(getNthPinnedStatus(1).getAttribute('aria-posinset')).eql('1')
    .expect(getNthPinnedStatus(1).getAttribute('aria-setsize')).eql('2')
    .expect(getNthPinnedStatus(1).innerText).contains('pinned toot 1')
    .expect(getNthPinnedStatus(2).getAttribute('aria-setsize')).eql('2')
    .expect(getNthPinnedStatus(2).innerText).contains('pinned toot 2')
})


code:
import { getNthStatusSelector } from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'

fixture`005-status-types.js`
  .page`http://localhost:4002`

test('shows followers-only vs regular in home timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect($(`${getNthStatusSelector(2)} .status-content`).innerText).contains('notification of unlisted message')
    .expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
    .eql('Boost')
    .expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk()
    .expect($(`${getNthStatusSelector(3)} .status-content`).innerText).contains('notification of followers-only message')
    .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
    .eql('Cannot be boosted because this is followers-only')
    .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok()
})

test('shows direct vs followers-only vs regular in notifications', async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/notifications')
    .expect($(`${getNthStatusSelector(3)} .status-content`).innerText).contains('notification of unlisted message')
    .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
    .eql('Boost')
    .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk()
    .expect($(`${getNthStatusSelector(4)} .status-content`).innerText).contains('notification of followers-only message')
    .expect($(`${getNthStatusSelector(4)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
    .eql('Cannot be boosted because this is followers-only')
    .expect($(`${getNthStatusSelector(4)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok()
    .expect($(`${getNthStatusSelector(5)} .status-content`).innerText).contains('notification of direct message')
    .expect($(`${getNthStatusSelector(5)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
    .eql('Cannot be boosted because this is a direct message')
    .expect($(`${getNthStatusSelector(5)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok()
})


code:
import { Selector as $ } from 'testcafe'
import { getNthStatus } from '../utils'
import { loginAsFoobar } from '../roles'

fixture`006-tabindex.js`
  .page`http://localhost:4002`

test('shows correct tabindex in home timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getNthStatus(1).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(2).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(3).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(4).getAttribute('tabindex')).eql('0')
})

test('shows correct tabindex in notifications', async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/notifications')
    .expect(getNthStatus(1).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(2).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(3).getAttribute('tabindex')).eql('0')
    .hover(getNthStatus(3))
    .expect(getNthStatus(4).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(5).getAttribute('tabindex')).eql('0')
    .hover(getNthStatus(5))
    .expect(getNthStatus(6).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(7).getAttribute('tabindex')).eql('0')
    .hover(getNthStatus(7))
    .expect(getNthStatus(8).getAttribute('tabindex')).eql('0')
    .expect(getNthStatus(8).getAttribute('aria-setsize')).eql('8')
})

test('shows correct tabindex in pinned statuses', async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/pinned')
    .expect($('.status-article').getAttribute('tabindex')).eql('0')
    .expect($('.status-article').getAttribute('aria-posinset')).eql('1')
    .expect($('.status-article').getAttribute('aria-setsize')).eql('1')
})


code:
import { Selector as $ } from 'testcafe'
import {
  accountProfileFollowButton,
  accountProfileFollowedBy, accountProfileName, accountProfileUsername, getUrl,
  validateTimeline
} from '../utils'
import { loginAsFoobar } from '../roles'
import { quuxStatuses } from '../fixtures'

fixture`007-account-profile.js`
  .page`http://localhost:4002`

test('shows account profile', async t => {
  await loginAsFoobar(t)
  await t
    .click($('.status-author-name').withText(('quux')))
    .expect(getUrl()).contains('/accounts/3')
    .expect(accountProfileName.innerText).contains('quux')
    .expect(accountProfileUsername.innerText).contains('@quux')
    .expect(accountProfileFollowedBy.innerText).match(/follows you/i)
    .expect(accountProfileFollowButton.getAttribute('aria-label')).eql('Follow')
    .expect(accountProfileFollowButton.getAttribute('aria-pressed')).eql('false')
})

test('shows account profile 2', async t => {
  await loginAsFoobar(t)
  await t
    .click($('.status-author-name').withText(('admin')))
    .expect(getUrl()).contains('/accounts/1')
    .expect(accountProfileName.innerText).contains('admin')
    .expect(accountProfileUsername.innerText).contains('@admin')
    .expect(accountProfileFollowedBy.innerText).match(/follows you/i)
    .expect(accountProfileFollowButton.getAttribute('aria-label')).eql('Unfollow')
    .expect(accountProfileFollowButton.getAttribute('aria-pressed')).eql('true')
})

test('shows account profile 3', async t => {
  await loginAsFoobar(t)
  await t
    .click($('.mention').withText(('foobar')))
    .expect(getUrl()).contains('/accounts/2')
    .expect(accountProfileName.innerText).contains('foobar')
    .expect(accountProfileUsername.innerText).contains('@foobar')
    // can't follow or be followed by your own account
    .expect(accountProfileFollowedBy.innerText).eql('')
    .expect($('.account-profile .account-profile-follow').innerText).eql('')
})

test('shows account profile statuses', async t => {
  await loginAsFoobar(t)
  await t
    .click($('.status-author-name').withText(('quux')))
    .expect(getUrl()).contains('/accounts/3')
    .expect($('.pinned-statuses .status-article').getAttribute('aria-setsize')).eql('2')
    .expect($('.pinned-statuses .status-article').getAttribute('aria-posinset')).eql('1')
    .expect($('.timeline .status-article').getAttribute('aria-posinset')).eql('1')
  await validateTimeline(t, quuxStatuses)
  await t.expect($('.timeline .status-article').getAttribute('aria-setsize')).eql('27')
})


code:
import { closeDialogButton, getNthStatus, getNthStatusSelector, modalDialogContents, scrollToStatus } from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
import { homeTimeline } from '../fixtures'

fixture`008-status-media.js`
  .page`http://localhost:4002`

test('shows sensitive images and videos', async t => {
  await loginAsFoobar(t)

  let kittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  let videoIdx = homeTimeline.findIndex(_ => _.content === 'secret video')

  await scrollToStatus(t, 1 + kittenIdx)
  await t.expect($(`${getNthStatusSelector(1 + kittenIdx)} .status-media img`).exists).notOk()
    .click($(`${getNthStatusSelector(1 + kittenIdx)} .status-sensitive-media-button`))
    .expect($(`${getNthStatusSelector(1 + kittenIdx)} .status-media img`).getAttribute('alt')).eql('kitten')
    .expect($(`${getNthStatusSelector(1 + kittenIdx)} .status-media img`).hasAttribute('src')).ok()
    .hover(getNthStatus(1 + videoIdx))
    .expect($(`${getNthStatusSelector(1 + videoIdx)} .status-media .play-video-button`).exists).notOk()
    .click($(`${getNthStatusSelector(1 + videoIdx)} .status-sensitive-media-button`))
    .expect($(`${getNthStatusSelector(1 + videoIdx)} .status-media .play-video-button`).exists).ok()
})

test('click and close image and video modals', async t => {
  await loginAsFoobar(t)

  let videoIdx = homeTimeline.findIndex(_ => _.content === "here's a video")
  let kittenIdx = homeTimeline.findIndex(_ => _.content === "here's an animated kitten gif")

  await scrollToStatus(t, 1 + videoIdx)
  await t.expect(modalDialogContents.exists).notOk()
    .click($(`${getNthStatusSelector(1 + videoIdx)} .play-video-button`))
    .expect(modalDialogContents.exists).ok()
    .expect($('.modal-dialog video').getAttribute('src')).contains('mp4')
    .expect($('.modal-dialog video').getAttribute('poster')).contains('png')
    .click(closeDialogButton)
    .expect(modalDialogContents.exists).notOk()
    .hover(getNthStatus(1 + kittenIdx - 1))
    .hover(getNthStatus(1 + kittenIdx))
    .click($(`${getNthStatusSelector(1 + kittenIdx)} .show-image-button`))
    .expect(modalDialogContents.exists).ok()
    .expect($('.modal-dialog video').getAttribute('src')).contains('mp4')
    .expect($('.modal-dialog video').getAttribute('poster')).contains('png')
    .click(closeDialogButton)
    .expect(modalDialogContents.exists).notOk()
})


code:
import { Selector as $ } from 'testcafe'
import {
  getNthStatus, getUrl, validateTimeline, getFirstVisibleStatus,
  goBack, forceOffline, forceOnline, searchNavButton, searchInput, getNthSearchResult, scrollToStatus
} from '../utils'
import { loginAsFoobar } from '../roles'
import { bazThreadRelativeTo2, bazThreadRelativeTo2b, bazThreadRelativeTo2B2, quuxThread } from '../fixtures'

fixture`009-threads.js`
  .page`http://localhost:4002`

test('Shows a thread', async t => {
  await loginAsFoobar(t)
  await t
    .click($('a').withText('quux'))

  await scrollToStatus(t, 27)
  await t
    .hover(getNthStatus(27))
    .click(getNthStatus(27))
    .expect(getUrl()).contains('/statuses/')

  await validateTimeline(t, quuxThread)

  await t.expect(getNthStatus(25).getAttribute('aria-setsize')).eql('25')
})

test('Scrolls to proper point in thread', async t => {
  await loginAsFoobar(t)
  await t
    .click($('a').withText('quux'))
    .hover(getNthStatus(1))
    .hover(getNthStatus(3))
    .hover(getNthStatus(5))
    .hover(getNthStatus(7))
    .hover(getNthStatus(9))
    .hover(getNthStatus(11))
    .click(getNthStatus(11))
    .expect(getUrl()).contains('/statuses/')
    .expect(getNthStatus(17).innerText).contains('unlisted thread 17')
    .expect(Math.round(getNthStatus(17).boundingClientRect.top))
    .eql(Math.round($('.main-content').boundingClientRect.top))
})

async function navigateToBazAccount (t) {
  await t.click(searchNavButton)
    .expect(getUrl()).contains('/search')
    .typeText(searchInput, 'baz', { paste: true })
    .pressKey('enter')
    .click(getNthSearchResult(1))
    .expect(getUrl()).contains('/accounts/5')
}

async function validateForkedThread (t) {
  await t.hover(getNthStatus(2))
    .click(getNthStatus(3))
    .expect(getUrl()).contains('/statuses')
  await validateTimeline(t, bazThreadRelativeTo2B2)
  await goBack()
  await t.hover(getNthStatus(4))
    .hover(getNthStatus(6))
    .hover(getNthStatus(8))
    .hover(getNthStatus(10))
    .click(getNthStatus(10))
    .expect(getUrl()).contains('/statuses')
  await validateTimeline(t, bazThreadRelativeTo2b)
  await goBack()
  await t.hover(getNthStatus(12))
    .click(getNthStatus(12))
    .expect(getUrl()).contains('/statuses')
  await validateTimeline(t, bazThreadRelativeTo2)
}

test('Forked threads look correct online and offline', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getFirstVisibleStatus())
  await navigateToBazAccount(t)
  await validateForkedThread(t)
  await t.navigateTo('/') // clears in-memory cache while still preserving IDB cache
    .hover(getFirstVisibleStatus())
  await navigateToBazAccount(t)
  await forceOffline()
  await validateForkedThread(t)
  await forceOnline()
})


code:
import {
  getNthStatus, scrollToStatus, closeDialogButton, modalDialogContents, goBack, getUrl,
  goBackButton, getActiveElementInnerText, getNthReplyButton, getActiveElementInsideNthStatus, focus,
  getNthStatusSelector, getActiveElementTagName, getActiveElementClassList
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'

import { homeTimeline } from '../fixtures'

fixture`010-focus.js`
  .page`http://localhost:4002`

test('modal preserves focus', async t => {
  await loginAsFoobar(t)

  let idx = homeTimeline.findIndex(_ => _.content === "here's a video")

  await scrollToStatus(t, 1 + idx)
  // explicitly hover-focus-click
  await t.hover($(`${getNthStatusSelector(1 + idx)} .play-video-button`))
  await focus(`${getNthStatusSelector(1 + idx)} .play-video-button`)()
  await t.click($(`${getNthStatusSelector(1 + idx)} .play-video-button`))
    .click(closeDialogButton)
    .expect(modalDialogContents.exists).notOk()
    .expect(getActiveElementClassList()).contains('play-video-button')
    .expect(getActiveElementInsideNthStatus()).eql((idx + 1).toString())
})

test('timeline preserves focus', async t => {
  await loginAsFoobar(t)
  // explicitly hover-focus-click
  await t.hover(getNthStatus(1))
  await focus(getNthStatusSelector(1))()
  await t.click(getNthStatus(1))
    .expect(getUrl()).contains('/statuses/')

  await goBack()
  await t.expect(getUrl()).eql('http://localhost:4002/')
    .expect(getActiveElementClassList()).contains('status-article')
    .expect(getActiveElementClassList()).contains('status-in-timeline')
    .expect(getActiveElementInsideNthStatus()).eql('1')
})

test('timeline link preserves focus', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getNthStatus(1).exists).ok({ timeout: 20000 })
    .click($(`${getNthStatusSelector(1)} .status-header a`))
    .expect(getUrl()).contains('/accounts/')
    .click(goBackButton)
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getNthStatus(1).exists).ok()
    .expect(getActiveElementInnerText()).eql('admin')
    .click($(`${getNthStatusSelector(1)} .status-sidebar`))
    .expect(getUrl()).contains('/accounts/')
    .click(goBackButton)
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(getActiveElementClassList()).contains('status-sidebar')
    .expect(getActiveElementInsideNthStatus()).eql('1')
})

test('notification timeline preserves focus', async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/notifications')
  await scrollToStatus(t, 6)
  await t.click($(`${getNthStatusSelector(6)} .status-header a`))
    .expect(getUrl()).contains('/accounts/')
    .click(goBackButton)
    .expect(getUrl()).contains('/notifications')
    .expect(getNthStatus(1).exists).ok()
    .expect(getActiveElementInnerText()).eql('quux')
    .expect(getActiveElementInsideNthStatus()).eql('6')
})

test('thread preserves focus', async t => {
  const timeout = 30000

  await loginAsFoobar(t)
  await t
    .navigateTo('/accounts/3')
    .expect(getNthStatus(1).exists).ok({ timeout })
    .hover(getNthStatus(1))
  await scrollToStatus(t, 3)
  await t.click(getNthStatus(3))
    .expect(getUrl()).contains('/statuses/')
    .click($(`${getNthStatusSelector(25)} .status-sidebar`))
    .expect(getUrl()).contains('/accounts/')
    .click(goBackButton)
    .expect(getUrl()).contains('/statuses/')
    .expect(getNthStatus(25).exists).ok()
    .expect(getActiveElementClassList()).contains('status-sidebar')
    .expect(getActiveElementInsideNthStatus()).eql('25')
    .hover(getNthStatus(24))
    .click(getNthStatus(24))
    .expect($(`${getNthStatusSelector(24)} .status-absolute-date`).exists).ok({ timeout })
  await goBack()
  await t.expect($(`${getNthStatusSelector(25)} .status-absolute-date`).exists).ok({ timeout })
    .expect(getActiveElementClassList()).contains('status-article', { timeout })
    .expect(getActiveElementClassList()).contains('status-in-timeline', { timeout })
    .expect(getActiveElementInsideNthStatus()).eql('24', { timeout })
})

test('reply preserves focus and moves focus to the text input', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getNthStatus(2).exists).ok({ timeout: 20000 })
    .click(getNthReplyButton(2))
    .expect(getActiveElementClassList()).contains('compose-box-input')
})

test('focus main content element on index page load', async t => {
  await t.expect(getActiveElementTagName()).match(/body/i)
})


code:
import { Selector as $ } from 'testcafe'
import {
  favoritesCountElement, getFavoritesCount, getNthStatus, getReblogsCount, getUrl,
  reblogsCountElement
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`011-reblog-favorites-count.js`
  .page`http://localhost:4002`

test('shows favorites', async t => {
  await loginAsFoobar(t)
  await t
    .click(getNthStatus(1))
    .expect(getUrl()).contains('/statuses/')
    .expect(getFavoritesCount()).eql(2)
    .expect(favoritesCountElement.getAttribute('aria-label')).eql('Favorited 2 times')
    .expect($('.icon-button[aria-label="Favorite"]').getAttribute('aria-pressed')).eql('true')
    .click(favoritesCountElement)
    .expect(getUrl()).match(/\/statuses\/[^/]+\/favorites/)
    .expect($('.search-result-account-name').nth(0).innerText).eql('foobar')
    .expect($('.search-result-account-username').nth(0).innerText).eql('@foobar')
    .expect($('.search-result-account-name').nth(1).innerText).eql('admin')
    .expect($('.search-result-account-username').nth(1).innerText).eql('@admin')
})

test('shows boosts', async t => {
  await loginAsFoobar(t)
  await t
    .click(getNthStatus(1))
    .expect(getUrl()).contains('/statuses/')
    .expect(getReblogsCount()).eql(1)
    .expect(reblogsCountElement.getAttribute('aria-label')).eql('Boosted 1 time')
    .expect($('.icon-button[aria-label="Boost"]').getAttribute('aria-pressed')).eql('false')
    .click(reblogsCountElement)
    .expect(getUrl()).match(/\/statuses\/[^/]+\/reblogs/)
    .expect($('.search-result-account-name').nth(0).innerText).eql('admin')
    .expect($('.search-result-account-username').nth(0).innerText).eql('@admin')
})


code:
import {
  composeButton, composeInput, composeLengthIndicator, emojiButton, emojiSearchInput, getComposeSelectionStart,
  getNthStatusContent, getUrl,
  homeNavButton, modalDialog,
  notificationsNavButton, sleep,
  times
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`012-compose.js`
  .page`http://localhost:4002`

test('shows compose limits', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .expect(composeLengthIndicator.innerText).eql('500')
    .expect(composeButton.hasAttribute('disabled')).notOk()
    .typeText(composeInput, 'typing some text')
    .expect(composeLengthIndicator.innerText).eql('484')
    .expect(composeButton.hasAttribute('disabled')).notOk()
    .typeText(composeInput, times(50, () => 'hello world').join(' '), { replace: true, paste: true })
    .expect(composeLengthIndicator.innerText).eql('-99')
    .expect(composeButton.getAttribute('disabled')).eql('')
    .typeText(composeInput, 'hello world', { replace: true })
    .click(notificationsNavButton)
    .expect(getUrl()).contains('/notifications')
    .click(homeNavButton)
    .expect(getUrl()).eql('http://localhost:4002/')
    .expect(composeInput.value).eql('hello world')
    .expect(composeLengthIndicator.innerText).eql('489')
    .expect(composeButton.hasAttribute('disabled')).notOk()
    .selectText(composeInput)
    .pressKey('delete')
    .expect(composeInput.value).eql('')
    .expect(composeLengthIndicator.innerText).eql('500')
    .expect(composeButton.hasAttribute('disabled')).notOk()
})

test('shows compose limits for URLs/handles', async t => {
  await loginAsFoobar(t)
  await t
    .expect(composeLengthIndicator.innerText).eql('500')
    .expect(composeButton.hasAttribute('disabled')).notOk()
    .typeText(composeInput, 'hello world ' +
      'http://foo.bar.baz.whatever.example.com/hello ' +
      '@reallylongnamethatstretchesonandon@foo.example.com', { paste: true })
    .expect(composeLengthIndicator.innerText).eql('429')
    .expect(composeButton.hasAttribute('disabled')).notOk()
})

test('shows compose limits for emoji', async t => {
  await loginAsFoobar(t)
  await t
    .typeText(composeInput, 'hello world \ud83c\ude01 \ud83d\udc6a')
    .expect(composeLengthIndicator.innerText).eql('485')
    .expect(composeButton.hasAttribute('disabled')).notOk()
})

test('shows compose limits for custom emoji', async t => {
  await loginAsFoobar(t)
  await t
    .typeText(composeInput, 'hello world ')
    .click(emojiButton)
    .typeText(emojiSearchInput, 'blobnom')
    .pressKey('enter')
    .expect(composeInput.value).eql('hello world :blobnom: ')
    .expect(composeLengthIndicator.innerText).eql('478')
})

test('inserts custom emoji correctly', async t => {
  await loginAsFoobar(t)
  await t
    .typeText(composeInput, 'hello world')
    .selectText(composeInput, 6, 6)
    .expect(getComposeSelectionStart()).eql(6)
    .click(emojiButton)
    .typeText(emojiSearchInput, 'blobpats')
    .pressKey('enter')
    .expect(composeInput.value).eql('hello :blobpats: world')
    .selectText(composeInput, 0, 0)
    .expect(getComposeSelectionStart()).eql(0)
    .click(emojiButton)
    .typeText(emojiSearchInput, 'blobnom')
    .pressKey('enter')
    .expect(composeInput.value).eql(':blobnom: hello :blobpats: world')
    .typeText(composeInput, ' foobar ')
    .click(emojiButton)
    .typeText(emojiSearchInput, 'blobpeek')
    .pressKey('enter')
    .expect(composeInput.value).eql(':blobnom: hello :blobpats: world foobar :blobpeek: ')
})

test('inserts emoji without typing anything', async t => {
  await loginAsFoobar(t)
  await sleep(1000)
  await t
    .click(emojiButton)
    .click(modalDialog.find('button[aria-label="blobpats"]'))
    .expect(composeInput.value).eql(':blobpats: ')
  await sleep(1000)
  await t
    .click(emojiButton)
    .click(modalDialog.find('button[aria-label="blobpeek"]'))
    .expect(composeInput.value).eql(':blobpeek: :blobpats: ')
})

test('inserts native emoji without typing anything', async t => {
  await loginAsFoobar(t)
  await t
    .click(emojiButton)
  await sleep(1000)
  await t
    .expect(emojiSearchInput.value).eql('')
    .typeText(emojiSearchInput, 'pineapple', { paste: true })
  await sleep(1000)
  await t
    .pressKey('enter')
    .expect(composeInput.value).eql('\ud83c\udf4d ')
    .click(emojiButton)
  await sleep(1000)
  await t
    .expect(emojiSearchInput.value).eql('')
    .typeText(emojiSearchInput, 'elephant', { paste: true })
  await sleep(1000)
  await t
    .pressKey('enter')
    .expect(composeInput.value).eql('\ud83d\udc18 \ud83c\udf4d ')
})

test('cannot post an empty status', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getNthStatusContent(1).innerText).contains('pinned toot 1')
    .click(composeButton)
  await sleep(2)
  await t
    .expect(getNthStatusContent(1).innerText).contains('pinned toot 1')
})


code:
import {
  composeInput, getNthDeleteMediaButton, getNthMedia, getNthMediaAltInput, homeNavButton, mediaButton,
  settingsNavButton, sleep,
  uploadKittenImage
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`013-compose-media.js`
  .page`http://localhost:4002`

test('inserts media', async t => {
  await loginAsFoobar(t)
  await t
    .expect(mediaButton.hasAttribute('disabled')).notOk()
  await (uploadKittenImage(1)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
  await (uploadKittenImage(2)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .expect(mediaButton.hasAttribute('disabled')).notOk()
  await (uploadKittenImage(3)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .expect(getNthMedia(3).getAttribute('alt')).eql('kitten3.jpg')
    .expect(mediaButton.hasAttribute('disabled')).notOk()
  await (uploadKittenImage(4)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .expect(getNthMedia(3).getAttribute('alt')).eql('kitten3.jpg')
    .expect(getNthMedia(4).getAttribute('alt')).eql('kitten4.jpg')
    .expect(mediaButton.getAttribute('disabled')).eql('')
    .click(getNthDeleteMediaButton(4))
    .click(getNthDeleteMediaButton(3))
    .click(getNthDeleteMediaButton(2))
    .click(getNthDeleteMediaButton(1))
    .expect(mediaButton.hasAttribute('disabled')).notOk()
})

test('removes media', async t => {
  await loginAsFoobar(t)
  await t
    .expect(mediaButton.exists).ok()
  await (uploadKittenImage(1)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
  await (uploadKittenImage(2)())
  await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .click(getNthDeleteMediaButton(2))
    .expect(getNthMedia(2).exists).notOk()
    .expect(getNthMedia(1).exists).ok()
    .click(getNthDeleteMediaButton(1))
    .expect(getNthMedia(2).exists).notOk()
})

test('does not add URLs as media is added/removed', async t => {
  await loginAsFoobar(t)
  await t
    .typeText(composeInput, 'this is a toot')
    .expect(mediaButton.exists).ok()
  await (uploadKittenImage(1)())
  await t.expect(composeInput.value).eql('this is a toot')
  await (uploadKittenImage(1)())
  await t.expect(composeInput.value).eql('this is a toot')
    .click(getNthDeleteMediaButton(1))
    .expect(composeInput.value).eql('this is a toot')
    .click(getNthDeleteMediaButton(1))
    .expect(composeInput.value).eql('this is a toot')
})

test('keeps media descriptions as media is removed', async t => {
  await loginAsFoobar(t)
  await t
    .expect(mediaButton.exists).ok()
  await (uploadKittenImage(1)())
  await t
    .typeText(getNthMediaAltInput(1), 'kitten numero uno')
  await (uploadKittenImage(2)())
  await t
    .typeText(getNthMediaAltInput(2), 'kitten numero dos')
    .expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
    .expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
    .expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .click(getNthDeleteMediaButton(1))
    .expect(getNthMediaAltInput(1).value).eql('kitten numero dos')
    .expect(getNthMedia(1).getAttribute('alt')).eql('kitten2.jpg')
})

test('keeps media in local storage', async t => {
  await loginAsFoobar(t)
  await t
    .expect(mediaButton.exists).ok()
  await (uploadKittenImage(1)())
  await t
    .typeText(getNthMediaAltInput(1), 'kitten numero uno')
  await (uploadKittenImage(2)())
  await t
    .typeText(getNthMediaAltInput(2), 'kitten numero dos')
  await t
    .typeText(composeInput, 'hello hello')
    .expect(composeInput.value).eql('hello hello')
    .expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
    .expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
    .expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
  await sleep(1)
  await t
    .click(settingsNavButton)
    .click(homeNavButton)
    .expect(composeInput.value).eql('hello hello')
    .expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
    .expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
    .expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
    .navigateTo('/')
    .expect(composeInput.value).eql('hello hello')
    .expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
    .expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
    .expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
    .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
})


code:
import {
  composeButton,
  composeModalPostPrivacyButton,
  getNthPostPrivacyOptionInDialog,
  postPrivacyButton, postPrivacyDialogButtonUnlisted,
  scrollToStatus,
  sleep
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`014-compose-post-privacy.js`
  .page`http://localhost:4002`

test('Changes post privacy', async t => {
  await loginAsFoobar(t)
  await t
    .expect(postPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
    .click(postPrivacyButton)
    .click(getNthPostPrivacyOptionInDialog(2))
    .expect(postPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Unlisted)')
    .click(postPrivacyButton)
    .click(getNthPostPrivacyOptionInDialog(1))
    .expect(postPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
})

test('can use privacy dialog within compose dialog', async t => {
  await loginAsFoobar(t)
  await scrollToStatus(t, 16)
  await t.expect(composeButton.getAttribute('aria-label')).eql('Compose')
  await sleep(2000)
  await t.click(composeButton)
    .click(composeModalPostPrivacyButton)
    .click(postPrivacyDialogButtonUnlisted)
    .expect(composeModalPostPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Unlisted)')
})


code:
import {
  composeContentWarning, composeInput, composeLengthIndicator, contentWarningButton, homeNavButton,
  notificationsNavButton
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`015-compose-content-warnings.js`
  .page`http://localhost:4002`

test('Changes content warnings', async t => {
  await loginAsFoobar(t)
  await t
    .expect(composeContentWarning.exists).notOk()
    .expect(contentWarningButton.getAttribute('aria-label')).eql('Add content warning')
    .expect(contentWarningButton.getAttribute('aria-pressed')).eql('false')
    .click(contentWarningButton)
    .expect(composeContentWarning.exists).ok()
    .expect(contentWarningButton.getAttribute('aria-label')).eql('Remove content warning')
    .expect(contentWarningButton.getAttribute('aria-pressed')).eql('true')
    .typeText(composeContentWarning, 'hello content warning', { paste: true })
    .typeText(composeInput, 'secret text', { paste: true })
    .click(notificationsNavButton)
    .click(homeNavButton)
    .expect(contentWarningButton.getAttribute('aria-label')).eql('Remove content warning')
    .expect(contentWarningButton.getAttribute('aria-pressed')).eql('true')
    .expect(composeContentWarning.value).eql('hello content warning')
    .expect(composeInput.value).eql('secret text')
    .selectText(composeInput)
    .pressKey('delete')
    .selectText(composeContentWarning)
    .pressKey('delete')
    .expect(composeContentWarning.value).eql('')
    .expect(composeInput.value).eql('')
    .click(contentWarningButton)
    .expect(composeContentWarning.exists).notOk()
    .expect(contentWarningButton.getAttribute('aria-label')).eql('Add content warning')
    .expect(contentWarningButton.getAttribute('aria-pressed')).eql('false')
})

test('Considers content warnings for length limits', async t => {
  await loginAsFoobar(t)
  await t
    .expect(composeLengthIndicator.innerText).eql('500')
    .click(contentWarningButton)
    .typeText(composeContentWarning, 'my content warning', { paste: true })
    .expect(composeLengthIndicator.innerText).eql('482')
    .typeText(composeInput, 'secret text', { paste: true })
    .expect(composeLengthIndicator.innerText).eql('471')
    .selectText(composeContentWarning)
    .pressKey('delete')
    .expect(composeLengthIndicator.innerText).eql('489')
    .selectText(composeInput)
    .pressKey('delete')
    .expect(composeLengthIndicator.innerText).eql('500')
})

test('Content warning goes away if you hide it', async t => {
  await loginAsFoobar(t)
  await t
    .click(contentWarningButton)
    .expect(composeContentWarning.value).eql('')
    .typeText(composeContentWarning, 'yo', { paste: true })
    .expect(composeContentWarning.value).eql('yo')
    .expect(composeLengthIndicator.innerText).eql('498')
    .click(contentWarningButton)
    .expect(composeContentWarning.exists).notOk()
    .expect(composeLengthIndicator.innerText).eql('500')
    .click(contentWarningButton)
    .expect(composeContentWarning.value).eql('')
    .expect(composeLengthIndicator.innerText).eql('500')
})


code:
import { getNthStatus, getNthStatusSelector, getUrl } from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'

fixture`016-external-links.js`
  .page`http://localhost:4002`

function getAnchor (nthStatus, nthAnchor) {
  return $(`${getNthStatusSelector(1 + nthStatus)} .status-content a`).nth(nthAnchor)
}

function getAnchorInProfile (n) {
  return $('.account-profile-note a').nth(n)
}

test('converts external links in statuses', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .navigateTo('/accounts/4')
    .expect(getUrl()).contains('/accounts/4')
    .expect(getAnchor(0, 0).getAttribute('href')).eql('/accounts/1')
    .expect(getAnchor(0, 1).getAttribute('href')).eql('/accounts/3')
    .expect(getAnchor(1, 0).getAttribute('href')).eql('https://joinmastodon.org')
    .expect(getAnchor(1, 0).getAttribute('title')).eql('https://joinmastodon.org')
    .expect(getAnchor(1, 0).getAttribute('rel')).eql('nofollow noopener')
    .expect(getAnchor(1, 1).getAttribute('href')).eql('https://github.com/tootsuite/mastodon')
    .expect(getAnchor(1, 1).getAttribute('title')).eql('https://github.com/tootsuite/mastodon')
    .expect(getAnchor(1, 1).getAttribute('rel')).eql('nofollow noopener')
    .expect(getAnchor(2, 0).getAttribute('href')).eql('/tags/kitten')
    .expect(getAnchor(2, 1).getAttribute('href')).eql('/tags/kitties')
})

test('converts external links in profiles', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .navigateTo('/accounts/4')
    .expect(getUrl()).contains('/accounts/4')
    .expect($('.account-profile-name').innerText).contains('External Lonk')
    .expect($('.account-profile-name a').getAttribute('href')).eql('http://localhost:3000/@ExternalLinks')
    .expect($('.account-profile-name a').getAttribute('rel')).eql('nofollow noopener')
    .expect(getAnchorInProfile(0).getAttribute('href')).eql('https://joinmastodon.org')
    .expect(getAnchorInProfile(0).getAttribute('rel')).eql('nofollow noopener')
    .expect(getAnchorInProfile(1).getAttribute('href')).eql('http://localhost:3000/tags/cat')
    .expect(getAnchorInProfile(2).getAttribute('href')).eql('http://localhost:3000/tags/mastocats')
    .expect(getAnchorInProfile(3).getAttribute('href')).eql('http://localhost:3000/@quux')
})


code:
import {
  composeInput,
  getNthComposeReplyInput, getNthPostPrivacyButton, getNthPostPrivacyOptionInDialog, getNthReplyButton,
  getNthReplyContentWarningButton,
  getNthReplyContentWarningInput, getNthReplyPostPrivacyButton,
  getNthStatus, getNthStatusRelativeDate, getUrl, homeNavButton, notificationsNavButton, scrollToStatus
} from '../utils'
import { loginAsFoobar } from '../roles'
import { homeTimeline } from '../fixtures'

fixture`017-compose-reply.js`
  .page`http://localhost:4002`

test('account handle populated correctly for replies', async t => {
  await loginAsFoobar(t)
  await t
    .click(getNthReplyButton(1))
    .expect(getNthComposeReplyInput(1).value).eql('@quux ')
    .typeText(getNthComposeReplyInput(1), 'hello quux', { paste: true })
    .expect(getNthComposeReplyInput(1).value).eql('@quux hello quux')
    .click(notificationsNavButton)
    .expect(getUrl()).contains('/notifications')
    .click(homeNavButton)
    .expect(getUrl()).notContains('/notifications')
    .expect(getNthComposeReplyInput(1).value).eql('@quux hello quux')
    .expect(composeInput.value).eql('')
    .hover(getNthStatus(3))
    .hover(getNthStatus(5))
    .click(getNthReplyButton(5))
    .expect(getNthComposeReplyInput(5).value).eql('')
})

test('replying to posts with mentions', async t => {
  await loginAsFoobar(t)
  await t
    .click(getNthReplyButton(2))
    .expect(getNthComposeReplyInput(2).value).eql('@admin ')
    .navigateTo('/accounts/4')
    .click(getNthReplyButton(1))
    .expect(getNthComposeReplyInput(1).value).eql('@ExternalLinks @admin @quux ')
})

test('replies have same privacy as replied-to status by default', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .hover(getNthStatus(2))
    .click(getNthReplyButton(2))
    .expect(getNthPostPrivacyButton(2).getAttribute('aria-label')).eql('Adjust privacy (currently Unlisted)')
    .click(getNthReplyButton(2))
    .hover(getNthStatus(3))
    .click(getNthReplyButton(3))
    .expect(getNthPostPrivacyButton(3).getAttribute('aria-label')).eql('Adjust privacy (currently Followers-only)')
    .click(getNthReplyButton(3))
    .hover(getNthStatus(4))
    .hover(getNthStatus(5))
    .hover(getNthStatus(6))
    .click(getNthReplyButton(6))
    .expect(getNthPostPrivacyButton(6).getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
    .click(getNthReplyButton(6))
})

test('replies have same CW as replied-to status', async t => {
  await loginAsFoobar(t)
  let kittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  await scrollToStatus(t, 1 + kittenIdx)
  await t.click(getNthReplyButton(1 + kittenIdx))
    .expect(getNthReplyContentWarningInput(1 + kittenIdx).value).eql('kitten CW')
    .click(getNthStatusRelativeDate(1 + kittenIdx))
    .expect(getUrl()).contains('/statuses')
    .click(getNthReplyButton(1))
    .expect(getNthReplyContentWarningInput(1).value).eql('kitten CW')
})

test('replies save deletions of CW', async t => {
  await loginAsFoobar(t)
  let kittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  await scrollToStatus(t, 1 + kittenIdx)
  await t.click(getNthReplyButton(1 + kittenIdx))
    .expect(getNthReplyContentWarningInput(1 + kittenIdx).value).eql('kitten CW')
    .click(getNthReplyContentWarningButton(1 + kittenIdx))
    .expect(getNthReplyContentWarningInput(1 + kittenIdx).exists).notOk()
    .click(getNthStatusRelativeDate(1 + kittenIdx))
    .expect(getUrl()).contains('/statuses')
    .click(getNthReplyButton(1))
    .expect(getNthReplyContentWarningInput(1).exists).notOk()
})

test('replies save changes to CW', async t => {
  await loginAsFoobar(t)
  let kittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  await scrollToStatus(t, 1 + kittenIdx)
  await t.click(getNthReplyButton(1 + kittenIdx))
    .expect(getNthReplyContentWarningInput(1 + kittenIdx).value).eql('kitten CW')
    .typeText(getNthReplyContentWarningInput(1 + kittenIdx), ' yolo', { paste: true })
    .expect(getNthReplyContentWarningInput(1 + kittenIdx).value).eql('kitten CW yolo')
    .click(getNthStatusRelativeDate(1 + kittenIdx))
    .expect(getUrl()).contains('/statuses')
    .click(getNthReplyButton(1))
    .expect(getNthReplyContentWarningInput(1).value).eql('kitten CW yolo')
})

test('replies save changes to post privacy', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .hover(getNthStatus(2))
    .click(getNthReplyButton(2))
    .expect(getNthPostPrivacyButton(2).getAttribute('aria-label')).eql('Adjust privacy (currently Unlisted)')
    .click(getNthReplyPostPrivacyButton(2))
    .click(getNthPostPrivacyOptionInDialog(1))
    .expect(getNthPostPrivacyButton(2).getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
    .click(getNthStatusRelativeDate(2))
    .expect(getUrl()).contains('/statuses')
    .click(getNthReplyButton(1))
    .expect(getNthPostPrivacyButton(1).getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
})

test('replies are the same whatever thread they are in', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .hover(getNthStatus(2))
    .click(getNthReplyButton(2))
    .typeText(getNthComposeReplyInput(2), 'this is a reply', { paste: true })
    .expect(getNthComposeReplyInput(2).value).eql('@admin this is a reply')
    .click(getNthStatusRelativeDate(2))
    .expect(getUrl()).contains('/statuses')
    .click(getNthReplyButton(1))
    .expect(getNthComposeReplyInput(1).value).eql('@admin this is a reply')
})


code:
import {
  composeInput, getNthAutosuggestionResult, getNthComposeReplyInput, getNthReplyButton, getNthStatus, sleep
} from '../utils'
import { Selector as $ } from 'testcafe'
import { loginAsFoobar } from '../roles'

fixture`018-compose-autosuggest.js`
  .page`http://localhost:4002`

const timeout = 30000

test('autosuggests user handles', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
  await sleep(1000)
  await t
    .typeText(composeInput, 'hey @qu')
    .click(getNthAutosuggestionResult(1), { timeout })
    .expect(composeInput.value).eql('hey @quux ')
    .typeText(composeInput, 'and also @adm')
    .click(getNthAutosuggestionResult(1), { timeout })
    .expect(composeInput.value).eql('hey @quux and also @admin ')
    .typeText(composeInput, 'and also @AdM')
    .expect(getNthAutosuggestionResult(1).innerText).contains('@admin', { timeout })
    .pressKey('tab')
    .expect(composeInput.value).eql('hey @quux and also @admin and also @admin ')
    .typeText(composeInput, 'and @QU')
    .expect(getNthAutosuggestionResult(1).innerText).contains('@quux', { timeout })
    .pressKey('enter')
    .expect(composeInput.value).eql('hey @quux and also @admin and also @admin and @quux ')
})

test('autosuggests custom emoji', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, ':blob')
    .click(getNthAutosuggestionResult(1))
    .expect(composeInput.value).eql(':blobnom: ')
    .typeText(composeInput, 'and :blob')
    .expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout })
    .expect(getNthAutosuggestionResult(2).innerText).contains(':blobpats:')
    .expect(getNthAutosuggestionResult(3).innerText).contains(':blobpeek:')
    .pressKey('down')
    .pressKey('down')
    .pressKey('enter')
    .expect(composeInput.value).eql(':blobnom: and :blobpeek: ', { timeout })
    .typeText(composeInput, 'and also :blobpa')
    .expect(getNthAutosuggestionResult(1).innerText).contains(':blobpats:', { timeout })
    .pressKey('tab')
    .expect(composeInput.value).eql(':blobnom: and :blobpeek: and also :blobpats: ')
})

test('autosuggest custom emoji works with regular emoji - keyboard', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '\ud83c\udf4d :blobno')
    .expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout })
    .pressKey('enter')
    .expect(composeInput.value).eql('\ud83c\udf4d :blobnom: ')
})

test('autosuggest custom emoji works with regular emoji - clicking', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '\ud83c\udf4d :blobno')
    .expect(getNthAutosuggestionResult(1).innerText).contains(':blobnom:', { timeout })
    .click(getNthAutosuggestionResult(1))
    .expect(composeInput.value).eql('\ud83c\udf4d :blobnom: ')
})

test('autosuggest handles works with regular emoji - keyboard', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '\ud83c\udf4d @quu')
    .expect(getNthAutosuggestionResult(1).innerText).contains('@quux', { timeout })
    .pressKey('enter')
    .expect(composeInput.value).eql('\ud83c\udf4d @quux ')
})

test('autosuggest handles works with regular emoji - clicking', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '\ud83c\udf4d @quu')
    .expect(getNthAutosuggestionResult(1).innerText).contains('@quux', { timeout })
    .click(getNthAutosuggestionResult(1))
    .expect(composeInput.value).eql('\ud83c\udf4d @quux ')
})

test('autosuggest only shows for one input', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '@quu')
    .hover(getNthStatus(1))
    .click(getNthReplyButton(1))
    .selectText(getNthComposeReplyInput(1))
    .pressKey('delete')
    .typeText(getNthComposeReplyInput(1), 'uu')
    .expect($('.compose-autosuggest.shown').exists).notOk()
})

test('autosuggest only shows for one input part 2', async t => {
  await loginAsFoobar(t)
  await t
    .hover(composeInput)
    .typeText(composeInput, '@adm')
    .expect($('.compose-autosuggest.shown').exists).ok({ timeout })
    .expect(getNthAutosuggestionResult(1).innerText).contains('@admin')
    .hover(getNthStatus(1))
    .click(getNthReplyButton(1))
    .selectText(getNthComposeReplyInput(1))
    .pressKey('delete')
    .typeText(getNthComposeReplyInput(1), '@dd')
  await sleep(1000)
  await t.pressKey('backspace')
    .expect($('.compose-autosuggest.shown').exists).notOk()
})


code:
import {
  accountProfileMoreOptionsButton, closeDialogButton, composeModalInput,
  getNthDialogOptionsOption, modalDialog
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`019-mention.js`
  .page`http://localhost:4002`

test('can mention from account profile', async t => {
  await loginAsFoobar(t)
  await t
    .navigateTo('/accounts/5')
    .click(accountProfileMoreOptionsButton)
    .expect(getNthDialogOptionsOption(1).innerText).contains('Mention @baz')
    .click(getNthDialogOptionsOption(1))
    .expect(composeModalInput.value).eql('@baz ')
    .click(closeDialogButton)
    .expect(modalDialog.exists).notOk()
})


code:
import {
  getCurrentTheme,
  settingsNavButton
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'

fixture`020-themes.js`
  .page`http://localhost:4002`

test('can set a theme', async t => {
  await loginAsFoobar(t)
  await t
    .click(settingsNavButton)
    .click($('a[href="/settings/instances"]'))
    .click($('a[href="/settings/instances/localhost:3000"]'))
    .expect(getCurrentTheme()).eql('default')
    .click($('input[value="scarlet"]'))
    .expect(getCurrentTheme()).eql('scarlet')
    .click($('input[value="default"]'))
    .expect(getCurrentTheme()).eql('default')
})


code:
import {
  followersButton,
  followsButton, getNthSearchResult,
  getNthStatusSelector, getUrl, goBack
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'

fixture`021-followers-follows.js`
  .page`http://localhost:4002`

test('shows followers and follows', async t => {
  await loginAsFoobar(t)
  await t
    .click($(`${getNthStatusSelector(1)} .status-author-name`))
    .expect(getUrl()).match(/\/accounts\/3$/)
    .expect(followsButton.getAttribute('aria-label')).eql('Follows 2')
    .click(followsButton)
    .expect(getUrl()).contains('/accounts/3/follows')
    .expect(getNthSearchResult(1).innerText).contains('@foobar')
    .expect(getNthSearchResult(2).innerText).contains('@admin')
  await goBack()
  await t.expect(getUrl()).match(/\/accounts\/3$/)
    .expect(followersButton.getAttribute('aria-label')).eql('Followed by 1')
    .click(followersButton)
    .expect(getUrl()).contains('/accounts/3/followers')
    .expect(getNthSearchResult(1).innerText).contains('@admin')
})


code:
import { loginAsFoobar } from '../roles'
import {
  generalSettingsButton,
  getNthShowOrHideButton,
  getNthStatus, homeNavButton,
  notificationsNavButton,
  scrollToStatus,
  settingsNavButton
} from '../utils'
import { Selector as $ } from 'testcafe'

import { homeTimeline } from '../fixtures'

fixture`022-status-aria-label.js`
  .page`http://localhost:4002`

test('basic aria-labels for statuses', async t => {
  await loginAsFoobar(t)
  await t
    .hover(getNthStatus(1))
    .expect(getNthStatus(1).getAttribute('aria-label')).match(
      /quux, pinned toot 1, .+ ago, @quux, Unlisted, Boosted by admin/i
    )
    .hover(getNthStatus(1))
    .expect(getNthStatus(2).getAttribute('aria-label')).match(
      /admin, @foobar notification of unlisted message, .* ago, @admin, Unlisted/i
    )
})

test('aria-labels for CWed statuses', async t => {
  await loginAsFoobar(t)
  let kittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  await scrollToStatus(t, 1 + kittenIdx)
  await t
    .hover(getNthStatus(1 + kittenIdx))
    .expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
      /foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
    )
    .click(getNthShowOrHideButton(1 + kittenIdx))
    .expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
      /foobar, here's a kitten with a CW, .* ago, @foobar, Public/i
    )
    .click(getNthShowOrHideButton(1 + kittenIdx))
    .expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
      /foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
    )
})

test('aria-labels for notifications', async t => {
  await loginAsFoobar(t)
  await t
    .click(notificationsNavButton)
    .hover(getNthStatus(1))
    .expect(getNthStatus(1).getAttribute('aria-label')).match(
      /admin favorited your status, foobar, this is unlisted, .* ago, @foobar, Unlisted/i
    )
    .hover(getNthStatus(2))
    .expect(getNthStatus(2).getAttribute('aria-label')).match(
      /admin boosted your status, foobar, this is unlisted, .* ago, @foobar, Unlisted/i
    )
    .hover(getNthStatus(3))
    .expect(getNthStatus(3).getAttribute('aria-label')).match(
      /admin, @foobar notification of unlisted message, .* ago, @admin, Unlisted/i
    )
  await scrollToStatus(t, 5)
  await t
    .hover(getNthStatus(5))
    .expect(getNthStatus(5).getAttribute('aria-label')).match(
      /admin, @foobar notification of direct message, .* ago, @admin, Direct/i
    )
  await scrollToStatus(t, 6)
  await t
    .hover(getNthStatus(6))
    .expect(getNthStatus(6).getAttribute('aria-label')).match(
      /quux followed you, @quux/i
    )
})

test('can shorten aria-labels', async t => {
  await loginAsFoobar(t)
  await t
    .click(settingsNavButton)
    .click(generalSettingsButton)
    .click($('#choice-disable-long-aria-labels'))
    .click(homeNavButton)
    .hover(getNthStatus(1))
    .expect(getNthStatus(1).getAttribute('aria-label')).match(
      /Unlisted status by quux/
    )
    .click(settingsNavButton)
    .click(generalSettingsButton)
    .click($('#choice-disable-long-aria-labels'))
    .click(homeNavButton)
    .hover(getNthStatus(1))
    .expect(getNthStatus(1).getAttribute('aria-label')).match(
      /quux, pinned toot 1, .+ ago, @quux, Unlisted, Boosted by admin/i
    )
})


code:
import { loginAsFoobar } from '../roles'
import {
  generalSettingsButton,
  getNthStatus, getNthStatusMedia, getNthStatusSensitiveMediaButton, homeNavButton, markMediaSensitiveInput,
  scrollToStatus, settingsNavButton, neverMarkMediaSensitiveInput
} from '../utils'

import { homeTimeline } from '../fixtures'

fixture`023-mark-media-as-sensitive.js`
  .page`http://localhost:4002`

async function checkSensitivityForStatus (t, idx, sensitive) {
  if (sensitive) {
    await t
      .expect(getNthStatusSensitiveMediaButton(1 + idx).exists).ok()
      .expect(getNthStatusMedia(1 + idx).exists).notOk()
  } else {
    await t
      .expect(getNthStatusSensitiveMediaButton(1 + idx).exists).notOk()
      .expect(getNthStatusMedia(1 + idx).exists).ok()
  }
}

async function checkSensitivity (t, shouldBeSensitive) {
  let sensitiveKittenIdx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
  let sensitiveVideoIdx = homeTimeline.findIndex(_ => _.content === 'secret video')
  let videoIdx = homeTimeline.findIndex(_ => _.content === "here's a video")
  let sensitiveAnimatedKittenIdx = homeTimeline.findIndex(_ => _.content === "here's a secret animated kitten gif")
  let animatedKittenIdx = homeTimeline.findIndex(_ => _.content === "here's an animated kitten gif")

  await t.hover(getNthStatus(1))

  let expected = [
    [ sensitiveKittenIdx, shouldBeSensitive(true) ],
    [ sensitiveVideoIdx, shouldBeSensitive(true) ],
    [ videoIdx, shouldBeSensitive(false) ],
    [ sensitiveAnimatedKittenIdx, shouldBeSensitive(true) ],
    [ animatedKittenIdx, shouldBeSensitive(false) ]
  ]

  for (let [ idx, sensitive ] of expected) {
    await scrollToStatus(t, 1 + sensitiveKittenIdx)
    await checkSensitivityForStatus(t, idx, sensitive)
  }
}

async function changeSetting (t, input, checked) {
  await t
    .click(settingsNavButton)
    .click(generalSettingsButton)
    .click(input)
  if (checked) {
    await t.expect(input.checked).ok()
  } else {
    await t.expect(input.checked).notOk()
  }
  await t.click(homeNavButton)
}

async function doMarkMediaAsSensitive (t, checked) {
  await changeSetting(t, markMediaSensitiveInput, checked)
}

async function doNeverMarkMediaAsSensitive (t, checked) {
  await changeSetting(t, neverMarkMediaSensitiveInput, checked)
}

test('default sensitive settings', async t => {
  await loginAsFoobar(t)
  await checkSensitivity(t, sensitive => sensitive)
})

test('always mark media sensitive', async t => {
  await loginAsFoobar(t)
  await doMarkMediaAsSensitive(t, true)
  await checkSensitivity(t, () => true)
  // cleanup
  await doMarkMediaAsSensitive(t, false)
})

test('never mark media sensitive', async t => {
  await loginAsFoobar(t)
  await doNeverMarkMediaAsSensitive(t, true)
  await checkSensitivity(t, () => false)
  // cleanup
  await doNeverMarkMediaAsSensitive(t, false)
})

test('settings are mutually exclusive', async t => {
  await loginAsFoobar(t)
  await t
    .click(settingsNavButton)
    .click(generalSettingsButton)
    .click(markMediaSensitiveInput)
    .expect(markMediaSensitiveInput.checked).ok()
    .expect(neverMarkMediaSensitiveInput.checked).notOk()
    .click(neverMarkMediaSensitiveInput)
    .expect(markMediaSensitiveInput.checked).notOk()
    .expect(neverMarkMediaSensitiveInput.checked).ok()
    .click(markMediaSensitiveInput)
    .expect(markMediaSensitiveInput.checked).ok()
    .expect(neverMarkMediaSensitiveInput.checked).notOk()
    .click(markMediaSensitiveInput)
    .expect(markMediaSensitiveInput.checked).notOk()
    .expect(neverMarkMediaSensitiveInput.checked).notOk()
})


code:
import {
  getNthStatus,
  getUrl, isNthStatusActive,
  modalDialogContents,
  notificationsNavButton, scrollToStatus
} from '../utils'
import { loginAsFoobar } from '../roles'

fixture`024-shortcuts-navigation.js`
  .page`http://localhost:4002`

test('Shortcut g+l goes to the local timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g l')
    .expect(getUrl()).contains('/local')
})

test('Shortcut g+t goes to the federated timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g t')
    .expect(getUrl()).contains('/federated')
})

test('Shortcut g+h goes back to the home timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .click(notificationsNavButton)
    .pressKey('g h')
})

test('Shortcut g+f goes to the favorites', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g f')
    .expect(getUrl()).contains('/favorites')
})

test('Shortcut g+c goes to the community page', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g c')
    .expect(getUrl()).contains('/community')
})

test('Shortcut s goes to the search page', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('s')
    .expect(getUrl()).contains('/search')
})

test('Shortcut backspace goes back from favorites', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g t')
    .expect(getUrl()).contains('/federated')
    .pressKey('g f')
    .expect(getUrl()).contains('/favorites')
    .pressKey('backspace')
    .expect(getUrl()).contains('/federated')
})

test('Shortcut h toggles shortcut help dialog', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('h')
    .expect(modalDialogContents.exists).ok()
    .expect(modalDialogContents.hasClass('shortcut-help-modal-dialog')).ok()
    .pressKey('h')
    .expect(modalDialogContents.exists).notOk()
})

test('Global shortcut has no effects while in modal dialog', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('g f')
    .expect(getUrl()).contains('/favorites')
    .pressKey('h')
    .expect(modalDialogContents.exists).ok()
    .pressKey('s') // does nothing
    .expect(getUrl()).contains('/favorites')
    .pressKey('backspace')
    .expect(modalDialogContents.exists).notOk()
    .pressKey('s') // now works
    .expect(getUrl()).contains('/search')
})

test('Shortcut 1 goes to the home timeline', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('1')
    .expect(getUrl()).contains('/')
})

test('Shortcut 6 goes to the settings', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .pressKey('6')
    .expect(getUrl()).contains('/settings')
})

test('Shortcut . scrolls to top and focuses', async t => {
  await loginAsFoobar(t)
  await t
    .expect(getUrl()).eql('http://localhost:4002/')
    .hover(getNthStatus(1))
  await scrollToStatus(t, 10)
  await t
    .pressKey('.')
    .expect(isNthStatusActive(1)).ok()
})