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 .DS_Store
node_modules node_modules
/__sapper__ .sapper
yarn.lock
templates/.*
assets/*.css
/mastodon /mastodon
/mastodon.log mastodon.log
/src/template.html assets/robots.txt
/static/*.css /inline-script-checksum.json
/static/robots.txt
/static/inline-script.js.map
/static/emoji-mart-all.json
/src/inline-script/checksum.js
yarn-error.log

View File

@ -1,53 +1,55 @@
language: node_js language: node_js
node_js: node_js:
- "10" - "8"
dist: trusty # needed for chrome headless dist: trusty # needed for chrome headless
sudo: required # needed for various sudo operations sudo: required # needed for chrome headless
addons: addons:
chrome: stable chrome: stable
postgresql: "10" postgresql: "10"
apt: apt:
packages: 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-10
- postgresql-client-10 - postgresql-client-10
- postgresql-contrib-10 - postgresql-contrib-10
# the following are mastodon dependencies
- imagemagick
- libpq-dev
- libxml2-dev
- libxslt1-dev
- file
- g++
- libprotobuf-dev
- protobuf-compiler - protobuf-compiler
- redis-server - pkg-config nodejs
- redis-tools - gcc
- autoconf
- bison
- build-essential
- libssl-dev
- libyaml-dev
- libreadline6-dev
- zlib1g-dev - zlib1g-dev
- libncurses5-dev
- libffi-dev
- libgdbm3
- libgdbm-dev
- redis-tools
- libidn11-dev
- libicu-dev
services:
- redis-server
before_install: before_install:
- npm install -g npm@5
- npm install -g greenkeeper-lockfile@1 - 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 - ./bin/setup-mastodon-in-travis.sh
before_script: before_script:
- yarn run lint - npm run lint
- greenkeeper-lockfile-update - greenkeeper-lockfile-update
after_script: after_script:
- greenkeeper-lockfile-upload - greenkeeper-lockfile-upload
script: travis_retry yarn run $COMMAND install:
- npm ci || npm i
script: travis_retry npm run $COMMAND
env: env:
global: global:
- PGPORT=5433 - PGPORT=5433
@ -57,17 +59,16 @@ matrix:
include: include:
- env: BROWSER=chrome:headless COMMAND=test-browser-suite0 - env: BROWSER=chrome:headless COMMAND=test-browser-suite0
- env: BROWSER=chrome:headless COMMAND=test-browser-suite1 - env: BROWSER=chrome:headless COMMAND=test-browser-suite1
- env: COMMAND=test-unit - env: COMMAND=deploy-dev-travis
- env: COMMAND=deploy-all-travis
allow_failures: allow_failures:
- env: COMMAND=deploy-all-travis - env: COMMAND=deploy-dev-travis
branches: branches:
only: only:
- master - master
- /^greenkeeper/.*$/ - /^greenkeeper/.*$/
cache: cache:
yarn: true
bundler: true
directories: directories:
- /home/travis/.rvm/ - $HOME/.npm
- /home/travis/ffmpeg-static/ - $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 # 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: To run a dev server with hot reloading:
yarn run dev npm run dev
Now it's running at `localhost:4002`. 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 ## Linting
Pinafore uses [JavaScript Standard Style](https://standardjs.com/). Pinafore uses [JavaScript Standard Style](https://standardjs.com/).
Lint: Lint:
yarn run lint npm run lint
Automatically fix most linting issues: 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 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.
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`.
Run integration tests, using headless Chrome by default: 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: Run tests for a particular browser:
BROWSER=chrome yarn run test-browser BROWSER=chrome npm run test-browser
BROWSER=chrome:headless yarn run test-browser BROWSER=chrome:headless npm run test-browser
BROWSER=firefox yarn run test-browser BROWSER=firefox npm run test-browser
BROWSER=firefox:headless yarn run test-browser BROWSER=firefox:headless npm run test-browser
BROWSER=safari yarn run test-browser BROWSER=safari npm run test-browser
BROWSER=edge yarn run test-browser BROWSER=edge npm run test-browser
If the script isn't able to set up the Postgres database, try running: ## Testing in development mode
sudo su - postgres
Then:
psql -d template1 -c "CREATE USER pinafore WITH PASSWORD 'pinafore' CREATEDB;"
### Testing in development mode
In separate terminals: In separate terminals:
1\. Run a Mastodon dev server: 1\. Run a Mastodon dev server:
yarn run run-mastodon npm run run-mastodon
2\. Run a Pinafore dev server: 2\. Run a Pinafore dev server:
yarn run dev npm run dev
3\. Run a debuggable TestCafé instance: 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) ## Writing tests
* `1xx-test-name.js`: tests that do modify the Mastodon database (read-write)
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 In principle the `0-` tests don't have to worry about
clobbering each other, whereas the `1-` ones do. 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 ## Debugging Webpack
The Webpack Bundle Analyzer `report.html` and `stats.json` are available publicly via e.g.: 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/report.html](https://dev.pinafore.social/report.html)
- [dev.pinafore.social/stats.json](https://dev.pinafore.social/stats.json) - [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 1. Run `rm -fr mastodon` to clear out all Mastodon data
are some quirks, which are described below. This list of quirks is non-exhaustive. 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 Check `mastodon.log` if you have any issues.
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.

View File

@ -7,17 +7,17 @@ ADD . /app
# Install updates and NodeJS+Dependencies # Install updates and NodeJS+Dependencies
RUN apk update && apk upgrade 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 # Upgrading NPM
RUN npm i yarn -g RUN npm i npm@latest -g
# Install Pinafore # Install Pinafore
RUN yarn --pure-lockfile RUN npm install
RUN yarn build RUN npm run build
# Expose port 4002 # Expose port 4002
EXPOSE 4002 EXPOSE 4002
# Setting run-command # 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. 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 ## Browser support
@ -24,68 +24,51 @@ Compatible versions of each (Opera, Brave, Samsung, etc.) should be fine.
### Goals ### Goals
- Support the most common use cases - Support the most common use cases
- Small page weight - Fast even on low-end phones
- Fast even on low-end devices - Works offline in read-only mode
- Accessibility
- Offline support in read-only mode
- Progressive Web App features - Progressive Web App features
- Multi-instance support - Multi-instance support
- Support latest versions of Chrome, Edge, Firefox, and Safari - 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 - Works as an alternative frontend self-hosted by instances
- Serve as an alternative frontend tied to a particular instance - Android/iOS apps (using Cordova or similar)
- Support for non-English languages (i18n) - Support Pleroma/non-Mastodon backends
- i18n
- Offline search - Offline search
- Full emoji keyboard
- Keyboard shortcuts
### Non-goals ### Non-goals
- Supporting old browsers, proxy browsers, or text-based browsers - Supporting old browsers, proxy browsers, or text-based browsers
- React Native / NativeScript / hybrid-native version - React Native / NativeScript / hybrid-native version
- Android/iOS apps (using Cordova or similar)
- Full functionality with JavaScript disabled - Full functionality with JavaScript disabled
- Emoji support beyond the built-in system emoji - Emoji support beyond the built-in system emoji
- Multi-column support - Multi-column support
- Admin/moderation panel - 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 ## Building
Pinafore requires [Node.js](https://nodejs.org/en/) v8+ and [Yarn](https://yarnpkg.com).
To build Pinafore for production: To build Pinafore for production:
yarn --pure-lockfile npm install
yarn build npm run build
PORT=4002 yarn start PORT=4002 npm start
### Docker ### Docker
To build a Docker image for production: To build a docker image for production:
docker build . docker build .
docker run -d -p 4002:4002 [your-image] docker run -d -p 4002:4002 [your-image]
Now Pinafore is running at `localhost:4002`. Now Pinafore is running at `localhost:4002`.
### Updating Pinafore requires [Node.js](https://nodejs.org/en/) v8+.
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.
## Developing and testing ## Developing and testing
@ -95,9 +78,3 @@ how to run Pinafore in dev mode and run tests.
## Changelog ## Changelog
For a changelog, see the [GitHub releases](http://github.com/nolanlawson/pinafore/releases/). 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' #!/usr/bin/env node
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'
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 checksum = crypto.createHash('sha256').update(headScript).digest('base64')
let inlineScriptPath = path.join(__dirname, '../src/inline-script/inline-script.js')
let bundle = await rollup({ let checksumFilepath = path.join(__dirname, '../inline-script-checksum.json')
input: inlineScriptPath, await writeFile(checksumFilepath, JSON.stringify({checksum}), 'utf8')
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 { code, map } = output[0] let html2xxFilepath = path.join(__dirname, '../templates/2xx.html')
let html2xxFile = await readFile(html2xxFilepath, 'utf8')
let fullCode = `${code}//# sourceMappingURL=/inline-script.js.map` html2xxFile = html2xxFile.replace(
let checksum = crypto.createHash('sha256').update(fullCode).digest('base64') /<!-- 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(path.resolve(__dirname, '../src/inline-script/checksum.js'), )
`module.exports = ${JSON.stringify(checksum)}`, 'utf8') await writeFile(html2xxFilepath, html2xxFile, 'utf8')
await writeFile(path.resolve(__dirname, '../static/inline-script.js.map'),
map.toString(), 'utf8')
return '<script>' + fullCode + '</script>'
} }
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,46 +1,73 @@
import sass from 'node-sass' #!/usr/bin/env node
import path from 'path'
import fs from 'fs'
import { promisify } from 'util'
import cssDedoupe from 'css-dedoupe'
import { TextDecoder } from 'text-encoding'
const writeFile = promisify(fs.writeFile) const sass = require('node-sass')
const readdir = promisify(fs.readdir) const chokidar = require('chokidar')
const render = promisify(sass.render.bind(sass)) 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 globalScss = path.join(__dirname, '../scss/global.scss')
const defaultThemeScss = path.join(__dirname, '../src/scss/themes/_default.scss') const defaultThemeScss = path.join(__dirname, '../scss/themes/_default.scss')
const offlineThemeScss = path.join(__dirname, '../src/scss/themes/_offline.scss') const offlineThemeScss = path.join(__dirname, '../scss/themes/_offline.scss')
const customScrollbarScss = path.join(__dirname, '../src/scss/custom-scrollbars.scss') const html2xxFile = path.join(__dirname, '../templates/2xx.html')
const themesScssDir = path.join(__dirname, '../src/scss/themes') const scssDir = path.join(__dirname, '../scss')
const assetsDir = path.join(__dirname, '../static') const themesScssDir = path.join(__dirname, '../scss/themes')
const assetsDir = path.join(__dirname, '../assets')
async function renderCss (file) { function doWatch () {
return (await render({ file, outputStyle: 'compressed' })).css 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 () { async function compileGlobalSass () {
let mainStyle = (await Promise.all([defaultThemeScss, globalScss].map(renderCss))).join('') let results = await Promise.all([
let offlineStyle = (await renderCss(offlineThemeScss)) render({file: defaultThemeScss, outputStyle: 'compressed'}),
let scrollbarStyle = (await renderCss(customScrollbarScss)) render({file: globalScss, outputStyle: 'compressed'}),
render({file: offlineThemeScss, outputStyle: 'compressed'})
])
return `<style>\n${mainStyle}</style>\n` + let css = results.map(_ => _.css).join('')
`<style media="only x" id="theOfflineStyle">\n${offlineStyle}</style>\n` +
`<style media="all" id="theScrollbarStyle">\n${scrollbarStyle}</style>\n` 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 () { async function compileThemesSass () {
let files = (await readdir(themesScssDir)).filter(file => !path.basename(file).startsWith('_')) let files = (await readdir(themesScssDir)).filter(file => !path.basename(file).startsWith('_'))
await Promise.all(files.map(async file => { await Promise.all(files.map(async file => {
let css = await renderCss(path.join(themesScssDir, file)) let res = await render({file: path.join(themesScssDir, file), outputStyle: 'compressed'})
css = cssDedoupe(new TextDecoder('utf-8').decode(css)) // remove duplicate custom properties
let outputFilename = 'theme-' + path.basename(file).replace(/\.scss$/, '.css') 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 () { async function main () {
let [ result ] = await Promise.all([compileGlobalSass(), compileThemesSass()]) await Promise.all([compileGlobalSass(), compileThemesSass()])
return result 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' #!/usr/bin/env node
import path from 'path'
import fs from 'fs'
import { promisify } from 'util'
import SVGO from 'svgo'
import $ from 'cheerio'
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 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 result = (await Promise.all(svgs.map(async svg => {
let filepath = path.join(__dirname, '../', svg.src) let filepath = path.join(__dirname, '../', svg.src)
let content = await readFile(filepath, 'utf8') let content = await readFile(filepath, 'utf8')
@ -18,9 +21,23 @@ export async function buildSvg () {
let $symbol = $('<symbol></symbol>') let $symbol = $('<symbol></symbol>')
.attr('id', svg.id) .attr('id', svg.id)
.attr('viewBox', `0 0 ${optimized.info.width} ${optimized.info.height}`) .attr('viewBox', `0 0 ${optimized.info.width} ${optimized.info.height}`)
.append($('<title></title>').text(svg.title))
.append($path) .append($path)
return $.xml($symbol) return $.xml($symbol)
}))).join('\n') }))).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 { actions } from './mastodon-data'
import { users } from '../tests/users' import { users } from '../tests/users'
import { postStatus } from '../src/routes/_api/statuses' import { postStatus } from '../routes/_api/statuses'
import { followAccount } from '../src/routes/_api/follow' import { followAccount } from '../routes/_api/follow'
import { favoriteStatus } from '../src/routes/_api/favorite' import { favoriteStatus } from '../routes/_api/favorite'
import { reblogStatus } from '../src/routes/_api/reblog' import { reblogStatus } from '../routes/_api/reblog'
import fetch from 'node-fetch' import fetch from 'node-fetch'
import FileApi from 'file-api' import FileApi from 'file-api'
import { pinStatus } from '../src/routes/_api/pin' import path from 'path'
import { submitMedia } from '../tests/submitMedia' 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.File = FileApi.File
global.FormData = FileApi.FormData global.FormData = FileApi.FormData
global.fetch = fetch 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 () { export async function restoreMastodonData () {
console.log('Restoring mastodon data...') console.log('Restoring mastodon data...')
let internalIdsToIds = {} let internalIdsToIds = {}
for (let action of actions) { for (let action of actions) {
if (!action.post) { await new Promise(resolve => setTimeout(resolve, 1000)) // delay so that notifications have proper order
// 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))
}
console.log(JSON.stringify(action)) console.log(JSON.stringify(action))
let accessToken = users[action.user].accessToken let accessToken = users[action.user].accessToken
if (action.post) { if (action.post) {
let { text, media, sensitive, spoiler, privacy, inReplyTo, internalId } = 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 mediaIds = media && await Promise.all(media.map(async mediaItem => {
let mediaResponse = await submitMedia(accessToken, mediaItem, 'kitten') let mediaResponse = await submitMedia(accessToken, mediaItem, 'kitten')
return mediaResponse.id return mediaResponse.id
})) }))
let inReplyToId = inReplyTo && internalIdsToIds[inReplyTo] let status = await postStatus('localhost:3000', accessToken, text, inReplyTo, mediaIds,
let status = await postStatus('localhost:3000', accessToken, text, inReplyToId, mediaIds,
sensitive, spoiler, privacy || 'public') sensitive, spoiler, privacy || 'public')
if (typeof internalId !== 'undefined') { if (typeof internalId !== 'undefined') {
internalIdsToIds[internalId] = status.id internalIdsToIds[internalId] = status.id

View File

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

View File

@ -2,39 +2,20 @@
set -e 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 exit 0 # no need to setup mastodon in this case
fi fi
# install ruby
source "$HOME/.rvm/scripts/rvm" source "$HOME/.rvm/scripts/rvm"
rvm install 2.6.0 rvm install 2.5.1
rvm use 2.6.0 rvm use 2.5.1
# fix for redis IPv6 issue sudo -E add-apt-repository -y ppa:mc3man/trusty-media
# https://travis-ci.community/t/trusty-environment-redis-server-not-starting-with-redis-tools-installed/650/2 sudo -E apt-get update
sudo sed -e 's/^bind.*/bind 127.0.0.1/' /etc/redis/redis.conf > redis.conf sudo -E apt-get install -y ffmpeg
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
ruby --version ruby --version
node --version node --version
yarn --version npm --version
postgres --version postgres --version
redis-server --version redis-server --version
ffmpeg -version ffmpeg -version

