Compare commits

...

372 Commits

Author SHA1 Message Date
khr 74ee427201 Merge remote-tracking branch 'origin/master' into update-cybrespace 2019-02-23 16:20:48 -08:00
Nolan Lawson 243f0fd71d add cybre theme, make it default 2019-02-12 00:19:59 -08:00
Nolan Lawson 3aee6fb050
fix: only call Royal the default theme if it is (#970) 2019-02-11 22:36:31 -08:00
Nolan Lawson 30048a7f12
perf: remove duplicate custom props from theme CSS (#969) 2019-02-11 21:04:19 -08:00
Nolan Lawson 652ffffec4
fix: fix SettingsListItem missing className dev warning (#968) 2019-02-11 21:04:08 -08:00
Nolan Lawson 4bf3c2fd28
fix: don't show theme picker if not logged in (#967)
Also tweak some of the styles and layout in the theme picker itself
2019-02-11 21:04:00 -08:00
Nolan Lawson 8179c1b53f
fix: set dark theme based on prefers-color-scheme (#966)
* fix: set dark theme based on prefers-color-scheme

fixes #728

* add an explicit banner fill color
2019-02-11 21:03:51 -08:00
Nolan Lawson de4016029f
fix: fix legibility of settings text in dark themes (#965) 2019-02-11 19:49:18 -08:00
Nolan Lawson 73182552d4 1.0.1 2019-02-10 13:29:15 -08:00
Nolan Lawson 795999e5ac
perf: use keyed each for media (#963) 2019-02-10 13:27:29 -08:00
Nolan Lawson fdcaa864af
fix: allow right-click to save image (#962)
fixes #961
2019-02-10 13:03:24 -08:00
Nolan Lawson 37a95c04ab 1.0.0 2019-02-10 11:34:03 -08:00
Nolan Lawson 9963473eaa
docs: fix markdown error in readme (#960)
[skip ci]
2019-02-10 11:33:03 -08:00
Nolan Lawson 734857d3bf
docs: add documentation about 1.0.0 yarn changes (#959)
[skip ci]
2019-02-10 11:30:00 -08:00
Nolan Lawson 37c85ec7e2
fix: disable smooth scroll for users who prefer reduced motion (#958) 2019-02-09 19:52:40 -08:00
Nolan Lawson 56f5a45221
fix: fix aria-hidden on hidden icons (#957) 2019-02-09 19:05:59 -08:00
Nolan Lawson 2884955d67
fix: carousel should use ul/li for a11y (#956) 2019-02-09 18:06:58 -08:00
Nolan Lawson 9cb15a3396
fix: fix hand cursor on non-tappable status (#955)
fixes #951
2019-02-09 17:31:24 -08:00
Nolan Lawson 135fb24873
fix indicator button size on small screens (#954) 2019-02-09 15:25:30 -08:00
Nolan Lawson e82066dcc2
fix: fix scroll-snap in firefox when scrolling manually (#953) 2019-02-09 14:31:49 -08:00
greenkeeper[bot] b05855f7ca Update testcafe to the latest version 🚀 (#950)
* chore(package): update testcafe to version 1.0.0

* chore(package): update lockfile yarn.lock
2019-02-09 12:01:38 -08:00
Nolan Lawson 73eb9fba2c
fix: fix carousel and scrollbars for edge browser (#952) 2019-02-09 12:01:29 -08:00
Nolan Lawson 180055da70
fix: fix items appearing over nav (#949)
fixes #937
2019-02-06 22:24:03 -08:00
Nolan Lawson 2a96e0eeda
fix: minor fixups for accessibility and responsive design (#948)
* fix: minor fixups for accessibility and responsive design

* fix lint
2019-02-06 22:23:54 -08:00
Nolan Lawson 157f5db690
fix: fix zoomable videos (#947)
fixes #946
2019-02-06 21:00:30 -08:00
Isabelle Knott 503378a400 feat: Add 'switch to instance' buttons next to instances in instance list. (#945) 2019-02-06 20:56:30 -08:00
Nolan Lawson 6e0f2ef6bb
fix: do not add duplicates to threads (#944)
attempt to fix #943
2019-02-06 11:36:46 -08:00
Nolan Lawson 10b14abcdb
fix: tweak carousel to show button change sooner (#940) 2019-02-05 09:54:38 -08:00
Nolan Lawson 7583d488a0
chore: update sapper to v0.25.0 (#938) 2019-02-03 15:45:51 -08:00
Nolan Lawson e17d3974d5
fix: add label to dummy icon to prevent svelte warning (#936) 2019-02-03 14:47:14 -08:00
Nolan Lawson f5be28d99a
fix: use intrinsicsize for images/videos (#935)
* fix: use intrinsicsize for images/videos

* add comment
2019-02-03 14:01:45 -08:00
Nolan Lawson 6d2b3ec072
feat: add pinch-zoom to media dialog (#933)
* feat: add pinch-zoom to media dialog

* fix zoom buttons
2019-02-03 12:33:15 -08:00
Nolan Lawson 4c430bd1c9
fix: remove dead code from MediaDialog (#934) 2019-02-03 11:56:27 -08:00
Ivan Kupalov 437236bf3c feat: Add shortcuts to the media dialog (#930)
* Add shortcuts to the media dialog

* fix: unify logic for next/prev buttons and keyboard shortcuts

* fix: add info about left/right shortcuts
2019-02-03 11:56:07 -08:00
Nolan Lawson 84e9bfc8e5
fix: fix rounding of scroll math on Safari (#932) 2019-02-03 11:10:58 -08:00
Nolan Lawson 9231e66612
fix: fix pressed color on media dialog buttons (#931) 2019-02-03 11:10:52 -08:00
Nolan Lawson 5e082e5f5f
chore: give "now" the yarn.lock (#929) 2019-02-02 23:24:32 -08:00
Nolan Lawson 9d594f0bac
feat: add carousel for media modal (#928) 2019-02-02 23:03:40 -08:00
Nolan Lawson 2ef4743b3c
core: update webpack and remove browserlist (#926) 2019-01-27 18:23:25 -08:00
Nolan Lawson d198250eab
chore: switch to yarn (#927)
* chore: switch to yarn

BREAKING CHANGE: Pinafore is now using yarn rather than npm, so those
who self-host will need to stop running e.g. `npm install` and run `yarn
install` instead.

* install latest yarn
2019-01-27 17:44:30 -08:00
Nolan Lawson 58b0c56ad8 0.18.0 2019-01-27 12:36:54 -08:00
Nolan Lawson 7a8be06412
chore: update dep rollup-plugin-terser (#925) 2019-01-27 12:29:27 -08:00
Nolan Lawson 14932e2479
chore: update dep rollup (#924) 2019-01-27 12:29:20 -08:00
Nolan Lawson 3dfab37f53
chore: update dep inferno-compat (#923) 2019-01-27 12:29:13 -08:00
Nolan Lawson 2f743299ec
chore: update dep esm (#922) 2019-01-27 12:29:06 -08:00
Nolan Lawson 648d9a3cf6
fix: tweak underlining of links (#920) 2019-01-26 13:50:45 -08:00
Nolan Lawson 109022fab9
feat: add option to underline links in toot text (#919)
* feat: add option to underline links in toot text

* change text
2019-01-26 12:58:11 -08:00
Nolan Lawson 0b1efab0c1
fix: fix "copy link" on iOS (#918)
* fix: fix "copy link" on iOS

fixes #912

* fix lint
2019-01-26 12:05:14 -08:00
Stephane Zermatten 2656e11bb0 fix: Follow-up for pull request #870 (#910)
* Cache main-nav in scrollIntoView.js.

This change avoids continuously calling document.getElementById for the
same element.

* Fix firstVisibleElementIndex to always return a dictionary.

Before this change, firstVisibleElementIndex would return -1 if it
doesn't find anything. This made no sense since this function returns a
dictionary on success.

With this change, the function always returns a dictionary with the
expected keys.

* lint fix
2019-01-26 10:14:27 -08:00
Nolan Lawson d976b621b8
chore: move inline-script to src (#917) 2019-01-26 10:14:15 -08:00
Nolan Lawson eb5437e32a
fix: increase custom scrollbar width (#914) 2019-01-26 09:34:16 -08:00
Nolan Lawson 95336e0657
chore: fix watchers in dev mode (#913) 2019-01-26 09:34:05 -08:00
Nolan Lawson c5394524df
chore: test in mastodon v2.7.0 (#909)
* chore: test in mastodon v2.7.0

* update ruby version

* fix backup script and update fixtures
2019-01-20 13:44:06 -08:00
Nolan Lawson 74ab056f18
fix: fix notification m/p keyboard shortcuts (#907)
fixes #905
2019-01-19 23:52:39 -08:00
Nolan Lawson c1f6c1582d
docs: remove mention of keyboard shortcuts (#906)
[skip ci]
2019-01-19 16:43:49 -08:00
Nolan Lawson 45d70e8e6b
feat: add more keyboard shortcuts (#904)
* feat: add more keyboard shortcuts

largely fixes #895

* oops wrong test name
2019-01-19 16:40:31 -08:00
Nolan Lawson b014778761
fix: fix keyboard shortcuts in notifications (#903)
fixes #894
2019-01-19 16:04:06 -08:00
Nolan Lawson 031caec406
fix: fix Alt key in keyboard shortcuts (#902)
fixes #896
2019-01-19 15:50:39 -08:00
Nolan Lawson 6b3d53a795
chore: remove pify dependency, use util.promisify (#901) 2019-01-19 15:06:25 -08:00
Nolan Lawson 4a8f65b7fc
chore: refactor src files to src/ directory (#900) 2019-01-19 13:32:36 -08:00
Nolan Lawson ae918a226c
chore: update browserslist/esm/now (#899) 2019-01-19 13:32:29 -08:00
Nolan Lawson a508f494f0 0.17.0 2019-01-13 17:15:45 -08:00
Nolan Lawson cb58a49c04
fix: use more consistent method for checking external links (#893) 2019-01-13 17:11:27 -08:00
Will Pearson cb35a088f4 Make external links open in new tab (#892) 2019-01-13 16:05:41 -08:00
Nolan Lawson ef44c19e8a
feat: date title shows absolute date (#890)
fixes #759
2019-01-13 15:56:39 -08:00
Nolan Lawson 8f84ae5a51
feat: add setting to disable hotkeys (#889) 2019-01-13 14:02:15 -08:00
Nolan Lawson 4a6f7b74a4
feat: 1-6 hotkeys switch columns (#888) 2019-01-13 14:02:08 -08:00
Nolan Lawson 6d1bb64bbb
fix: fix question mark hotkey (#887)
* fix: fix question mark hotkey

* fix tests
2019-01-13 14:02:01 -08:00
Nolan Lawson 29a2892dd0
feat: add hotkey help to settings (#886) 2019-01-13 11:35:04 -08:00
Will Pearson aa69e651ac feat: wrap long posts with spoilers (#873)
* Wrap LONG posts with spoilers

Some ActivityPub software facilitates long form blog posts being pushed
out onto timelines.
Some ActivityPub software have toot lengths which are much larger than
Mastodon's default of 500.

This wraps spoiler tags around those statuses.

* fix lint, extract consts

* extract consts to separate file

* fix test hopefully

* Revert "fix test hopefully"

This reverts commit 7d8e2671ad158b317f6f75a7dd5dacac9e12cf80.

* Fix failing test

* Revert "Fix failing test"

This reverts commit 15b06e11589b49979ca5eb85b7fe5c7510a62ba7.

* Revert "Revert "fix test hopefully""

This reverts commit d3776bc9d64dcfd209a307a0639d33cbe6ca3884.

* measure text after shortening URLs
2019-01-13 10:47:07 -08:00
Stephane Zermatten c2bd2f306a feat: Add support for keyboard shortcuts (#870)
* Add support for keyboard shortcuts.

This change introduces a Shortcut component for defining global
keyboard shortcuts from whichever component makes more sense.

This change also adds an initial set of navigation shortcuts:
- Backspace to leave a modal dialog or to go back
- g t to go to the federated timeline
- g f to go to the favorite page
- g h to go to the home page
- g n to go to the notification page
- g c to go to the community page
- s to go to the search page

These shortcuts are loaded asynchronously from _layout.html

In modal dialogs, shortcuts are also modal, to avoid strange or
overly complex behavior. This is implemented by grouping
shortcuts into scopes, and activating a separate 'modal' scope
when entering a modal dialog, so a separate set of shortcuts can
be enabled in modal dialog. Modal dialogs can be exited by
pressing 'Backspace'.

* Navigate up/down lists using keyboard shortcuts.

This change introduces keyboard shortcuts for navigating in lists and
virtual lists. j or arrow up selects the next element, k or arrow down,
the previous element. Selecting an element scrolls the list up and down,
as necessary.

This change also allows directing keyboard shortcuts to the active
element and defines the following shortcuts, for the active status:
- f to favorite or unfavorite it
- b to boost or unboost it
- r to reply to it
- o to open its thread
- x to toggle the display of a CW
- y to toggle the display of sensitive medias

This works by defining a keyboard shortcut scope for each list element.
A new component, ScrollListShortcuts, keeps track of the active element,
based on list or virtual list elements and redirects shortcuts to the
active element's scope. ScrollListShortcuts keeps the active element in
the current realm of the store, so the active element is restored when
going back to the list.

* Typing h or ? displays the list of available keyboard shortcuts.

This change introduces a new modal dialog that documents the list of
available shortcuts.
2019-01-13 10:03:29 -08:00
Nolan Lawson 981af04c6d
chore: update now to latest (#885) 2019-01-12 13:13:59 -08:00
Nolan Lawson 610f5be1e9
chore: update testcafe to latest (#884) 2019-01-12 13:13:53 -08:00
Nolan Lawson f2d1054af6
chore: update webpack to latest (#883) 2019-01-12 13:13:47 -08:00
Nolan Lawson 39e77eeb4a
chore: update terser-webpack-plugin to latest (#882) 2019-01-12 13:13:39 -08:00
Nolan Lawson adf04aa1ad
chore: update svelte-loader to latest (#881) 2019-01-12 13:13:32 -08:00
Nolan Lawson 55879362a4
chore: update rollup-plugin-terser to latest (#880) 2019-01-12 13:13:24 -08:00
Nolan Lawson a39c57af8d
chore: update rollup to latest (#879) 2019-01-12 13:13:18 -08:00
Nolan Lawson af827d1338
chore: update inferno-compat to latest (#878) 2019-01-12 13:13:12 -08:00
Nolan Lawson dfd53c056d
chore: update emoji-regex to latest (#877) 2019-01-12 13:13:05 -08:00
Nolan Lawson 14faed41e5
chore: update css-loader to latest (#876) 2019-01-12 13:12:56 -08:00
Nolan Lawson ec01534e00
chore: update browserslist to latest (#875) 2019-01-12 13:11:56 -08:00
Nolan Lawson a5a6c49269
chore: improve steps in export process (#871) 2019-01-12 10:17:37 -08:00
greenkeeper[bot] b90bcbcfef chore: Update now to the latest version 🚀 (#868)
* chore(package): update now to version 13.0.0

* chore(package): update lockfile package-lock.json
2019-01-12 09:50:43 -08:00
Nathan Minchow d7aaec16d3 feat: Add labels to theme groups (#867) 2019-01-12 09:50:17 -08:00
greenkeeper[bot] 5bb48e89e2 chore: Update rollup-plugin-terser to the latest version 🚀 (#864)
* fix(package): update rollup-plugin-terser to version 4.0.0

* chore(package): update lockfile package-lock.json
2019-01-12 09:49:44 -08:00
Nolan Lawson 8b26fe0eab
chore: use latest npm in travis (#863) 2019-01-12 09:49:29 -08:00
greenkeeper[bot] 26d0b827bc Update rollup to the latest version 🚀 (#860)
* fix(package): update rollup to version 1.0.0

* chore(package): update lockfile package-lock.json

* fix inline script script
2019-01-01 10:42:50 -08:00
Nolan Lawson e5ef4b9bb1
fix: add better browser notification badges (#857)
* fix: add better browser notification badges

* fix test

* fix tests for real

* actually fix tests
2018-12-30 14:51:03 -08:00
Nolan Lawson 795d6bce35
docs: add some more contributing instructions (#861)
[skip ci]
2018-12-30 14:50:51 -08:00
Nolan Lawson 52d1ab5703
Revert "perf: use webpack splitChunks defaults (#855)" (#856)
This reverts commit 49b85623d5.
2018-12-23 11:25:35 -08:00
Nolan Lawson 49b85623d5
perf: use webpack splitChunks defaults (#855) 2018-12-23 10:52:47 -08:00
Nolan Lawson e666eb5955
perf: use lodash-lite for some functions (#853) 2018-12-23 10:10:16 -08:00
Nolan Lawson cf94e7d61e
fix: fix unnecessary request for theme-undefined.css (#852) 2018-12-22 15:37:56 -08:00
Nolan Lawson 4ab9687200
perf: load Toast asynchronously (#851) 2018-12-22 15:37:51 -08:00
Nolan Lawson 59f9be448d 0.16.0 2018-12-19 09:09:33 -08:00
Nolan Lawson a98fb4f7f6
docs: fix typo
[skip ci]
2018-12-19 01:08:45 -08:00
Nolan Lawson 27da387a01
fix: preserve newlines correctly in delete-and-redraft (#845)
fixes #830
2018-12-19 00:57:56 -08:00
Nolan Lawson d5eac4e119
docs: add way more contributing docs (#846)
[skip ci]
2018-12-19 00:57:48 -08:00
Nolan Lawson 32981ffeb2
Update README.md
[skip ci]
2018-12-18 20:50:30 -08:00
Nolan Lawson d047a265a3
fix(picker): only focus picker search on desktop (#843)
* fix(picker): only focus picker search on desktop

* detect using touch detection instead
2018-12-18 20:01:53 -08:00
Nolan Lawson 7596d905ab
fix(emoji): make emoji picker more like Mastodon's (#842) 2018-12-18 18:24:05 -08:00
Nolan Lawson cd44e33a7e
perf: reduce size of emoji-mart dep using fork (#841) 2018-12-18 14:40:14 -08:00
Nolan Lawson e6ca246527
fix: use emoji-mart all.json, split out json (#839) 2018-12-18 12:45:49 -08:00
Nolan Lawson 2d32a91145
fix: fix appearance of native emoji on linux (#838) 2018-12-18 12:45:41 -08:00
Nolan Lawson 7da2076791
chore: add prop-types to deps to unbreak now (#837) 2018-12-18 10:00:12 -08:00
Nolan Lawson 098a20db49
feat: add full emoji picker using emoji-mart (#836)
* feat: add full emoji picker using emoji-mart

Fixes #4

* use a sailboat as the default emoji in the emoji picker

* fix tests

* fix lint
2018-12-18 00:43:51 -08:00
Nolan Lawson 943a1ed5e6
chore: fix build process in dev mode (#835) 2018-12-17 22:42:39 -08:00
Nolan Lawson 93c2358a71
chore: upgrade travis/now to node v10 (#834) 2018-12-17 17:30:12 -08:00
Nolan Lawson bb7fe6e30a
chore: make build process faster/simpler (#833)
This gets rid of the awkward checking-in of `template.html` to git (when
it's a built file) and also makes the rebuilds faster and more
consistent by running everything through the same pipeline. So inline
CSS, SVG, and JS are all partially built on-the-fly.

I've basically reinvented gulp, but it's pretty lightweight and
zero-dep, so I'm happy with it.
2018-12-17 17:21:29 -08:00
Nolan Lawson fc30ef1c8c
fix: add perf budgets to webpack, shorten chunk names (#831) 2018-12-17 13:42:10 -08:00
Carlos Fernández 669be2e32b Fix Pleroma compability issue (#829)
* Now image and video modals width and height will fallback to "auto" instead of 300x200. Allows proper image visualization in modals when there's no attachment metadata.

* Move background-image in gifv modal to the computed property videoStyle

fixes #750
2018-12-17 13:13:27 -08:00
Nolan Lawson 049bbba639 0.15.0 2018-12-17 11:54:51 -08:00
Nolan Lawson 4ca5b4611f
fix: use correct this.store format in all components (#827) 2018-12-17 11:28:31 -08:00
Nolan Lawson bf9ba22c35
fix: fix logging in and out with a refresh (#824)
* fix: fix logging in and out with a refresh

fixes #805

* simplify code

* make test less flaky

* fix dumb mistake
2018-12-16 12:39:16 -08:00
Nolan Lawson 14a618f374
perf: lazy-load logged-in observers, fix circular dependencies (#823)
* perf: lazy-load logged-in observers, fix circular dependencies

* I guess async deps don't count as circular deps
2018-12-16 10:22:34 -08:00
Nolan Lawson beb4d6e119
perf: avoid caching robots.txt in service worker (#820) 2018-12-16 09:35:02 -08:00
greenkeeper[bot] 77b84d44f4 Update rollup to the latest version 🚀 (#822)
* fix(package): update rollup to version 0.68.0

* chore(package): update lockfile package-lock.json
2018-12-16 09:34:53 -08:00
Nolan Lawson 146ac8d4aa
chore(package): update svelte to version 2.16.0 (#821)
* chore(package): update svelte to version 2.16.0

* chore(package): update lockfile package-lock.json
2018-12-16 09:34:41 -08:00
Nolan Lawson 4220df9418
fix: fix "now" files for thirdparty (#819) 2018-12-15 19:58:39 -08:00
Nolan Lawson 3ae532aee5
fix: fix NODE_ENV and webpack config (#818) 2018-12-15 19:21:20 -08:00
Nolan Lawson f2f5508144
perf: only run getSelection() check if we have to (#817) 2018-12-15 19:21:14 -08:00
Nolan Lawson 3a335a9f4a
perf: use terser for service worker too (#816) 2018-12-15 17:36:36 -08:00
Nolan Lawson 260f6acf0e
perf: download and cache polyfills on-the-fly (#814)
* perf: download and cache polyfills on-the-fly

* fixup the localhost switch for service worker, does nothing
2018-12-15 17:13:46 -08:00
Nolan Lawson dbd6c35a88
perf: move indexeddb operations to async module (#813) 2018-12-15 17:13:40 -08:00
Nolan Lawson 89566cacaa
perf: do prefetch to other instance to load it faster (#811) 2018-12-15 17:13:34 -08:00
Nolan Lawson b4164653db
fix: remove font-awesome-svg-png (#810)
* fix: remove font-awesome-svg-png

* update readme
2018-12-15 17:13:27 -08:00
Nolan Lawson 7ddfe3830a
feat: add drag and drop for media uploads (#809)
* feat: add drag and drop for media uploads

fixes #65

* tweak colors
2018-12-15 02:06:12 -08:00
Nolan Lawson fd1310c2c1 0.14.1 2018-12-14 00:26:03 -08:00
Nolan Lawson c90ad17686
fix: logging out results in visible homescreen (#807)
* fix: logging out results in visible homescreen

fixes #805

* fixup
2018-12-14 00:25:12 -08:00
Nolan Lawson d5c0268ef2
fix: move page-lifecycle and idb-getall-shim to dynamic modules (#804)
This would help with Rollup compat if we ever decided to switch to
Rollup
2018-12-13 22:55:04 -08:00
Nolan Lawson 25793e2fec
fix(style): use dark placeholder on dark theme (#802)
fixes #797
2018-12-13 21:32:37 -08:00
Nolan Lawson 15e8840eb6
chore(git): remove ignored files from git (#801) 2018-12-13 21:32:32 -08:00
Nolan Lawson 319a158deb
fix: remove non-functional globalize/deglobalize scripts (#800)
fixes #787
2018-12-13 21:32:25 -08:00
Nolan Lawson 0fa0658b59
Revert "fix: re-enable mini-css-extract-plugin (#791)" (#799)
This reverts commit dc93685c18.

This fixes #798
2018-12-13 21:32:12 -08:00
Nolan Lawson a442b5ef43 0.14.0 2018-12-13 08:04:22 -08:00
Nolan Lawson 3462113c2f
fix: drop CSP support from the exported version (#795)
[skip ci]
2018-12-13 07:58:45 -08:00
Nolan Lawson 5e089ba27d
fix: fix sensitive content warning on mobile (#794) 2018-12-13 00:39:38 -08:00
Nolan Lawson 381d1dd120
chore(package): update dependencies (#792) 2018-12-12 23:46:51 -08:00
Nolan Lawson dc93685c18
fix: re-enable mini-css-extract-plugin (#791)
fixes #776
2018-12-12 23:46:02 -08:00
Nolan Lawson 1940260631
fix: fix delete and redraft on replies (#789)
fixes #786
2018-12-12 23:45:52 -08:00
Nolan Lawson 631603b0b7
fix: fix flaky test by clicking relative date (#790)
* fix: fix flaky test by clicking relative date

* fix test and add new one
2018-12-12 23:45:42 -08:00
sgenoud 30705da19d fix: Change the size of sensitive media box (#784)
It is now slightly bigger than what images in a box would look
like.

fixes #771
2018-12-12 23:44:29 -08:00
Nolan Lawson 76a8072e04
fix: show warning when local storage is unavailable (#782)
fixes #780
2018-12-11 22:07:01 -08:00
Nolan Lawson e3f7b3e65c
perf: slightly improve framerate of navbar indicator (#781)
this seems to help mobile firefox on android especially
2018-12-11 22:06:56 -08:00
Nolan Lawson 42978c3c84
fix: fix duplicate statuses in threads (#783)
fixes #511
2018-12-11 22:06:50 -08:00
Nolan Lawson 5d3ceb9eb5
perf(svgs): remove unused svg <title>s (#778)
* perf(svgs): remove unused svg <title>s

* remove double svg wrapper
2018-12-11 08:12:57 -08:00
Nolan Lawson 4bd181d3cc
fix: update Sapper to latest (#775)
* fix: update to latest sapper

fixes #416

* fix error and debug pages

* requestIdleCallback makes column switching feel way nicer than double rAF

* add export feature

* add better csp info

* workaround for sapper sub-page issue

* clarify in readme about exporting

* fix now config

* switch from rIC to triple raf

* style-loader is no longer used

* update theming guide
2018-12-11 07:31:48 -08:00
Nolan Lawson 4d3a2ded2a
chore(tests): skip flaky "thread preserves focus" test (#777) 2018-12-11 07:29:31 -08:00
Nolan Lawson 852d86b9e3
fix: fix media in grouped mode in focused threads (#774) 2018-12-10 22:26:48 -08:00
sgenoud 94d0590070 Fix the Pitch Black buttons color (#773)
Fix #757
2018-12-10 20:52:24 -08:00
Nolan Lawson b2f5f36207
fix(svg): re-inline svg into html (#772)
* Revert "fix(icons): fix icons.svg url (#769)"

This reverts commit 1d34d45da7.

* Revert "perf: use prefetch instead of preload for svgs (#765)"

This reverts commit b73dd548ae.

* Revert "perf: build separate icons.svg file (#762)"

This reverts commit ee45c07314.

* move svg to end of 2xx.html
2018-12-10 00:13:54 -08:00
Nolan Lawson 6a69b193d5
chore: fix wrong order in now alias command (#770) 2018-12-09 10:39:20 -08:00
Nolan Lawson 1d34d45da7
fix(icons): fix icons.svg url (#769)
in #762 I forgot to set the absolute path for these; it fails on
subpages
2018-12-08 20:04:32 -08:00
Nolan Lawson 481a567807
chore: fix travis deploy script (#768) 2018-12-08 18:07:50 -08:00
Nolan Lawson 8eb30d02e9
chore: cleanup travis scripts (#766)
* chore: cleanup travis scripts

* remove unused script
2018-12-08 17:15:01 -08:00
Nolan Lawson b73dd548ae
perf: use prefetch instead of preload for svgs (#765) 2018-12-08 16:04:11 -08:00
Nolan Lawson ee45c07314
perf: build separate icons.svg file (#762) 2018-12-08 12:46:16 -08:00
Nolan Lawson dd349e2ae3
chore(tests): update mastodon to v2.6.5 (#764)
* chore(tests): update mastodon to v2.6.5

* fix for travis cache of mastodon git repo
2018-12-08 12:46:00 -08:00
Nolan Lawson 7876f82871
fix: build inline script using Rollup (#761)
* fix: build inline script using Rollup

This reduces code duplication and allows the theme engine to work the
same without modifying the code in two places. It does extra extra deps,
but I tried to keep them to a minimum.

* change code comment

* remove unnecessary constant
2018-12-08 11:21:54 -08:00
Nolan Lawson 34cfaf27b3
fix: make inline media changes suggested in #747 (#760) 2018-12-08 09:44:12 -08:00
sgenoud 530ad6b35c feat: Allow Media to be shown in a grid (as an option) (#747)
* Allow Media to be shown in a grid

* Bring back the sidebar for ungrouped images
2018-12-08 09:42:38 -08:00
Nolan Lawson ab548a0a5d
fix(scroll): slightly tweak flicker fix (#758) 2018-12-08 07:53:14 -08:00
Nolan Lawson 7954a63588
chore(package): update sapper's deps (#755) 2018-12-07 23:24:28 -08:00
Nolan Lawson 99c44f348a
fix(scroll): improve flicker on back navigation (#756)
* fix(scroll): improve flicker on back navigation

It's still not perfect in Firefox for Android, but it's improved.

Partial fix for #753

* the double raf makes no difference here
2018-12-07 23:23:48 -08:00
Nolan Lawson 4b028b1a62
fix: use native smooth scroll when possible (#751) 2018-12-07 08:20:16 -08:00
greenkeeper[bot] 2280ff2832 Update css-loader to the latest version 🚀 (#749)
* fix(package): update css-loader to version 2.0.0

* chore(package): update lockfile package-lock.json
2018-12-07 07:55:23 -08:00
greenkeeper[bot] 495d9b7438 Update mini-css-extract-plugin to the latest version 🚀 (#748)
* fix(package): update mini-css-extract-plugin to version 0.5.0

* chore(package): update lockfile package-lock.json
2018-12-07 07:55:14 -08:00
Nolan Lawson 0e524f3e9a
fix: detect private browsing and safari blocked cookies (#733)
* WIP: detect private browsing and safari blocked cookies

* just check for indexeddb

* just check for indexeddb

* change warning text

* change text

* change text again

* change text again

fixes #444
2018-12-05 21:34:30 -08:00
Nolan Lawson c0f857336a
fix: error msg when logging in to same instance (#735) 2018-12-05 21:34:02 -08:00
Nolan Lawson f7164dd4c0
fix(observers): refactor instance observers, minor optimizations (#730)
try to defer more work, split everything up into more functional code for easier reading
2018-12-05 00:21:54 -08:00
Nolan Lawson ef32bfb278
fix(a11y): fix a11y issues caught by lighthouse (#729) 2018-12-04 22:31:46 -08:00
sgenoud 03d883423c feat(themes): Add the Pitch Black theme (#727)
This theme is made with mobile OLED screens (iPhone XS for instance)
2018-12-04 20:40:47 -08:00
Nolan Lawson 0f0db010eb 0.13.0 2018-12-04 07:26:12 -08:00
Nolan Lawson aae73f0cc6
fix(design): "add instance" input has dark bg on dark theme (#726)
fixes #723
2018-12-04 07:25:22 -08:00
Nolan Lawson d83d7322dc
Revert "chore(store): clean up usage of svelte store (#713)" (#725)
This reverts commit 36d90d34e5.

Fixes #724
2018-12-04 07:24:55 -08:00
Nolan Lawson 618ea31a57
chore: refactor instance observer code, remove double call (#722) 2018-12-04 06:44:19 -08:00
Nolan Lawson 09f3281e36
chore(package): use events-lights instead of events (#721) 2018-12-04 06:44:13 -08:00
Nolan Lawson 60751b3339
feat(statuses): implement "Delete and redraft" (#719)
Fixes #469
2018-12-03 23:23:29 -08:00
Nolan Lawson 92edb3d835
fix(firefox): fix firefox with blocked images (#718)
* fix(firefox): fix firefox with blocked images

* remove excessive perf marks

* fixup

* fix lint
2018-12-03 23:08:38 -08:00
Nolan Lawson da7a29d503
chore: refactor PR #716 (#717) 2018-12-02 23:19:15 -08:00
Nolan Lawson e894e031fb
feat(a11y): add option to disable tappable toots (#716)
fixes #163. also fixed the issue where selecting text would cause the toot to be tapped
2018-12-02 21:12:58 -08:00
Nolan Lawson ee3dfd8e28 0.12.0 2018-12-02 16:14:10 -08:00
Nolan Lawson b22a1ec90c
fix(iOS): fix faux-sticky button placement (#715)
hopefully the final fix for #667 to make it actually work
2018-12-02 15:57:39 -08:00
Nolan Lawson 26b84c435a
fix(design): use dark compose button halo on dark themes (#714)
Also only show the halo when we're actually scrolled down, because the
point is to make it more visible against arbitrary content.
2018-12-02 14:25:40 -08:00
Nolan Lawson 36d90d34e5
chore(store): clean up usage of svelte store (#713) 2018-12-02 14:25:35 -08:00
Nolan Lawson ef656301f6
fix(a11y): decrease brightness of hacker theme (#712)
fixes #576
2018-12-02 14:25:30 -08:00
Nolan Lawson 945c1e7a23
fix(iOS): fix horizontal scroll, use fake sticky button on iOS (#711)
fixes #667
2018-12-02 11:22:18 -08:00
Nolan Lawson 537ad208a3
chore(editor): add an editorconfig (#710) 2018-12-01 23:06:34 -08:00
Nolan Lawson ce61b821c5
feat(ui): add option to always show sensitive media (#709)
fixes #699. I also went ahead and divided the settings into two groups: Preferences and Accessibility
2018-12-01 14:09:08 -08:00
Nolan Lawson f3254bb22d
feat(settings): add theme settings to general (#706) 2018-12-01 12:57:00 -08:00
Nolan Lawson a760687c6d
chore(tests): make focus test less flaky (#708) 2018-12-01 12:56:55 -08:00
Nolan Lawson 153e4f4fcd
feat(a11y): add option for short article aria labels (#705)
Actually fixes #694 by providing an option to make the labels like they used to be.
2018-12-01 11:53:20 -08:00
Nolan Lawson 0515133ece
fix(a11y): fix NVDA crash on long aria-label (#702)
* fix(a11y): fix NVDA crash on long aria-label

fixes #694

* use the word truncated instead of ellipsis

* fix test

* really fix tests
2018-12-01 00:10:30 -08:00
Nolan Lawson 12892d0032
chore(package): update to webpack 4.26.1 (#703) 2018-12-01 00:00:11 -08:00
Nolan Lawson ea4e21281f
chore(package): update now to 12.1.8 (#704) 2018-12-01 00:00:05 -08:00
Nolan Lawson e44cafb5fb
feat(scrollbars): add option to disable scrollbars, fix macOS style (#701)
fixes #698 and fixes #700
2018-11-27 19:17:18 -08:00
Nolan Lawson 58f9c09bb8
fix(scrollbars): use standard CSS for scrollbars (#697)
Also use correct SCSS escaping everywhere, and fix a missing semicolon. Fixes #691
2018-11-26 13:15:33 -08:00
Nolan Lawson 7f1ec6036d
fix(a11y): default "prefers reduced motion" to OS/browser default (#696)
fixes #695
2018-11-26 13:13:52 -08:00
Nolan Lawson 9c74a072bf
fix(emojos): actually fix trademark character (#693)
another fix for #679
2018-11-25 12:35:52 -08:00
Nolan Lawson 41d7e40662
fix(deps): remove timeago.js (#692) 2018-11-25 10:33:59 -08:00
Nolan Lawson cc81a7bec6
fix(a11y): improved aria-label for status and notifications (#690)
* fix(a11y): improved aria-label for status and notifications

fixes #689

* only calculate formatted date once

* fixup tests

*  fixup tests more

* fixup

* fixup tests again
2018-11-25 01:20:58 -08:00
Nolan Lawson 2db06ea472
chore(package): update npm-run-all to version 4.1.5 (#688)
* chore(package): update npm-run-all to version 4.1.5

* chore(package): update lockfile package-lock.json
2018-11-24 21:21:42 -08:00
Nolan Lawson bfa37f5105
fix(design): tweak scrollbar colors (#687) 2018-11-24 09:33:07 -08:00
Nolan Lawson 2569b59b32
feat(design): add custom scrollbars for each theme (#685)
* feat(design): add custom scrollbars for each theme

fixes #683

* improve contrast of hotpants theme
2018-11-24 01:39:00 -08:00
Nolan Lawson 48a1bd47b3
refactor(themes): use CSS specificity order for themes (#684)
The point of this PR is to make it easier to implement scrollbars (#683).

With this PR, the themes move from a body tag-based system (e.g. `body.theme-scarlet`) to a system where they simply declare global CSS and we use CSS specificity order to give us the right theme.
2018-11-24 00:41:36 -08:00
Nolan Lawson f0b3115be1
fix(scrolling): fix body scrollable when modal is open (#681)
Fixes #680
2018-11-23 21:33:03 -08:00
Nolan Lawson e3debcc5e1
fix(emoji): fix ™® characters treated as emoji (#682)
fixes #679
2018-11-23 21:32:57 -08:00
Nolan Lawson 999d560703 0.11.1 2018-11-21 22:09:36 -08:00
Nolan Lawson bae367da7b
test: make tests less flaky (#678) 2018-11-21 22:08:37 -08:00
Nolan Lawson 673e7b951c
chore(package): update deps (#677) 2018-11-21 20:53:43 -08:00
Nolan Lawson 83d92711e1
fix(safari): fix sticky-positioned button (#674)
fixes #673
2018-11-21 07:27:56 -08:00
Nolan Lawson 689dae5d39
fix(dialog): when dialog is hidden, don't scroll to top (#672)
* fix(dialog): when dialog is hidden, don't scroll to top

* update package-lock.json
2018-11-21 00:33:46 -08:00
Nolan Lawson 5fdba9366a
fix(scroll): prevent horizontal scroll in mobile safari/firefox (#669)
* fix(scroll): prevent horizontal scroll in mobile safari/firefox

fixes #667

* fix safari vs non-safari browsers
2018-11-21 00:17:59 -08:00
Nolan Lawson 3dae883761
fix(emoji): asterisk should not be treated as emoji (#668) 2018-11-20 22:41:41 -08:00
Nolan Lawson 35a42c9fd3
fix(emoji): fix textarea emoji on linux (#670) 2018-11-20 22:41:32 -08:00
Nolan Lawson d9e79daa6a
fix(emoji): emoji replacer should ignore pound sign (#666)
* fix(emoji): emoji replacer should ignore pound sign

* add test

* fix regex
2018-11-20 09:42:49 -08:00
Nolan Lawson 5f5cb0d36d
fix(scroll): fix offsetHeight for scrolling whole document (#664) 2018-11-20 08:58:13 -08:00
Nolan Lawson 20ae390308
fix(design): input bg should be white on "add instance" page (#663) 2018-11-20 07:54:40 -08:00
Nolan Lawson 4124da2439
fix(emojos): fix emojos on Ubuntu and Chrome on Windows (#661)
* fix(emojos): fix emojos on Ubuntu and Chrome on Windows

* fixup

* start working on unit tests

* fixup

* add more tests and fix emoji
2018-11-20 00:01:23 -08:00
Nolan Lawson 4e35c82f94
perf(terser): remove console logs in production (#658) 2018-11-18 09:07:39 -08:00
Nolan Lawson 639c6eaed7
fix(eventBus): increase event listeners to 1000 (#657) 2018-11-18 09:07:32 -08:00
Nolan Lawson b7f5d04b4c
fix(scrolling): use body as scrolling container (#656)
* fix(scrolling): use body as scrolling container

Fixes #526

* fixup tests and focus
2018-11-17 18:06:49 -08:00
Nolan Lawson c1820f62f7
fix(design): fix large metadata cells (#655)
fixes #654
2018-11-17 12:24:09 -08:00
Nolan Lawson 255bd3b341
chore(travis): use npm ci || npm i (#651) 2018-11-16 00:45:54 -08:00
Nolan Lawson 92d2dbddfc
perf(virtuallist): add contain:content to virtual list items (#650)
* perf(virtuallist): add contain:content to virtual list items

* add a few more contain:contents
2018-11-16 00:37:28 -08:00
Nolan Lawson c99cc7ed67 0.11.0 2018-11-12 18:38:00 -08:00
Nolan Lawson 62ac7330fc
feat(title): add dynamic document title (#645)
* feat(title): add dynamic document title

fixes #490 and #643

* fix code style
2018-11-12 18:28:43 -08:00
Nolan Lawson eee2eb288b
update to svelte 2.15.3 (#644) 2018-11-12 17:34:12 -08:00
Nolan Lawson c54aaf2fa4
fix(video): stop playing video when modal closed (#640)
fixes #622
2018-11-12 16:20:59 -08:00
Nolan Lawson 94baf9e396
feat(metadata): add verified metadata checkmark (#642)
fixes #641
2018-11-12 16:20:54 -08:00
Nolan Lawson 0964442815
chore(travis): update to mastodon v2.6.1 (#630)
* chore(travis): update to mastodon v2.6.1

* check if mastodon v2.6.1 has a race condition

* apparently in 2.6.1 direct messages no longer appear in home timeline

* Revert "check if mastodon v2.6.1 has a race condition"

This reverts commit dde8ef8be58eda0563170e6b73165fdcbea54f6b.

* try to fix tests

* fix more tests
2018-11-12 12:59:47 -08:00
Nolan Lawson 1fa37df59a
chore(tests): fix minor bug in restore mastodon data script (#638) 2018-11-12 08:42:08 -08:00
Nolan Lawson 8ff174b42d
chore(travis): download ffmpeg static file (#636) 2018-11-11 21:34:59 -08:00
Nolan Lawson 31c6f152c1
chore(travis): try to speed up ffmpeg install (#635) 2018-11-11 16:37:19 -08:00
greenkeeper[bot] bf0812df6a Update eslint-plugin-html to the latest version 🚀 (#634)
* chore(package): update eslint-plugin-html to version 5.0.0

* chore(package): update lockfile package-lock.json
2018-11-11 16:37:10 -08:00
Nolan Lawson d36dfc0ee8
chore(travis): more concise travis cache config (#632) 2018-11-11 14:22:28 -08:00
Nolan Lawson 5b5c6937d0 chore(travis): fix travis deploy script logic
fixes a mistake introduced in #631
2018-11-11 14:21:10 -08:00
Nolan Lawson dd824822cb
chore(travis): use travis to deploy to production (#631) 2018-11-11 13:01:32 -08:00
Nolan Lawson ae6ae34b7d
fix(server): use compression instead of shrink-ray-current (#629) 2018-11-11 11:31:32 -08:00
Nolan Lawson 00cafece8c
make mastodon installation faster in dev mode (#624) 2018-11-11 11:12:47 -08:00
Nolan Lawson 6bb4c80450
chore(tests): avoid delaying when inserting every post in tests (#625) 2018-11-11 11:12:41 -08:00
Nolan Lawson 8dd9f00135
chore(travis): no explicit npm install (#626) 2018-11-11 11:12:32 -08:00
greenkeeper[bot] cedf33b2cb Update quick-lru to the latest version 🚀 (#623)
* fix(package): update quick-lru to version 2.0.0

* chore(package): update lockfile package-lock.json
2018-11-11 09:47:00 -08:00
greenkeeper[bot] db4ab87adc Update now to the latest version 🚀 (#621)
* chore(package): update now to version 12.0.0

* chore(package): update lockfile package-lock.json
2018-11-10 13:41:49 -08:00
Nolan Lawson 924885e532
fix redis issue in travis without building redis from source (#619) 2018-11-05 08:58:26 -08:00
Nolan Lawson 819c1e6b8d 0.10.1 2018-11-05 08:28:54 -08:00
Nolan Lawson 4fe0cf3f18
fix: fix null reference error in Safari/Edge (#618)
fixes #617
2018-11-05 08:28:38 -08:00
Nolan Lawson 4519a3fe2d 0.10.0 2018-11-04 23:07:10 -08:00
Nolan Lawson 6f4c7e6f4e
chore: update svelte to 2.15.2 (#616) 2018-11-04 23:03:21 -08:00
Nolan Lawson 754e4da638
chore(package): update shrink-ray-current to version 3.0.1 (#615)
* chore(package): update shrink-ray-current to version 3.0.1

* chore(package): update lockfile package-lock.json
2018-11-04 23:03:14 -08:00
Nolan Lawson b3a31aa21a
update express to 4.16.4 (#614) 2018-11-04 21:30:07 -08:00
Nolan Lawson f591b90629
update mini-css-extract-plugin to 0.4.4 (#613) 2018-11-04 21:29:58 -08:00
Nolan Lawson 2cf35e58eb
update now to 11.5.2 (#612) 2018-11-04 21:29:52 -08:00
Nolan Lawson 16d21947a4
update to webpack-bundle-analyzer 3.0.3 (#611) 2018-11-04 16:39:10 -08:00
Nolan Lawson 398fb2fcd7
update style-loader to 0.23.1 (#610) 2018-11-04 16:37:33 -08:00
Nolan Lawson cf7ec984e1
chore(package): update node-sass to version 4.10.0 (#609)
* chore(package): update node-sass to version 4.10.0

* chore(package): update lockfile package-lock.json
2018-11-04 16:35:17 -08:00
Nolan Lawson 951c2b6527
chore(package): update css-loader to version 1.0.1 (#608)
* chore(package): update css-loader to version 1.0.1

* chore(package): update lockfile package-lock.json
2018-11-04 16:35:08 -08:00
Nolan Lawson 7fdf8ca721
chore: update to webpack 4.24.0 (#606) 2018-11-04 15:57:39 -08:00
Nolan Lawson 599f56ab02
chore(package): update svelte to version 2.15.0 (#594)
* chore(package): update svelte to version 2.15.0

* chore(package): update lockfile package-lock.json
2018-11-04 15:57:33 -08:00
greenkeeper[bot] 5936e978dd Update testcafe to the latest version 🚀 (#590)
* chore(package): update testcafe to version 0.23.0

* update package-lock
2018-11-04 15:57:21 -08:00
Nolan Lawson df91057334
chore(package): update browserslist to version 4.3.4 (#592)
* chore(package): update browserslist to version 4.3.4

* chore(package): update lockfile package-lock.json
2018-11-04 15:57:14 -08:00
Nolan Lawson dfacbdaaa5
Slight changes to theme settings hint (#591) 2018-11-04 15:57:05 -08:00
Nolan Lawson 68c2dc47b9
update form-data, helmet, pify, intersection-observer (#596) 2018-11-04 14:52:51 -08:00
Nolan Lawson c1c3c755ce
reduce limit of stored data to 5 days (#597) 2018-11-04 14:41:22 -08:00
greenkeeper[bot] d4a208bf20 Update shrink-ray-current to the latest version 🚀 (#599)
* fix(package): update shrink-ray-current to version 3.0.0

* chore(package): update lockfile package-lock.json
2018-11-04 14:30:38 -08:00
Nolan Lawson ee942df1e3 build redis from source in travis (#602) 2018-11-04 13:59:14 -08:00
Nolan Lawson e11738a711 fix tests broken in chrome 70 (#602) 2018-11-04 13:59:14 -08:00
Nolan Lawson bc3a74bbcb don't wait if idb is blocked, remove workerize-loader (#602)
There are two issues here:

- if IDB is blocked, then the promise never resolves when you log out (and call indexedDB.deleteDatabase) and the app remains in a permanently hung state
- why is IDB blocked? well, something seems to have changed in Chrome 70 such that doing these operations in a web worker causes the blocked error. The benefits of workerizing IDB is so small that I'd rather just remove it at this point.
2018-11-04 13:59:14 -08:00
Pheng Heong TAN c305a9827a Provide directions to change the theme (#585)
* Add directions to change themes

The page 'general' was the first place I looked when I
wanted to change the theme. Not managing to find it,
I went on a search throughout various issues ( #46 , for
example) on Github before locating it.

Now I hope to spare others the search

* Hint the user about themes

When I was on this page, it wasn't immediately apparent
to me that the SettingsListItem was clickable (I thought it
was a list rather than a button)
2018-10-28 15:34:34 -07:00
Sorin Davidoi c1917318ca fix: Show boost action only for toots with relevant visibility (#584) 2018-10-28 15:28:55 -07:00
Nolan Lawson bf0eb99fe4
refactor instance settings (#581) 2018-10-28 15:28:22 -07:00
Sorin Davidoi e45af16bf9 Push notifications (#579)
* feat: Push notifications

* feat: Feature-detect push notifications support

* feat: Prompt user to reauthenticate when missing push scope

* fix(service-worker): Add tags to notifications

* feat: Push notification actions for mentions
2018-10-06 13:06:10 -07:00
Nolan Lawson 50f2cadf50
chore(package): update webpack to version 4.20.2 (#580)
* chore(package): update webpack to version 4.20.2

* chore(package): update lockfile package-lock.json
2018-09-30 12:14:07 -07:00
Nolan Lawson 568352bcd5 0.9.0 2018-09-23 14:40:41 -07:00
Nolan Lawson 5c204b8001
fix Safari search bar appearance (#575) 2018-09-23 14:17:48 -07:00
Nolan Lawson 9b2b90b46e
simplify theme definitions (#574)
* simplify theme definitions

* remove rollup dep which is overkill

* fix syntax error

* fix lint

* fix test
2018-09-23 12:26:01 -07:00
Nolan Lawson 2387a18ddb
add a theme preview (#573) 2018-09-23 11:18:02 -07:00
Nolan Lawson 85a4df0c04
use npm 6 (#572) 2018-09-23 10:12:07 -07:00
Nolan Lawson 289c7eb7a7
update now and optimize-css-assets-webpack-plugin deps (#571) 2018-09-23 10:11:18 -07:00
Nolan Lawson fbd57d67a7
remove yargs as dependency (#570) 2018-09-23 07:57:07 -07:00
Nolan Lawson 1cc22fee7a
update some more deps (#569) 2018-09-23 07:55:06 -07:00
Nolan Lawson ec1d50f998
fix safari10 uglify issue (#568)
fixes #546
2018-09-23 07:54:59 -07:00
Nolan Lawson d1a666aa4f
chore(package): update mini-css-extract-plugin to version 0.4.3 (#553)
* chore(package): update mini-css-extract-plugin to version 0.4.3

* chore(package): update lockfile package-lock.json
2018-09-23 07:54:49 -07:00
Nolan Lawson 56190efce1
switch from uglify to terser (#567) 2018-09-22 23:08:23 -07:00
Nolan Lawson 0402d825bc
chore(package): update lodash-es to version 4.17.11 (#557)
* chore(package): update lodash-es to version 4.17.11

* chore(package): update lockfile package-lock.json
2018-09-22 23:08:17 -07:00
Nolan Lawson 40336dbf41
use Mastodon v2.5.0 for testing (#566)
* use Mastodon v2.5.0 for testing

* update fixtures too
2018-09-22 22:19:02 -07:00
Nolan Lawson e2ab92107e
update svelte-loader to 2.11.0 (#561) 2018-09-22 22:18:45 -07:00
Nolan Lawson f92f6f7261
update webpack-bundle-analyzer to 3.0.2 (#558) 2018-09-22 22:18:37 -07:00
Nolan Lawson ce2c23463a
chore(package): update svgo to version 1.1.1 (#556)
* chore(package): update svgo to version 1.1.1

* chore(package): update lockfile package-lock.json
2018-09-22 22:05:50 -07:00
Nolan Lawson c449d3a209
chore(package): update now to version 11.4.5 (#552) 2018-09-22 22:04:44 -07:00
Nolan Lawson 7588ff2cb8
update svelte to version 2.13.5 (#551)
* chore(package): update svelte to version 2.13.5

* chore(package): update lockfile package-lock.json
2018-09-22 22:02:07 -07:00
Nolan Lawson cb4c7b18c0
update eslint-plugin-html to version 4.0.6 (#550)
* chore(package): update eslint-plugin-html to version 4.0.6

* chore(package): update lockfile package-lock.json
2018-09-22 22:01:11 -07:00
Nolan Lawson 1fecbb4c8e
chore(package): update esm to version 3.0.84 (#555)
* chore(package): update esm to version 3.0.84

* chore(package): update lockfile package-lock.json
2018-09-22 21:59:37 -07:00
Nolan Lawson 2fc9053322
update sapper's deps (#549) 2018-09-22 21:59:20 -07:00
Nolan Lawson 07c48f23a5
Revert to older webpack-uglifyjs-plugin to unbreak build (#565)
* Revert "Update uglifyjs-webpack-plugin to the latest version 🚀 (#545)"

This reverts commit 15d8137f6c.

* update package-lock.json
2018-09-22 19:32:21 -07:00
Nolan Lawson a026f395ce update generated HTML file 2018-09-22 16:49:49 -07:00
greenkeeper[bot] 15d8137f6c Update uglifyjs-webpack-plugin to the latest version 🚀 (#545)
* fix(package): update uglifyjs-webpack-plugin to version 2.0.0

* chore(package): update lockfile package-lock.json
2018-09-22 16:48:05 -07:00
greenkeeper[bot] 7a705c83ba Update webpack-bundle-analyzer to the latest version 🚀 (#544)
* fix(package): update webpack-bundle-analyzer to version 3.0.0

* chore(package): update lockfile
2018-09-22 16:47:55 -07:00
Nolan Lawson 24dc3ad2ae rename darkscarlet and darkpink to Punk and Riot 2018-09-22 16:42:57 -07:00
pianycist 9dac979cb6 add dark pink, dark red, dark green themes 2018-09-22 16:42:57 -07:00
Sorin Davidoi 1852f4842f fix(scss/global): Inherit colors for input and textarea (#541) 2018-09-22 16:26:53 -07:00
Nolan Lawson fc46835dec
update package lock (#542) 2018-09-07 08:05:19 -07:00
Nolan Lawson a30bd23155 0.8.3 2018-09-06 17:56:07 -07:00
Nolan Lawson c16718982f
switch timeline batch size back to 20 (#539) 2018-09-06 09:35:30 -07:00
Nolan Lawson 431d1e1051
clean up unused idb functions (#537) 2018-09-05 21:08:38 -07:00
Nolan Lawson fd43dc6e5d
fix idb getall in edge (#535)
* fix idb getall in edge

fixes #532

* try to fix

* this should work

* fixup
2018-09-05 19:52:51 -07:00
greenkeeper[bot] 334a6e1e74 Update testcafe to the latest version 🚀 (#533)
* chore(package): update testcafe to version 0.22.0

* chore(package): update lockfile

https://npm.im/greenkeeper-lockfile
2018-09-05 19:22:40 -07:00
Nolan Lawson 65c026a32a 0.8.2 2018-09-02 09:22:30 -07:00
Nolan Lawson 20dda272ba
fix auto-loading of toot content as you scroll (#529) 2018-09-01 14:11:39 -07:00
Nolan Lawson 9d27ba6c10
tweak design of notification digits (#528) 2018-09-01 13:24:02 -07:00
Nolan Lawson e92bed8e58
fix image loading (#527) 2018-08-31 16:35:26 -07:00
Nolan Lawson 9641b7ad1e
tweak timeline loading and worker dev mode (#525) 2018-08-31 09:12:48 -07:00
Nolan Lawson 2f1e4077ea
fix status thread update logic (#524) 2018-08-31 09:12:39 -07:00
Nolan Lawson 96c2858d7a
lower timeline fetch batch size from 20 to 10 (#523) 2018-08-29 22:49:14 -07:00
Nolan Lawson 6d8f4e22ef
fix worker ordering (#522) 2018-08-29 22:03:29 -07:00
greenkeeper[bot] 8dbc1b0503 Update standard to the latest version 🚀 (#519)
* chore(package): update standard to version 12.0.0

* package lock update

* fix eslint
2018-08-29 21:42:57 -07:00
Nolan Lawson d599f2f308
run idb operations in a web worker (#517) 2018-08-29 19:03:12 -07:00
Nolan Lawson 2449a27767
fix pinned/unpinned state of recently pinned statuses (#521)
* fix pinned/unpinned state of recently pinned statuses

* fixup

* fix test
2018-08-29 18:10:09 -07:00
Nolan Lawson b55c042ff4
fix display of application name in edge (#516)
fixes #512
2018-08-29 09:25:24 -07:00
Nolan Lawson 1c20c6b762 0.8.1 2018-08-28 08:52:57 -07:00
Nolan Lawson 01b1d083a9
update fav boost accounts for stale views (#510) 2018-08-28 06:45:15 -07:00
Nolan Lawson 6d50c65352
add warning text about private browsing (#509) 2018-08-28 06:44:58 -07:00
Nolan Lawson 120f50919e
improve a11y of fields, status page, and more (#505)
* improve a11y of fields, status page, and more

* tweak nav name

* fix community page and tweak text

* don't show pinned statuses heading unless there are pinned statuses
2018-08-28 06:44:36 -07:00
Nolan Lawson 46fa65f25a
fix null error in profile (#506) 2018-08-27 19:32:30 -07:00
Nolan Lawson 8334598786
fix autosuggest for dark themes (#507) 2018-08-27 19:32:22 -07:00
Nolan Lawson 464ed5ab71
update now to v11.4.0 (#502) 2018-08-27 18:30:17 -07:00
greenkeeper[bot] 29dca5d8f4 Update style-loader to the latest version 🚀 (#504)
* fix(package): update style-loader to version 0.23.0

* chore(package): update lockfile

https://npm.im/greenkeeper-lockfile
2018-08-27 18:30:08 -07:00
Nolan Lawson 02bce843aa 0.8.0 2018-08-26 20:13:42 -07:00
Nolan Lawson b59f544efb
fix application name (#501)
it should not show "Web" by default but should be absent if `application` is null
2018-08-26 19:46:19 -07:00
Nolan Lawson b60d636ee2
media uploads no longer add URLs to status text (#500)
fixes #8
2018-08-26 18:54:59 -07:00
Nolan Lawson d49af06fbd
use async clipboard API (#499) 2018-08-26 16:40:48 -07:00
Nolan Lawson 270df188cb
Store IDB data for 1 week rather than 2 weeks (#498)
Based on issues I'm seeing on iOS Safari, I'm guessing that my IDB caching is too aggressive and is hitting origin storage limits, which causes IDB transactions in safari to fail silently and never call any transaction callbacks (`onblocked`, `onerror`, `oncomplete`).

This is pretty bad and causes Pinafore to basically just not work unless you clear site data, which we should avoid. 2 weeks was probably unnecessary anyway; in a social network, who cares about content that's 2 weeks old?
2018-08-26 15:38:54 -07:00
Nolan Lawson 543536409b
add application (client) name to statuses (#497)
* add application (client) name to statuses

fixes another thing in #6

* add domain blocking (#496)

* add domain blocking

fixes another thing from #6

* show "domain blocking" on profile page

* fix stuff
2018-08-26 15:38:45 -07:00
Nolan Lawson 95665f6d74
add domain blocking (#496)
* add domain blocking

fixes another thing from #6

* show "domain blocking" on profile page
2018-08-26 14:16:00 -07:00
Nolan Lawson 47315c7f6d
add support for max_toot_chars > 500 (#495) 2018-08-26 12:14:16 -07:00
Nolan Lawson 17b80e5a79
simplify model for updating account relationships (#494) 2018-08-26 12:14:08 -07:00
Nolan Lawson 8959cdaeb1
fix incorrect toast notification for follow requests (#493) 2018-08-26 10:31:13 -07:00
Nolan Lawson 4a0cfb8d6e
tweak CSS in options dialog menu (#492) 2018-08-25 22:03:40 -07:00
Nolan Lawson d6af3b69a7
Add ability to show/hide boosts from accounts (#491)
Fixes some stuff in #6
2018-08-25 22:03:33 -07:00
Nolan Lawson dc091f1360
fix requested view for follow requests (#489) 2018-08-25 22:03:26 -07:00
Nolan Lawson 73c99904cf
fix CSS specificity to fix firefox (#488) 2018-08-25 22:03:18 -07:00
Nolan Lawson ed1813fd53
Update README.md 2018-08-25 13:04:41 -07:00
Nolan Lawson 9bdb723edb 0.7.0 2018-08-25 12:54:04 -07:00
Nolan Lawson 4edec81a0f
fix incorrect autocomplete avatars (#487)
fixes #486
2018-08-24 21:35:02 -07:00
Nolan Lawson 1423a6b14b
slight refactor of timeline fetching (#485) 2018-08-24 11:50:40 -07:00
Nolan Lawson 8d2e0636d6
add null check for index.html fetch in SW (#484)
hopefully should fix #483
2018-08-24 08:51:09 -07:00
Nolan Lawson 91a92b0003 profile fields: add #each loop, allow custom emoji, tweak CSS 2018-08-23 22:24:55 -07:00
Spanky 698d8f5730 added account meta fields to profile 2018-08-23 22:24:55 -07:00
Nolan Lawson 32ea30f4bb
fix style of account search results (#481) 2018-08-23 19:40:03 -07:00
Nolan Lawson 1753e20f29
add label for bots (#479)
fixes #463
2018-08-23 18:41:43 -07:00
Nolan Lawson c4c128030e
allow custom emoji in user profiles (#475)
fixes #471
2018-08-23 14:47:33 -07:00
Nolan Lawson 5fdde8c63f
update deps again (#476) 2018-08-23 14:47:26 -07:00
Nolan Lawson 8949b36873
Use img.decode() (#473)
* remove will-change:transform from container

* WIP: use img.decode()

* more work on img.decode
2018-08-22 21:00:53 -07:00
Nolan Lawson d10f924620
remove will-change:transform from container (#470) 2018-08-22 19:26:13 -07:00
Nolan Lawson 39cd96da70
update now to v11.3.12 (#468) 2018-08-22 07:53:02 -07:00
Nolan Lawson e9c704c7fc 0.6.2 2018-08-21 23:39:47 -07:00
Nolan Lawson d30ffc6683
disable service worker image cache for avatars (#465)
fixes #464
2018-08-21 22:22:21 -07:00
Nolan Lawson 2956e20d18 0.6.1 2018-08-21 08:04:58 -07:00
Nolan Lawson 65ac7e22f4
fix null error in VirtualListFooter.html (#456) 2018-08-21 07:06:53 -07:00
Nolan Lawson 6ad20e72a7
don't use rIC while scrolling (#455)
* don't use rIC while scrolling

* remove unnecessary import
2018-08-20 17:57:38 -07:00
Nolan Lawson c4c70dfd89 0.6.0 2018-08-19 20:43:50 -07:00
Nolan Lawson aea952daf0
use better emoji removal algorithm (#452)
another follow-up to #450 to fix #449
2018-08-19 20:25:28 -07:00
Nolan Lawson af1d4b63d3
better support for de-emojified user display names (#451)
improvements to #450 to fix #449, especially for aria labels
2018-08-19 19:31:54 -07:00
Nolan Lawson 37e12e8d73
add option to remove emoji from user display names (#450)
* add option to remove emoji from user display names

fixes #449

* slight memory perf improvement
2018-08-19 18:03:26 -07:00
Nolan Lawson 350667e5df
allow user display names to contain custom emoji (#448)
* allow user display names to contain custom emoji

fixes #445

* fix tests

* fix focus issue
2018-08-19 15:23:40 -07:00
Nolan Lawson c660c7d3a3
update Mastodon used in tests to v2.4.3 (#447)
* update Mastodon used in tests to v2.4.3

* fix count of total number of statuses
2018-08-19 14:46:48 -07:00
Nolan Lawson f732bd44ab
fix statuses with empty content text (#446)
fixes #445
2018-08-19 11:31:02 -07:00
Nolan Lawson 1aeb57fb57
remove link rel=manifest for iOS (#443)
fixes #45 in a better way than before
2018-08-18 23:14:30 -07:00
Nolan Lawson a6039f6247
make mentions the same size when focused (#442)
fixes #408
2018-08-18 22:56:56 -07:00
Nolan Lawson 9a79a6ff7f
disable manifest.json for iOS (#441)
fixes #45
2018-08-18 22:17:28 -07:00
koyu a1b89805e7 Fixing Dockerfile (#440) 2018-08-18 21:20:33 -07:00
1333 changed files with 21754 additions and 17269 deletions

9
.editorconfig Normal file
View File

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

View File

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

17
BREAKING_CHANGES.md Normal file
View File

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

View File

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

View File

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

8
bin/backup-mastodon-data.sh Executable file
View File

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

View File

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

View File

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

107
bin/build-template-html.js Normal file
View File

@ -0,0 +1,107 @@
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

@ -0,0 +1,33 @@
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)
})

8
bin/deploy-all-travis.sh Executable file
View File

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

38
bin/deploy.sh Executable file
View File

@ -0,0 +1,38 @@
#!/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

View File

@ -1,46 +0,0 @@
#!/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)
})

28
bin/print-export-info.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -1,37 +0,0 @@
// 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)
}

12932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,61 +0,0 @@
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,35 +0,0 @@
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

@ -1,22 +0,0 @@
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,25 +0,0 @@
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

@ -1,19 +0,0 @@
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,21 +0,0 @@
<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,37 +0,0 @@
<Nav {page} />
<div class="container" tabindex="0" ref:container>
<main>
<slot></slot>
</main>
{#if !$isUserLoggedIn && page === 'home'}
<InformationalFooter />
{/if}
</div>
<script>
import Nav from './Nav.html'
import { store } from '../_store/store'
import InformationalFooter from './InformationalFooter.html'
// Only focus the `.container` div on first load so it does not intefere
// with other desired behaviours (e.g. you click a toot, you navigate from
// a timeline view to a thread view, you press the back button, and now
// you're still focused on the toot).
let firstTime = true
export default {
components: {
Nav,
InformationalFooter
},
oncreate () {
if (firstTime) {
firstTime = false
this.refs.container.focus()
}
let { page } = this.get()
this.store.set({currentPage: page})
},
store: () => store
}
</script>

View File

@ -1,55 +0,0 @@
<div class="lazy-image"
style="width: {width}px; height: {height}px; background: {background};">
{#if displaySrc}
<img
class="{hidden ? 'hidden' : ''} {className || ''}"
aria-hidden={ariaHidden || ''}
alt={alt || ''}
title={alt || ''}
src={displaySrc}
{width}
{height}
/>
{/if}
</div>
<style>
.lazy-image {
overflow: hidden;
}
.lazy-image img {
transition: opacity 0.2s linear;
}
</style>
<script>
import { mark, stop } from '../_utils/marks'
export default {
oncreate () {
mark('LazyImage oncreate()')
let img = new Image()
let { src } = this.get()
let { fallback } = this.get()
img.onload = () => {
requestAnimationFrame(() => {
this.set({
displaySrc: src,
hidden: true
})
requestAnimationFrame(() => {
this.set({hidden: false})
})
})
}
img.onerror = () => {
this.set({displaySrc: fallback})
}
img.src = src
stop('LazyImage oncreate()')
},
data: () => ({
displaySrc: void 0,
hidden: false,
ariaHidden: false
})
}
</script>

View File

@ -1,43 +0,0 @@
{#if staticSrc === src}
<img class={className || ''}
aria-hidden={ariaHidden}
alt={alt || ''}
title={alt || ''}
{src}
on:imgLoad
on:imgLoadError />
{:else}
<img class="{className || ''} non-autoplay-zoom-in {isLink ? 'is-link' : ''}"
aria-hidden={ariaHidden}
alt={alt || ''}
title={alt || ''}
src={staticSrc}
on:imgLoad
on:imgLoadError
on:mouseover="onMouseOver(event)"
ref:node />
{/if}
<style>
.non-autoplay-zoom-in {
cursor: zoom-in;
}
.non-autoplay-zoom-in.is-link {
cursor: pointer;
}
</style>
<script>
import { imgLoad, imgLoadError, mouseover } from '../_utils/events'
export default {
methods: {
onMouseOver (mouseOver) {
let { src, staticSrc } = this.get()
this.refs.node.src = mouseOver ? src : staticSrc
}
},
events: {
imgLoad,
imgLoadError,
mouseover
}
}
</script>

View File

@ -1,91 +0,0 @@
<ModalDialog
{id}
{label}
{title}
background="var(--main-bg)"
>
<div class="custom-emoji-container">
{#if emojis.length}
<ul class="custom-emoji-list">
{#each emojis as emoji}
<li class="custom-emoji">
<button type="button" on:click="onClickEmoji(emoji)">
<img src={$autoplayGifs ? emoji.url : emoji.static_url}
alt=":{emoji.shortcode}:"
title=":{emoji.shortcode}:"
/>
</button>
</li>
{/each}
</ul>
{:else}
<div class="custom-emoji-no-emoji">No custom emoji found for this instance.</div>
{/if}
</div>
</ModalDialog>
<style>
.custom-emoji-container {
max-width: 100%;
width: 400px;
height: 300px;
overflow: auto;
}
.custom-emoji-no-emoji {
font-size: 1.3em;
padding: 20px;
}
.custom-emoji-list {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
grid-gap: 5px;
padding: 20px 10px;
}
.custom-emoji button {
padding: 0;
margin: 0;
border: none;
cursor: pointer;
box-shadow: none;
background: none;
}
.custom-emoji img {
width: 48px;
height: 48px;
object-fit: contain;
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import { store } from '../../../_store/store'
import { insertEmoji } from '../../../_actions/emoji'
import { show } from '../helpers/showDialog'
import { close } from '../helpers/closeDialog'
import { oncreate } from '../helpers/onCreateDialog'
export default {
oncreate,
components: {
ModalDialog
},
store: () => store,
computed: {
emojis: ({ $currentCustomEmoji }) => {
if (!$currentCustomEmoji) {
return []
}
return $currentCustomEmoji.filter(emoji => emoji.visible_in_picker)
}
},
methods: {
show,
close,
onClickEmoji (emoji) {
let { realm } = this.get()
insertEmoji(realm, emoji)
this.close()
}
}
}
</script>

View File

@ -1,50 +0,0 @@
<ModalDialog
{id}
{label}
background="var(--muted-modal-bg)"
muted="true"
className="image-modal-dialog"
>
{#if type === 'gifv'}
<AutoplayVideo
ariaLabel="Animated GIF: {description || ''}"
{poster}
{src}
{width}
{height}
/>
{:else}
<img
{src}
{width}
{height}
alt={description || ''}
title={description || ''}
/>
{/if}
</ModalDialog>
<style>
:global(.image-modal-dialog img, .image-modal-dialog video) {
object-fit: contain;
max-width: calc(100vw - 20px);
max-height: calc(100% - 20px);
overflow: hidden;
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import AutoplayVideo from '../../AutoplayVideo.html'
import { show } from '../helpers/showDialog'
import { oncreate } from '../helpers/onCreateDialog'
export default {
oncreate,
components: {
ModalDialog,
AutoplayVideo
},
methods: {
show
}
}
</script>

View File

@ -1,38 +0,0 @@
<ModalDialog
{id}
{label}
background="var(--muted-modal-bg)"
muted="true"
className="video-modal-dialog"
>
<video {poster}
{src}
{width}
{height}
aria-label="Video: {description || ''}"
controls
/>
</ModalDialog>
<style>
:global(.video-modal-dialog video) {
object-fit: contain;
max-width: calc(100vw - 20px);
max-height: calc(100% - 20px);
overflow: hidden;
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import { show } from '../helpers/showDialog'
import { oncreate } from '../helpers/onCreateDialog'
export default {
oncreate,
components: {
ModalDialog
},
methods: {
show
}
}
</script>

View File

@ -1,20 +0,0 @@
import ImageDialog from '../components/ImageDialog.html'
import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId'
export default function showImageDialog (poster, src, type, width, height, description) {
let imageDialog = new ImageDialog({
target: createDialogElement(),
data: {
id: createDialogId(),
label: 'Image dialog',
poster,
src,
type,
width,
height,
description
}
})
imageDialog.show()
}

View File

@ -1,19 +0,0 @@
import VideoDialog from '../components/VideoDialog.html'
import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId'
export default function showVideoDialog (poster, src, width, height, description) {
let videoDialog = new VideoDialog({
target: createDialogElement(),
data: {
id: createDialogId(),
label: 'Video dialog',
poster,
src,
width,
height,
description
}
})
videoDialog.show()
}

View File

@ -1,73 +0,0 @@
<li class="settings-list-item">
<a {href}>
{#if icon}
<svg class="settings-list-item-svg">
<use xlink:href={icon} />
</svg>
{/if}
<span aria-label={ariaLabel || label} class={offsetForIcon ? 'offset-for-icon' : ''}>
{label}
</span>
</a>
</li>
<style>
.settings-list-item {
border: 1px solid var(--settings-list-item-border);
font-size: 1.3em;
display: flex;
flex-direction: column;
}
.settings-list-item a {
display: flex;
padding: 20px 40px;
background: var(--settings-list-item-bg);
}
.settings-list-item a, .settings-list-item a:visited {
color: var(--settings-list-item-text);
}
.settings-list-item a:hover {
text-decoration: none;
background: var(--settings-list-item-bg-hover);
color: var(--settings-list-item-text-hover);
}
.settings-list-item a:active {
background: var(--settings-list-item-bg-active);
}
.settings-list-item-svg {
width: 24px;
height: 24px;
display: inline-block;
margin-right: 20px;
fill: var(--svg-fill);
}
.settings-list-item .offset-for-icon {
margin-left: 44px;
}
.settings-list-item span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 767px) {
.settings-list-item a {
padding: 20px 10px;
}
.settings-list-item-svg {
margin-right: 10px;
}
.settings-list-item .offset-for-icon {
margin-left: 34px;
}
}
</style>
<script>
export default {
data: () => ({
icon: void 0,
ariaLabel: void 0,
offsetForIcon: void 0
})
}
</script>

View File

@ -1,49 +0,0 @@
<div class="status-media {sensitive ? 'status-media-is-sensitive' : ''}"
style="grid-template-columns: repeat(auto-fit, minmax({maxMediaWidth}px, 1fr));" >
{#each mediaAttachments as media}
<Media {media} {uuid} />
{/each}
</div>
<style>
.status-media {
grid-area: media;
display: grid;
align-items: center;
justify-content: center;
justify-items: center;
grid-column-gap: 10px;
grid-row-gap: 10px;
margin: 10px 0;
}
.status-media.status-media-is-sensitive {
margin: 0;
}
.status-media {
overflow: hidden;
}
.status-media {
max-width: calc(100vw - 40px);
}
@media (max-width: 767px) {
.status-media {
max-width: calc(100vw - 20px);
}
}
</style>
<script>
import Media from './Media.html'
import { DEFAULT_MEDIA_WIDTH } from '../../_static/media'
export default {
computed: {
maxMediaWidth: ({ mediaAttachments }) => {
return Math.max.apply(Math, mediaAttachments.map(media => {
return media.meta && media.meta.small && typeof media.meta.small.width === 'number' ? media.meta.small.width : DEFAULT_MEDIA_WIDTH
}))
}
},
components: {
Media
}
}
</script>

View File

@ -1,51 +0,0 @@
{#if status}
<Status {index} {length} {timelineType} {timelineValue} {focusSelector}
{status} {notification} on:recalculateHeight
/>
{:else}
<article class="notification-article"
tabindex="0"
aria-posinset={index}
aria-setsize={length} >
<StatusHeader {notification} {notificationId} {status} {statusId} {timelineType}
{account} {accountId} {uuid} isStatusInNotification="true" />
</article>
{/if}
<style>
.notification-article {
width: 560px;
max-width: calc(100vw - 40px);
padding: 10px 20px;
border-bottom: 1px solid var(--main-border);
}
@media (max-width: 767px) {
.notification-article {
padding: 10px 10px;
max-width: calc(100vw - 20px);
width: 580px;
}
}
</style>
<script>
import Status from './Status.html'
import StatusHeader from './StatusHeader.html'
import { store } from '../../_store/store'
export default {
components: {
Status,
StatusHeader
},
store: () => store,
computed: {
account: ({ notification }) => notification.account,
accountId: ({ account }) => account.id,
notificationId: ({ notification }) => notification.id,
status: ({ notification }) => notification.status,
statusId: ({ status }) => status && status.id,
uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => {
return `${$currentInstance}/${timelineType}/${timelineValue}/${notificationId}/${statusId || ''}`
}
}
}
</script>

View File

@ -1,123 +0,0 @@
<div class="status-details">
<ExternalLink className="status-absolute-date"
href={originalStatus.url}
showIcon="true"
ariaLabel="{formattedDate} (opens in new window)"
>
<time datetime={createdAtDate} title={formattedDate}>{formattedDate}</time>
</ExternalLink>
<a class="status-favs-reblogs"
href="/statuses/{originalStatusId}/reblogs"
aria-label={reblogsLabel}>
<svg class="status-favs-reblogs-svg">
<use xlink:href="#fa-retweet"/>
</svg>
<span>{numReblogs}</span>
</a>
<a class="status-favs-reblogs"
href="/statuses/{originalStatusId}/favorites"
aria-label={favoritesLabel}>
<svg class="status-favs-reblogs-svg">
<use xlink:href="#fa-star" />
</svg>
<span>{numFavs}</span>
</a>
</div>
<style>
.status-details {
grid-area: details;
display: grid;
grid-template-columns: minmax(0, max-content) min-content min-content;
grid-gap: 20px;
align-items: center;
justify-content: left;
margin: 0 5px 10px;
}
:global(.status-absolute-date) {
font-size: 1.1em;
min-width: 0;
}
:global(.status-absolute-date time) {
word-wrap: break-word;
overflow: hidden;
white-space: pre-wrap;
}
.status-favs-reblogs {
font-size: 1.1em;
display: flex;
flex-direction: row;
align-items: center;
}
.status-favs-reblogs span {
margin-left: 5px;
}
.status-favs-reblogs,
.status-favs-reblogs:hover,
.status-favs-reblogs:visited {
color: var(--deemphasized-text-color);
}
.status-favs-reblogs-svg {
fill: var(--deemphasized-text-color);
width: 18px;
height: 18px;
}
:global(.status-absolute-date, .status-absolute-date:hover, .status-absolute-date:visited) {
color: var(--deemphasized-text-color);
}
@media (max-width: 479px) {
:global(.status-absolute-date) {
font-size: 1em;
}
.status-favs-reblogs {
font-size: 1em;
}
.status-details {
grid-gap: 5px;
justify-content: space-between;
}
}
</style>
<script>
import ExternalLink from '../ExternalLink.html'
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
export default {
computed: {
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
numReblogs: ({ originalStatus }) => originalStatus.reblogs_count || 0,
numFavs: ({ originalStatus }) => originalStatus.favourites_count || 0,
formattedDate: ({ createdAtDate }) => formatter.format(new Date(createdAtDate)),
reblogsLabel: ({ numReblogs }) => {
// TODO: intl
return numReblogs === 1
? `Boosted ${numReblogs} time`
: `Boosted ${numReblogs} times`
},
favoritesLabel: ({ numFavs }) => {
// TODO: intl
return numFavs === 1
? `Favorited ${numFavs} time`
: `Favorited ${numFavs} times`
}
},
components: {
ExternalLink
}
}
</script>

View File

@ -1,46 +0,0 @@
<SettingsLayout page='settings/general' label="General">
<h1>General Settings</h1>
<h2>UI Settings</h2>
<form class="ui-settings" aria-label="UI settings">
<div class="setting-group">
<input type="checkbox" id="choice-autoplay-gif"
bind:checked="$autoplayGifs" on:change="$save()">
<label for="choice-autoplay-gif">Autoplay GIFs</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-mark-media-sensitive"
bind:checked="$markMediaAsSensitive" on:change="$save()">
<label for="choice-mark-media-sensitive">Always mark media as sensitive</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-reduce-motion"
bind:checked="$reduceMotion" on:change="$save()">
<label for="choice-reduce-motion">Reduce motion in UI animations</label>
</div>
</form>
</SettingsLayout>
<style>
.ui-settings {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
padding: 20px;
line-height: 2em;
}
.setting-group {
padding: 5px 0;
}
</style>
<script>
import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import { store } from '../../_store/store'
export default {
components: {
SettingsLayout
},
store: () => store
}
</script>

View File

@ -1,23 +0,0 @@
<SettingsLayout page='settings' label="Settings">
<h1>Settings</h1>
<SettingsList>
<SettingsListItem href="/settings/general" label="General"/>
<SettingsListItem href="/settings/instances" label="Instances"/>
<SettingsListItem href="/settings/about" label="About Pinafore"/>
</SettingsList>
</SettingsLayout>
<script>
import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import SettingsList from '../../_components/settings/SettingsList.html'
import SettingsListItem from '../../_components/settings/SettingsListItem.html'
export default {
components: {
SettingsLayout,
SettingsList,
SettingsListItem
}
}
</script>

View File

@ -1,154 +0,0 @@
<SettingsLayout page='settings/instances/{params.instanceName}' label={params.instanceName}>
<h1 class="instance-name-h1">{params.instanceName}</h1>
{#if verifyCredentials}
<h2>Logged in as:</h2>
<div class="acct-current-user">
<Avatar account={verifyCredentials} className="acct-avatar" size="big"/>
<ExternalLink className="acct-handle"
href={verifyCredentials.url}>
{'@' + verifyCredentials.acct}
</ExternalLink>
<span class="acct-display-name">{verifyCredentials.display_name || verifyCredentials.acct}</span>
</div>
<h2>Theme:</h2>
<form class="theme-chooser" aria-label="Choose a theme">
{#each themes as theme}
<div class="theme-group">
<input type="radio" id="choice-theme-{theme.name}"
value={theme.name} checked="$currentTheme === theme.name"
bind:group="selectedTheme" on:change="onThemeChange()">
<label for="choice-theme-{theme.name}">{theme.label}</label>
</div>
{/each}
</form>
<form class="instance-actions" aria-label="Switch to or log out of this instance">
{#if $loggedInInstancesInOrder.length > 1 && $currentInstance !== params.instanceName}
<button class="primary"
on:click="onSwitchToThisInstance(event)">
Switch to this instance
</button>
{/if}
<button on:click="onLogOut(event)">Log out</button>
</form>
{/if}
</SettingsLayout>
<style>
.acct-current-user {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
padding: 20px;
display: grid;
align-items: center;
font-size: 1.3em;
grid-template-areas:
"avatar handle"
"avatar display-name";
grid-template-columns: min-content 1fr;
grid-column-gap: 20px;
grid-row-gap: 10px;
}
:global(.acct-avatar) {
grid-area: avatar;
}
:global(.acct-handle) {
grid-area: handle;
}
.acct-display-name {
grid-area: display-name;
}
.theme-chooser {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
display: block;
padding: 20px;
line-height: 2em;
}
.theme-group {
display: flex;
align-items: center;
}
.theme-chooser label {
margin: 2px 10px 0;
}
.instance-actions {
width: 100%;
display: flex;
justify-content: right;
margin: 20px 0;
}
.instance-actions button {
margin: 0 5px;
flex-basis: 100%;
}
.instance-name-h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<script>
import { store } from '../../../_store/store'
import SettingsLayout from '../../../_components/settings/SettingsLayout.html'
import ExternalLink from '../../../_components/ExternalLink.html'
import Avatar from '../../../_components/Avatar.html'
import { importShowConfirmationDialog } from '../../../_components/dialog/asyncDialogs'
import {
changeTheme,
switchToInstance,
logOutOfInstance,
updateVerifyCredentialsForInstance
} from '../../../_actions/instances'
import { themes } from '../../../_static/themes'
export default {
async oncreate () {
let { instanceName } = this.get()
let { instanceThemes } = this.store.get()
this.set({
selectedTheme: instanceThemes[instanceName] || 'default'
})
await updateVerifyCredentialsForInstance(instanceName)
},
store: () => store,
data: () => ({
themes: themes,
selectedTheme: 'default'
}),
computed: {
instanceName: ({ params }) => params.instanceName,
verifyCredentials: ({ $verifyCredentials, instanceName }) => $verifyCredentials && $verifyCredentials[instanceName]
},
methods: {
onThemeChange () {
let { selectedTheme, instanceName } = this.get()
changeTheme(instanceName, selectedTheme)
},
onSwitchToThisInstance (e) {
e.preventDefault()
let { instanceName } = this.get()
switchToInstance(instanceName)
},
async onLogOut (e) {
e.preventDefault()
let { instanceName } = this.get()
let showConfirmationDialog = await importShowConfirmationDialog()
showConfirmationDialog({
text: `Log out of ${instanceName}?`,
onPositive () {
logOutOfInstance(instanceName)
}
})
}
},
components: {
SettingsLayout,
ExternalLink,
Avatar
}
}
</script>

View File

@ -1,35 +0,0 @@
<SettingsLayout page='settings/instances' label="Instances">
<h1>Instances</h1>
{#if $isUserLoggedIn}
<p>Instances you've logged in to:</p>
<SettingsList label="Instances">
{#each $loggedInInstancesAsList as instance}
<SettingsListItem offsetForIcon={instance.name !== $currentInstance}
icon={instance.name === $currentInstance ? '#fa-star' : ''}
href="/settings/instances/{instance.name}"
label={instance.name}
ariaLabel="{instance.name} {instance.name === $currentInstance ? '(current instance)' : ''}" />
{/each}
</SettingsList>
<p><a href="/settings/instances/add">Add another instance</a></p>
{:else}
<p>You're not logged in to any instances.</p>
<p><a href="/settings/instances/add">Log in to an instance</a> to start using Pinafore.</p>
{/if}
</SettingsLayout>
<script>
import { store } from '../../../_store/store'
import SettingsLayout from '../../../_components/settings/SettingsLayout.html'
import SettingsList from '../../../_components/settings/SettingsList.html'
import SettingsListItem from '../../../_components/settings/SettingsListItem.html'
export default {
components: {
SettingsLayout,
SettingsList,
SettingsListItem
},
store: () => store
}
</script>

View File

@ -1,5 +0,0 @@
export const DEFAULT_MEDIA_WIDTH = 300
export const DEFAULT_MEDIA_HEIGHT = 200
export const ONE_TRANSPARENT_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='

View File

@ -1,48 +0,0 @@
const themes = [
{
name: 'default',
label: 'Cybre (default)'
},
{
name: 'royal',
label: 'Royal'
},
{
name: 'scarlet',
label: 'Scarlet'
},
{
name: 'seafoam',
label: 'Seafoam'
},
{
name: 'hotpants',
label: 'Hotpants'
},
{
name: 'oaken',
label: 'Oaken'
},
{
name: 'majesty',
label: 'Majesty'
},
{
name: 'gecko',
label: 'Gecko'
},
{
name: 'ozark',
label: 'Ozark'
},
{
name: 'cobalt',
label: 'Cobalt'
},
{
name: 'sorcery',
label: 'Sorcery'
}
]
export { themes }

View File

@ -1,22 +0,0 @@
function getStatusModifications (store, instanceName) {
let { statusModifications } = store.get()
statusModifications[instanceName] = statusModifications[instanceName] || {
favorites: {},
reblogs: {}
}
return statusModifications
}
export function statusMixins (Store) {
Store.prototype.setStatusFavorited = function (instanceName, statusId, favorited) {
let statusModifications = getStatusModifications(this, instanceName)
statusModifications[instanceName].favorites[statusId] = favorited
this.set({statusModifications})
}
Store.prototype.setStatusReblogged = function (instanceName, statusId, reblogged) {
let statusModifications = getStatusModifications(this, instanceName)
statusModifications[instanceName].reblogs[statusId] = reblogged
this.set({statusModifications})
}
}

View File

@ -1,87 +0,0 @@
import { updateInstanceInfo, updateVerifyCredentialsForInstance } from '../../_actions/instances'
import { updateLists } from '../../_actions/lists'
import { createStream } from '../../_actions/streaming'
import { updateCustomEmojiForInstance } from '../../_actions/emoji'
import { addStatusesOrNotifications } from '../../_actions/addStatusOrNotification'
import { getTimeline } from '../../_api/timelines'
export function instanceObservers (store) {
// stream to watch for home timeline updates and notifications
let currentInstanceStream
store.observe('currentInstance', async (currentInstance) => {
if (!process.browser) {
return
}
if (currentInstanceStream) {
currentInstanceStream.close()
currentInstanceStream = null
if (process.env.NODE_ENV !== 'production') {
window.currentInstanceStream = null
}
}
if (!currentInstance) {
return
}
updateVerifyCredentialsForInstance(currentInstance)
updateInstanceInfo(currentInstance)
updateCustomEmojiForInstance(currentInstance)
updateLists()
await updateInstanceInfo(currentInstance)
let currentInstanceIsUnchanged = () => {
let { currentInstance: newCurrentInstance } = store.get()
return newCurrentInstance === currentInstance
}
if (!currentInstanceIsUnchanged()) {
return
}
let { currentInstanceInfo } = store.get()
if (!currentInstanceInfo) {
return
}
let homeTimelineItemIds = store.getForTimeline(currentInstance,
'home', 'timelineItemIds')
let firstHomeTimelineItemId = homeTimelineItemIds && homeTimelineItemIds[0]
let notificationItemIds = store.getForTimeline(currentInstance,
'notifications', 'timelineItemIds')
let firstNotificationTimelineItemId = notificationItemIds && notificationItemIds[0]
let onOpenStream = async () => {
if (!currentInstanceIsUnchanged()) {
return
}
// fill in the "streaming gap" i.e. fetch the most recent 20 items so that there isn't
// a big gap in the timeline if you haven't looked at it in awhile
async function fillGap (timelineName, firstTimelineItemId) {
if (!firstTimelineItemId) {
return
}
let newTimelineItems = await getTimeline(currentInstance, accessToken,
timelineName, null, firstTimelineItemId)
if (newTimelineItems.length) {
addStatusesOrNotifications(currentInstance, timelineName, newTimelineItems)
}
}
await Promise.all([
fillGap('home', firstHomeTimelineItemId),
fillGap('notifications', firstNotificationTimelineItemId)
])
}
let { accessToken } = store.get()
let streamingApi = currentInstanceInfo.urls.streaming_api
currentInstanceStream = createStream(streamingApi,
currentInstance, accessToken, 'home', onOpenStream)
if (process.env.NODE_ENV !== 'production') {
window.currentInstanceStream = currentInstanceStream
}
})
}

View File

@ -1,17 +0,0 @@
import { instanceObservers } from './instanceObservers'
import { timelineObservers } from './timelineObservers'
import { notificationObservers } from './notificationObservers'
import { onlineObservers } from './onlineObservers'
import { navObservers } from './navObservers'
import { autosuggestObservers } from './autosuggestObservers'
import { pageVisibilityObservers } from './pageVisibilityObservers'
export function observers (store) {
instanceObservers(store)
timelineObservers(store)
notificationObservers(store)
onlineObservers(store)
navObservers(store)
autosuggestObservers(store)
pageVisibilityObservers(store)
}

View File

@ -1,65 +0,0 @@
import { observers } from './observers/observers'
import { computations } from './computations/computations'
import { mixins } from './mixins/mixins'
import { LocalStorageStore } from './LocalStorageStore'
import { observe } from 'svelte-extras'
const KEYS_TO_STORE_IN_LOCAL_STORAGE = new Set([
'currentInstance',
'currentRegisteredInstance',
'currentRegisteredInstanceName',
'instanceNameInSearch',
'instanceThemes',
'loggedInInstances',
'loggedInInstancesInOrder',
'autoplayGifs',
'markMediaAsSensitive',
'reduceMotion',
'pinnedPages',
'composeData'
])
class PinaforeStore extends LocalStorageStore {
constructor (state) {
super(state, KEYS_TO_STORE_IN_LOCAL_STORAGE)
}
}
PinaforeStore.prototype.observe = observe
export const store = new PinaforeStore({
instanceNameInSearch: '',
queryInSearch: '',
currentInstance: null,
loggedInInstances: {},
loggedInInstancesInOrder: [],
instanceThemes: {},
spoilersShown: {},
sensitivesShown: {},
repliesShown: {},
autoplayGifs: false,
markMediaAsSensitive: false,
reduceMotion: false,
pinnedPages: {},
instanceLists: {},
pinnedStatuses: {},
instanceInfos: {},
statusModifications: {},
customEmoji: {},
composeData: {},
verifyCredentials: {},
online: !process.browser || navigator.onLine
})
mixins(PinaforeStore)
computations(store)
observers(store)
if (process.browser && process.env.NODE_ENV !== 'production') {
window.store = store // for debugging
}
// needed for tests
if (process.browser) {
window.__forceOnline = online => store.set({online})
}

View File

@ -1,39 +0,0 @@
export const importTimeline = () => import(
/* webpackChunkName: 'Timeline' */ '../_components/timeline/Timeline.html'
).then(mod => mod.default)
export const importIntersectionObserver = () => import(
/* webpackChunkName: 'intersection-observer' */ 'intersection-observer'
)
export const importRequestIdleCallback = () => import(
/* webpackChunkName: 'requestidlecallback' */ 'requestidlecallback'
)
export const importIndexedDBGetAllShim = () => import(
/* webpackChunkName: 'indexeddb-getall-shim' */ 'indexeddb-getall-shim'
)
export const importWebAnimationPolyfill = () => import(
/* webpackChunkName: 'web-animations-js' */ 'web-animations-js'
)
export const importWebSocketClient = () => import(
/* webpackChunkName: '@gamestdio/websocket' */ '@gamestdio/websocket'
).then(mod => mod.default)
export const importVirtualList = () => import(
/* webpackChunkName: 'VirtualList.html' */ '../_components/virtualList/VirtualList.html'
).then(mod => mod.default)
export const importList = () => import(
/* webpackChunkName: 'List.html' */ '../_components/list/List.html'
).then(mod => mod.default)
export const importStatusVirtualListItem = () => import(
/* webpackChunkName: 'StatusVirtualListItem.html' */ '../_components/timeline/StatusVirtualListItem.html'
).then(mod => mod.default)
export const importNotificationVirtualListItem = () => import(
/* webpackChunkName: 'NotificationVirtualListItem.html' */ '../_components/timeline/NotificationVirtualListItem.html'
).then(mod => mod.default)

View File

@ -1,47 +0,0 @@
// via https://github.com/tootsuite/mastodon/blob/f59ed3a4fafab776b4eeb92f805dfe1fecc17ee3/app/javascript/mastodon/scroll.js
const easingOutQuint = (x, t, b, c, d) =>
c * ((t = t / d - 1) * t * t * t * t + 1) + b
const scroll = (node, key, target) => {
const startTime = Date.now()
const offset = node[key]
const gap = target - offset
const duration = 1000
let interrupt = false
const step = () => {
const elapsed = Date.now() - startTime
const percentage = elapsed / duration
if (interrupt) {
return
}
if (percentage > 1) {
cleanup()
return
}
node[key] = easingOutQuint(0, elapsed, offset, gap, duration)
requestAnimationFrame(step)
}
const cancel = () => {
interrupt = true
cleanup()
}
const cleanup = () => {
node.removeEventListener('wheel', cancel)
node.removeEventListener('touchstart', cancel)
}
node.addEventListener('wheel', cancel, {passive: true})
node.addEventListener('touchstart', cancel, {passive: true})
step()
return cancel
}
export const smoothScrollToTop = node => scroll(node, 'scrollTop', 0)

View File

@ -1,19 +0,0 @@
import { loadCSS } from 'fg-loadcss'
let meta = process.browser && document.getElementById('theThemeColor')
export function switchToTheme (themeName) {
let clazzList = document.body.classList
for (let i = 0; i < clazzList.length; i++) {
let clazz = clazzList.item(i)
if (clazz.startsWith('theme-')) {
clazzList.remove(clazz)
}
}
let themeColor = window.__themeColors[themeName]
meta.content = themeColor || window.__themeColors['default']
if (themeName !== 'default') {
clazzList.add(`theme-${themeName}`)
loadCSS(`/theme-${themeName}.css`)
}
}

View File

@ -1,18 +0,0 @@
import Toast from '../_components/Toast.html'
let toast
if (process.browser) {
toast = new Toast({
target: document.querySelector('#toast')
})
if (process.env.NODE_ENV !== 'production') {
window.toast = toast // for debugging
}
} else {
toast = {
say: () => {}
}
}
export { toast }

View File

@ -1,21 +0,0 @@
<svelte:head>
<title>Pinafore Profile</title>
</svelte:head>
<Layout page='tags'>
<LazyPage {pageComponent} {params} />
</Layout>
<script>
import Layout from '../_components/Layout.html'
import LazyPage from '../_components/LazyPage.html'
import pageComponent from '../_pages/accounts/[accountId].html'
export default {
components: {
Layout,
LazyPage
},
data: () => ({
pageComponent
})
}
</script>

View File

@ -1,27 +0,0 @@
$main-theme-color: #999999;
$body-bg-color: lighten($main-theme-color, 38%);
$anchor-color: $main-theme-color;
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
$secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 15%);
$compose-background: lighten($main-theme-color, 17%);
@import "_base.scss";
body.offline,
body.theme-cybre.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,
body.theme-ozark.offline,
body.theme-cobalt.offline,
body.theme-sorcery.offline {
@include baseTheme();
}

58
src/build/template.html Normal file
View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset='utf-8' >
<meta name="viewport" content="width=device-width, initial-scale=1" >
<meta id='theThemeColor' name='theme-color' content='#4169e1' >
<meta name="description" content="An alternative web client for Mastodon, focused on speed and simplicity." >
%sapper.base%
<link id='theManifest' rel='manifest' href='/manifest.json' >
<link id='theFavicon' rel='icon' type='image/png' href='/favicon.png' >
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120.png" >
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png" >
<meta name="mobile-web-app-capable" content="yes" >
<meta name="apple-mobile-web-app-title" content="Pinafore" >
<meta name="apple-mobile-web-app-status-bar-style" content="white" >
<!-- inline CSS -->
<noscript>
<style>
.hidden-from-ssr {
opacity: 1 !important;
}
</style>
</noscript>
<!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the src is
lazily loaded when it precaches secondary pages -->
%sapper.styles%
<!-- This contains the contents of the <:Head> component, if
the current page has one -->
%sapper.head%
</head>
<body>
<!-- inline JS -->
<!-- The application will be rendered inside this element,
because `templates/client.js` references it -->
<div id='sapper'>%sapper.html%</div>
<!-- Toast.html gets rendered here -->
<div id="theToast"></div>
<!-- LoadingMask.html gets rendered here -->
<div id="loading-mask" aria-hidden="true"></div>
<!-- inline SVG -->
<!-- Sapper creates a <script> tag containing `templates/client.js`
and anything else it needs to hydrate the src and
initialise the router -->
%sapper.scripts%
</body>
</html>

16
src/client.js Normal file
View File

@ -0,0 +1,16 @@
import * as sapper from '../__sapper__/client.js'
import { loadPolyfills } from './routes/_utils/loadPolyfills'
import './routes/_utils/serviceWorkerClient'
import './routes/_utils/historyEvents'
import './routes/_utils/loadingMask'
loadPolyfills().then(() => {
console.log('init()')
sapper.start({ target: document.querySelector('#sapper') })
})
console.log('process.env.NODE_ENV', process.env.NODE_ENV)
if (module.hot) {
module.hot.accept()
}

View File

@ -0,0 +1,58 @@
// 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 checksum.js.
import { testHasLocalStorageOnce } from '../routes/_utils/testStorage'
import { DEFAULT_LIGHT_THEME, DEFAULT_THEME, switchToTheme } from '../routes/_utils/themeEngine'
import { basename } from '../routes/_api/utils'
import { onUserIsLoggedOut } from '../routes/_actions/onUserIsLoggedOut'
window.__themeColors = process.env.THEME_COLORS
const safeParse = str => (typeof str === 'undefined' || str === 'undefined') ? undefined : JSON.parse(str)
const hasLocalStorage = testHasLocalStorageOnce()
const currentInstance = hasLocalStorage && safeParse(localStorage.store_currentInstance)
if (currentInstance) {
// Do prefetch if we're logged in, so we can connect faster to the other origin.
// Note that /api/v1/instance is basically the only URL that doesn't require credentials,
// which is why we can do this. Also we do end up calling this on loading the home page,
// so it's not a wasted request.
let link = document.createElement('link')
link.setAttribute('rel', 'prefetch')
link.setAttribute('href', `${basename(currentInstance)}/api/v1/instance`)
link.setAttribute('crossorigin', 'anonymous')
document.head.appendChild(link)
}
let theme = (currentInstance &&
localStorage.store_instanceThemes &&
safeParse(localStorage.store_instanceThemes)[safeParse(localStorage.store_currentInstance)]) ||
DEFAULT_THEME
if (theme !== DEFAULT_LIGHT_THEME) {
// switch theme ASAP to minimize flash of default theme
switchToTheme(theme)
}
if (!hasLocalStorage || !currentInstance) {
// if not logged in, show all these 'hidden-from-ssr' elements
onUserIsLoggedOut()
}
if (hasLocalStorage && localStorage.store_disableCustomScrollbars === 'true') {
// if user has disabled custom scrollbars, remove this style
let theScrollbarStyle = document.getElementById('theScrollbarStyle')
theScrollbarStyle.setAttribute('media', 'only x') // disables the style
}
// hack to make the scrollbars rounded only on macOS
if (/mac/i.test(navigator.platform)) {
document.documentElement.style.setProperty('--scrollbar-border-radius', '50px')
}
// TODO: remove this hack when Safari works with cross-origin window.open()
// in a PWA: https://github.com/nolanlawson/pinafore/issues/45
if (/iP(?:hone|ad|od)/.test(navigator.userAgent)) {
document.head.removeChild(document.getElementById('theManifest'))
}

View File

@ -0,0 +1,63 @@
import { getAccountAccessibleName } from './getAccountAccessibleName'
import { POST_PRIVACY_OPTIONS } from '../_static/statuses'
function getNotificationText (notification, omitEmojiInDisplayNames) {
if (!notification) {
return
}
let notificationAccountDisplayName = getAccountAccessibleName(notification.account, omitEmojiInDisplayNames)
if (notification.type === 'reblog') {
return `${notificationAccountDisplayName} boosted your status`
} else if (notification.type === 'favourite') {
return `${notificationAccountDisplayName} favorited your status`
}
}
function getPrivacyText (visibility) {
for (let option of POST_PRIVACY_OPTIONS) {
if (option.key === visibility) {
return option.label
}
}
}
function getReblogText (reblog, account, omitEmojiInDisplayNames) {
if (!reblog) {
return
}
let accountDisplayName = getAccountAccessibleName(account, omitEmojiInDisplayNames)
return `Boosted by ${accountDisplayName}`
}
function cleanupText (text) {
return text.replace(/\s+/g, ' ').trim()
}
export function getAccessibleLabelForStatus (originalAccount, account, plainTextContent,
timeagoFormattedDate, spoilerText, showContent,
reblog, notification, visibility, omitEmojiInDisplayNames,
disableLongAriaLabels) {
let originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames)
let contentTextToShow = (showContent || !spoilerText)
? cleanupText(plainTextContent)
: `Content warning: ${cleanupText(spoilerText)}`
let privacyText = getPrivacyText(visibility)
if (disableLongAriaLabels) {
// Long text can crash NVDA; allow users to shorten it like we had it before.
// https://github.com/nolanlawson/pinafore/issues/694
return `${privacyText} status by ${originalAccountDisplayName}`
}
let values = [
getNotificationText(notification, omitEmojiInDisplayNames),
originalAccountDisplayName,
contentTextToShow,
timeagoFormattedDate,
`@${originalAccount.acct}`,
privacyText,
getReblogText(reblog, account, omitEmojiInDisplayNames)
].filter(Boolean)
return values.join(', ')
}

View File

@ -0,0 +1,10 @@
import { removeEmoji } from '../_utils/removeEmoji'
export function getAccountAccessibleName (account, omitEmojiInDisplayNames) {
let emojis = account.emojis
let displayName = account.display_name || account.username
if (omitEmojiInDisplayNames) {
displayName = removeEmoji(displayName, emojis) || displayName
}
return displayName
}

View File

@ -0,0 +1,72 @@
import { getAccount } from '../_api/user'
import { getRelationship } from '../_api/relationships'
import { database } from '../_database/database'
import { store } from '../_store/store'
async function _updateAccount (accountId, instanceName, accessToken) {
let localPromise = database.getAccount(instanceName, accountId)
let remotePromise = getAccount(instanceName, accessToken, accountId).then(account => {
/* no await */ database.setAccount(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 = database.getRelationship(instanceName, accountId)
let remotePromise = getRelationship(instanceName, accessToken, accountId).then(relationship => {
/* no await */ database.setRelationship(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 updateLocalRelationship (instanceName, accountId, relationship) {
await database.setRelationship(instanceName, relationship)
try {
store.set({ currentAccountRelationship: relationship })
} 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)
])
}
export async function updateRelationship (accountId) {
let { currentInstance, accessToken } = store.get()
await _updateRelationship(accountId, currentInstance, accessToken)
}

View File

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

View File

@ -1,15 +1,11 @@
import throttle from 'lodash-es/throttle'
import { mark, stop } from '../_utils/marks'
import { store } from '../_store/store'
import uniqBy from 'lodash-es/uniqBy'
import uniq from 'lodash-es/uniq'
import isEqual from 'lodash-es/isEqual'
import {
insertTimelineItems as insertTimelineItemsInDatabase
} from '../_database/timelines/insertion'
import { runMediumPriorityTask } from '../_utils/runMediumPriorityTask'
const STREAMING_THROTTLE_DELAY = 3000
import { database } from '../_database/database'
import { concat } from '../_utils/arrays'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
function getExistingItemIdsSet (instanceName, timelineName) {
let timelineItemIds = store.getForTimeline(instanceName, timelineName, 'timelineItemIds') || []
@ -29,14 +25,31 @@ async function insertUpdatesIntoTimeline (instanceName, timelineName, updates) {
return
}
await insertTimelineItemsInDatabase(instanceName, timelineName, updates)
await database.insertTimelineItems(instanceName, timelineName, updates)
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let newItemIdsToAdd = uniq([].concat(itemIdsToAdd).concat(updates.map(_ => _.id)))
let newItemIdsToAdd = uniq(concat(itemIdsToAdd, updates.map(_ => _.id)))
if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for timeline', timelineName)
store.setForTimeline(instanceName, timelineName, {itemIdsToAdd: newItemIdsToAdd})
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)
)
}
}
@ -46,21 +59,20 @@ async function insertUpdatesIntoThreads (instanceName, updates) {
}
let threads = store.getThreads(instanceName)
for (let timelineName of Object.keys(threads)) {
let timelineNames = Object.keys(threads)
for (let timelineName of timelineNames) {
let thread = threads[timelineName]
let updatesForThisThread = updates.filter(
status => thread.includes(status.in_reply_to_id) && !thread.includes(status.id)
)
if (!updatesForThisThread.length) {
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let validUpdates = updates.filter(isValidStatusForThread(thread, timelineName, itemIdsToAdd))
if (!validUpdates.length) {
continue
}
let itemIdsToAdd = store.getForTimeline(instanceName, timelineName, 'itemIdsToAdd') || []
let newItemIdsToAdd = uniq([].concat(itemIdsToAdd).concat(updatesForThisThread.map(_ => _.id)))
let newItemIdsToAdd = uniq(concat(itemIdsToAdd, validUpdates.map(_ => _.id)))
if (!isEqual(itemIdsToAdd, newItemIdsToAdd)) {
console.log('adding ', (newItemIdsToAdd.length - itemIdsToAdd.length),
'items to itemIdsToAdd for thread', timelineName)
store.setForTimeline(instanceName, timelineName, {itemIdsToAdd: newItemIdsToAdd})
store.setForTimeline(instanceName, timelineName, { itemIdsToAdd: newItemIdsToAdd })
}
}
}
@ -70,7 +82,7 @@ async function processFreshUpdates (instanceName, timelineName) {
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates')
if (freshUpdates && freshUpdates.length) {
let updates = freshUpdates.slice()
store.setForTimeline(instanceName, timelineName, {freshUpdates: []})
store.setForTimeline(instanceName, timelineName, { freshUpdates: [] })
await Promise.all([
insertUpdatesIntoTimeline(instanceName, timelineName, updates),
@ -80,11 +92,11 @@ async function processFreshUpdates (instanceName, timelineName) {
stop('processFreshUpdates')
}
const lazilyProcessFreshUpdates = throttle((instanceName, timelineName) => {
runMediumPriorityTask(() => {
function lazilyProcessFreshUpdates (instanceName, timelineName) {
scheduleIdleTask(() => {
/* no await */ processFreshUpdates(instanceName, timelineName)
})
}, STREAMING_THROTTLE_DELAY)
}
export function addStatusOrNotification (instanceName, timelineName, newStatusOrNotification) {
addStatusesOrNotifications(instanceName, timelineName, [newStatusOrNotification])
@ -93,8 +105,8 @@ export function addStatusOrNotification (instanceName, timelineName, newStatusOr
export function addStatusesOrNotifications (instanceName, timelineName, newStatusesOrNotifications) {
console.log('addStatusesOrNotifications', Date.now())
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates') || []
freshUpdates = [].concat(freshUpdates).concat(newStatusesOrNotifications)
freshUpdates = concat(freshUpdates, newStatusesOrNotifications)
freshUpdates = uniqBy(freshUpdates, _ => _.id)
store.setForTimeline(instanceName, timelineName, {freshUpdates: freshUpdates})
store.setForTimeline(instanceName, timelineName, { freshUpdates: freshUpdates })
lazilyProcessFreshUpdates(instanceName, timelineName)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import { importShowCopyDialog } from '../_components/dialog/asyncDialogs'
import { toast } from '../_components/toast/toast'
export async function copyText (text) {
if (navigator.clipboard) { // not supported in all browsers
try {
await navigator.clipboard.writeText(text)
toast.say('Copied to clipboard')
return
} catch (e) {
console.error(e)
}
}
let showCopyDialog = await importShowCopyDialog()
showCopyDialog(text)
}

View File

@ -0,0 +1,47 @@
import { database } from '../_database/database'
async function getNotification (instanceName, timelineType, timelineValue, itemId) {
return {
timelineType,
timelineValue,
notification: await database.getNotification(instanceName, itemId)
}
}
async function getStatus (instanceName, timelineType, timelineValue, itemId) {
return {
timelineType,
timelineValue,
status: await database.getStatus(instanceName, itemId)
}
}
export function createMakeProps (instanceName, timelineType, timelineValue) {
let taskCount = 0
let pending = []
// The worker-powered indexeddb promises can resolve in arbitrary order,
// causing the timeline to load in a jerky way. With this function, we
// wait for all promises to resolve before resolving them all in one go.
function awaitAllTasksComplete () {
return new Promise(resolve => {
taskCount--
pending.push(resolve)
if (taskCount === 0) {
pending.forEach(_ => _())
pending = []
}
})
}
return (itemId) => {
taskCount++
let promise = timelineType === 'notifications'
? getNotification(instanceName, timelineType, timelineValue, itemId)
: getStatus(instanceName, timelineType, timelineValue, itemId)
return promise.then(res => {
return awaitAllTasksComplete().then(() => res)
})
}
}

View File

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

View File

@ -0,0 +1,24 @@
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText'
import { importShowComposeDialog } from '../_components/dialog/asyncDialogs'
import { doDeleteStatus } from './delete'
import { store } from '../_store/store'
export async function deleteAndRedraft (status) {
let deleteStatusPromise = doDeleteStatus(status.id)
let dialogPromise = importShowComposeDialog()
await deleteStatusPromise
store.setComposeData('dialog', {
text: statusHtmlToPlainText(status.content, status.mentions),
contentWarningShown: !!status.spoiler_text,
contentWarning: status.spoiler_text || '',
postPrivacy: status.visibility,
media: status.media_attachments && status.media_attachments.map(_ => ({
description: _.description || '',
data: _
})),
inReplyToId: status.in_reply_to_id
})
let showComposeDialog = await dialogPromise
showComposeDialog()
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { store } from '../_store/store'
import { followAccount, unfollowAccount } from '../_api/follow'
import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts'
export async function setAccountFollowed (accountId, follow, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
let relationship
if (follow) {
relationship = await followAccount(currentInstance, accessToken, accountId)
} else {
relationship = await unfollowAccount(currentInstance, accessToken, accountId)
}
await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) {
if (follow) {
toast.say('Followed account')
} else {
toast.say('Unfollowed account')
}
}
} catch (e) {
console.error(e)
toast.say(`Unable to ${follow ? 'follow' : 'unfollow'} account: ` + (e.message || ''))
}
}

View File

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

View File

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

View File

@ -0,0 +1,20 @@
import { store } from '../_store/store'
import { getLists } from '../_api/lists'
import { cacheFirstUpdateAfter } from '../_utils/sync'
import { database } from '../_database/database'
export async function updateListsForInstance (instanceName) {
let { loggedInInstances } = store.get()
let accessToken = loggedInInstances[instanceName].access_token
await cacheFirstUpdateAfter(
() => getLists(instanceName, accessToken),
() => database.getLists(instanceName),
lists => database.setLists(instanceName, lists),
lists => {
let { instanceLists } = store.get()
instanceLists[instanceName] = lists
store.set({ instanceLists: instanceLists })
}
)
}

View File

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

View File

@ -0,0 +1,8 @@
import { importShowComposeDialog } from '../_components/dialog/asyncDialogs'
import { store } from '../_store/store'
export async function composeNewStatusMentioning (account) {
store.setComposeData('dialog', { text: `@${account.acct} ` })
let showComposeDialog = await importShowComposeDialog()
showComposeDialog()
}

View File

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

View File

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

View File

@ -0,0 +1,11 @@
// When the user is logged out, we need to be sure to re-show all the "hidden from SSR" styles
// so that we don't get a blank page.
export function onUserIsLoggedOut () {
if (document.getElementById('hiddenFromSsrStyle')) {
return
}
let style = document.createElement('style')
style.setAttribute('id', 'hiddenFromSsrStyle')
style.textContent = '.hidden-from-ssr { opacity: 1 !important; }'
document.head.appendChild(style)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,89 @@
import { getSubscription, deleteSubscription, postSubscription, putSubscription } from '../_api/pushSubscription'
import { store } from '../_store/store'
import { urlBase64ToUint8Array } from '../_utils/base64'
const dummyApplicationServerKey = 'BImgAz4cF_yvNFp8uoBJCaGpCX4d0atNIFMHfBvAAXCyrnn9IMAFQ10DW_ZvBCzGeR4fZI5FnEi2JVcRE-L88jY='
export async function updatePushSubscriptionForInstance (instanceName) {
const { loggedInInstances, pushSubscription } = store.get()
const accessToken = loggedInInstances[instanceName].access_token
if (pushSubscription === null) {
return
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription === null) {
store.set({ pushSubscription: null })
store.save()
return
}
try {
const backendSubscription = await getSubscription(instanceName, accessToken)
// Check if applicationServerKey changed (need to get another subscription from the browser)
if (btoa(urlBase64ToUint8Array(backendSubscription.server_key).buffer) !== btoa(subscription.options.applicationServerKey)) {
await subscription.unsubscribe()
await deleteSubscription(instanceName, accessToken)
await updateAlerts(instanceName, pushSubscription.alerts)
} else {
store.set({ pushSubscription: backendSubscription })
store.save()
}
} catch (e) {
// TODO: Better way to detect 404
if (e.message.startsWith('404:')) {
await subscription.unsubscribe()
store.set({ pushSubscription: null })
store.save()
}
}
}
export async function updateAlerts (instanceName, alerts) {
const { loggedInInstances } = store.get()
const accessToken = loggedInInstances[instanceName].access_token
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (subscription === null) {
// We need applicationServerKey in order to register a push subscription
// but the API doesn't expose it as a constant (as it should).
// So we need to register a subscription with a dummy applicationServerKey,
// send it to the backend saves it and return applicationServerKey, which
// we use to register a new subscription.
// https://github.com/tootsuite/mastodon/issues/8785
subscription = await registration.pushManager.subscribe({
applicationServerKey: urlBase64ToUint8Array(dummyApplicationServerKey),
userVisibleOnly: true
})
let backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
await subscription.unsubscribe()
subscription = await registration.pushManager.subscribe({
applicationServerKey: urlBase64ToUint8Array(backendSubscription.server_key),
userVisibleOnly: true
})
backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
store.set({ pushSubscription: backendSubscription })
store.save()
} else {
try {
const backendSubscription = await putSubscription(instanceName, accessToken, alerts)
store.set({ pushSubscription: backendSubscription })
store.save()
} catch (e) {
const backendSubscription = await postSubscription(instanceName, accessToken, subscription, alerts)
store.set({ pushSubscription: backendSubscription })
store.save()
}
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import { store } from '../_store/store'
import { blockDomain, unblockDomain } from '../_api/blockDomain'
import { toast } from '../_components/toast/toast'
import { updateRelationship } from './accounts'
export async function setDomainBlocked (accountId, domain, block, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
if (block) {
await blockDomain(currentInstance, accessToken, domain)
} else {
await unblockDomain(currentInstance, accessToken, domain)
}
await updateRelationship(accountId)
if (toastOnSuccess) {
if (block) {
toast.say(`Hiding ${domain}`)
} else {
toast.say(`Unhiding ${domain}`)
}
}
} catch (e) {
console.error(e)
toast.say(`Unable to ${block ? 'hide' : 'unhide'} domain: ` + (e.message || ''))
}
}

View File

@ -0,0 +1,22 @@
import { store } from '../_store/store'
import { setShowReblogs as setShowReblogsApi } from '../_api/showReblogs'
import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts'
export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
try {
let relationship = await setShowReblogsApi(currentInstance, accessToken, accountId, showReblogs)
await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) {
if (showReblogs) {
toast.say('Showing boosts')
} else {
toast.say('Hiding boosts')
}
}
} catch (e) {
console.error(e)
toast.say(`Unable to ${showReblogs ? 'show' : 'hide'} boosts: ` + (e.message || ''))
}
}

View File

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

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