Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

1333 changed files with 17268 additions and 21753 deletions

View File

@ -1,9 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

16
.gitignore vendored
View File

@ -1,12 +1,10 @@
.DS_Store
node_modules
/__sapper__
.sapper
yarn.lock
templates/.*
assets/*.css
/mastodon
/mastodon.log
/src/template.html
/static/*.css
/static/robots.txt
/static/inline-script.js.map
/static/emoji-mart-all.json
/src/inline-script/checksum.js
yarn-error.log
mastodon.log
assets/robots.txt
/inline-script-checksum.json

View File

@ -1,53 +1,55 @@
language: node_js
node_js:
- "10"
- "8"
dist: trusty # needed for chrome headless
sudo: required # needed for various sudo operations
sudo: required # needed for chrome headless
addons:
chrome: stable
postgresql: "10"
apt:
packages:
- autoconf
- bison
- build-essential
- file
- g++
- gcc
- imagemagick
- libffi-dev
- libgdbm-dev
- libgdbm3
- libicu-dev
- libidn11-dev
- libncurses5-dev
- libpq-dev
- libprotobuf-dev
- libreadline6-dev
- libssl-dev
- libxml2-dev
- libxslt1-dev
- libyaml-dev
- pkg-config nodejs
- postgresql-10
- postgresql-client-10
- postgresql-contrib-10
# the following are mastodon dependencies
- imagemagick
- libpq-dev
- libxml2-dev
- libxslt1-dev
- file
- g++
- libprotobuf-dev
- protobuf-compiler
- redis-server
- redis-tools
- pkg-config nodejs
- gcc
- autoconf
- bison
- build-essential
- libssl-dev
- libyaml-dev
- libreadline6-dev
- zlib1g-dev
- libncurses5-dev
- libffi-dev
- libgdbm3
- libgdbm-dev
- redis-tools
- libidn11-dev
- libicu-dev
services:
- redis-server
before_install:
- npm install -g npm@5
- npm install -g greenkeeper-lockfile@1
# install yarn
- curl -o- -L https://yarnpkg.com/install.sh | bash -s
- export PATH="$HOME/.yarn/bin:$PATH"
- ./bin/setup-mastodon-in-travis.sh
before_script:
- yarn run lint
- npm run lint
- greenkeeper-lockfile-update
after_script:
- greenkeeper-lockfile-upload
script: travis_retry yarn run $COMMAND
install:
- npm ci || npm i
script: travis_retry npm run $COMMAND
env:
global:
- PGPORT=5433
@ -57,17 +59,16 @@ matrix:
include:
- env: BROWSER=chrome:headless COMMAND=test-browser-suite0
- env: BROWSER=chrome:headless COMMAND=test-browser-suite1
- env: COMMAND=test-unit
- env: COMMAND=deploy-all-travis
- env: COMMAND=deploy-dev-travis
allow_failures:
- env: COMMAND=deploy-all-travis
- env: COMMAND=deploy-dev-travis
branches:
only:
- master
- /^greenkeeper/.*$/
cache:
yarn: true
bundler: true
directories:
- /home/travis/.rvm/
- /home/travis/ffmpeg-static/
- $HOME/.npm
- $HOME/.rvm
- $HOME/.bundle
- $HOME/.yarn-cache

View File

@ -1,17 +0,0 @@
# Breaking changes
This document contains a list of _breaking changes_ for Pinafore. For a full changelog, see [the GitHub release page](https://github.com/nolanlawson/pinafore/releases).
## 1.0.0
**Breaking change:** This version [switches Pinafore from npm to yarn](https://github.com/nolanlawson/pinafore/pull/927). Those who self-host Pinafore will need to make the following changes:
1. [Install yarn](https://yarnpkg.com/en/docs/install) if you haven't already.
2. Instead of `npm install`, run `yarn --pure-lockfile`.
This change fixes [a functional bug in Webpack](https://github.com/nolanlawson/pinafore/pull/926). If you use npm instead of yarn, Pinafore may not build correctly.
### Notes:
- Using `yarn start` instead of `npm start` should not make a difference.
- Those using Docker shouldn't need to change anything.

View File

@ -1,40 +1,41 @@
# Contributing to Pinafore
## Dev server
## Caveats
Please note that this project is _very_ beta right now, and I'm
not in a good position to accept large PRs for
big new features.
I'm making my code open-source for the sake of
transparency and because it's the right thing to do, but I'm hesitant
to start nurturing a community because of
[all that entails](https://nolanlawson.com/2017/03/05/what-it-feels-like-to-be-an-open-source-maintainer/).
So I may not be very responsive to PRs or issues. Thanks for understanding.
## Development
To run a dev server with hot reloading:
yarn run dev
npm run dev
Now it's running at `localhost:4002`.
**Linux users:** for file changes to work,
you'll probably want to run `export CHOKIDAR_USEPOLLING=1`
because of [this issue](https://github.com/paulmillr/chokidar/issues/237).
## Linting
Pinafore uses [JavaScript Standard Style](https://standardjs.com/).
Lint:
yarn run lint
npm run lint
Automatically fix most linting issues:
yarn run lint-fix
npm run lint-fix
## Integration tests
## Testing
Integration tests use [TestCafé](https://devexpress.github.io/testcafe/) and a live local Mastodon instance
running on `localhost:3000`.
### Running integration tests
The integration tests require running Mastodon itself,
meaning the[Mastodon development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md)
is relevant here. In particular, you'll need a recent
version of Ruby, Redis, and Postgres running. For a full list of deps, see `bin/setup-mastodon-in-travis.sh`.
Testing requires running Mastodon itself, meaning the [Mastodon development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) is relevant here. In particular, you'll need a recent version of Ruby, Redis, and Postgres running.
Run integration tests, using headless Chrome by default:
@ -42,92 +43,44 @@ Run integration tests, using headless Chrome by default:
Run tests for a particular browser:
BROWSER=chrome yarn run test-browser
BROWSER=chrome:headless yarn run test-browser
BROWSER=firefox yarn run test-browser
BROWSER=firefox:headless yarn run test-browser
BROWSER=safari yarn run test-browser
BROWSER=edge yarn run test-browser
BROWSER=chrome npm run test-browser
BROWSER=chrome:headless npm run test-browser
BROWSER=firefox npm run test-browser
BROWSER=firefox:headless npm run test-browser
BROWSER=safari npm run test-browser
BROWSER=edge npm run test-browser
If the script isn't able to set up the Postgres database, try running:
sudo su - postgres
Then:
psql -d template1 -c "CREATE USER pinafore WITH PASSWORD 'pinafore' CREATEDB;"
### Testing in development mode
## Testing in development mode
In separate terminals:
1\. Run a Mastodon dev server:
yarn run run-mastodon
npm run run-mastodon
2\. Run a Pinafore dev server:
yarn run dev
npm run dev
3\. Run a debuggable TestCafé instance:
npx testcafe --hostname localhost --skip-js-errors --debug-mode chrome tests/spec
npx testcafe --hostname localhost --skip-js-errors --debug-mode firefox tests/spec
### Test conventions
If you want to export the current data in the Mastodon instance as canned data,
so that it can be loaded later, run:
The tests have a naming convention:
npm run backup-mastodon-data
* `0xx-test-name.js`: tests that don't modify the Mastodon database (read-only)
* `1xx-test-name.js`: tests that do modify the Mastodon database (read-write)
## Writing tests
Tests use [TestCafé](https://devexpress.github.io/testcafe/). The tests have a naming convention:
* `0xx-test-name.js`: tests that don't modify the Mastodon database (post, delete, follow, etc.)
* `1xx-test-name.js`: tests that do modify the Mastodon database
In principle the `0-` tests don't have to worry about
clobbering each other, whereas the `1-` ones do.
### Mastodon used for testing
There are two parts to the Mastodon data used for testing:
1. A Postgres dump and a tgz containing the media files, located in `fixtures`
2. A script that populates the Mastodon backend with test data (`restore-mastodon-data.js`).
The reason we don't use a Postgres dump for everything
is that Mastodon will ignore changes made after a certain period of time, and we
don't want our tests to randomly start breaking one day. Running the script ensures that statuses,
favorites, boosts, etc. are all "fresh".
### Updating the test data
You probably don't want to do this, as the `0xx` tests are pretty rigidly defined against the test data.
Write a `1xx` test instead and insert what you need on-the-fly.
If you really need to, though, you can either:
1. Add new test data to `mastodon-data.js`
or
1. Comment out `await restoreMastodonData()` in `run-mastodon.js`
2. Make your changes manually to the live Mastodon
3. Run the steps in the next section to back it up to `fixtures/`
### Updating the Mastodon version
1. Run `rm -fr mastodon` to clear out all Mastodon data
1. Comment out `await restoreMastodonData()` in `run-mastodon.js` to avoid actually populating the database with statuses/favorites/etc.
2. Update the `GIT_TAG` in `run-mastodon.js` to whatever you want
3. Run `yarn run run-mastodon`
4. Run `yarn run backup-mastodon-data` to overwrite the data in `fixtures/`
5. Uncomment `await restoreMastodonData()` in `run-mastodon.js`
6. Commit all changed files
7. Run `rm -fr mastodon/` and `yarn run run-mastodon` to confirm everything's working
Check `mastodon.log` if you have any issues.
## Unit tests
There are also some unit tests that run in Node using Mocha. You can find them in `tests/unit` and
run them using `yarn run test-unit`.
## Debugging Webpack
The Webpack Bundle Analyzer `report.html` and `stats.json` are available publicly via e.g.:
@ -135,54 +88,17 @@ The Webpack Bundle Analyzer `report.html` and `stats.json` are available publicl
- [dev.pinafore.social/report.html](https://dev.pinafore.social/report.html)
- [dev.pinafore.social/stats.json](https://dev.pinafore.social/stats.json)
This is also available locally after `yarn run build` at `.sapper/client/report.html`.
This is also available locally after `npm run build` at `.sapper/client/report.html`.
## Codebase overview
## Updating Mastodon used for testing
Pinafore uses [SvelteJS](https://svelte.technology) and [SapperJS](https://sapper.svelte.technology). Most of it is a fairly typical Svelte/Sapper project, but there
are some quirks, which are described below. This list of quirks is non-exhaustive.
1. Run `rm -fr mastodon` to clear out all Mastodon data
1. Comment out `await restoreMastodonData()` in `run-mastodon.js` to avoid actually populating the database with statuses/favorites/etc.
2. Update the `GIT_TAG` in `run-mastodon.js` to whatever you want
3. Run `npm run run-mastodon`
4. Run `npm run backup-mastodon-data` to overwrite the data in `fixtures/`
5. Uncomment `await restoreMastodonData()` in `run-mastodon.js`
6. Commit all changed files
7. Run `rm -fr mastodon/` and `npm run run-mastodon` to confirm everything's working
### Prebuild process
The `template.html` is itself templated. The "template template" has some inline scripts, CSS, and SVGs
injected into it during the build process. SCSS is used for global CSS and themed CSS, but inside of the
components themselves, it's just vanilla CSS because I couldn't figure out how to get Svelte to run a SCSS
preprocessor.
### Lots of small files
Highly modular, highly functional, lots of single-function files. Tends to help with tree-shaking and
code-splitting, as well as avoiding circular dependencies.
### Inferno is loaded dynamically
This is a Svelte project, but `emoji-mart` is used for the emoji picker, and it's written in React. So we
lazy-load the React-compatible Inferno library when we load `emoji-mart`.
### Some third-party code is bundled
For various reasons, `a11y-dialog`, `autosize`, and `timeago` are forked and bundled into the source code.
This was either because something needed to be tweaked or fixed, or I was trimming unused code and didn't
see much value in contributing it back, because it was too Pinafore-specific.
### Every Sapper page is "duplicated"
To get a nice animation on the nav bar when you switch columns, every page is lazy-loaded as `LazyPage.html`.
This "lazy page" is merely delayed a few frames to let the animation run. Therefore there is a duplication
between `src/routes` and `src/routes/_pages`. The "lazy page" is in the former, and the actual page is in the
latter. One imports the other.
### There are multiple stores
Originally I conceived of separating out the virtual list into a separate npm package, so I gave it its
own Svelte store (`virtualListStore.js`). This never happened, but it still has its own store. This is useful
anyway, because each store has its state maintained in an LRU cache that allows us to keep the scroll position
in the virtual list e.g. when the user hits the back button.
Also, the main `store.js` store is explicitly
loaded by every component that uses it. So there's no `store` inheritance; every component just declares
whatever store it uses. The main `store.js` is the primary one.
### There is a global event bus
It's in `eventBus.js`. This is useful for some stuff that is hard to do with standard Svelte or DOM events.
Check `mastodon.log` if you have any issues.

View File

@ -7,17 +7,17 @@ ADD . /app
# Install updates and NodeJS+Dependencies
RUN apk update && apk upgrade
RUN apk add nodejs npm git python build-base clang
RUN apk add nodejs git python build-base clang
# Install yarn
RUN npm i yarn -g
# Upgrading NPM
RUN npm i npm@latest -g
# Install Pinafore
RUN yarn --pure-lockfile
RUN yarn build
RUN npm install
RUN npm run build
# Expose port 4002
EXPOSE 4002
# Setting run-command
CMD PORT=4002 yarn start
CMD PORT=4002 npm start

View File

@ -2,11 +2,11 @@
An alternative web client for [Mastodon](https://joinmastodon.org), focused on speed and simplicity.
Pinafore is available at [pinafore.social](https://pinafore.social). Beta releases are at [dev.pinafore.social](https://dev.pinafore.social).
Pinafore is available at [pinafore.social](https://pinafore.social). Bleeding-edge releases are at [dev.pinafore.social](https://dev.pinafore.social).
See the [user guide](https://github.com/nolanlawson/pinafore/blob/master/docs/User-Guide.md) for basic usage. See the [admin guide](https://github.com/nolanlawson/pinafore/blob/master/docs/Admin-Guide.md) if Pinafore cannot connect to your instance.
See the [user guide](https://github.com/nolanlawson/pinafore/blob/master/docs/User-Guide.md) for basic usage. See the [admin guide](https://github.com/nolanlawson/pinafore/blob/master/docs/Admin-Guide.md) to troubleshoot instance compatibility issues.
For updates and support, follow [@pinafore@mastodon.technology](https://mastodon.technology/@pinafore).
For updates and support, follow us at [@pinafore@mastodon.technology](https://mastodon.technology/@pinafore).
## Browser support
@ -24,68 +24,51 @@ Compatible versions of each (Opera, Brave, Samsung, etc.) should be fine.
### Goals
- Support the most common use cases
- Small page weight
- Fast even on low-end devices
- Accessibility
- Offline support in read-only mode
- Fast even on low-end phones
- Works offline in read-only mode
- Progressive Web App features
- Multi-instance support
- Support latest versions of Chrome, Edge, Firefox, and Safari
- a11y (keyboard navigation, screen readers)
### Secondary / possible future goals
### Possible future goals
- Support for Pleroma or other non-Mastodon backends
- Serve as an alternative frontend tied to a particular instance
- Support for non-English languages (i18n)
- Works as an alternative frontend self-hosted by instances
- Android/iOS apps (using Cordova or similar)
- Support Pleroma/non-Mastodon backends
- i18n
- Offline search
- Full emoji keyboard
- Keyboard shortcuts
### Non-goals
- Supporting old browsers, proxy browsers, or text-based browsers
- React Native / NativeScript / hybrid-native version
- Android/iOS apps (using Cordova or similar)
- Full functionality with JavaScript disabled
- Emoji support beyond the built-in system emoji
- Multi-column support
- Admin/moderation panel
- Offline support in read-write mode (would require sophisticated sync logic)
- Works offline in read-write mode (would require sophisticated sync logic)
## Building
Pinafore requires [Node.js](https://nodejs.org/en/) v8+ and [Yarn](https://yarnpkg.com).
To build Pinafore for production:
yarn --pure-lockfile
yarn build
PORT=4002 yarn start
npm install
npm run build
PORT=4002 npm start
### Docker
To build a Docker image for production:
To build a docker image for production:
docker build .
docker run -d -p 4002:4002 [your-image]
Now Pinafore is running at `localhost:4002`.
### Updating
To keep your version of Pinafore up to date, you can use `git` to check out the latest tag:
git checkout $(git tag -l | sort -Vr | head -n 1)
### Exporting
You can export Pinafore as a static site. Run:
yarn run export
Static files will be written to `__sapper__/export`.
Note that this is not the recommended method, because
[CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers are not
currently supported for the exported version.
Pinafore requires [Node.js](https://nodejs.org/en/) v8+.
## Developing and testing
@ -95,9 +78,3 @@ how to run Pinafore in dev mode and run tests.
## Changelog
For a changelog, see the [GitHub releases](http://github.com/nolanlawson/pinafore/releases/).
For a list of breaking changes, see [BREAKING_CHANGES.md](https://github.com/nolanlawson/pinafore/blob/master/BREAKING_CHANGES.md).
## What's with the name?
Pinafore is named after the [Gilbert and Sullivan play](https://en.wikipedia.org/wiki/Hms_pinafore). The [soundtrack](https://www.allmusic.com/album/gilbert-sullivan-hms-pinafore-1949-mw0001830483) is very good.

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 599 B

View File

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 514 B

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 660 B

After

Width:  |  Height:  |  Size: 660 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 682 B

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -x
set -e
PGPASSWORD=pinafore pg_dump -U pinafore -w pinafore_development -h 127.0.0.1 > tests/fixtures/dump.sql
cd mastodon/public/system
tar -czf ../../../tests/fixtures/system.tgz .

View File

@ -1,47 +1,32 @@
import crypto from 'crypto'
import fs from 'fs'
import { promisify } from 'util'
import path from 'path'
import { rollup } from 'rollup'
import { terser } from 'rollup-plugin-terser'
import replace from 'rollup-plugin-replace'
import fromPairs from 'lodash-es/fromPairs'
import { themes } from '../src/routes/_static/themes'
#!/usr/bin/env node
const writeFile = promisify(fs.writeFile)
const crypto = require('crypto')
const fs = require('fs')
const pify = require('pify')
const readFile = pify(fs.readFile.bind(fs))
const writeFile = pify(fs.writeFile.bind(fs))
const path = require('path')
const themeColors = fromPairs(themes.map(_ => ([_.name, _.color])))
async function main () {
let headScriptFilepath = path.join(__dirname, '../inline-script.js')
let headScript = await readFile(headScriptFilepath, 'utf8')
headScript = `(function () {'use strict'; ${headScript}})()`
export async function buildInlineScript () {
let inlineScriptPath = path.join(__dirname, '../src/inline-script/inline-script.js')
let checksum = crypto.createHash('sha256').update(headScript).digest('base64')
let bundle = await rollup({
input: inlineScriptPath,
plugins: [
replace({
'process.browser': true,
'process.env.THEME_COLORS': JSON.stringify(themeColors)
}),
terser({
mangle: true,
compress: true
})
]
})
let { output } = await bundle.generate({
format: 'iife',
sourcemap: true
})
let checksumFilepath = path.join(__dirname, '../inline-script-checksum.json')
await writeFile(checksumFilepath, JSON.stringify({checksum}), 'utf8')
let { code, map } = output[0]
let fullCode = `${code}//# sourceMappingURL=/inline-script.js.map`
let checksum = crypto.createHash('sha256').update(fullCode).digest('base64')
await writeFile(path.resolve(__dirname, '../src/inline-script/checksum.js'),
`module.exports = ${JSON.stringify(checksum)}`, 'utf8')
await writeFile(path.resolve(__dirname, '../static/inline-script.js.map'),
map.toString(), 'utf8')
return '<script>' + fullCode + '</script>'
let html2xxFilepath = path.join(__dirname, '../templates/2xx.html')
let html2xxFile = await readFile(html2xxFilepath, 'utf8')
html2xxFile = html2xxFile.replace(
/<!-- insert inline script here -->[\s\S]+<!-- end insert inline script here -->/,
'<!-- insert inline script here --><script>' + headScript + '</script><!-- end insert inline script here -->'
)
await writeFile(html2xxFilepath, html2xxFile, 'utf8')
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,46 +1,73 @@
import sass from 'node-sass'
import path from 'path'
import fs from 'fs'
import { promisify } from 'util'
import cssDedoupe from 'css-dedoupe'
import { TextDecoder } from 'text-encoding'
#!/usr/bin/env node
const writeFile = promisify(fs.writeFile)
const readdir = promisify(fs.readdir)
const render = promisify(sass.render.bind(sass))
const sass = require('node-sass')
const chokidar = require('chokidar')
const argv = require('yargs').argv
const path = require('path')
const debounce = require('lodash/debounce')
const fs = require('fs')
const pify = require('pify')
const writeFile = pify(fs.writeFile.bind(fs))
const readdir = pify(fs.readdir.bind(fs))
const readFile = pify(fs.readFile.bind(fs))
const render = pify(sass.render.bind(sass))
const now = require('performance-now')
const globalScss = path.join(__dirname, '../src/scss/global.scss')
const defaultThemeScss = path.join(__dirname, '../src/scss/themes/_default.scss')
const offlineThemeScss = path.join(__dirname, '../src/scss/themes/_offline.scss')
const customScrollbarScss = path.join(__dirname, '../src/scss/custom-scrollbars.scss')
const themesScssDir = path.join(__dirname, '../src/scss/themes')
const assetsDir = path.join(__dirname, '../static')
const globalScss = path.join(__dirname, '../scss/global.scss')
const defaultThemeScss = path.join(__dirname, '../scss/themes/_default.scss')
const offlineThemeScss = path.join(__dirname, '../scss/themes/_offline.scss')
const html2xxFile = path.join(__dirname, '../templates/2xx.html')
const scssDir = path.join(__dirname, '../scss')
const themesScssDir = path.join(__dirname, '../scss/themes')
const assetsDir = path.join(__dirname, '../assets')
async function renderCss (file) {
return (await render({ file, outputStyle: 'compressed' })).css
function doWatch () {
let start = now()
chokidar.watch(scssDir).on('change', debounce(() => {
console.log('Recompiling SCSS...')
Promise.all([
compileGlobalSass(),
compileThemesSass()
]).then(() => {
console.log('Recompiled SCSS in ' + (now() - start) + 'ms')
})
}, 500))
chokidar.watch()
}
async function compileGlobalSass () {
let mainStyle = (await Promise.all([defaultThemeScss, globalScss].map(renderCss))).join('')
let offlineStyle = (await renderCss(offlineThemeScss))
let scrollbarStyle = (await renderCss(customScrollbarScss))
let results = await Promise.all([
render({file: defaultThemeScss, outputStyle: 'compressed'}),
render({file: globalScss, outputStyle: 'compressed'}),
render({file: offlineThemeScss, outputStyle: 'compressed'})
])
return `<style>\n${mainStyle}</style>\n` +
`<style media="only x" id="theOfflineStyle">\n${offlineStyle}</style>\n` +
`<style media="all" id="theScrollbarStyle">\n${scrollbarStyle}</style>\n`
let css = results.map(_ => _.css).join('')
let html = await readFile(html2xxFile, 'utf8')
html = html.replace(/<style>[\s\S]+?<\/style>/,
`<style>\n/* auto-generated w/ build-sass.js */\n${css}\n</style>`)
await writeFile(html2xxFile, html, 'utf8')
}
async function compileThemesSass () {
let files = (await readdir(themesScssDir)).filter(file => !path.basename(file).startsWith('_'))
await Promise.all(files.map(async file => {
let css = await renderCss(path.join(themesScssDir, file))
css = cssDedoupe(new TextDecoder('utf-8').decode(css)) // remove duplicate custom properties
let res = await render({file: path.join(themesScssDir, file), outputStyle: 'compressed'})
let outputFilename = 'theme-' + path.basename(file).replace(/\.scss$/, '.css')
await writeFile(path.join(assetsDir, outputFilename), css, 'utf8')
await writeFile(path.join(assetsDir, outputFilename), res.css, 'utf8')
}))
}
export async function buildSass () {
let [ result ] = await Promise.all([compileGlobalSass(), compileThemesSass()])
return result
async function main () {
await Promise.all([compileGlobalSass(), compileThemesSass()])
if (argv.watch) {
doWatch()
}
}
Promise.resolve().then(main).catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,14 +1,17 @@
import svgs from './svgs'
import path from 'path'
import fs from 'fs'
import { promisify } from 'util'
import SVGO from 'svgo'
import $ from 'cheerio'
#!/usr/bin/env node
const svgs = require('./svgs')
const path = require('path')
const fs = require('fs')
const pify = require('pify')
const SVGO = require('svgo')
const svgo = new SVGO()
const readFile = promisify(fs.readFile)
const $ = require('cheerio')
export async function buildSvg () {
const readFile = pify(fs.readFile.bind(fs))
const writeFile = pify(fs.writeFile.bind(fs))
async function main () {
let result = (await Promise.all(svgs.map(async svg => {
let filepath = path.join(__dirname, '../', svg.src)
let content = await readFile(filepath, 'utf8')
@ -18,9 +21,23 @@ export async function buildSvg () {
let $symbol = $('<symbol></symbol>')
.attr('id', svg.id)
.attr('viewBox', `0 0 ${optimized.info.width} ${optimized.info.height}`)
.append($('<title></title>').text(svg.title))
.append($path)
return $.xml($symbol)
}))).join('\n')
return `<svg xmlns="http://www.w3.org/2000/svg" style="display:none;">\n${result}\n</svg>`
result = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none;">\n${result}\n</svg>`
let html2xxFilepath = path.join(__dirname, '../templates/2xx.html')
let html2xxFile = await readFile(html2xxFilepath, 'utf8')
html2xxFile = html2xxFile.replace(
/<!-- insert svg here -->[\s\S]+<!-- end insert svg here -->/,
'<!-- insert svg here -->' + result + '<!-- end insert svg here -->'
)
await writeFile(html2xxFilepath, html2xxFile, 'utf8')
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,107 +0,0 @@
import chokidar from 'chokidar'
import fs from 'fs'
import path from 'path'
import { promisify } from 'util'
import { buildSass } from './build-sass'
import { buildInlineScript } from './build-inline-script'
import { buildSvg } from './build-svg'
import now from 'performance-now'
import debounce from 'lodash-es/debounce'
const writeFile = promisify(fs.writeFile)
const DEBOUNCE = 500
const builders = [
{
watch: 'src/scss',
comment: '<!-- inline CSS -->',
rebuild: buildSass
},
{
watch: 'src/inline-script/inline-script.js',
comment: '<!-- inline JS -->',
rebuild: buildInlineScript
},
{
watch: 'bin/svgs.js',
comment: '<!-- inline SVG -->',
rebuild: buildSvg
}
]
// array of strings and builder functions, we build this on-the-fly
const partials = buildPartials()
function buildPartials () {
let rawTemplate = fs.readFileSync(path.resolve(__dirname, '../src/build/template.html'), 'utf8')
let partials = [rawTemplate]
builders.forEach(builder => {
for (let i = 0; i < partials.length; i++) {
let partial = partials[i]
if (typeof partial !== 'string') {
continue
}
let idx = partial.indexOf(builder.comment)
if (idx !== -1) {
partials.splice(
i,
1,
partial.substring(0, idx),
builder,
partial.substring(idx + builder.comment.length)
)
break
}
}
})
return partials
}
function doWatch () {
// rebuild each of the partials on-the-fly if something changes
partials.forEach(partial => {
if (typeof partial === 'string') {
return
}
chokidar.watch(partial.watch).on('change', debounce(path => {
console.log(`Detected change in ${path}...`)
delete partial.result
buildAll()
}), DEBOUNCE)
})
}
async function buildAll () {
let start = now()
let html = (await Promise.all(partials.map(async partial => {
if (typeof partial === 'string') {
return partial
}
if (!partial.result) {
partial.result = partial.comment + '\n' + (await partial.rebuild())
}
return partial.result
}))).join('')
await writeFile(path.resolve(__dirname, '../src/template.html'), html, 'utf8')
let end = now()
console.log(`Built template.html in ${(end - start).toFixed(2)}ms`)
}
async function main () {
if (process.argv.includes('--watch')) {
doWatch()
} else {
await buildAll()
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,33 +0,0 @@
import path from 'path'
import fs from 'fs'
import { promisify } from 'util'
import CleanCSS from 'clean-css'
const writeFile = promisify(fs.writeFile)
const readFile = promisify(fs.readFile)
const copyFile = promisify(fs.copyFile)
async function compileThirdPartyCss () {
let css = await readFile(path.resolve(__dirname, '../node_modules/emoji-mart/css/emoji-mart.css'), 'utf8')
css = `/* compiled from emoji-mart.css */` + new CleanCSS().minify(css).styles
await writeFile(path.resolve(__dirname, '../static/emoji-mart.css'), css, 'utf8')
}
async function compileThirdPartyJson () {
await copyFile(
path.resolve(__dirname, '../node_modules/emoji-mart/data/all.json'),
path.resolve(__dirname, '../static/emoji-mart-all.json')
)
}
async function main () {
await Promise.all([
compileThirdPartyCss(),
compileThirdPartyJson()
])
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [ "$TRAVIS_BRANCH" = master -a "$TRAVIS_PULL_REQUEST" = false ]; then
yarn run deploy-dev
fi

View File

@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
PATH="$PATH:./node_modules/.bin"
# set up robots.txt
if [[ "$DEPLOY_TYPE" == "dev" ]]; then
printf 'User-agent: *\nDisallow: /' > static/robots.txt
else
rm -f static/robots.txt
fi
# if in travis, use the $NOW_TOKEN
NOW_COMMAND="now --team nolanlawson"
if [[ ! -z "$NOW_TOKEN" ]]; then
NOW_COMMAND="$NOW_COMMAND --token $NOW_TOKEN"
fi
# launch
URL=$($NOW_COMMAND -e SAPPER_TIMESTAMP=$(date +%s%3N))
# fixes issues with now being unavailable immediately
sleep 60
# choose the right alias
NOW_ALIAS="dev.pinafore.social"
if [[ "$DEPLOY_TYPE" == "prod" ]]; then
NOW_ALIAS="pinafore.social"
fi
# alias
$NOW_COMMAND alias "$URL" "$NOW_ALIAS"
# cleanup
$NOW_COMMAND rm pinafore --safe --yes

46
bin/globalize-css.js Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
// Change all the Svelte CSS to just use globals everywhere,
// to reduce CSS size and complexity.
const argv = require('yargs').argv
const path = require('path')
const fs = require('fs')
const pify = require('pify')
const writeFile = pify(fs.writeFile.bind(fs))
const readFile = pify(fs.readFile.bind(fs))
const glob = pify(require('glob'))
const rimraf = pify(require('rimraf'))
const selectorRegex = /\n[ \t\n]*([0-9\w\- \t\n.:#,]+?)[ \t\n]*{/g
const styleRegex = /<style>[\s\S]+?<\/style>/
async function main () {
if (argv.reverse) { // reverse the operation we just did
let tmpComponents = await glob('./routes/**/.tmp-*.html')
for (let filename of tmpComponents) {
let text = await readFile(filename, 'utf8')
await rimraf(filename)
let originalFilename = path.join(path.dirname(filename), path.basename(filename).substring(5))
await writeFile(originalFilename, text, 'utf8')
}
} else { // read all files, copy to tmp files, rewrite files to include global CSS everywhere
let components = await glob('./routes/**/*.html')
for (let filename of components) {
let text = await readFile(filename, 'utf8')
let newText = text.replace(styleRegex, style => {
return style.replace(selectorRegex, selectorMatch => {
return selectorMatch.replace(/\S[^{]+/, selector => `:global(${selector})`)
})
})
let newFilename = path.join(path.dirname(filename), '.tmp-' + path.basename(filename))
await writeFile(newFilename, text, 'utf8')
await writeFile(filename, newText, 'utf8')
}
}
}
Promise.resolve().then(main).catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,28 +0,0 @@
console.log(`
,((*
,((* (,
,((* (((*
,((* (((((.
* ,((* ((((((*
.(/ ,((* (((((((/
.((/ ,((* ((((((((/
,(((/ ,((* (((((((((*
.(((((/ ,((* ((((((((((
,((*
//////////((((/////////////
/((((((((((((((((((((((((((
/((((((((((((((((((((((((,
*(((((((((((((((((((((/.
./((((((((((((((((.
P I N A F O R E
Export successful! Static files are in:
__sapper__/export/
Enjoy Pinafore!
`)

View File

@ -1,38 +1,64 @@
import { actions } from './mastodon-data'
import { users } from '../tests/users'
import { postStatus } from '../src/routes/_api/statuses'
import { followAccount } from '../src/routes/_api/follow'
import { favoriteStatus } from '../src/routes/_api/favorite'
import { reblogStatus } from '../src/routes/_api/reblog'
import { postStatus } from '../routes/_api/statuses'
import { followAccount } from '../routes/_api/follow'
import { favoriteStatus } from '../routes/_api/favorite'
import { reblogStatus } from '../routes/_api/reblog'
import fetch from 'node-fetch'
import FileApi from 'file-api'
import { pinStatus } from '../src/routes/_api/pin'
import { submitMedia } from '../tests/submitMedia'
import path from 'path'
import fs from 'fs'
import FormData from 'form-data'
import { auth } from '../routes/_api/utils'
import { pinStatus } from '../routes/_api/pin'
global.File = FileApi.File
global.FormData = FileApi.FormData
global.fetch = fetch
async function submitMedia (accessToken, filename, alt) {
let form = new FormData()
form.append('file', fs.createReadStream(path.join(__dirname, '../tests/images/' + filename)))
form.append('description', alt)
return new Promise((resolve, reject) => {
form.submit({
host: 'localhost',
port: 3000,
path: '/api/v1/media',
headers: auth(accessToken)
}, (err, res) => {
if (err) {
return reject(err)
}
let data = ''
res.on('data', chunk => {
data += chunk
})
res.on('end', () => resolve(JSON.parse(data)))
})
})
}
export async function restoreMastodonData () {
console.log('Restoring mastodon data...')
let internalIdsToIds = {}
for (let action of actions) {
if (!action.post) {
// If the action is a boost, favorite, etc., then it needs to
// be delayed, otherwise it may appear in an unpredictable order and break the tests.
await new Promise(resolve => setTimeout(resolve, 1000))
}
await new Promise(resolve => setTimeout(resolve, 1000)) // delay so that notifications have proper order
console.log(JSON.stringify(action))
let accessToken = users[action.user].accessToken
if (action.post) {
let { text, media, sensitive, spoiler, privacy, inReplyTo, internalId } = action.post
if (typeof inReplyTo !== 'undefined') {
inReplyTo = internalIdsToIds[inReplyTo]
}
let mediaIds = media && await Promise.all(media.map(async mediaItem => {
let mediaResponse = await submitMedia(accessToken, mediaItem, 'kitten')
return mediaResponse.id
}))
let inReplyToId = inReplyTo && internalIdsToIds[inReplyTo]
let status = await postStatus('localhost:3000', accessToken, text, inReplyToId, mediaIds,
let status = await postStatus('localhost:3000', accessToken, text, inReplyTo, mediaIds,
sensitive, spoiler, privacy || 'public')
if (typeof internalId !== 'undefined') {
internalIdsToIds[internalId] = status.id

View File

@ -1,5 +1,5 @@
import { restoreMastodonData } from './restore-mastodon-data'
import { promisify } from 'util'
import pify from 'pify'
import childProcessPromise from 'child-process-promise'
import path from 'path'
import fs from 'fs'
@ -8,13 +8,13 @@ import mkdirpCB from 'mkdirp'
const exec = childProcessPromise.exec
const spawn = childProcessPromise.spawn
const mkdirp = promisify(mkdirpCB)
const stat = promisify(fs.stat)
const writeFile = promisify(fs.writeFile)
const mkdirp = pify(mkdirpCB)
const stat = pify(fs.stat.bind(fs))
const writeFile = pify(fs.writeFile.bind(fs))
const dir = __dirname
const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
const GIT_TAG = 'v2.7.0'
const GIT_TAG = 'v2.4.0'
const DB_NAME = 'pinafore_development'
const DB_USER = 'pinafore'
@ -43,7 +43,6 @@ async function cloneMastodon () {
} catch (e) {
console.log('Cloning mastodon...')
await exec(`git clone --single-branch --branch master ${GIT_URL} "${mastodonDir}"`)
await exec(`git fetch origin --tags`, { cwd: mastodonDir }) // may already be cloned, e.g. in CI
await exec(`git checkout ${GIT_TAG}`, { cwd: mastodonDir })
await writeFile(path.join(dir, '../mastodon/.env'), envFile, 'utf8')
}
@ -57,24 +56,24 @@ async function setupMastodonDatabase () {
try {
await exec(`dropdb -h 127.0.0.1 -U ${DB_USER} -w ${DB_NAME}`, {
cwd: mastodonDir,
env: Object.assign({ PGPASSWORD: DB_PASS }, process.env)
env: Object.assign({PGPASSWORD: DB_PASS}, process.env)
})
} catch (e) { /* ignore */ }
await exec(`createdb -h 127.0.0.1 -U ${DB_USER} -w ${DB_NAME}`, {
cwd: mastodonDir,
env: Object.assign({ PGPASSWORD: DB_PASS }, process.env)
env: Object.assign({PGPASSWORD: DB_PASS}, process.env)
})
let dumpFile = path.join(dir, '../tests/fixtures/dump.sql')
let dumpFile = path.join(dir, '../fixtures/dump.sql')
await exec(`psql -h 127.0.0.1 -U ${DB_USER} -w -d ${DB_NAME} -f "${dumpFile}"`, {
cwd: mastodonDir,
env: Object.assign({ PGPASSWORD: DB_PASS }, process.env)
env: Object.assign({PGPASSWORD: DB_PASS}, process.env)
})
let tgzFile = path.join(dir, '../tests/fixtures/system.tgz')
let tgzFile = path.join(dir, '../fixtures/system.tgz')
let systemDir = path.join(mastodonDir, 'public/system')
await mkdirp(systemDir)
await exec(`tar -xzf "${tgzFile}"`, { cwd: systemDir })
await exec(`tar -xzf "${tgzFile}"`, {cwd: systemDir})
}
async function runMastodon () {
@ -96,20 +95,12 @@ async function runMastodon () {
'yarn --pure-lockfile'
]
const installedFile = path.join(mastodonDir, 'installed.txt')
try {
await stat(installedFile)
console.log('Already installed Mastodon')
} catch (e) {
console.log('Installing Mastodon...')
for (let cmd of cmds) {
console.log(cmd)
await exec(cmd, { cwd, env })
}
await writeFile(installedFile, '', 'utf8')
for (let cmd of cmds) {
console.log(cmd)
await exec(cmd, {cwd, env})
}
const promise = spawn('foreman', ['start'], { cwd, env })
const log = fs.createWriteStream('mastodon.log', { flags: 'a' })
const promise = spawn('foreman', ['start'], {cwd, env})
const log = fs.createWriteStream('mastodon.log', {flags: 'a'})
childProc = promise.childProcess
childProc.stdout.pipe(log)
childProc.stderr.pipe(log)

View File

@ -2,39 +2,20 @@
set -e
if [[ "$COMMAND" = deploy-all-travis || "$COMMAND" = test-unit ]]; then
if [[ "$COMMAND" = deploy-dev-travis ]]; then
exit 0 # no need to setup mastodon in this case
fi
# install ruby
source "$HOME/.rvm/scripts/rvm"
rvm install 2.6.0
rvm use 2.6.0
rvm install 2.5.1
rvm use 2.5.1
# fix for redis IPv6 issue
# https://travis-ci.community/t/trusty-environment-redis-server-not-starting-with-redis-tools-installed/650/2
sudo sed -e 's/^bind.*/bind 127.0.0.1/' /etc/redis/redis.conf > redis.conf
sudo mv redis.conf /etc/redis
sudo service redis-server start
echo PING | nc localhost 6379 # check redis running
# install ffmpeg because it's not in Trusty
if [ ! -f /home/travis/ffmpeg-static/ffmpeg ]; then
rm -fr /home/travis/ffmpeg-static
mkdir -p /home/travis/ffmpeg-static
curl -sL \
-A 'https://github.com/nolanlawson/pinafore' \
-o ffmpeg.tar.xz \
'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz'
tar -x -C /home/travis/ffmpeg-static --strip-components 1 -f ffmpeg.tar.xz --wildcards '*/ffmpeg' --wildcards '*/ffprobe'
fi
sudo ln -s /home/travis/ffmpeg-static/ffmpeg /usr/local/bin/ffmpeg
sudo ln -s /home/travis/ffmpeg-static/ffprobe /usr/local/bin/ffprobe
# check versions
sudo -E add-apt-repository -y ppa:mc3man/trusty-media
sudo -E apt-get update
sudo -E apt-get install -y ffmpeg
ruby --version
node --version
yarn --version
npm --version
postgres --version
redis-server --version
ffmpeg -version

View File

@ -1,47 +1,40 @@
module.exports = [
{ id: 'pinafore-logo', src: 'src/static/sailboat.svg' },
{ id: 'fa-bell', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell.svg' },
{ id: 'fa-users', src: 'src/thirdparty/font-awesome-svg-png/white/svg/users.svg' },
{ id: 'fa-globe', src: 'src/thirdparty/font-awesome-svg-png/white/svg/globe.svg' },
{ id: 'fa-gear', src: 'src/thirdparty/font-awesome-svg-png/white/svg/gear.svg' },
{ id: 'fa-reply', src: 'src/thirdparty/font-awesome-svg-png/white/svg/reply.svg' },
{ id: 'fa-reply-all', src: 'src/thirdparty/font-awesome-svg-png/white/svg/reply-all.svg' },
{ id: 'fa-retweet', src: 'src/thirdparty/font-awesome-svg-png/white/svg/retweet.svg' },
{ id: 'fa-star', src: 'src/thirdparty/font-awesome-svg-png/white/svg/star.svg' },
{ id: 'fa-star-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/star-o.svg' },
{ id: 'fa-ellipsis-h', src: 'src/thirdparty/font-awesome-svg-png/white/svg/ellipsis-h.svg' },
{ id: 'fa-spinner', src: 'src/thirdparty/font-awesome-svg-png/white/svg/spinner.svg' },
{ id: 'fa-user', src: 'src/thirdparty/font-awesome-svg-png/white/svg/user.svg' },
{ id: 'fa-play-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/play-circle.svg' },
{ id: 'fa-eye', src: 'src/thirdparty/font-awesome-svg-png/white/svg/eye.svg' },
{ id: 'fa-eye-slash', src: 'src/thirdparty/font-awesome-svg-png/white/svg/eye-slash.svg' },
{ id: 'fa-lock', src: 'src/thirdparty/font-awesome-svg-png/white/svg/lock.svg' },
{ id: 'fa-unlock', src: 'src/thirdparty/font-awesome-svg-png/white/svg/unlock.svg' },
{ id: 'fa-envelope', src: 'src/thirdparty/font-awesome-svg-png/white/svg/envelope.svg' },
{ id: 'fa-user-times', src: 'src/thirdparty/font-awesome-svg-png/white/svg/user-times.svg' },
{ id: 'fa-user-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/user-plus.svg' },
{ id: 'fa-external-link', src: 'src/thirdparty/font-awesome-svg-png/white/svg/external-link.svg' },
{ id: 'fa-search', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search.svg' },
{ id: 'fa-comments', src: 'src/thirdparty/font-awesome-svg-png/white/svg/comments.svg' },
{ id: 'fa-paperclip', src: 'src/thirdparty/font-awesome-svg-png/white/svg/paperclip.svg' },
{ id: 'fa-thumb-tack', src: 'src/thirdparty/font-awesome-svg-png/white/svg/thumb-tack.svg' },
{ id: 'fa-bars', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bars.svg' },
{ id: 'fa-ban', src: 'src/thirdparty/font-awesome-svg-png/white/svg/ban.svg' },
{ id: 'fa-camera', src: 'src/thirdparty/font-awesome-svg-png/white/svg/camera.svg' },
{ id: 'fa-smile', src: 'src/thirdparty/font-awesome-svg-png/white/svg/smile-o.svg' },
{ id: 'fa-exclamation-triangle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/exclamation-triangle.svg' },
{ id: 'fa-check', src: 'src/thirdparty/font-awesome-svg-png/white/svg/check.svg' },
{ id: 'fa-trash', src: 'src/thirdparty/font-awesome-svg-png/white/svg/trash-o.svg' },
{ id: 'fa-hourglass', src: 'src/thirdparty/font-awesome-svg-png/white/svg/hourglass.svg' },
{ id: 'fa-pencil', src: 'src/thirdparty/font-awesome-svg-png/white/svg/pencil.svg' },
{ id: 'fa-times', src: 'src/thirdparty/font-awesome-svg-png/white/svg/times.svg' },
{ id: 'fa-volume-off', src: 'src/thirdparty/font-awesome-svg-png/white/svg/volume-off.svg' },
{ id: 'fa-volume-up', src: 'src/thirdparty/font-awesome-svg-png/white/svg/volume-up.svg' },
{ id: 'fa-link', src: 'src/thirdparty/font-awesome-svg-png/white/svg/link.svg' },
{ id: 'fa-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle.svg' },
{ id: 'fa-circle-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle-o.svg' },
{ id: 'fa-angle-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-left.svg' },
{ id: 'fa-angle-right', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-right.svg' },
{ id: 'fa-search-minus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-minus.svg' },
{ id: 'fa-search-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-plus.svg' }
{id: 'pinafore-logo', src: 'original-assets/sailboat.svg', title: 'Home'},
{id: 'fa-bell', src: 'node_modules/font-awesome-svg-png/white/svg/bell.svg', title: 'Notifications'},
{id: 'fa-users', src: 'node_modules/font-awesome-svg-png/white/svg/users.svg', title: 'Local'},
{id: 'fa-globe', src: 'node_modules/font-awesome-svg-png/white/svg/globe.svg', title: 'Federated'},
{id: 'fa-gear', src: 'node_modules/font-awesome-svg-png/white/svg/gear.svg', title: 'Settings'},
{id: 'fa-reply', src: 'node_modules/font-awesome-svg-png/white/svg/reply.svg', title: 'Reply'},
{id: 'fa-reply-all', src: 'node_modules/font-awesome-svg-png/white/svg/reply-all.svg', title: 'Reply to thread'},
{id: 'fa-retweet', src: 'node_modules/font-awesome-svg-png/white/svg/retweet.svg', title: 'Boost'},
{id: 'fa-star', src: 'node_modules/font-awesome-svg-png/white/svg/star.svg', title: 'Favorite'},
{id: 'fa-ellipsis-h', src: 'node_modules/font-awesome-svg-png/white/svg/ellipsis-h.svg', title: 'More'},
{id: 'fa-spinner', src: 'node_modules/font-awesome-svg-png/white/svg/spinner.svg', title: 'Spinner'},
{id: 'fa-user', src: 'node_modules/font-awesome-svg-png/white/svg/user.svg', title: 'Empty user profile'},
{id: 'fa-play-circle', src: 'node_modules/font-awesome-svg-png/white/svg/play-circle.svg', title: 'Play'},
{id: 'fa-eye', src: 'node_modules/font-awesome-svg-png/white/svg/eye.svg', title: 'Show Sensitive Content'},
{id: 'fa-eye-slash', src: 'node_modules/font-awesome-svg-png/white/svg/eye-slash.svg', title: 'Hide Sensitive Content'},
{id: 'fa-lock', src: 'node_modules/font-awesome-svg-png/white/svg/lock.svg', title: 'Locked'},
{id: 'fa-unlock', src: 'node_modules/font-awesome-svg-png/white/svg/unlock.svg', title: 'Unlocked'},
{id: 'fa-envelope', src: 'node_modules/font-awesome-svg-png/white/svg/envelope.svg', title: 'Sealed Envelope'},
{id: 'fa-user-times', src: 'node_modules/font-awesome-svg-png/white/svg/user-times.svg', title: 'Stop Following'},
{id: 'fa-user-plus', src: 'node_modules/font-awesome-svg-png/white/svg/user-plus.svg', title: 'Follow'},
{id: 'fa-external-link', src: 'node_modules/font-awesome-svg-png/white/svg/external-link.svg', title: 'External Link'},
{id: 'fa-search', src: 'node_modules/font-awesome-svg-png/white/svg/search.svg', title: 'Search'},
{id: 'fa-comments', src: 'node_modules/font-awesome-svg-png/white/svg/comments.svg', title: 'Conversations'},
{id: 'fa-paperclip', src: 'node_modules/font-awesome-svg-png/white/svg/paperclip.svg', title: 'Paperclip'},
{id: 'fa-thumb-tack', src: 'node_modules/font-awesome-svg-png/white/svg/thumb-tack.svg', title: 'Thumbtack'},
{id: 'fa-bars', src: 'node_modules/font-awesome-svg-png/white/svg/bars.svg', title: 'List'},
{id: 'fa-ban', src: 'node_modules/font-awesome-svg-png/white/svg/ban.svg', title: 'Ban'},
{id: 'fa-camera', src: 'node_modules/font-awesome-svg-png/white/svg/camera.svg', title: 'Add media'},
{id: 'fa-smile', src: 'node_modules/font-awesome-svg-png/white/svg/smile-o.svg', title: 'Custom emoji'},
{id: 'fa-exclamation-triangle', src: 'node_modules/font-awesome-svg-png/white/svg/exclamation-triangle.svg', title: 'Content warning'},
{id: 'fa-check', src: 'node_modules/font-awesome-svg-png/white/svg/check.svg', title: 'Check'},
{id: 'fa-trash', src: 'node_modules/font-awesome-svg-png/white/svg/trash-o.svg', title: 'Delete'},
{id: 'fa-hourglass', src: 'node_modules/font-awesome-svg-png/white/svg/hourglass.svg', title: 'Follow requested'},
{id: 'fa-pencil', src: 'node_modules/font-awesome-svg-png/white/svg/pencil.svg', title: 'Compose'},
{id: 'fa-times', src: 'node_modules/font-awesome-svg-png/white/svg/times.svg', title: 'Close'},
{id: 'fa-volume-off', src: 'node_modules/font-awesome-svg-png/white/svg/volume-off.svg', title: 'Mute'},
{id: 'fa-volume-up', src: 'node_modules/font-awesome-svg-png/white/svg/volume-up.svg', title: 'Unmute'},
{id: 'fa-link', src: 'node_modules/font-awesome-svg-png/white/svg/link.svg', title: 'Link'}
]

View File

@ -1,11 +1,7 @@
import fetch from 'node-fetch'
import { actions } from './mastodon-data'
const numStatuses = actions
.map(_ => _.post || _.boost)
.filter(Boolean)
.filter(_ => _.privacy !== 'direct')
.length
const numStatuses = actions.filter(_ => _.post || _.boost).length
async function waitForMastodonData () {
while (true) {

View File

@ -1,9 +1,6 @@
## Theming
This document describes how to write your own theme for Pinafore.
First, create a file `scss/themes/foobar.scss`, write some SCSS inside and add
the following at the bottom of `scss/themes/foobar.scss`.
Create a file `scss/themes/foobar.scss`, write some SCSS inside and add the following at the bottom of `scss/themes/foobar.scss`.
```scss
@import "_base.scss";
@ -12,23 +9,50 @@ body.theme-foobar {
}
```
> Note: You can find all the SCSS variables available in `scss/themes/_default.scss`
> while the all CSS Custom Properties available are listed in `scss/themes/_base.scss`.
> Note: You can find all the SCSS variables available in `scss/themes/_default.scss` while the all CSS Custom Properties available are listed in `scss/themes/_base.scss`.
Then, Add your theme to `src/routes/_static/themes.js`
Add the CSS class you just define to `scss/themes/_offlines`.
```scss
...
body.offline,
body.theme-foobar.offline, // <-
body.theme-hotpants.offline,
body.theme-majesty.offline,
body.theme-oaken.offline,
body.theme-scarlet.offline,
body.theme-seafoam.offline,
body.theme-gecko.offline {
@include baseTheme();
}
```
Add your theme to `routes/_static/themes.js`
```js
const themes = [
...
{
name: 'foobar',
label: 'Foobar', // user-visible name
color: 'magenta', // main theme color
dark: true // whether it's a dark theme or not
label: 'Foobar'
}
]
export { themes }
```
Start the development server (`yarn run dev`), go to
`http://localhost:4002/settings/instances/your-instance-name` and select your
newly-created theme. Once you've done that, you can update your theme, and refresh
the page to see the change (you don't have to restart the server).
Add your theme in `inline-script.js`.
```js
window.__themeColors = {
'default': "royalblue",
scarlet: "#e04e41",
seafoam: "#177380",
hotpants: "hotpink",
oaken: "saddlebrown",
majesty: "blueviolet",
gecko: "#4ab92f",
foobar: "#BADA55", // <-
offline: "#999999"
}
```
Start the development server (`npm run dev`), go to `http://localhost:4002/settings/instances/your-instance-name` and select your newly created theme. Once you've done that, you can update your theme, and refresh the page to see the change (you don't have to restart the server).

File diff suppressed because it is too large Load Diff

BIN
fixtures/system.tgz Normal file

Binary file not shown.

37
inline-script.js Normal file
View File

@ -0,0 +1,37 @@
// For perf reasons, this script is run inline to quickly set certain styles.
// To allow CSP to work correctly, we also calculate a sha256 hash during
// the build process and write it to inline-script-checksum.json.
window.__themeColors = {
'default': '#1ea21e',
royal: 'royalblue',
scarlet: '#e04e41',
seafoam: '#177380',
hotpants: 'hotpink',
oaken: 'saddlebrown',
majesty: 'blueviolet',
gecko: '#4ab92f',
ozark: '#5263af',
cobalt: '#08439b',
sorcery: '#ae91e8',
offline: '#999999'
}
if (localStorage.store_currentInstance && localStorage.store_instanceThemes) {
let safeParse = (str) => str === 'undefined' ? undefined : JSON.parse(str)
let theme = safeParse(localStorage.store_instanceThemes)[safeParse(localStorage.store_currentInstance)]
if (theme && theme !== 'default') {
document.body.classList.add(`theme-${theme}`)
let link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `/theme-${theme}.css`
document.head.appendChild(link)
if (window.__themeColors[theme]) {
document.getElementById('theThemeColor').content = window.__themeColors[theme]
}
}
}
if (!localStorage.store_currentInstance) {
// if not logged in, show all these 'hidden-from-ssr' elements
let style = document.createElement('style')
style.textContent = '.hidden-from-ssr { opacity: 1 !important; }'
document.head.appendChild(style)
}

View File

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 708 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 403 B

View File

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

12932
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,24 @@
{
"name": "pinafore",
"description": "Alternative web client for Mastodon",
"version": "1.0.1",
"version": "0.5.2",
"scripts": {
"lint": "standard && standard --plugin html 'src/routes/**/*.html'",
"lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'",
"dev": "run-s build-template-html build-third-party-assets serve-dev",
"serve-dev": "run-p --race build-template-html-watch sapper-dev",
"sapper-dev": "cross-env NODE_ENV=development PORT=4002 sapper dev",
"sapper-prod": "cross-env PORT=4002 node __sapper__/build",
"before-build": "run-s build-template-html build-third-party-assets",
"build": "cross-env NODE_ENV=production run-s build-steps",
"build-steps": "run-s before-build sapper-build",
"lint": "standard && standard --plugin html 'routes/**/*.html'",
"lint-fix": "standard --fix && standard --fix --plugin html 'routes/**/*.html'",
"dev": "run-s build-svg build-inline-script serve-dev",
"serve-dev": "run-p --race build-sass-watch serve",
"serve": "node server.js",
"build": "cross-env NODE_ENV=production npm run build-steps",
"build-steps": "run-s globalize-css build-sass build-svg build-inline-script sapper-build deglobalize-css",
"sapper-build": "sapper build",
"start": "cross-env NODE_ENV=production run-s sapper-prod",
"start": "cross-env NODE_ENV=production npm run serve",
"build-and-start": "run-s build start",
"build-template-html": "node -r esm ./bin/build-template-html.js",
"build-template-html-watch": "node -r esm ./bin/build-template-html.js --watch",
"build-third-party-assets": "node -r esm ./bin/build-third-party-assets.js",
"build-svg": "node ./bin/build-svg.js",
"build-inline-script": "node ./bin/build-inline-script.js",
"build-sass": "node ./bin/build-sass.js",
"build-sass-watch": "node ./bin/build-sass.js --watch",
"run-mastodon": "node -r esm ./bin/run-mastodon.js",
"test": "cross-env BROWSER=chrome:headless run-s test-browser",
"test": "cross-env BROWSER=chrome:headless npm run test-browser",
"test-browser": "run-p --race run-mastodon build-and-start test-mastodon",
"test-mastodon": "run-s wait-for-mastodon-to-start wait-for-mastodon-data testcafe",
"test-browser-suite0": "run-p --race run-mastodon build-and-start test-mastodon-suite0",
@ -29,87 +28,82 @@
"testcafe": "run-s testcafe-suite0 testcafe-suite1",
"testcafe-suite0": "cross-env-shell testcafe --hostname localhost --skip-js-errors -c 4 $BROWSER tests/spec/0*",
"testcafe-suite1": "cross-env-shell testcafe --hostname localhost --skip-js-errors $BROWSER tests/spec/1*",
"test-unit": "mocha -r esm tests/unit/",
"wait-for-mastodon-to-start": "node -r esm bin/wait-for-mastodon-to-start.js",
"wait-for-mastodon-data": "node -r esm bin/wait-for-mastodon-data.js",
"deploy-prod": "DEPLOY_TYPE=prod ./bin/deploy.sh",
"deploy-dev": "DEPLOY_TYPE=dev ./bin/deploy.sh",
"deploy-all-travis": "./bin/deploy-all-travis.sh",
"backup-mastodon-data": "./bin/backup-mastodon-data.sh",
"sapper-export": "sapper export",
"print-export-info": "node ./bin/print-export-info.js",
"export-steps": "run-s before-build sapper-export print-export-info",
"export": "cross-env NODE_ENV=production run-s export-steps"
"globalize-css": "node ./bin/globalize-css.js",
"deglobalize-css": "node ./bin/globalize-css.js --reverse",
"stage-dev": "printf 'User-agent: *\nDisallow: /' > assets/robots.txt",
"stage-prod": "rm -f assets/robots.txt",
"launch": "now -e SAPPER_TIMESTAMP=$(date +%s%3N) --team nolanlawson && sleep 60",
"launch-travis": "now -e SAPPER_TIMESTAMP=$(date +%s%3N) --team nolanlawson --token $NOW_TOKEN && sleep 60",
"alias-prod": "now alias pinafore.social --team nolanlawson",
"alias-dev": "now alias dev.pinafore.social --team nolanlawson",
"alias-dev-travis": "now alias dev.pinafore.social --team nolanlawson --token $NOW_TOKEN",
"cleanup": "now rm pinafore --safe --yes --team nolanlawson",
"cleanup-travis": "now rm pinafore --safe --yes --team nolanlawson --token $NOW_TOKEN",
"deploy-prod": "run-s stage-prod launch alias-prod cleanup",
"deploy-dev": "run-s stage-dev launch alias-dev cleanup",
"deploy-dev-travis": "if [ $TRAVIS_BRANCH = master -a $TRAVIS_PULL_REQUEST = false ]; then run-s stage-dev launch-travis alias-dev-travis cleanup-travis; fi",
"backup-mastodon-data": "PGPASSWORD=pinafore pg_dump -U pinafore -w mastodon_development > fixtures/dump.sql && cd mastodon/public/system && tar -czf ../../../fixtures/system.tgz ."
},
"dependencies": {
"@gamestdio/websocket": "^0.2.8",
"@webcomponents/custom-elements": "^1.2.1",
"@gamestdio/websocket": "^0.2.7",
"a11y-dialog": "^4.0.1",
"browserslist": "^4.0.2",
"cheerio": "^1.0.0-rc.2",
"child-process-promise": "^2.2.1",
"chokidar": "^2.0.4",
"circular-dependency-plugin": "^5.0.2",
"clean-css": "^4.2.1",
"compression": "^1.7.3",
"cross-env": "^5.2.0",
"css-dedoupe": "^0.1.1",
"css-loader": "^2.1.0",
"emoji-mart": "github:nolanlawson/emoji-mart#for-pinafore-1",
"emoji-regex": "^7.0.3",
"encoding": "^0.1.12",
"css-loader": "^1.0.0",
"escape-html": "^1.0.3",
"esm": "^3.1.4",
"events-light": "^1.0.5",
"express": "^4.16.4",
"esm": "^3.0.77",
"events": "^3.0.0",
"express": "^4.16.3",
"fg-loadcss": "^2.0.1",
"file-api": "^0.10.4",
"file-drop-element": "0.0.9",
"form-data": "^2.3.3",
"glob": "^7.1.3",
"helmet": "^3.15.0",
"idb-keyval": "^3.1.0",
"font-awesome-svg-png": "^1.2.2",
"form-data": "^2.3.2",
"glob": "^7.1.2",
"helmet": "^3.13.0",
"indexeddb-getall-shim": "^1.3.5",
"inferno-compat": "^7.1.0",
"intersection-observer": "^0.5.1",
"localstorage-memory": "^1.0.3",
"lodash-es": "^4.17.11",
"intersection-observer": "^0.5.0",
"lodash-es": "^4.17.10",
"lodash-webpack-plugin": "^0.11.5",
"mini-css-extract-plugin": "^0.4.1",
"mkdirp": "^0.5.1",
"node-fetch": "^2.3.0",
"node-sass": "^4.11.0",
"npm-run-all": "^4.1.5",
"node-fetch": "^2.2.0",
"node-sass": "^4.9.3",
"npm-run-all": "^4.1.3",
"optimize-css-assets-webpack-plugin": "^5.0.0",
"p-any": "^1.1.0",
"page-lifecycle": "^0.1.1",
"performance-now": "^2.1.0",
"pinch-zoom-element": "^1.1.0",
"prop-types": "^15.6.2",
"quick-lru": "^2.0.0",
"remount": "^0.9.3",
"pify": "^4.0.0",
"quick-lru": "^1.1.0",
"requestidlecallback": "^0.3.0",
"rollup": "^1.1.2",
"rollup-plugin-replace": "^2.1.0",
"rollup-plugin-terser": "^4.0.3",
"sapper": "^0.25.0",
"sapper": "github:nolanlawson/sapper#for-pinafore-7",
"serve-static": "^1.13.2",
"shrink-ray-current": "^2.1.2",
"stringz": "^1.0.0",
"svelte": "^2.16.0",
"style-loader": "^0.22.1",
"svelte": "^2.11.0",
"svelte-extras": "^2.0.2",
"svelte-loader": "^2.12.0",
"svelte-loader": "^2.10.1",
"svelte-transitions": "^1.2.0",
"svgo": "^1.1.1",
"terser-webpack-plugin": "^1.2.1",
"text-encoding": "^0.7.0",
"svgo": "^1.0.5",
"timeago.js": "^3.0.2",
"tiny-queue": "^0.2.1",
"uuid": "^3.3.2",
"uglifyjs-webpack-plugin": "^1.3.0",
"web-animations-js": "^2.3.1",
"webpack": "^4.29.0",
"webpack-bundle-analyzer": "^3.0.3"
"webpack": "^4.16.5",
"webpack-bundle-analyzer": "^2.13.1",
"yargs": "^12.0.1"
},
"devDependencies": {
"assert": "^1.4.1",
"eslint-plugin-html": "^5.0.0",
"mocha": "^5.2.0",
"now": "^13.1.2",
"standard": "^12.0.1",
"testcafe": "^1.0.0"
"eslint-plugin-html": "^4.0.5",
"now": "^11.3.10",
"standard": "^11.0.1",
"testcafe": "^0.21.1"
},
"engines": {
"node": ">= 8"
@ -142,18 +136,12 @@
"btoa",
"Blob",
"Element",
"Image",
"NotificationEvent",
"NodeList",
"DOMParser",
"CSS",
"customElements"
"Image"
],
"ignore": [
"dist",
"src/routes/_utils/asyncModules.js",
"src/routes/_utils/asyncPolyfills.js",
"src/routes/_components/dialog/asyncDialogs.js"
"routes/_utils/asyncModules.js",
"routes/_components/dialog/asyncDialogs.js"
]
},
"esm": {
@ -166,26 +154,27 @@
"NODE_ENV": "production"
},
"files": [
"assets",
"bin",
"inline-script.js",
"original-static",
"original-assets",
"routes",
"scss",
"src",
"src-build",
"static",
"templates",
"package.json",
"thirdparty",
"webpack",
"webpack.config.js",
"yarn.lock"
"package-lock.json",
"server.js",
"inline-script.js",
"webpack.client.config.js",
"webpack.server.config.js"
],
"engines": {
"node": "^10.0.0"
"node": "^8.0.0"
}
},
"greenkeeper": {
"ignore": [
"sapper"
"sapper",
"a11y-dialog"
]
},
"repository": {

View File

@ -0,0 +1,61 @@
import { getAccount, getRelationship } from '../_api/user'
import {
getAccount as getAccountFromDatabase,
setAccount as setAccountInDatabase,
getRelationship as getRelationshipFromDatabase,
setRelationship as setRelationshipInDatabase
} from '../_database/accountsAndRelationships'
import { store } from '../_store/store'
async function updateAccount (accountId, instanceName, accessToken) {
let localPromise = getAccountFromDatabase(instanceName, accountId)
let remotePromise = getAccount(instanceName, accessToken, accountId).then(account => {
/* no await */ setAccountInDatabase(instanceName, account)
return account
})
try {
store.set({currentAccountProfile: (await localPromise)})
} catch (e) {
console.error(e)
}
try {
store.set({currentAccountProfile: (await remotePromise)})
} catch (e) {
console.error(e)
}
}
async function updateRelationship (accountId, instanceName, accessToken) {
let localPromise = getRelationshipFromDatabase(instanceName, accountId)
let remotePromise = getRelationship(instanceName, accessToken, accountId).then(relationship => {
/* no await */ setRelationshipInDatabase(instanceName, relationship)
return relationship
})
try {
store.set({currentAccountRelationship: (await localPromise)})
} catch (e) {
console.error(e)
}
try {
store.set({currentAccountRelationship: (await remotePromise)})
} catch (e) {
console.error(e)
}
}
export async function clearProfileAndRelationship () {
store.set({
currentAccountProfile: null,
currentAccountRelationship: null
})
}
export async function updateProfileAndRelationship (accountId) {
let { currentInstance, accessToken } = store.get()
await Promise.all([
updateAccount(accountId, currentInstance, accessToken),
updateRelationship(accountId, currentInstance, accessToken)
])
}

View File

@ -1,11 +1,11 @@
import { getAccessTokenFromAuthCode, registerApplication, generateAuthLink } from '../_api/oauth'
import { getInstanceInfo } from '../_api/instance'
import { goto } from '../../../__sapper__/client'
import { DEFAULT_THEME, switchToTheme } from '../_utils/themeEngine'
import { goto } from 'sapper/runtime.js'
import { switchToTheme } from '../_utils/themeEngine'
import { store } from '../_store/store'
import { updateVerifyCredentialsForInstance } from './instances'
import { updateCustomEmojiForInstance } from './emoji'
import { database } from '../_database/database'
import { setInstanceInfo as setInstanceInfoInDatabase } from '../_database/meta'
const REDIRECT_URI = (typeof location !== 'undefined'
? location.origin : 'https://pinafore.social') + '/settings/instances/add'
@ -14,13 +14,12 @@ async function redirectToOauth () {
let { instanceNameInSearch, loggedInInstances } = store.get()
instanceNameInSearch = instanceNameInSearch.replace(/^https?:\/\//, '').replace(/\/$/, '').replace('/$', '').toLowerCase()
if (Object.keys(loggedInInstances).includes(instanceNameInSearch)) {
let err = new Error(`You've already logged in to ${instanceNameInSearch}`)
err.knownError = true
throw err
store.set({logInToInstanceError: `You've already logged in to ${instanceNameInSearch}`})
return
}
let registrationPromise = registerApplication(instanceNameInSearch, REDIRECT_URI)
let instanceInfo = await getInstanceInfo(instanceNameInSearch)
await database.setInstanceInfo(instanceNameInSearch, instanceInfo) // cache for later
await setInstanceInfoInDatabase(instanceNameInSearch, instanceInfo) // cache for later
let instanceData = await registrationPromise
store.set({
currentRegisteredInstanceName: instanceNameInSearch,
@ -45,17 +44,16 @@ export async function logInToInstance () {
} catch (err) {
console.error(err)
let error = `${err.message || err.name}. ` +
(err.knownError ? '' : (navigator.onLine
? `Is this a valid Mastodon instance? Is a browser extension
blocking the request? Are you in private browsing mode?`
: `Are you offline?`))
(navigator.onLine
? `Is this a valid Mastodon instance? Is a browser extension blocking the request?`
: `Are you offline?`)
let { instanceNameInSearch } = store.get()
store.set({
logInToInstanceError: error,
logInToInstanceErrorForText: instanceNameInSearch
})
} finally {
store.set({ logInToInstanceLoading: false })
store.set({logInToInstanceLoading: false})
}
}
@ -69,7 +67,7 @@ async function registerNewInstance (code) {
REDIRECT_URI
)
let { loggedInInstances, loggedInInstancesInOrder, instanceThemes } = store.get()
instanceThemes[currentRegisteredInstanceName] = DEFAULT_THEME
instanceThemes[currentRegisteredInstanceName] = 'default'
loggedInInstances[currentRegisteredInstanceName] = instanceData
if (!loggedInInstancesInOrder.includes(currentRegisteredInstanceName)) {
loggedInInstancesInOrder.push(currentRegisteredInstanceName)
@ -84,7 +82,7 @@ async function registerNewInstance (code) {
instanceThemes: instanceThemes
})
store.save()
switchToTheme(DEFAULT_THEME)
switchToTheme('default')
// fire off these requests so they're cached
/* no await */ updateVerifyCredentialsForInstance(currentRegisteredInstanceName)
/* no await */ updateCustomEmojiForInstance(currentRegisteredInstanceName)
@ -93,11 +91,11 @@ async function registerNewInstance (code) {
export async function handleOauthCode (code) {
try {
store.set({ logInToInstanceLoading: true })
store.set({logInToInstanceLoading: true})
await registerNewInstance(code)
} catch (err) {
store.set({ logInToInstanceError: `${err.message || err.name}. Failed to connect to instance.` })
store.set({logInToInstanceError: `${err.message || err.name}. Failed to connect to instance.`})
} finally {
store.set({ logInToInstanceLoading: false })
store.set({logInToInstanceLoading: false})
}
}

View File

@ -1,11 +1,15 @@
import throttle from 'lodash-es/throttle'
import { mark, stop } from '../_utils/marks'
import { store } from '../_store/store'
import uniqBy from 'lodash-es/uniqBy'
import uniq from 'lodash-es/uniq'
import isEqual from 'lodash-es/isEqual'
import { database } from '../_database/database'
import { concat } from '../_utils/arrays'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import {
insertTimelineItems as insertTimelineItemsInDatabase
} from '../_database/timelines/insertion'
import { runMediumPriorityTask } from '../_utils/runMediumPriorityTask'
const STREAMING_THROTTLE_DELAY = 3000
function getExistingItemIdsSet (instanceName, timelineName) {
let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || []
@ -25,31 +29,14 @@ async function insertUpdatesIntoTimeline (instanceName, timelineName, updates) {
return
}
await database.insertTimelineItems(instanceName, timelineName, updates)
await insertTimelineItemsInDatabase(instanceName, timelineName, updates)
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let newItemIdsToAdd = uniq(concat(itemIdsToAdd, updates.map(_ => _.id)))
let newItemIdsToAdd = uniq([].concat(itemIdsToAdd).concat(updates.map(_ => _.id)))
if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for timeline', timelineName)
store.setForTimeline(instanceName, timelineName, { itemIdsToAdd: newItemIdsToAdd })
}
}
function isValidStatusForThread (thread, timelineName, itemIdsToAdd) {
let focusedStatusId = timelineName.split('/')[1] // e.g. "status/123456"
let focusedStatusIdx = thread.indexOf(focusedStatusId)
return status => {
let repliedToStatusIdx = thread.indexOf(status.in_reply_to_id)
return (
// A reply to an ancestor status is not valid for this thread, but for the focused status
// itself or any of its descendents, it is valid.
repliedToStatusIdx >= focusedStatusIdx &&
// Not a duplicate
!thread.includes(status.id) &&
// Not already about to be added
!itemIdsToAdd.includes(status.id)
)
store.setForTimeline(instanceName, timelineName, {itemIdsToAdd: newItemIdsToAdd})
}
}
@ -59,20 +46,21 @@ async function insertUpdatesIntoThreads (instanceName, updates) {
}
let threads = store.getThreads(instanceName)
let timelineNames = Object.keys(threads)
for (let timelineName of timelineNames) {
let thread = threads[timelineName]
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let validUpdates = updates.filter(isValidStatusForThread(thread, timelineName, itemIdsToAdd))
if (!validUpdates.length) {
for (let timelineName of Object.keys(threads)) {
let thread = threads[timelineName]
let updatesForThisThread = updates.filter(
status => thread.includes(status.in_reply_to_id) && !thread.includes(status.id)
)
if (!updatesForThisThread.length) {
continue
}
let newItemIdsToAdd = uniq(concat(itemIdsToAdd, validUpdates.map(_ => _.id)))
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let newItemIdsToAdd = uniq([].concat(itemIdsToAdd).concat(updatesForThisThread.map(_ => _.id)))
if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for thread', timelineName)
store.setForTimeline(instanceName, timelineName, { itemIdsToAdd: newItemIdsToAdd })
store.setForTimeline(instanceName, timelineName, {itemIdsToAdd: newItemIdsToAdd})
}
}
}
@ -82,7 +70,7 @@ async function processFreshUpdates (instanceName, timelineName) {
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates')
if (freshUpdates && freshUpdates.length) {
let updates = freshUpdates.slice()
store.setForTimeline(instanceName, timelineName, { freshUpdates: [] })
store.setForTimeline(instanceName, timelineName, {freshUpdates: []})
await Promise.all([
insertUpdatesIntoTimeline(instanceName, timelineName, updates),
@ -92,11 +80,11 @@ async function processFreshUpdates (instanceName, timelineName) {
stop('processFreshUpdates')
}
function lazilyProcessFreshUpdates (instanceName, timelineName) {
scheduleIdleTask(() => {
const lazilyProcessFreshUpdates = throttle((instanceName, timelineName) => {
runMediumPriorityTask(() => {
/* no await */ processFreshUpdates(instanceName, timelineName)
})
}
}, STREAMING_THROTTLE_DELAY)
export function addStatusOrNotification (instanceName, timelineName, newStatusOrNotification) {
addStatusesOrNotifications(instanceName, timelineName, [newStatusOrNotification])
@ -105,8 +93,8 @@ export function addStatusOrNotification (instanceName, timelineName, newStatusOr
export function addStatusesOrNotifications (instanceName, timelineName, newStatusesOrNotifications) {
console.log('addStatusesOrNotifications', Date.now())
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates') || []
freshUpdates = concat(freshUpdates, newStatusesOrNotifications)
freshUpdates = [].concat(freshUpdates).concat(newStatusesOrNotifications)
freshUpdates = uniqBy(freshUpdates, _ => _.id)
store.setForTimeline(instanceName, timelineName, { freshUpdates: freshUpdates })
store.setForTimeline(instanceName, timelineName, {freshUpdates: freshUpdates})
lazilyProcessFreshUpdates(instanceName, timelineName)
}

View File

@ -6,8 +6,8 @@ export async function insertUsername (realm, username, startIndex, endIndex) {
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}@${username} ${post}`
store.setComposeData(realm, { text: newText })
store.setForAutosuggest(currentInstance, realm, { autosuggestSearchResults: [] })
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionUsername (realm) {
@ -29,8 +29,8 @@ export function insertEmojiAtPosition (realm, emoji, startIndex, endIndex) {
let pre = oldText.substring(0, startIndex)
let post = oldText.substring(endIndex)
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, { text: newText })
store.setForAutosuggest(currentInstance, realm, { autosuggestSearchResults: [] })
store.setComposeData(realm, {text: newText})
store.setForAutosuggest(currentInstance, realm, {autosuggestSearchResults: []})
}
export async function clickSelectedAutosuggestionEmoji (realm) {

View File

@ -1,19 +1,18 @@
import { store } from '../_store/store'
import { blockAccount, unblockAccount } from '../_api/block'
import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts'
import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountBlocked (accountId, block, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
let relationship
if (block) {
relationship = await blockAccount(currentInstance, accessToken, accountId)
await blockAccount(currentInstance, accessToken, accountId)
} else {
relationship = await unblockAccount(currentInstance, accessToken, accountId)
await unblockAccount(currentInstance, accessToken, accountId)
}
await updateLocalRelationship(currentInstance, accountId, relationship)
await updateProfileAndRelationship(accountId)
if (toastOnSuccess) {
if (block) {
toast.say('Blocked account')

View File

@ -1,14 +1,14 @@
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { postStatus as postStatusToServer } from '../_api/statuses'
import { addStatusOrNotification } from './addStatusOrNotification'
import { database } from '../_database/database'
import { getStatus as getStatusFromDatabase } from '../_database/timelines/getStatusOrNotification'
import { emit } from '../_utils/eventBus'
import { putMediaDescription } from '../_api/media'
export async function insertHandleForReply (statusId) {
let { currentInstance } = store.get()
let status = await database.getStatus(currentInstance, statusId)
let status = await getStatusFromDatabase(currentInstance, statusId)
let { currentVerifyCredentials } = store.get()
let originalStatus = status.reblog || status
let accounts = [originalStatus.account].concat(originalStatus.mentions || [])
@ -22,7 +22,7 @@ export async function insertHandleForReply (statusId) {
export async function postStatus (realm, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility,
mediaDescriptions, inReplyToUuid) {
mediaDescriptions = [], inReplyToUuid) {
let { currentInstance, accessToken, online } = store.get()
if (!online) {
@ -30,9 +30,6 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
return
}
text = text || ''
mediaDescriptions = mediaDescriptions || []
store.set({
postingStatus: true
})
@ -49,7 +46,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
console.error(e)
toast.say('Unable to post status: ' + (e.message || ''))
} finally {
store.set({ postingStatus: false })
store.set({postingStatus: false})
}
}
@ -84,5 +81,5 @@ export function setReplyVisibility (realm, replyVisibility) {
let visibility = PRIVACY_LEVEL[replyVisibility] < PRIVACY_LEVEL[defaultVisibility]
? replyVisibility
: defaultVisibility
store.setComposeData(realm, { postPrivacy: visibility })
store.setComposeData(realm, {postPrivacy: visibility})
}

View File

@ -1,13 +1,11 @@
import { store } from '../_store/store'
import { deleteStatus } from '../_api/delete'
import { toast } from '../_components/toast/toast'
import { deleteStatus as deleteStatusLocally } from './deleteStatuses'
import { toast } from '../_utils/toast'
export async function doDeleteStatus (statusId) {
let { currentInstance, accessToken } = store.get()
try {
await deleteStatus(currentInstance, accessToken, statusId)
deleteStatusLocally(currentInstance, statusId)
toast.say('Status deleted.')
} catch (e) {
console.error(e)

View File

@ -1,8 +1,10 @@
import { getIdsThatRebloggedThisStatus, getNotificationIdsForStatuses } from './statuses'
import { store } from '../_store/store'
import isEqual from 'lodash-es/isEqual'
import { database } from '../_database/database'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import isEqual from 'lodash-es/isEqual'
import {
deleteStatusesAndNotifications as deleteStatusesAndNotificationsFromDatabase
} from '../_database/timelines/deletion'
function filterItemIdsFromTimelines (instanceName, timelineFilter, idFilter) {
let keys = ['timelineItemIds', 'itemIdsToAdd']
@ -16,7 +18,6 @@ function filterItemIdsFromTimelines (instanceName, timelineFilter, idFilter) {
}
let filteredIds = ids.filter(idFilter)
if (!isEqual(ids, filteredIds)) {
console.log('deleting an item from timelineName', timelineName, 'for key', key)
store.setForTimeline(instanceName, timelineName, {
[key]: filteredIds
})
@ -44,7 +45,7 @@ function deleteNotificationIdsFromStore (instanceName, idsToDelete) {
async function deleteStatusesAndNotifications (instanceName, statusIdsToDelete, notificationIdsToDelete) {
deleteStatusIdsFromStore(instanceName, statusIdsToDelete)
deleteNotificationIdsFromStore(instanceName, notificationIdsToDelete)
await database.deleteStatusesAndNotifications(instanceName, statusIdsToDelete, notificationIdsToDelete)
await deleteStatusesAndNotificationsFromDatabase(instanceName, statusIdsToDelete, notificationIdsToDelete)
}
async function doDeleteStatus (instanceName, statusId) {

View File

@ -1,28 +1,30 @@
import { cacheFirstUpdateAfter } from '../_utils/sync'
import { database } from '../_database/database'
import {
getCustomEmoji as getCustomEmojiFromDatabase,
setCustomEmoji as setCustomEmojiInDatabase
} from '../_database/meta'
import { getCustomEmoji } from '../_api/emoji'
import { store } from '../_store/store'
export async function updateCustomEmojiForInstance (instanceName) {
await cacheFirstUpdateAfter(
() => getCustomEmoji(instanceName),
() => database.getCustomEmoji(instanceName),
emoji => database.setCustomEmoji(instanceName, emoji),
() => getCustomEmojiFromDatabase(instanceName),
emoji => setCustomEmojiInDatabase(instanceName, emoji),
emoji => {
let { customEmoji } = store.get()
customEmoji[instanceName] = emoji
store.set({ customEmoji: customEmoji })
store.set({customEmoji: customEmoji})
}
)
}
export function insertEmoji (realm, emoji) {
let emojiText = emoji.custom ? emoji.colons : emoji.native
let { composeSelectionStart } = store.get()
let idx = composeSelectionStart || 0
let oldText = store.getComposeData(realm, 'text') || ''
let pre = oldText.substring(0, idx)
let post = oldText.substring(idx)
let newText = `${pre}${emojiText} ${post}`
store.setComposeData(realm, { text: newText })
let newText = `${pre}:${emoji.shortcode}: ${post}`
store.setComposeData(realm, {text: newText})
}

View File

@ -1,7 +1,9 @@
import { favoriteStatus, unfavoriteStatus } from '../_api/favorite'
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { database } from '../_database/database'
import { toast } from '../_utils/toast'
import {
setStatusFavorited as setStatusFavoritedInDatabase
} from '../_database/timelines/updateStatus'
export async function setFavorited (statusId, favorited) {
let { online } = store.get()
@ -16,7 +18,7 @@ export async function setFavorited (statusId, favorited) {
store.setStatusFavorited(currentInstance, statusId, favorited) // optimistic update
try {
await networkPromise
await database.setStatusFavorited(currentInstance, statusId, favorited)
await setStatusFavoritedInDatabase(currentInstance, statusId, favorited)
} catch (e) {
console.error(e)
toast.say(`Failed to ${favorited ? 'favorite' : 'unfavorite'}. ` + (e.message || ''))

35
routes/_actions/follow.js Normal file
View File

@ -0,0 +1,35 @@
import { store } from '../_store/store'
import { followAccount, unfollowAccount } from '../_api/follow'
import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts'
import {
getRelationship as getRelationshipFromDatabase
} from '../_database/accountsAndRelationships'
export async function setAccountFollowed (accountId, follow, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
let account
if (follow) {
account = await followAccount(currentInstance, accessToken, accountId)
} else {
account = await unfollowAccount(currentInstance, accessToken, accountId)
}
await updateProfileAndRelationship(accountId)
let relationship = await getRelationshipFromDatabase(currentInstance, accountId)
if (toastOnSuccess) {
if (follow) {
if (account.locked && relationship.requested) {
toast.say('Requested to follow account')
} else {
toast.say('Followed account')
}
} else {
toast.say('Unfollowed account')
}
}
} catch (e) {
console.error(e)
toast.say(`Unable to ${follow ? 'follow' : 'unfollow'} account: ` + (e.message || ''))
}
}

View File

@ -3,15 +3,15 @@ import { auth, basename } from '../_api/utils'
export async function getFollowRequests (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/follow_requests`
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export async function authorizeFollowRequest (instanceName, accessToken, id) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${id}/authorize`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function rejectFollowRequest (instanceName, accessToken, id) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${id}/reject`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -1,16 +1,22 @@
import { getVerifyCredentials } from '../_api/user'
import { store } from '../_store/store'
import { DEFAULT_THEME, switchToTheme } from '../_utils/themeEngine'
import { toast } from '../_components/toast/toast'
import { goto } from '../../../__sapper__/client'
import { switchToTheme } from '../_utils/themeEngine'
import { toast } from '../_utils/toast'
import { goto } from 'sapper/runtime.js'
import { cacheFirstUpdateAfter } from '../_utils/sync'
import { getInstanceInfo } from '../_api/instance'
import { database } from '../_database/database'
import { clearDatabaseForInstance } from '../_database/clear'
import {
getInstanceVerifyCredentials as getInstanceVerifyCredentialsFromDatabase,
setInstanceVerifyCredentials as setInstanceVerifyCredentialsInDatabase,
getInstanceInfo as getInstanceInfoFromDatabase,
setInstanceInfo as setInstanceInfoInDatabase
} from '../_database/meta'
export function changeTheme (instanceName, newTheme) {
let { instanceThemes } = store.get()
instanceThemes[instanceName] = newTheme
store.set({ instanceThemes: instanceThemes })
store.set({instanceThemes: instanceThemes})
store.save()
let { currentInstance } = store.get()
if (instanceName === currentInstance) {
@ -55,15 +61,15 @@ export async function logOutOfInstance (instanceName) {
})
store.save()
toast.say(`Logged out of ${instanceName}`)
switchToTheme(instanceThemes[newInstance] || DEFAULT_THEME)
/* no await */ database.clearDatabaseForInstance(instanceName)
switchToTheme(instanceThemes[newInstance] || 'default')
await clearDatabaseForInstance(instanceName)
goto('/settings/instances')
}
function setStoreVerifyCredentials (instanceName, thisVerifyCredentials) {
let { verifyCredentials } = store.get()
verifyCredentials[instanceName] = thisVerifyCredentials
store.set({ verifyCredentials: verifyCredentials })
store.set({verifyCredentials: verifyCredentials})
}
export async function updateVerifyCredentialsForInstance (instanceName) {
@ -71,8 +77,8 @@ export async function updateVerifyCredentialsForInstance (instanceName) {
let accessToken = loggedInInstances[instanceName].access_token
await cacheFirstUpdateAfter(
() => getVerifyCredentials(instanceName, accessToken),
() => database.getInstanceVerifyCredentials(instanceName),
verifyCredentials => database.setInstanceVerifyCredentials(instanceName, verifyCredentials),
() => getInstanceVerifyCredentialsFromDatabase(instanceName),
verifyCredentials => setInstanceVerifyCredentialsInDatabase(instanceName, verifyCredentials),
verifyCredentials => setStoreVerifyCredentials(instanceName, verifyCredentials)
)
}
@ -85,12 +91,12 @@ export async function updateVerifyCredentialsForCurrentInstance () {
export async function updateInstanceInfo (instanceName) {
await cacheFirstUpdateAfter(
() => getInstanceInfo(instanceName),
() => database.getInstanceInfo(instanceName),
info => database.setInstanceInfo(instanceName, info),
() => getInstanceInfoFromDatabase(instanceName),
info => setInstanceInfoInDatabase(instanceName, info),
info => {
let { instanceInfos } = store.get()
instanceInfos[instanceName] = info
store.set({ instanceInfos: instanceInfos })
store.set({instanceInfos: instanceInfos})
}
)
}

22
routes/_actions/lists.js Normal file
View File

@ -0,0 +1,22 @@
import { store } from '../_store/store'
import { getLists } from '../_api/lists'
import { cacheFirstUpdateAfter } from '../_utils/sync'
import {
getLists as getListsFromDatabase,
setLists as setListsInDatabase
} from '../_database/meta'
export async function updateLists () {
let { currentInstance, accessToken } = store.get()
await cacheFirstUpdateAfter(
() => getLists(currentInstance, accessToken),
() => getListsFromDatabase(currentInstance),
lists => setListsInDatabase(currentInstance, lists),
lists => {
let { instanceLists } = store.get()
instanceLists[currentInstance] = lists
store.set({instanceLists: instanceLists})
}
)
}

View File

@ -1,40 +1,49 @@
import { store } from '../_store/store'
import { uploadMedia } from '../_api/media'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
export async function doMediaUpload (realm, file) {
let { currentInstance, accessToken } = store.get()
store.set({ uploadingMedia: true })
store.set({uploadingMedia: true})
try {
let response = await uploadMedia(currentInstance, accessToken, file)
let composeMedia = store.getComposeData(realm, 'media') || []
if (composeMedia.length === 4) {
throw new Error('Only 4 media max are allowed')
}
composeMedia.push({
data: response,
file: { name: file.name },
description: ''
file: { name: file.name }
})
let composeText = store.getComposeData(realm, 'text') || ''
composeText += ' ' + response.text_url
store.setComposeData(realm, {
media: composeMedia
media: composeMedia,
text: composeText
})
scheduleIdleTask(() => store.save())
} catch (e) {
console.error(e)
toast.say('Failed to upload media: ' + (e.message || ''))
} finally {
store.set({ uploadingMedia: false })
store.set({uploadingMedia: false})
}
}
export function deleteMedia (realm, i) {
let composeMedia = store.getComposeData(realm, 'media')
composeMedia.splice(i, 1)
let deletedMedia = composeMedia.splice(i, 1)[0]
let composeText = store.getComposeData(realm, 'text') || ''
composeText = composeText.replace(' ' + deletedMedia.data.text_url, '')
let mediaDescriptions = store.getComposeData(realm, 'mediaDescriptions') || []
if (mediaDescriptions[i]) {
mediaDescriptions[i] = null
}
store.setComposeData(realm, {
media: composeMedia
media: composeMedia,
text: composeText,
mediaDescriptions: mediaDescriptions
})
scheduleIdleTask(() => store.save())
}

View File

@ -1,19 +1,18 @@
import { store } from '../_store/store'
import { muteAccount, unmuteAccount } from '../_api/mute'
import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts'
import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountMuted (accountId, mute, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
let relationship
if (mute) {
relationship = await muteAccount(currentInstance, accessToken, accountId)
await muteAccount(currentInstance, accessToken, accountId)
} else {
relationship = await unmuteAccount(currentInstance, accessToken, accountId)
await unmuteAccount(currentInstance, accessToken, accountId)
}
await updateLocalRelationship(currentInstance, accountId, relationship)
await updateProfileAndRelationship(accountId)
if (toastOnSuccess) {
if (mute) {
toast.say('Muted account')

View File

@ -1,7 +1,7 @@
import { store } from '../_store/store'
import { muteConversation, unmuteConversation } from '../_api/muteConversation'
import { toast } from '../_components/toast/toast'
import { database } from '../_database/database'
import { toast } from '../_utils/toast'
import { setStatusMuted as setStatusMutedInDatabase } from '../_database/timelines/updateStatus'
export async function setConversationMuted (statusId, mute, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
@ -11,7 +11,7 @@ export async function setConversationMuted (statusId, mute, toastOnSuccess) {
} else {
await unmuteConversation(currentInstance, accessToken, statusId)
}
await database.setStatusMuted(currentInstance, statusId, mute)
await setStatusMutedInDatabase(currentInstance, statusId, mute)
if (toastOnSuccess) {
if (mute) {
toast.say('Muted conversation')

View File

@ -1,7 +1,7 @@
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { pinStatus, unpinStatus } from '../_api/pin'
import { database } from '../_database/database'
import { setStatusPinned as setStatusPinnedInDatabase } from '../_database/timelines/updateStatus'
import { emit } from '../_utils/eventBus'
export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSuccess) {
@ -19,8 +19,7 @@ export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSucces
toast.say('Unpinned status')
}
}
store.setStatusPinned(currentInstance, statusId, pinned)
await database.setStatusPinned(currentInstance, statusId, pinned)
await setStatusPinnedInDatabase(currentInstance, statusId, pinned)
emit('updatePinnedStatuses')
} catch (e) {
console.error(e)

View File

@ -1,6 +1,9 @@
import { store } from '../_store/store'
import { cacheFirstUpdateAfter } from '../_utils/sync'
import { database } from '../_database/database'
import {
getPinnedStatuses as getPinnedStatusesFromDatabase,
insertPinnedStatuses as insertPinnedStatusesInDatabase
} from '../_database/timelines/pinnedStatuses'
import {
getPinnedStatuses
} from '../_api/pinnedStatuses'
@ -10,13 +13,13 @@ export async function updatePinnedStatusesForAccount (accountId) {
await cacheFirstUpdateAfter(
() => getPinnedStatuses(currentInstance, accessToken, accountId),
() => database.getPinnedStatuses(currentInstance, accountId),
statuses => database.insertPinnedStatuses(currentInstance, accountId, statuses),
() => getPinnedStatusesFromDatabase(currentInstance, accountId),
statuses => insertPinnedStatusesInDatabase(currentInstance, accountId, statuses),
statuses => {
let { pinnedStatuses } = store.get()
pinnedStatuses[currentInstance] = pinnedStatuses[currentInstance] || {}
pinnedStatuses[currentInstance][accountId] = statuses
store.set({ pinnedStatuses: pinnedStatuses })
store.set({pinnedStatuses: pinnedStatuses})
}
)
}

View File

@ -2,5 +2,5 @@
import { store } from '../_store/store'
export function setPostPrivacy (realm, postPrivacyKey) {
store.setComposeData(realm, { postPrivacy: postPrivacyKey })
store.setComposeData(realm, {postPrivacy: postPrivacyKey})
}

View File

@ -1,7 +1,7 @@
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { reblogStatus, unreblogStatus } from '../_api/reblog'
import { database } from '../_database/database'
import { setStatusReblogged as setStatusRebloggedInDatabase } from '../_database/timelines/updateStatus'
export async function setReblogged (statusId, reblogged) {
let online = store.get()
@ -16,7 +16,7 @@ export async function setReblogged (statusId, reblogged) {
store.setStatusReblogged(currentInstance, statusId, reblogged) // optimistic update
try {
await networkPromise
await database.setStatusReblogged(currentInstance, statusId, reblogged)
await setStatusRebloggedInDatabase(currentInstance, statusId, reblogged)
} catch (e) {
console.error(e)
toast.say(`Failed to ${reblogged ? 'boost' : 'unboost'}. ` + (e.message || ''))

View File

@ -1,7 +1,7 @@
import { store } from '../_store/store'
import { approveFollowRequest, rejectFollowRequest } from '../_api/requests'
import { emit } from '../_utils/eventBus'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
export async function setFollowRequestApprovedOrRejected (accountId, approved, toastOnSuccess) {
let {

View File

@ -1,10 +1,10 @@
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { search } from '../_api/search'
export async function doSearch () {
let { currentInstance, accessToken, queryInSearch } = store.get()
store.set({ searchLoading: true })
store.set({searchLoading: true})
try {
let results = await search(currentInstance, accessToken, queryInSearch)
let { queryInSearch: newQueryInSearch } = store.get() // avoid race conditions
@ -18,6 +18,6 @@ export async function doSearch () {
toast.say('Error during search: ' + (e.name || '') + ' ' + (e.message || ''))
console.error(e)
} finally {
store.set({ searchLoading: false })
store.set({searchLoading: false})
}
}

View File

@ -1,7 +1,13 @@
import { database } from '../_database/database'
import {
getNotificationIdsForStatuses as getNotificationIdsForStatusesFromDatabase,
getReblogsForStatus as getReblogsForStatusFromDatabase
} from '../_database/timelines/lookup'
import {
getStatus as getStatusFromDatabase
} from '../_database/timelines/getStatusOrNotification'
export async function getIdThatThisStatusReblogged (instanceName, statusId) {
let status = await database.getStatus(instanceName, statusId)
let status = await getStatusFromDatabase(instanceName, statusId)
return status.reblog && status.reblog.id
}
@ -13,9 +19,9 @@ export async function getIdsThatTheseStatusesReblogged (instanceName, statusIds)
}
export async function getIdsThatRebloggedThisStatus (instanceName, statusId) {
return database.getReblogsForStatus(instanceName, statusId)
return getReblogsForStatusFromDatabase(instanceName, statusId)
}
export async function getNotificationIdsForStatuses (instanceName, statusIds) {
return database.getNotificationIdsForStatuses(instanceName, statusIds)
return getNotificationIdsForStatusesFromDatabase(instanceName, statusIds)
}

View File

@ -1,55 +1,34 @@
import { store } from '../_store/store'
import { getTimeline } from '../_api/timelines'
import { toast } from '../_components/toast/toast'
import { toast } from '../_utils/toast'
import { mark, stop } from '../_utils/marks'
import { concat, mergeArrays } from '../_utils/arrays'
import { mergeArrays } from '../_utils/arrays'
import { byItemIds } from '../_utils/sorting'
import isEqual from 'lodash-es/isEqual'
import { database } from '../_database/database'
import { getStatus, getStatusContext } from '../_api/statuses'
import { emit } from '../_utils/eventBus'
import { TIMELINE_BATCH_SIZE } from '../_static/timelines'
import {
insertTimelineItems as insertTimelineItemsInDatabase
} from '../_database/timelines/insertion'
import {
getTimeline as getTimelineFromDatabase
} from '../_database/timelines/pagination'
async function storeFreshTimelineItemsInDatabase (instanceName, timelineName, items) {
await database.insertTimelineItems(instanceName, timelineName, items)
if (timelineName.startsWith('status/')) {
// For status threads, we want to be sure to update the favorite/reblog counts even if
// this is a stale "view" of the status. See 119-status-counts-update.js for
// an example of why we need this.
items.forEach(item => {
emit('statusUpdated', item)
})
}
}
async function fetchTimelineItemsFromNetwork (instanceName, accessToken, timelineName, lastTimelineItemId) {
if (timelineName.startsWith('status/')) { // special case - this is a list of descendents and ancestors
let statusId = timelineName.split('/').slice(-1)[0]
let statusRequest = getStatus(instanceName, accessToken, statusId)
let contextRequest = getStatusContext(instanceName, accessToken, statusId)
let [ status, context ] = await Promise.all([statusRequest, contextRequest])
return concat(context.ancestors, status, context.descendants)
} else { // normal timeline
return getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, null, TIMELINE_BATCH_SIZE)
}
}
const FETCH_LIMIT = 20
async function fetchTimelineItems (instanceName, accessToken, timelineName, lastTimelineItemId, online) {
mark('fetchTimelineItems')
let items
let stale = false
if (!online) {
items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, TIMELINE_BATCH_SIZE)
items = await getTimelineFromDatabase(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
stale = true
} else {
try {
console.log('fetchTimelineItemsFromNetwork')
items = await fetchTimelineItemsFromNetwork(instanceName, accessToken, timelineName, lastTimelineItemId)
/* no await */ storeFreshTimelineItemsInDatabase(instanceName, timelineName, items)
items = await getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, FETCH_LIMIT)
/* no await */ insertTimelineItemsInDatabase(instanceName, timelineName, items)
} catch (e) {
console.error(e)
toast.say('Internet request failed. Showing offline content.')
items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, TIMELINE_BATCH_SIZE)
items = await getTimelineFromDatabase(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
stale = true
}
}
@ -72,10 +51,10 @@ export async function addTimelineItemIds (instanceName, timelineName, newIds, ne
let mergedIds = mergeArrays(oldIds || [], newIds)
if (!isEqual(oldIds, mergedIds)) {
store.setForTimeline(instanceName, timelineName, { timelineItemIds: mergedIds })
store.setForTimeline(instanceName, timelineName, {timelineItemIds: mergedIds})
}
if (oldStale !== newStale) {
store.setForTimeline(instanceName, timelineName, { timelineItemIdsAreStale: newStale })
store.setForTimeline(instanceName, timelineName, {timelineItemIdsAreStale: newStale})
}
}
@ -114,10 +93,8 @@ export async function setupTimeline () {
}
export async function fetchTimelineItemsOnScrollToBottom (instanceName, timelineName) {
console.log('setting runningUpdate: true')
store.setForTimeline(instanceName, timelineName, { runningUpdate: true })
await fetchTimelineItemsAndPossiblyFallBack()
console.log('setting runningUpdate: false')
store.setForTimeline(instanceName, timelineName, { runningUpdate: false })
}
@ -147,11 +124,7 @@ export async function showMoreItemsForThread (instanceName, timelineName) {
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd')
let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds')
// TODO: update database and do the thread merge correctly
for (let itemIdToAdd of itemIdsToAdd) {
if (!timelineItemIds.includes(itemIdToAdd)) {
timelineItemIds.push(itemIdToAdd)
}
}
timelineItemIds = timelineItemIds.concat(itemIdsToAdd)
store.setForTimeline(instanceName, timelineName, {
itemIdsToAdd: [],
timelineItemIds: timelineItemIds

View File

@ -3,10 +3,10 @@ import { post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function blockAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/block`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unblockAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unblock`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -4,11 +4,11 @@ import { auth, basename } from './utils'
export async function getBlockedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/blocks`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export async function getMutedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/mutes`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

View File

@ -3,5 +3,5 @@ import { del, WRITE_TIMEOUT } from '../_utils/ajax'
export async function deleteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}`
return del(url, auth(accessToken), { timeout: WRITE_TIMEOUT })
return del(url, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -3,5 +3,5 @@ import { DEFAULT_TIMEOUT, get } from '../_utils/ajax'
export async function getCustomEmoji (instanceName) {
let url = `${basename(instanceName)}/api/v1/custom_emojis`
return get(url, null, { timeout: DEFAULT_TIMEOUT })
return get(url, null, {timeout: DEFAULT_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { basename, auth } from './utils'
export async function favoriteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourite`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unfavoriteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unfavourite`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { auth, basename } from './utils'
export async function followAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/follow`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unfollowAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unfollow`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -4,11 +4,11 @@ import { auth, basename } from './utils'
export async function getFollows (instanceName, accessToken, accountId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/following`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export async function getFollowers (instanceName, accessToken, accountId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/followers`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

View File

@ -3,5 +3,5 @@ import { basename } from './utils'
export function getInstanceInfo (instanceName) {
let url = `${basename(instanceName)}/api/v1/instance`
return get(url, null, { timeout: DEFAULT_TIMEOUT })
return get(url, null, {timeout: DEFAULT_TIMEOUT})
}

View File

@ -3,5 +3,5 @@ import { auth, basename } from './utils'
export function getLists (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/lists`
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

View File

@ -8,10 +8,10 @@ export async function uploadMedia (instanceName, accessToken, file, description)
formData.append('description', description)
}
let url = `${basename(instanceName)}/api/v1/media`
return post(url, formData, auth(accessToken), { timeout: MEDIA_WRITE_TIMEOUT })
return post(url, formData, auth(accessToken), {timeout: MEDIA_WRITE_TIMEOUT})
}
export async function putMediaDescription (instanceName, accessToken, mediaId, description) {
let url = `${basename(instanceName)}/api/v1/media/${mediaId}`
return put(url, { description }, auth(accessToken), { timeout: WRITE_TIMEOUT })
return put(url, {description}, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function muteAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/mute`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unmuteAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unmute`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function muteConversation (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/mute`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unmuteConversation (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unmute`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -2,7 +2,7 @@ import { post, paramsString, WRITE_TIMEOUT } from '../_utils/ajax'
import { basename } from './utils'
const WEBSITE = 'https://pinafore.social'
const SCOPES = 'read write follow push'
const SCOPES = 'read write follow'
const CLIENT_NAME = 'Pinafore'
export function registerApplication (instanceName, redirectUri) {
@ -12,7 +12,7 @@ export function registerApplication (instanceName, redirectUri) {
redirect_uris: redirectUri,
scopes: SCOPES,
website: WEBSITE
}, null, { timeout: WRITE_TIMEOUT })
}, null, {timeout: WRITE_TIMEOUT})
}
export function generateAuthLink (instanceName, clientId, redirectUri) {
@ -33,5 +33,5 @@ export function getAccessTokenFromAuthCode (instanceName, clientId, clientSecret
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code: code
}, null, { timeout: WRITE_TIMEOUT })
}, null, {timeout: WRITE_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { auth, basename } from './utils'
export async function pinStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/pin`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function unpinStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unpin`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -7,5 +7,5 @@ export async function getPinnedStatuses (instanceName, accessToken, accountId) {
limit: 40,
pinned: true
})
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

View File

@ -4,11 +4,11 @@ import { auth, basename } from './utils'
export async function getReblogs (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export async function getFavorites (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by`
url += '?' + paramsString({ limit })
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

View File

@ -3,10 +3,10 @@ import { auth, basename } from './utils'
export async function approveFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/authorize`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}
export async function rejectFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/reject`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
return post(url, null, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -6,5 +6,5 @@ export function search (instanceName, accessToken, query) {
q: query,
resolve: true
})
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

25
routes/_api/statuses.js Normal file
View File

@ -0,0 +1,25 @@
import { auth, basename } from './utils'
import { post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility) {
let url = `${basename(instanceName)}/api/v1/statuses`
let body = {
status: text,
in_reply_to_id: inReplyToId,
media_ids: mediaIds,
sensitive: sensitive,
spoiler_text: spoilerText,
visibility: visibility
}
for (let key of Object.keys(body)) {
let value = body[key]
if (!value || (Array.isArray(value) && !value.length)) {
delete body[key]
}
}
return post(url, body, auth(accessToken), {timeout: WRITE_TIMEOUT})
}

View File

@ -15,6 +15,8 @@ function getTimelineUrlPath (timeline) {
}
if (timeline.startsWith('tag/')) {
return 'timelines/tag'
} else if (timeline.startsWith('status/')) {
return 'statuses'
} else if (timeline.startsWith('account/')) {
return 'accounts'
} else if (timeline.startsWith('list/')) {
@ -22,12 +24,14 @@ function getTimelineUrlPath (timeline) {
}
}
export function getTimeline (instanceName, accessToken, timeline, maxId, since, limit) {
export function getTimeline (instanceName, accessToken, timeline, maxId, since) {
let timelineUrlName = getTimelineUrlPath(timeline)
let url = `${basename(instanceName)}/api/v1/${timelineUrlName}`
if (timeline.startsWith('tag/')) {
url += '/' + timeline.split('/').slice(-1)[0]
} else if (timeline.startsWith('status/')) {
url += '/' + timeline.split('/').slice(-1)[0] + '/context'
} else if (timeline.startsWith('account/')) {
url += '/' + timeline.split('/').slice(-1)[0] + '/statuses'
} else if (timeline.startsWith('list/')) {
@ -43,15 +47,22 @@ export function getTimeline (instanceName, accessToken, timeline, maxId, since,
params.max_id = maxId
}
if (limit) {
params.limit = limit
}
if (timeline === 'local') {
params.local = true
}
url += '?' + paramsString(params)
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
if (timeline.startsWith('status/')) {
// special case - this is a list of descendents and ancestors
let statusUrl = `${basename(instanceName)}/api/v1/statuses/${timeline.split('/').slice(-1)[0]}`
return Promise.all([
get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT}),
get(statusUrl, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
]).then(res => {
return [].concat(res[0].ancestors).concat([res[1]]).concat(res[0].descendants)
})
}
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}

19
routes/_api/user.js Normal file
View File

@ -0,0 +1,19 @@
import { get, paramsString, DEFAULT_TIMEOUT } from '../_utils/ajax'
import { auth, basename } from './utils'
export function getVerifyCredentials (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/accounts/verify_credentials`
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export function getAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}`
return get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
}
export async function getRelationship (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/relationships`
url += '?' + paramsString({id: accountId})
let res = await get(url, auth(accessToken), {timeout: DEFAULT_TIMEOUT})
return res[0]
}

View File

@ -1,9 +1,13 @@
const isLocalhost = !process.browser ||
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1'
function targetIsLocalhost (instanceName) {
return instanceName.startsWith('localhost:') || instanceName.startsWith('127.0.0.1:')
}
export function basename (instanceName) {
if (targetIsLocalhost(instanceName)) {
if (isLocalhost && targetIsLocalhost(instanceName)) {
return `http://${instanceName}`
}
return `https://${instanceName}`

View File

@ -32,9 +32,9 @@
</style>
<script>
import { store } from '../_store/store'
import LoadingPage from './LoadingPage.html'
import AccountSearchResult from './search/AccountSearchResult.html'
import { toast } from './toast/toast'
import LoadingPage from '../_components/LoadingPage.html'
import AccountSearchResult from '../_components/search/AccountSearchResult.html'
import { toast } from '../_utils/toast'
import { on } from '../_utils/eventBus'
// TODO: paginate
@ -45,7 +45,7 @@
} catch (e) {
toast.say('Error: ' + (e.name || '') + ' ' + (e.message || ''))
} finally {
this.set({ loading: false })
this.set({loading: false})
}
on('refreshAccountsList', this, () => this.refreshAccounts())
},
@ -71,4 +71,4 @@
}
}
}
</script>
</script>

View File

@ -0,0 +1,21 @@
<video
class="autoplay-video {className || ''}"
aria-label={ariaLabel || ''}
style="background-image: url({poster});"
{poster}
{width}
{height}
{src}
autoplay
muted
loop
webkit-playsinline
playsinline
/>
<style>
.autoplay-video {
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
</style>

View File

@ -1,16 +1,13 @@
{#if error}
<svg class={computedClass} style={svgStyle} aria-hidden="true">
<svg class={computedClass} aria-hidden="true">
<use xlink:href="#fa-user" />
</svg>
{:elseif $autoplayGifs}
<LazyImage
className={computedClass}
ariaHidden="true"
forceSize=true
<img
class={computedClass}
aria-hidden="true"
alt=""
src={account.avatar}
{width}
{height}
on:imgLoad="set({loaded: true})"
on:imgLoadError="set({error: true})" />
{:else}
@ -20,8 +17,6 @@
alt=""
src={account.avatar}
staticSrc={account.avatar_static}
{width}
{height}
{isLink}
on:imgLoad="set({loaded: true})"
on:imgLoadError="set({error: true})"
@ -37,17 +32,48 @@
background: none;
}
:global(.avatar.size-extra-small) {
width: 24px;
height: 24px;
}
:global(.avatar.size-small) {
width: 48px;
height: 48px;
}
:global(.avatar.size-medium) {
width: 64px;
height: 64px;
}
:global(.avatar.size-big) {
width: 100px;
height: 100px;
}
@media (max-width: 767px) {
:global(.avatar.size-big) {
width: 80px;
height: 80px;
}
}
svg.avatar {
fill: var(--deemphasized-text-color);
}
</style>
<script>
import { imgLoad, imgLoadError } from '../_utils/events'
import { store } from '../_store/store'
import NonAutoplayImg from './NonAutoplayImg.html'
import { classname } from '../_utils/classname'
import LazyImage from './LazyImage.html'
export default {
events: {
imgLoad,
imgLoadError
},
data: () => ({
className: void 0,
loaded: false,
@ -56,30 +82,15 @@
}),
store: () => store,
computed: {
computedClass: ({ className, loaded }) => (classname(
computedClass: ({ className, loaded, size }) => (classname(
'avatar',
className,
loaded && 'loaded'
)),
width: ({ size, $isMobileSize }) => {
switch (size) {
case 'extra-small':
return 24
case 'small':
return 48
case 'big':
return $isMobileSize ? 80 : 100
case 'medium':
default:
return 64
}
},
height: ({ width }) => width,
svgStyle: ({ width, height }) => `width: ${width}px; height: ${height}px;`
loaded && 'loaded',
`size-${size}`
))
},
components: {
NonAutoplayImg,
LazyImage
NonAutoplayImg
}
}
</script>
</script>

Some files were not shown because too many files have changed in this diff Show More