View File

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

View File

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

View File

@ -1,9 +1,6 @@
## Theming ## Theming
This document describes how to write your own theme for Pinafore. Create a file `scss/themes/foobar.scss`, write some SCSS inside and add the following at the bottom of `scss/themes/foobar.scss`.
First, create a file `scss/themes/foobar.scss`, write some SCSS inside and add
the following at the bottom of `scss/themes/foobar.scss`.
```scss ```scss
@import "_base.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` > 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`.
> 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 ```js
const themes = [ const themes = [
... ...
{ {
name: 'foobar', name: 'foobar',
label: 'Foobar', // user-visible name label: 'Foobar'
color: 'magenta', // main theme color
dark: true // whether it's a dark theme or not
} }
] ]
export { themes }
``` ```
Start the development server (`yarn run dev`), go to Add your theme in `inline-script.js`.
`http://localhost:4002/settings/instances/your-instance-name` and select your ```js
newly-created theme. Once you've done that, you can update your theme, and refresh window.__themeColors = {
the page to see the change (you don't have to restart the server). '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", "name": "pinafore",
"description": "Alternative web client for Mastodon", "description": "Alternative web client for Mastodon",
"version": "1.0.1", "version": "0.5.2",
"scripts": { "scripts": {
"lint": "standard && standard --plugin html 'src/routes/**/*.html'", "lint": "standard && standard --plugin html 'routes/**/*.html'",
"lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'", "lint-fix": "standard --fix && standard --fix --plugin html 'routes/**/*.html'",
"dev": "run-s build-template-html build-third-party-assets serve-dev", "dev": "run-s build-svg build-inline-script serve-dev",
"serve-dev": "run-p --race build-template-html-watch sapper-dev", "serve-dev": "run-p --race build-sass-watch serve",
"sapper-dev": "cross-env NODE_ENV=development PORT=4002 sapper dev", "serve": "node server.js",
"sapper-prod": "cross-env PORT=4002 node __sapper__/build", "build": "cross-env NODE_ENV=production npm run build-steps",
"before-build": "run-s build-template-html build-third-party-assets", "build-steps": "run-s globalize-css build-sass build-svg build-inline-script sapper-build deglobalize-css",
"build": "cross-env NODE_ENV=production run-s build-steps",
"build-steps": "run-s before-build sapper-build",
"sapper-build": "sapper build", "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-and-start": "run-s build start",
"build-template-html": "node -r esm ./bin/build-template-html.js", "build-svg": "node ./bin/build-svg.js",
"build-template-html-watch": "node -r esm ./bin/build-template-html.js --watch", "build-inline-script": "node ./bin/build-inline-script.js",
"build-third-party-assets": "node -r esm ./bin/build-third-party-assets.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", "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-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-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", "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": "run-s testcafe-suite0 testcafe-suite1",
"testcafe-suite0": "cross-env-shell testcafe --hostname localhost --skip-js-errors -c 4 $BROWSER tests/spec/0*", "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*", "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-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", "wait-for-mastodon-data": "node -r esm bin/wait-for-mastodon-data.js",
"deploy-prod": "DEPLOY_TYPE=prod ./bin/deploy.sh", "globalize-css": "node ./bin/globalize-css.js",
"deploy-dev": "DEPLOY_TYPE=dev ./bin/deploy.sh", "deglobalize-css": "node ./bin/globalize-css.js --reverse",
"deploy-all-travis": "./bin/deploy-all-travis.sh", "stage-dev": "printf 'User-agent: *\nDisallow: /' > assets/robots.txt",
"backup-mastodon-data": "./bin/backup-mastodon-data.sh", "stage-prod": "rm -f assets/robots.txt",
"sapper-export": "sapper export", "launch": "now -e SAPPER_TIMESTAMP=$(date +%s%3N) --team nolanlawson && sleep 60",
"print-export-info": "node ./bin/print-export-info.js", "launch-travis": "now -e SAPPER_TIMESTAMP=$(date +%s%3N) --team nolanlawson --token $NOW_TOKEN && sleep 60",
"export-steps": "run-s before-build sapper-export print-export-info", "alias-prod": "now alias pinafore.social --team nolanlawson",
"export": "cross-env NODE_ENV=production run-s export-steps" "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": { "dependencies": {
"@gamestdio/websocket": "^0.2.8", "@gamestdio/websocket": "^0.2.7",
"@webcomponents/custom-elements": "^1.2.1", "a11y-dialog": "^4.0.1",
"browserslist": "^4.0.2",
"cheerio": "^1.0.0-rc.2", "cheerio": "^1.0.0-rc.2",
"child-process-promise": "^2.2.1", "child-process-promise": "^2.2.1",
"chokidar": "^2.0.4", "chokidar": "^2.0.4",
"circular-dependency-plugin": "^5.0.2",
"clean-css": "^4.2.1",
"compression": "^1.7.3",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"css-dedoupe": "^0.1.1", "css-loader": "^1.0.0",
"css-loader": "^2.1.0",
"emoji-mart": "github:nolanlawson/emoji-mart#for-pinafore-1",
"emoji-regex": "^7.0.3",
"encoding": "^0.1.12",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"esm": "^3.1.4", "esm": "^3.0.77",
"events-light": "^1.0.5", "events": "^3.0.0",
"express": "^4.16.4", "express": "^4.16.3",
"fg-loadcss": "^2.0.1",
"file-api": "^0.10.4", "file-api": "^0.10.4",
"file-drop-element": "0.0.9", "font-awesome-svg-png": "^1.2.2",
"form-data": "^2.3.3", "form-data": "^2.3.2",
"glob": "^7.1.3", "glob": "^7.1.2",
"helmet": "^3.15.0", "helmet": "^3.13.0",
"idb-keyval": "^3.1.0",
"indexeddb-getall-shim": "^1.3.5", "indexeddb-getall-shim": "^1.3.5",
"inferno-compat": "^7.1.0", "intersection-observer": "^0.5.0",
"intersection-observer": "^0.5.1", "lodash-es": "^4.17.10",
"localstorage-memory": "^1.0.3",
"lodash-es": "^4.17.11",
"lodash-webpack-plugin": "^0.11.5", "lodash-webpack-plugin": "^0.11.5",
"mini-css-extract-plugin": "^0.4.1",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"node-fetch": "^2.3.0", "node-fetch": "^2.2.0",
"node-sass": "^4.11.0", "node-sass": "^4.9.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.3",
"optimize-css-assets-webpack-plugin": "^5.0.0",
"p-any": "^1.1.0", "p-any": "^1.1.0",
"page-lifecycle": "^0.1.1", "page-lifecycle": "^0.1.1",
"performance-now": "^2.1.0", "performance-now": "^2.1.0",
"pinch-zoom-element": "^1.1.0", "pify": "^4.0.0",
"prop-types": "^15.6.2", "quick-lru": "^1.1.0",
"quick-lru": "^2.0.0",
"remount": "^0.9.3",
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"rollup": "^1.1.2", "sapper": "github:nolanlawson/sapper#for-pinafore-7",
"rollup-plugin-replace": "^2.1.0",
"rollup-plugin-terser": "^4.0.3",
"sapper": "^0.25.0",
"serve-static": "^1.13.2", "serve-static": "^1.13.2",
"shrink-ray-current": "^2.1.2",
"stringz": "^1.0.0", "stringz": "^1.0.0",
"svelte": "^2.16.0", "style-loader": "^0.22.1",
"svelte": "^2.11.0",
"svelte-extras": "^2.0.2", "svelte-extras": "^2.0.2",
"svelte-loader": "^2.12.0", "svelte-loader": "^2.10.1",
"svelte-transitions": "^1.2.0", "svelte-transitions": "^1.2.0",
"svgo": "^1.1.1", "svgo": "^1.0.5",
"terser-webpack-plugin": "^1.2.1", "timeago.js": "^3.0.2",
"text-encoding": "^0.7.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"uuid": "^3.3.2", "uglifyjs-webpack-plugin": "^1.3.0",
"web-animations-js": "^2.3.1", "web-animations-js": "^2.3.1",
"webpack": "^4.29.0", "webpack": "^4.16.5",
"webpack-bundle-analyzer": "^3.0.3" "webpack-bundle-analyzer": "^2.13.1",
"yargs": "^12.0.1"
}, },
"devDependencies": { "devDependencies": {
"assert": "^1.4.1", "eslint-plugin-html": "^4.0.5",
"eslint-plugin-html": "^5.0.0", "now": "^11.3.10",
"mocha": "^5.2.0", "standard": "^11.0.1",
"now": "^13.1.2", "testcafe": "^0.21.1"
"standard": "^12.0.1",
"testcafe": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -142,18 +136,12 @@
"btoa", "btoa",
"Blob", "Blob",
"Element", "Element",
"Image", "Image"
"NotificationEvent",
"NodeList",
"DOMParser",
"CSS",
"customElements"
], ],
"ignore": [ "ignore": [
"dist", "dist",
"src/routes/_utils/asyncModules.js", "routes/_utils/asyncModules.js",
"src/routes/_utils/asyncPolyfills.js", "routes/_components/dialog/asyncDialogs.js"
"src/routes/_components/dialog/asyncDialogs.js"
] ]
}, },
"esm": { "esm": {
@ -166,26 +154,27 @@
"NODE_ENV": "production" "NODE_ENV": "production"
}, },
"files": [ "files": [
"assets",
"bin", "bin",
"inline-script.js", "original-assets",
"original-static", "routes",
"scss", "scss",
"src", "templates",
"src-build",
"static",
"package.json", "package.json",
"thirdparty", "package-lock.json",
"webpack", "server.js",
"webpack.config.js", "inline-script.js",
"yarn.lock" "webpack.client.config.js",
"webpack.server.config.js"
], ],
"engines": { "engines": {
"node": "^10.0.0" "node": "^8.0.0"
} }
}, },
"greenkeeper": { "greenkeeper": {
"ignore": [ "ignore": [
"sapper" "sapper",
"a11y-dialog"
] ]
}, },
"repository": { "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 { getAccessTokenFromAuthCode, registerApplication, generateAuthLink } from '../_api/oauth'
import { getInstanceInfo } from '../_api/instance' import { getInstanceInfo } from '../_api/instance'
import { goto } from '../../../__sapper__/client' import { goto } from 'sapper/runtime.js'
import { DEFAULT_THEME, switchToTheme } from '../_utils/themeEngine' import { switchToTheme } from '../_utils/themeEngine'
import { store } from '../_store/store' import { store } from '../_store/store'
import { updateVerifyCredentialsForInstance } from './instances' import { updateVerifyCredentialsForInstance } from './instances'
import { updateCustomEmojiForInstance } from './emoji' import { updateCustomEmojiForInstance } from './emoji'
import { database } from '../_database/database' import { setInstanceInfo as setInstanceInfoInDatabase } from '../_database/meta'
const REDIRECT_URI = (typeof location !== 'undefined' const REDIRECT_URI = (typeof location !== 'undefined'
? location.origin : 'https://pinafore.social') + '/settings/instances/add' ? location.origin : 'https://pinafore.social') + '/settings/instances/add'
@ -14,13 +14,12 @@ async function redirectToOauth () {
let { instanceNameInSearch, loggedInInstances } = store.get() let { instanceNameInSearch, loggedInInstances } = store.get()
instanceNameInSearch = instanceNameInSearch.replace(/^https?:\/\//, '').replace(/\/$/, '').replace('/$', '').toLowerCase() instanceNameInSearch = instanceNameInSearch.replace(/^https?:\/\//, '').replace(/\/$/, '').replace('/$', '').toLowerCase()
if (Object.keys(loggedInInstances).includes(instanceNameInSearch)) { if (Object.keys(loggedInInstances).includes(instanceNameInSearch)) {
let err = new Error(`You've already logged in to ${instanceNameInSearch}`) store.set({logInToInstanceError: `You've already logged in to ${instanceNameInSearch}`})
err.knownError = true return
throw err
} }
let registrationPromise = registerApplication(instanceNameInSearch, REDIRECT_URI) let registrationPromise = registerApplication(instanceNameInSearch, REDIRECT_URI)
let instanceInfo = await getInstanceInfo(instanceNameInSearch) let instanceInfo = await getInstanceInfo(instanceNameInSearch)
await database.setInstanceInfo(instanceNameInSearch, instanceInfo) // cache for later await setInstanceInfoInDatabase(instanceNameInSearch, instanceInfo) // cache for later
let instanceData = await registrationPromise let instanceData = await registrationPromise
store.set({ store.set({
currentRegisteredInstanceName: instanceNameInSearch, currentRegisteredInstanceName: instanceNameInSearch,
@ -45,17 +44,16 @@ export async function logInToInstance () {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
let error = `${err.message || err.name}. ` + let error = `${err.message || err.name}. ` +
(err.knownError ? '' : (navigator.onLine (navigator.onLine
? `Is this a valid Mastodon instance? Is a browser extension ? `Is this a valid Mastodon instance? Is a browser extension blocking the request?`
blocking the request? Are you in private browsing mode?` : `Are you offline?`)
: `Are you offline?`))
let { instanceNameInSearch } = store.get() let { instanceNameInSearch } = store.get()
store.set({ store.set({
logInToInstanceError: error, logInToInstanceError: error,
logInToInstanceErrorForText: instanceNameInSearch logInToInstanceErrorForText: instanceNameInSearch
}) })
} finally { } finally {
store.set({ logInToInstanceLoading: false }) store.set({logInToInstanceLoading: false})
} }
} }
@ -69,7 +67,7 @@ async function registerNewInstance (code) {
REDIRECT_URI REDIRECT_URI
) )
let { loggedInInstances, loggedInInstancesInOrder, instanceThemes } = store.get() let { loggedInInstances, loggedInInstancesInOrder, instanceThemes } = store.get()
instanceThemes[currentRegisteredInstanceName] = DEFAULT_THEME instanceThemes[currentRegisteredInstanceName] = 'default'
loggedInInstances[currentRegisteredInstanceName] = instanceData loggedInInstances[currentRegisteredInstanceName] = instanceData
if (!loggedInInstancesInOrder.includes(currentRegisteredInstanceName)) { if (!loggedInInstancesInOrder.includes(currentRegisteredInstanceName)) {
loggedInInstancesInOrder.push(currentRegisteredInstanceName) loggedInInstancesInOrder.push(currentRegisteredInstanceName)
@ -84,7 +82,7 @@ async function registerNewInstance (code) {
instanceThemes: instanceThemes instanceThemes: instanceThemes
}) })
store.save() store.save()
switchToTheme(DEFAULT_THEME) switchToTheme('default')
// fire off these requests so they're cached // fire off these requests so they're cached
/* no await */ updateVerifyCredentialsForInstance(currentRegisteredInstanceName) /* no await */ updateVerifyCredentialsForInstance(currentRegisteredInstanceName)
/* no await */ updateCustomEmojiForInstance(currentRegisteredInstanceName) /* no await */ updateCustomEmojiForInstance(currentRegisteredInstanceName)
@ -93,11 +91,11 @@ async function registerNewInstance (code) {
export async function handleOauthCode (code) { export async function handleOauthCode (code) {
try { try {
store.set({ logInToInstanceLoading: true }) store.set({logInToInstanceLoading: true})
await registerNewInstance(code) await registerNewInstance(code)
} catch (err) { } 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 { } 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 { mark, stop } from '../_utils/marks'
import { store } from '../_store/store' import { store } from '../_store/store'
import uniqBy from 'lodash-es/uniqBy' import uniqBy from 'lodash-es/uniqBy'
import uniq from 'lodash-es/uniq' import uniq from 'lodash-es/uniq'
import isEqual from 'lodash-es/isEqual' import isEqual from 'lodash-es/isEqual'
import { database } from '../_database/database' import {
import { concat } from '../_utils/arrays' insertTimelineItems as insertTimelineItemsInDatabase
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' } from '../_database/timelines/insertion'
import { runMediumPriorityTask } from '../_utils/runMediumPriorityTask'
const STREAMING_THROTTLE_DELAY = 3000
function getExistingItemIdsSet (instanceName, timelineName) { function getExistingItemIdsSet (instanceName, timelineName) {
let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || [] let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || []
@ -25,31 +29,14 @@ async function insertUpdatesIntoTimeline (instanceName, timelineName, updates) {
return return
} }
await database.insertTimelineItems(instanceName, timelineName, updates) await insertTimelineItemsInDatabase(instanceName, timelineName, updates)
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || [] 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)) { if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length), console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for timeline', timelineName) 'items to itemIdsToAdd for timeline', timelineName)
store.setForTimeline(instanceName, timelineName, { itemIdsToAdd: newItemIdsToAdd }) 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)
)
} }
} }
@ -59,20 +46,21 @@ async function insertUpdatesIntoThreads (instanceName, updates) {
} }
let threads = store.getThreads(instanceName) 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') || [] for (let timelineName of Object.keys(threads)) {
let validUpdates = updates.filter(isValidStatusForThread(thread, timelineName, itemIdsToAdd)) let thread = threads[timelineName]
if (!validUpdates.length) { let updatesForThisThread = updates.filter(
status => thread.includes(status.in_reply_to_id) && !thread.includes(status.id)
)
if (!updatesForThisThread.length) {
continue 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)) { if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length), console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for thread', timelineName) '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') let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates')
if (freshUpdates && freshUpdates.length) { if (freshUpdates && freshUpdates.length) {
let updates = freshUpdates.slice() let updates = freshUpdates.slice()
store.setForTimeline(instanceName, timelineName, { freshUpdates: [] }) store.setForTimeline(instanceName, timelineName, {freshUpdates: []})
await Promise.all([ await Promise.all([
insertUpdatesIntoTimeline(instanceName, timelineName, updates), insertUpdatesIntoTimeline(instanceName, timelineName, updates),
@ -92,11 +80,11 @@ async function processFreshUpdates (instanceName, timelineName) {
stop('processFreshUpdates') stop('processFreshUpdates')
} }
function lazilyProcessFreshUpdates (instanceName, timelineName) { const lazilyProcessFreshUpdates = throttle((instanceName, timelineName) => {
scheduleIdleTask(() => { runMediumPriorityTask(() => {
/* no await */ processFreshUpdates(instanceName, timelineName) /* no await */ processFreshUpdates(instanceName, timelineName)
}) })
} }, STREAMING_THROTTLE_DELAY)
export function addStatusOrNotification (instanceName, timelineName, newStatusOrNotification) { export function addStatusOrNotification (instanceName, timelineName, newStatusOrNotification) {
addStatusesOrNotifications(instanceName, timelineName, [newStatusOrNotification]) addStatusesOrNotifications(instanceName, timelineName, [newStatusOrNotification])
@ -105,8 +93,8 @@ export function addStatusOrNotification (instanceName, timelineName, newStatusOr
export function addStatusesOrNotifications (instanceName, timelineName, newStatusesOrNotifications) { export function addStatusesOrNotifications (instanceName, timelineName, newStatusesOrNotifications) {
console.log('addStatusesOrNotifications', Date.now()) console.log('addStatusesOrNotifications', Date.now())
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates') || [] let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates') || []
freshUpdates = concat(freshUpdates, newStatusesOrNotifications) freshUpdates = [].concat(freshUpdates).concat(newStatusesOrNotifications)
freshUpdates = uniqBy(freshUpdates, _ => _.id) freshUpdates = uniqBy(freshUpdates, _ => _.id)
store.setForTimeline(instanceName, timelineName, { freshUpdates: freshUpdates }) store.setForTimeline(instanceName, timelineName, {freshUpdates: freshUpdates})
lazilyProcessFreshUpdates(instanceName, timelineName) lazilyProcessFreshUpdates(instanceName, timelineName)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import { favoriteStatus, unfavoriteStatus } from '../_api/favorite' import { favoriteStatus, unfavoriteStatus } from '../_api/favorite'
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { database } from '../_database/database' import {
setStatusFavorited as setStatusFavoritedInDatabase
} from '../_database/timelines/updateStatus'
export async function setFavorited (statusId, favorited) { export async function setFavorited (statusId, favorited) {
let { online } = store.get() let { online } = store.get()
@ -16,7 +18,7 @@ export async function setFavorited (statusId, favorited) {
store.setStatusFavorited(currentInstance, statusId, favorited) // optimistic update store.setStatusFavorited(currentInstance, statusId, favorited) // optimistic update
try { try {
await networkPromise await networkPromise
await database.setStatusFavorited(currentInstance, statusId, favorited) await setStatusFavoritedInDatabase(currentInstance, statusId, favorited)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Failed to ${favorited ? 'favorite' : 'unfavorite'}. ` + (e.message || '')) 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) { export async function getFollowRequests (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/follow_requests` 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) { export async function authorizeFollowRequest (instanceName, accessToken, id) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${id}/authorize` 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) { export async function rejectFollowRequest (instanceName, accessToken, id) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${id}/reject` 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 { getVerifyCredentials } from '../_api/user'
import { store } from '../_store/store' import { store } from '../_store/store'
import { DEFAULT_THEME, switchToTheme } from '../_utils/themeEngine' import { switchToTheme } from '../_utils/themeEngine'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { goto } from '../../../__sapper__/client' import { goto } from 'sapper/runtime.js'
import { cacheFirstUpdateAfter } from '../_utils/sync' import { cacheFirstUpdateAfter } from '../_utils/sync'
import { getInstanceInfo } from '../_api/instance' 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) { export function changeTheme (instanceName, newTheme) {
let { instanceThemes } = store.get() let { instanceThemes } = store.get()
instanceThemes[instanceName] = newTheme instanceThemes[instanceName] = newTheme
store.set({ instanceThemes: instanceThemes }) store.set({instanceThemes: instanceThemes})
store.save() store.save()
let { currentInstance } = store.get() let { currentInstance } = store.get()
if (instanceName === currentInstance) { if (instanceName === currentInstance) {
@ -55,15 +61,15 @@ export async function logOutOfInstance (instanceName) {
}) })
store.save() store.save()
toast.say(`Logged out of ${instanceName}`) toast.say(`Logged out of ${instanceName}`)
switchToTheme(instanceThemes[newInstance] || DEFAULT_THEME) switchToTheme(instanceThemes[newInstance] || 'default')
/* no await */ database.clearDatabaseForInstance(instanceName) await clearDatabaseForInstance(instanceName)
goto('/settings/instances') goto('/settings/instances')
} }
function setStoreVerifyCredentials (instanceName, thisVerifyCredentials) { function setStoreVerifyCredentials (instanceName, thisVerifyCredentials) {
let { verifyCredentials } = store.get() let { verifyCredentials } = store.get()
verifyCredentials[instanceName] = thisVerifyCredentials verifyCredentials[instanceName] = thisVerifyCredentials
store.set({ verifyCredentials: verifyCredentials }) store.set({verifyCredentials: verifyCredentials})
} }
export async function updateVerifyCredentialsForInstance (instanceName) { export async function updateVerifyCredentialsForInstance (instanceName) {
@ -71,8 +77,8 @@ export async function updateVerifyCredentialsForInstance (instanceName) {
let accessToken = loggedInInstances[instanceName].access_token let accessToken = loggedInInstances[instanceName].access_token
await cacheFirstUpdateAfter( await cacheFirstUpdateAfter(
() => getVerifyCredentials(instanceName, accessToken), () => getVerifyCredentials(instanceName, accessToken),
() => database.getInstanceVerifyCredentials(instanceName), () => getInstanceVerifyCredentialsFromDatabase(instanceName),
verifyCredentials => database.setInstanceVerifyCredentials(instanceName, verifyCredentials), verifyCredentials => setInstanceVerifyCredentialsInDatabase(instanceName, verifyCredentials),
verifyCredentials => setStoreVerifyCredentials(instanceName, verifyCredentials) verifyCredentials => setStoreVerifyCredentials(instanceName, verifyCredentials)
) )
} }
@ -85,12 +91,12 @@ export async function updateVerifyCredentialsForCurrentInstance () {
export async function updateInstanceInfo (instanceName) { export async function updateInstanceInfo (instanceName) {
await cacheFirstUpdateAfter( await cacheFirstUpdateAfter(
() => getInstanceInfo(instanceName), () => getInstanceInfo(instanceName),
() => database.getInstanceInfo(instanceName), () => getInstanceInfoFromDatabase(instanceName),
info => database.setInstanceInfo(instanceName, info), info => setInstanceInfoInDatabase(instanceName, info),
info => { info => {
let { instanceInfos } = store.get() let { instanceInfos } = store.get()
instanceInfos[instanceName] = info 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 { store } from '../_store/store'
import { uploadMedia } from '../_api/media' import { uploadMedia } from '../_api/media'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
export async function doMediaUpload (realm, file) { export async function doMediaUpload (realm, file) {
let { currentInstance, accessToken } = store.get() let { currentInstance, accessToken } = store.get()
store.set({ uploadingMedia: true }) store.set({uploadingMedia: true})
try { try {
let response = await uploadMedia(currentInstance, accessToken, file) let response = await uploadMedia(currentInstance, accessToken, file)
let composeMedia = store.getComposeData(realm, 'media') || [] let composeMedia = store.getComposeData(realm, 'media') || []
if (composeMedia.length === 4) {
throw new Error('Only 4 media max are allowed')
}
composeMedia.push({ composeMedia.push({
data: response, data: response,
file: { name: file.name }, file: { name: file.name }
description: ''
}) })
let composeText = store.getComposeData(realm, 'text') || ''
composeText += ' ' + response.text_url
store.setComposeData(realm, { store.setComposeData(realm, {
media: composeMedia media: composeMedia,
text: composeText
}) })
scheduleIdleTask(() => store.save()) scheduleIdleTask(() => store.save())
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Failed to upload media: ' + (e.message || '')) toast.say('Failed to upload media: ' + (e.message || ''))
} finally { } finally {
store.set({ uploadingMedia: false }) store.set({uploadingMedia: false})
} }
} }
export function deleteMedia (realm, i) { export function deleteMedia (realm, i) {
let composeMedia = store.getComposeData(realm, 'media') 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, { store.setComposeData(realm, {
media: composeMedia media: composeMedia,
text: composeText,
mediaDescriptions: mediaDescriptions
}) })
scheduleIdleTask(() => store.save()) scheduleIdleTask(() => store.save())
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -2,5 +2,5 @@
import { store } from '../_store/store' import { store } from '../_store/store'
export function setPostPrivacy (realm, postPrivacyKey) { 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 { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { reblogStatus, unreblogStatus } from '../_api/reblog' 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) { export async function setReblogged (statusId, reblogged) {
let online = store.get() let online = store.get()
@ -16,7 +16,7 @@ export async function setReblogged (statusId, reblogged) {
store.setStatusReblogged(currentInstance, statusId, reblogged) // optimistic update store.setStatusReblogged(currentInstance, statusId, reblogged) // optimistic update
try { try {
await networkPromise await networkPromise
await database.setStatusReblogged(currentInstance, statusId, reblogged) await setStatusRebloggedInDatabase(currentInstance, statusId, reblogged)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Failed to ${reblogged ? 'boost' : 'unboost'}. ` + (e.message || '')) toast.say(`Failed to ${reblogged ? 'boost' : 'unboost'}. ` + (e.message || ''))

View File

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

View File

@ -1,10 +1,10 @@
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { search } from '../_api/search' import { search } from '../_api/search'
export async function doSearch () { export async function doSearch () {
let { currentInstance, accessToken, queryInSearch } = store.get() let { currentInstance, accessToken, queryInSearch } = store.get()
store.set({ searchLoading: true }) store.set({searchLoading: true})
try { try {
let results = await search(currentInstance, accessToken, queryInSearch) let results = await search(currentInstance, accessToken, queryInSearch)
let { queryInSearch: newQueryInSearch } = store.get() // avoid race conditions 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 || '')) toast.say('Error during search: ' + (e.name || '') + ' ' + (e.message || ''))
console.error(e) console.error(e)
} finally { } 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) { 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 return status.reblog && status.reblog.id
} }
@ -13,9 +19,9 @@ export async function getIdsThatTheseStatusesReblogged (instanceName, statusIds)
} }
export async function getIdsThatRebloggedThisStatus (instanceName, statusId) { export async function getIdsThatRebloggedThisStatus (instanceName, statusId) {
return database.getReblogsForStatus(instanceName, statusId) return getReblogsForStatusFromDatabase(instanceName, statusId)
} }
export async function getNotificationIdsForStatuses (instanceName, statusIds) { 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 { store } from '../_store/store'
import { getTimeline } from '../_api/timelines' import { getTimeline } from '../_api/timelines'
import { toast } from '../_components/toast/toast' import { toast } from '../_utils/toast'
import { mark, stop } from '../_utils/marks' import { mark, stop } from '../_utils/marks'
import { concat, mergeArrays } from '../_utils/arrays' import { mergeArrays } from '../_utils/arrays'
import { byItemIds } from '../_utils/sorting' import { byItemIds } from '../_utils/sorting'
import isEqual from 'lodash-es/isEqual' import isEqual from 'lodash-es/isEqual'
import { database } from '../_database/database' import {
import { getStatus, getStatusContext } from '../_api/statuses' insertTimelineItems as insertTimelineItemsInDatabase
import { emit } from '../_utils/eventBus' } from '../_database/timelines/insertion'
import { TIMELINE_BATCH_SIZE } from '../_static/timelines' import {
getTimeline as getTimelineFromDatabase
} from '../_database/timelines/pagination'
async function storeFreshTimelineItemsInDatabase (instanceName, timelineName, items) { const FETCH_LIMIT = 20
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)
}
}
async function fetchTimelineItems (instanceName, accessToken, timelineName, lastTimelineItemId, online) { async function fetchTimelineItems (instanceName, accessToken, timelineName, lastTimelineItemId, online) {
mark('fetchTimelineItems') mark('fetchTimelineItems')
let items let items
let stale = false let stale = false
if (!online) { if (!online) {
items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, TIMELINE_BATCH_SIZE) items = await getTimelineFromDatabase(instanceName, timelineName, lastTimelineItemId, FETCH_LIMIT)
stale = true stale = true
} else { } else {
try { try {
console.log('fetchTimelineItemsFromNetwork') items = await getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, FETCH_LIMIT)
items = await fetchTimelineItemsFromNetwork(instanceName, accessToken, timelineName, lastTimelineItemId) /* no await */ insertTimelineItemsInDatabase(instanceName, timelineName, items)
/* no await */ storeFreshTimelineItemsInDatabase(instanceName, timelineName, items)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Internet request failed. Showing offline content.') 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 stale = true
} }
} }
@ -72,10 +51,10 @@ export async function addTimelineItemIds (instanceName, timelineName, newIds, ne
let mergedIds = mergeArrays(oldIds || [], newIds) let mergedIds = mergeArrays(oldIds || [], newIds)
if (!isEqual(oldIds, mergedIds)) { if (!isEqual(oldIds, mergedIds)) {
store.setForTimeline(instanceName, timelineName, { timelineItemIds: mergedIds }) store.setForTimeline(instanceName, timelineName, {timelineItemIds: mergedIds})
} }
if (oldStale !== newStale) { 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) { export async function fetchTimelineItemsOnScrollToBottom (instanceName, timelineName) {
console.log('setting runningUpdate: true')
store.setForTimeline(instanceName, timelineName, { runningUpdate: true }) store.setForTimeline(instanceName, timelineName, { runningUpdate: true })
await fetchTimelineItemsAndPossiblyFallBack() await fetchTimelineItemsAndPossiblyFallBack()
console.log('setting runningUpdate: false')
store.setForTimeline(instanceName, timelineName, { 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 itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd')
let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds')
// TODO: update database and do the thread merge correctly // TODO: update database and do the thread merge correctly
for (let itemIdToAdd of itemIdsToAdd) { timelineItemIds = timelineItemIds.concat(itemIdsToAdd)
if (!timelineItemIds.includes(itemIdToAdd)) {
timelineItemIds.push(itemIdToAdd)
}
}
store.setForTimeline(instanceName, timelineName, { store.setForTimeline(instanceName, timelineName, {
itemIdsToAdd: [], itemIdsToAdd: [],
timelineItemIds: timelineItemIds timelineItemIds: timelineItemIds

View File

@ -3,10 +3,10 @@ import { post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function blockAccount (instanceName, accessToken, accountId) { export async function blockAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/block` 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) { export async function unblockAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unblock` 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) { export async function getBlockedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/blocks` let url = `${basename(instanceName)}/api/v1/blocks`
url += '?' + paramsString({ limit }) 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) { export async function getMutedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/mutes` let url = `${basename(instanceName)}/api/v1/mutes`
url += '?' + paramsString({ limit }) 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) { export async function deleteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${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) { export async function getCustomEmoji (instanceName) {
let url = `${basename(instanceName)}/api/v1/custom_emojis` 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) { export async function favoriteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourite` 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) { export async function unfavoriteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unfavourite` 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) { export async function followAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/follow` 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) { export async function unfollowAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unfollow` 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) { export async function getFollows (instanceName, accessToken, accountId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/following` let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/following`
url += '?' + paramsString({ limit }) 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) { export async function getFollowers (instanceName, accessToken, accountId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/followers` let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/followers`
url += '?' + paramsString({ limit }) 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) { export function getInstanceInfo (instanceName) {
let url = `${basename(instanceName)}/api/v1/instance` 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) { export function getLists (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/lists` 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) formData.append('description', description)
} }
let url = `${basename(instanceName)}/api/v1/media` 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) { export async function putMediaDescription (instanceName, accessToken, mediaId, description) {
let url = `${basename(instanceName)}/api/v1/media/${mediaId}` 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) { export async function muteAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/mute` 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) { export async function unmuteAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/unmute` 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) { export async function muteConversation (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/mute` 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) { export async function unmuteConversation (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unmute` 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' import { basename } from './utils'
const WEBSITE = 'https://pinafore.social' const WEBSITE = 'https://pinafore.social'
const SCOPES = 'read write follow push' const SCOPES = 'read write follow'
const CLIENT_NAME = 'Pinafore' const CLIENT_NAME = 'Pinafore'
export function registerApplication (instanceName, redirectUri) { export function registerApplication (instanceName, redirectUri) {
@ -12,7 +12,7 @@ export function registerApplication (instanceName, redirectUri) {
redirect_uris: redirectUri, redirect_uris: redirectUri,
scopes: SCOPES, scopes: SCOPES,
website: WEBSITE website: WEBSITE
}, null, { timeout: WRITE_TIMEOUT }) }, null, {timeout: WRITE_TIMEOUT})
} }
export function generateAuthLink (instanceName, clientId, redirectUri) { export function generateAuthLink (instanceName, clientId, redirectUri) {
@ -33,5 +33,5 @@ export function getAccessTokenFromAuthCode (instanceName, clientId, clientSecret
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code: 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) { export async function pinStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/pin` 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) { export async function unpinStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unpin` 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, limit: 40,
pinned: true 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) { export async function getReblogs (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by`
url += '?' + paramsString({ limit }) 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) { export async function getFavorites (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by`
url += '?' + paramsString({ limit }) 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) { export async function approveFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/authorize` 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) { export async function rejectFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/reject` 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, q: query,
resolve: true 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/')) { if (timeline.startsWith('tag/')) {
return 'timelines/tag' return 'timelines/tag'
} else if (timeline.startsWith('status/')) {
return 'statuses'
} else if (timeline.startsWith('account/')) { } else if (timeline.startsWith('account/')) {
return 'accounts' return 'accounts'
} else if (timeline.startsWith('list/')) { } 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 timelineUrlName = getTimelineUrlPath(timeline)
let url = `${basename(instanceName)}/api/v1/${timelineUrlName}` let url = `${basename(instanceName)}/api/v1/${timelineUrlName}`
if (timeline.startsWith('tag/')) { if (timeline.startsWith('tag/')) {
url += '/' + timeline.split('/').slice(-1)[0] url += '/' + timeline.split('/').slice(-1)[0]
} else if (timeline.startsWith('status/')) {
url += '/' + timeline.split('/').slice(-1)[0] + '/context'
} else if (timeline.startsWith('account/')) { } else if (timeline.startsWith('account/')) {
url += '/' + timeline.split('/').slice(-1)[0] + '/statuses' url += '/' + timeline.split('/').slice(-1)[0] + '/statuses'
} else if (timeline.startsWith('list/')) { } else if (timeline.startsWith('list/')) {
@ -43,15 +47,22 @@ export function getTimeline (instanceName, accessToken, timeline, maxId, since,
params.max_id = maxId params.max_id = maxId
} }
if (limit) {
params.limit = limit
}
if (timeline === 'local') { if (timeline === 'local') {
params.local = true params.local = true
} }
url += '?' + paramsString(params) 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) { function targetIsLocalhost (instanceName) {
return instanceName.startsWith('localhost:') || instanceName.startsWith('127.0.0.1:') return instanceName.startsWith('localhost:') || instanceName.startsWith('127.0.0.1:')
} }
export function basename (instanceName) { export function basename (instanceName) {
if (targetIsLocalhost(instanceName)) { if (isLocalhost && targetIsLocalhost(instanceName)) {
return `http://${instanceName}` return `http://${instanceName}`
} }
return `https://${instanceName}` return `https://${instanceName}`

View File

@ -32,9 +32,9 @@
</style> </style>
<script> <script>
import { store } from '../_store/store' import { store } from '../_store/store'
import LoadingPage from './LoadingPage.html' import LoadingPage from '../_components/LoadingPage.html'
import AccountSearchResult from './search/AccountSearchResult.html' import AccountSearchResult from '../_components/search/AccountSearchResult.html'
import { toast } from './toast/toast' import { toast } from '../_utils/toast'
import { on } from '../_utils/eventBus' import { on } from '../_utils/eventBus'
// TODO: paginate // TODO: paginate
@ -45,7 +45,7 @@
} catch (e) { } catch (e) {
toast.say('Error: ' + (e.name || '') + ' ' + (e.message || '')) toast.say('Error: ' + (e.name || '') + ' ' + (e.message || ''))
} finally { } finally {
this.set({ loading: false }) this.set({loading: false})
} }
on('refreshAccountsList', this, () => this.refreshAccounts()) 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} {#if error}
<svg class={computedClass} style={svgStyle} aria-hidden="true"> <svg class={computedClass} aria-hidden="true">
<use xlink:href="#fa-user" /> <use xlink:href="#fa-user" />
</svg> </svg>
{:elseif $autoplayGifs} {:elseif $autoplayGifs}
<LazyImage <img
className={computedClass} class={computedClass}
ariaHidden="true" aria-hidden="true"
forceSize=true
alt="" alt=""
src={account.avatar} src={account.avatar}
{width}
{height}
on:imgLoad="set({loaded: true})" on:imgLoad="set({loaded: true})"
on:imgLoadError="set({error: true})" /> on:imgLoadError="set({error: true})" />
{:else} {:else}
@ -20,8 +17,6 @@
alt="" alt=""
src={account.avatar} src={account.avatar}
staticSrc={account.avatar_static} staticSrc={account.avatar_static}
{width}
{height}
{isLink} {isLink}
on:imgLoad="set({loaded: true})" on:imgLoad="set({loaded: true})"
on:imgLoadError="set({error: true})" on:imgLoadError="set({error: true})"
@ -37,17 +32,48 @@
background: none; 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 { svg.avatar {
fill: var(--deemphasized-text-color); fill: var(--deemphasized-text-color);
} }
</style> </style>
<script> <script>
import { imgLoad, imgLoadError } from '../_utils/events'
import { store } from '../_store/store' import { store } from '../_store/store'
import NonAutoplayImg from './NonAutoplayImg.html' import NonAutoplayImg from './NonAutoplayImg.html'
import { classname } from '../_utils/classname' import { classname } from '../_utils/classname'
import LazyImage from './LazyImage.html'
export default { export default {
events: {
imgLoad,
imgLoadError
},
data: () => ({ data: () => ({
className: void 0, className: void 0,
loaded: false, loaded: false,
@ -56,30 +82,15 @@
}), }),
store: () => store, store: () => store,
computed: { computed: {
computedClass: ({ className, loaded }) => (classname( computedClass: ({ className, loaded, size }) => (classname(
'avatar', 'avatar',
className, className,
loaded && 'loaded' loaded && 'loaded',
)), `size-${size}`
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;`
}, },
components: { components: {
NonAutoplayImg, NonAutoplayImg
LazyImage
} }
} }
</script> </script>

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