Compare commits

..

160 Commits

Author SHA1 Message Date
Surinna Curtis
4aa8d9d149 Remove workaround for fixed bug in SettingToggle
SettingToggle was toggling itself in response to keydown of space, and then the keyup was doing it again
2017-09-02 08:24:55 -05:00
Surinna Curtis
6ba67f92c9 UploadArea should only preventDefault for Escape
This will make accessibility for some things less effortful, since we won't have to define a prior event handler to do whatever should be happening by default.
2017-09-02 08:24:49 -05:00
Yamagishi Kazutoshi
a0294c8880 Add Japanese translate for #4561 (#4771) 2017-09-02 14:02:29 +02:00
Yamagishi Kazutoshi
ba8fb2fd0f Add text color style for noscript link (#4772) 2017-09-02 14:02:15 +02:00
Yamagishi Kazutoshi
6fd2e8c3c5 Fix profile page when use system's font (#4774) 2017-09-02 14:01:59 +02:00
Yamagishi Kazutoshi
15963a15c6 Disable embed modal when private status (#4773)
* Disable embed modal when private status

* Remove `reblogDisabled`
2017-09-02 14:01:44 +02:00
Eugen Rochko
1b5806b744 Define missing JSON-LD properties (#4767)
Using _: property names is discouraged, as in the future,
canonicalization may throw an error when encountering that instead
of discarding it silently like it does now.

We are defining some ActivityStreams properties which we expect
to land in ActivityStreams eventually, to ensure that future versions
of Mastodon will remain compatible with this even once that happens.
Those would be `locked`, `sensitive` and `Hashtag`

We are defining a custom context inline for some properties which we
do not expect to land in any other context. `atomUri`, `inReplyToAtomUri`
and `conversation` are part of the custom defined OStatus context.
2017-09-02 14:01:23 +02:00
Eugen Rochko
1b1e025b41 Use updated ActivityStreams context (added: sharedInbox) (#4764) 2017-09-02 14:00:58 +02:00
mayaeh
ab9f1b6e50 Add japanese translations for embed modal feature. (#4770) 2017-09-02 14:48:51 +09:00
Yamagishi Kazutoshi
b767eb7ff8 Add RoutingHelper (#4769) 2017-09-02 03:03:20 +02:00
m4sk1n
0b32338e3f Add link to 'noscript' message (#4561)
* Add link to 'noscript' message

Signed-off-by: Marcin Mikołajczak <me@m4sk.in>

* remove indent
2017-09-02 01:52:28 +02:00
Eugen Rochko
e482595a5d Add ActivityPub handler for Delete->Actor activities (#4761) 2017-09-01 21:54:42 +02:00
Eugen Rochko
9c04fadec9 Finish up embed modal feature (#4759)
* Add embed button to dropdowns of in-timeline statuses

* yarn run manage:translations
2017-09-01 21:30:13 +02:00
Eugen Rochko
390bfec6da Avoid sending some ActivityPub payloads if the receiver will get them through distribution (#4739) 2017-09-01 21:26:01 +02:00
Eugen Rochko
c2980d5b17 Do not rely on activity arriving exactly once after delete arrived (#4754) 2017-09-01 21:12:59 +02:00
Cygnan
a75aa62f5b Adjust padding on the public profile page (#4757)
* Fix a style issue on the public profile page for some mobile browsers

Signed-off-by: Cygnan <email@cygnan.com>

* Set padding-bottom to 20px

Signed-off-by: Cygnan <email@cygnan.com>
2017-09-01 21:01:23 +02:00
unarist
8fd8f81ae7 Deduplicate with local status on Create activity (#4763) 2017-09-01 21:00:43 +02:00
Eugen Rochko
921cf3e9c8 Fix NoMethodError (#4762) 2017-09-01 20:50:42 +02:00
Eugen Rochko
7dc5035031 Make PreviewCard records reuseable between statuses (#4642)
* Make PreviewCard records reuseable between statuses

**Warning!** Migration truncates preview_cards tablec

* Allow a wider thumbnail for link preview, display it in horizontal layout (#4648)

* Delete preview cards files before truncating

* Rename old table instead of truncating it

* Add mastodon:maintenance:remove_deprecated_preview_cards

* Ignore deprecated_preview_cards in schema definition

* Fix null behaviour
2017-09-01 16:20:16 +02:00
Yamagishi Kazutoshi
2305f7c391 Use system's default font on non web UI pages (#4553)
* Use system's default font on non web UI pages

* Remove import for Redirect
2017-09-01 16:13:31 +02:00
Eugen Rochko
ff7d02b236 Make first use less overwhelming with browser permissions (#4760)
- Ask for desktop notifications after 1 minute of use instead of
  instantly
- Ask for protocol handler permission after 5 minutes of use
  instead of instantly
2017-09-01 16:07:08 +02:00
Damien Erambert
1a0df58878 Update FR locales (#4714)
* Make the fr locales up-to-date with the last changes (new profile view, applications)

* Use the same wording for toots in fr.yml and fr.json

* Translate the pin related strings

* Translate pin-related locales on the front-end

* Add missing locales in doorkeeper.fr.yml and remove un-used ones

* Change "posts" back to "status" in the /about/more page in fr.yml

* Fix typos for "status" in fr.yml

* fix typo for "status" in fr.json

* Remove duplicate string

* Non-breaking space before punctuation

* 'Better' translation for "unpin"

* Put back 'pouet' where it was already

* Fix

* Fix
2017-09-01 14:09:01 +02:00
Eugen Rochko
74437c6bff Refactor Web::PushSubscription, remove welcome message (#4524)
* Refactor Web::PushSubscription, remove welcome message

* Add missing helper

* Use locale of the receiver on push notifications (#4519)

* Remove unused translations

* Fix dir on notifications
2017-09-01 13:35:23 +02:00
unarist
504737e860 Convert OStatus tag to ActivityPub id on in_reply_to resolution (#4756) 2017-09-01 13:34:04 +02:00
unarist
af2d22f88c Fallback from perform_via_activitypub on private posts (#4758)
Currently, private / direct posts via OStatus from AP compatible instance will be dropped due to failing to fetch AP version.

So this fallbacks to OStatus handling:

* when failed to fetch ActivityPub version
* when status is neither :public nor :unlisted
2017-09-01 12:33:02 +02:00
Eugen Rochko
9a5ae09620 Remove identity context from output of LinkedDataSignature (#4753) 2017-08-31 21:32:09 +02:00
unarist
f7937d903c Don't process ActivityPub payload if signature is invalid (#4752)
* Don't process ActivityPub payload if signature is invalid

* Fix style issue
2017-08-31 17:18:49 +02:00
James
6b2be5dbfb Guarantee Subscription service first account has proper URL details (#4732)
* Guarantee Subscription service first account has proper URL details

Subscription Service potentially could break if the first user suspended
themselves, creating a situation where the urls that populate throughout
subscription service's PuSH request would cause the remote API to throw 503 errors.

Guaranteeing that the first account picked is not suspended prevents this problem.

* Fix style issue
2017-08-31 15:44:00 +02:00
Akihiko Odaki
69957ed10a Fix the usages of Detect Passive Events (#4749) 2017-08-31 11:20:54 +02:00
Eugen Rochko
d1a78eba15 Embed modal (#4748)
* Embed modal

* Proxy OEmbed requests from web UI
2017-08-31 03:38:35 +02:00
Eugen Rochko
2db9ccaf3e Add sharedInbox to actors (#4737) 2017-08-31 00:02:59 +02:00
Wonderfall
cecf204bbb Update to Alpine 3.6 (#4747) 2017-08-30 23:52:19 +02:00
MitarashiDango
fec13735a7 error fixed (when loading pages in single column mode.) (#4746) 2017-08-30 17:30:25 +02:00
Eugen Rochko
7b8f262840 Forward ActivityPub creates that reply to local statuses (#4709)
* Forward ActivityPub creates that reply to local statuses

* Fix test

* Fix wrong signers
2017-08-30 15:37:02 +02:00
Yamagishi Kazutoshi
3f51a22d3b Add close tag of iframe for oEmbed response (#4745)
* Add close tag of iframe for oEmbed response

* add comma
2017-08-30 14:03:17 +02:00
nullkal
39e7a763ff Use request.remote_ip instead of request.ip (#4744) 2017-08-30 10:24:30 +02:00
Eugen Rochko
e95bdec7c5 Update status embeds (#4742)
- Use statuses controller for embeds instead of stream entries controller
- Prefer /@:username/:id/embed URL for embeds
- Use /@:username as author_url in OEmbed
- Add follow link to embeds which opens web intent in new window
- Use redis cache in development
- Cache entire embed
2017-08-30 10:23:43 +02:00
Eugen Rochko
fcca31350d Remove unneccesary indices (#4738)
We only look up status_pins by account_id, or account_id and status_id,
never by status_id
2017-08-30 05:04:20 +02:00
Eugen Rochko
ee72a39641 Update bundler-audit and brakeman (#4740) 2017-08-30 03:30:13 +02:00
abcang
f59ed3a4fa Scroll smoothly to the right (#4735) 2017-08-29 17:06:19 +02:00
MitarashiDango
7be620775e fix error when single columns mode. (#4734) 2017-08-29 16:11:28 +02:00
Eugen Rochko
4c76402ba1 Serialize ActivityPub alternate link into OStatus deletes, handle it (#4730)
Requires moving Atom rendering from DistributionWorker (where
`stream_entry.status` is already nil) to inline (where
`stream_entry.status.destroyed?` is true) and distributing that.

Unfortunately, such XML renderings can no longer be easily chained
together into one payload of n items.
2017-08-29 16:11:05 +02:00
Akihiko Odaki
9958eba356 Do not scroll the columns area due to redirection (#4541)
Commit 9d1f8b9d6a scrolls the columns area
when the route changes since the user is likely to want to see the
rightmost column in such cases.

However, redirection is automatic and does not indicate users' intension.
Do not scroll the columns area due to one.
2017-08-29 14:16:21 +02:00
abcang
0827c09c44 Generalized the infinite scrollable list (#4697) 2017-08-28 22:23:44 +02:00
unarist
938cd2875b Fix Delete activity handling when the status has been reblogged (#4729) 2017-08-28 22:08:11 +02:00
unarist
7876aed134 Fix deletion of status which has been reblogged (#4728) 2017-08-28 21:38:59 +02:00
abcang
ce9a5f358e rescue HTTP::ConnectionError in RemoteFollowController#create (#4726) 2017-08-28 19:12:09 +02:00
Lynx Kotoura
8f527bd588 Add japanese translations for shorten display of large numbers (#4722) 2017-08-28 08:16:49 +09:00
Lynx Kotoura
07994eed00 Adjust "signed in as" pages (#4720)
* Adjust "signed in as" pages


Fix min-width


Set width of .account-header .name

To apply text-overflow and overflow settings
Set overflow for detailed-status__display-name

* Remove trailing whitespace
2017-08-28 00:01:07 +02:00
Lynx Kotoura
bab9afaa09 Adjust public profile pages (#4713)
* Adjust account-grid in public profiles

Full-width card on mobile UI. Set break-word for long name and ID. Fix margin.

* Reduce padding-bottom of public profiles

* Revive next prev buttons in mobile public profiles

In followers followees pages.

* Revert break-word for username

* Fix overflow of display_name

Need re-setting text-overflow and overflow in display: block;
2017-08-27 23:59:51 +02:00
Eugen Rochko
15093f9113 Shorten display of large numbers on public profiles (#4711) 2017-08-27 17:04:45 +02:00
mayaeh
f92d991e52 Add japanese translations for Pinned statuses based on pawoo. (#4717)
Add japanese translations for pin_errors.
2017-08-28 00:03:27 +09:00
Eugen Rochko
26402ee2cb Adjust RTL styles (#4712) 2017-08-27 13:35:18 +02:00
unarist
f095a9f8a5 Allow Symbol keyed Hash in LinkedDataSignature (#4715)
SerializarbleResource#as_json serializes to Symbol keyed Hash, but current
implementation of LinkedDataSignature expects String keyed Hash.

So it generates broken payload.
2017-08-27 13:35:01 +02:00
Eugen Rochko
0d5d11eeff Add _:inReplyToAtomUri to ActivityPub (#4702) 2017-08-26 19:55:10 +02:00
Eugen Rochko
0397c58b61 Forward ActivityPub deletes to followers of rebloggers (#4706) 2017-08-26 18:52:53 +02:00
Eugen Rochko
884b085f53 Use Tombstone and _:atomUri in Delete activities as fallback (#4704) 2017-08-26 16:10:35 +02:00
Eugen Rochko
2a2698e450 Add ActivityPub serializer for Undo of Announce (#4703) 2017-08-26 15:32:40 +02:00
Lynx Kotoura
8ecfdd8795 Set margin between character-counter and compose-form__buttons (#4698)
For some languages publish translation is long.
2017-08-26 14:23:20 +02:00
Eugen Rochko
00840f4f2e Add handling of Linked Data Signatures in payloads (#4687)
* Add handling of Linked Data Signatures in payloads

* Add a way to sign JSON, fix canonicalization of signature options

* Fix signatureValue encoding, send out signed JSON when distributing

* Add missing security context
2017-08-26 13:47:38 +02:00
Anna e só
1cebfed23e Added new translations of error messages, block and mute domains and users, privacy disclaimers, etc (#4700)
* Added new translations of error messages, block and mute domains and users

* Added new translations of error messages, block and mute domains and users
2017-08-26 20:45:35 +09:00
masarakki
649a20ab46 authorize-follow-requests-after-unlocking (#4658) 2017-08-26 12:40:03 +02:00
Yamagishi Kazutoshi
3ac7b353f8 Fix missing at-sign (regression from #4688) (#4705) 2017-08-26 12:39:26 +02:00
Lynx Kotoura
21bb4a6c3b Fix ar.json (#4699)
Remove ! from compose_form.publish
2017-08-25 20:02:44 +02:00
nullkal
c2af138113 Allow multiple pinned statuses to be shown and make them be ordered b… (#4690)
* Allow multiple pinned statuses to be shown and make them be ordered by pinned date

* Set timestamps NOT NULL

* Make single-line pinned_statuses

* Spec for pinned_statuses

* Remove redundant empty line
2017-08-25 18:50:52 +02:00
unarist
fb8aa2b3ba Apply user timezone for the title attribute of .time-ago (#4693) 2017-08-25 17:21:16 +02:00
Yamagishi Kazutoshi
00f9f16f94 Change timezone of the datetime to what browser specifies (#4688) 2017-08-25 17:21:00 +02:00
Lynx Kotoura
18f69fb964 Adjust styles of landing pages. (#4682)
* Adjust about.scss

* Delete trailing whitespace.
2017-08-25 17:19:35 +02:00
Quent-in
04c3fb2189 i18n Updated strings (#4675 - pinned toot) (#4695)
* Added string for pinned toots

* Pinned toot #4675 + missing string

Somehow I deleted it "enabled_success"

* update after advice
2017-08-25 23:04:52 +09:00
Yamagishi Kazutoshi
7c03e59338 Update addressable to version 2.5.2 (#4686) 2017-08-25 14:17:08 +02:00
Yamagishi Kazutoshi
b88635202f Add label for application scopes (#4691)
* Add label for application scopes

* hint
2017-08-25 13:03:26 +02:00
m4sk1n
409051c22c i18n: Update Polish translation #4675 (#4692)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-25 17:58:31 +09:00
Eugen Rochko
9caa90025f Pinned statuses (#4675)
* Pinned statuses

* yarn manage:translations
2017-08-25 01:41:18 +02:00
Ratmir Karabut
c5157ef07b Update Russian translation (#4685)
* Add Russian translation (ru)

* Fix a missing comma

* Fix the wording for better consistency

* Update Russian translation

* Arrange Russian setting alphabetically

* Fix syntax error

* Update Russian translation

* Fix formatting error

* Update Russian translation

* Update Russian translation

* Update ru.jsx

* Fix syntax error

* Remove two_factor_auth.warning (appears obsolete)

* Add missing strings in ru.yml

A lot of new strings translated, especially for the newly added admin section

* Fix translation consistency

* Update Russian translation

* Update Russian translation (pluralizations)

* Update Russian translation

* Update Russian translation

* Update Russian translation (pin)

* Update Russian translation (account deletion)

* Fix extra line

* Update Russian translation (sessions)

* Update Russian translation

* Update Russian translation

* Fix merge conflicts (revert)
2017-08-25 07:11:06 +09:00
Damien Erambert
f72ed21cd6 Don't load Roboto webfont when system font is used in the app (#4591)
* Don't load Roboto webfont when system font is used in the app

* remove trailing whitespace
2017-08-24 19:28:49 +02:00
Yamagishi Kazutoshi
da172a8b1b Disable babel-loader cache when development environment (#4684) 2017-08-24 19:27:52 +02:00
Eugen Rochko
cf615abbf9 Add configuration to disable private status federation over PuSH (#4582) 2017-08-24 17:51:32 +02:00
unarist
b01a19fe39 Fetch reblogs as Announce activity instead of Note object (#4672)
* Process Create / Announce activity in FetchRemoteStatusService

* Use activity URL in ActivityPub for reblogs

* Redirect to the original status on StatusesController#show
2017-08-24 16:21:42 +02:00
Eugen Rochko
c66fe2aeba Minor performance improvement for test suite (#4678) 2017-08-24 13:31:55 +02:00
Yamagishi Kazutoshi
fbe1115114 Remove eslint-disable comments (#4681)
Do not reject console.error and console.warn with ESLint rules.
2017-08-24 12:15:36 +02:00
Quent-in
e4c761f902 l18n update OC new strings (#4664) (#4680)
* New strings

* Update

Thin non breaking spaces

* Update

Thin non breaking spaces

* Update

Thin non breaking spaces
2017-08-24 16:16:32 +09:00
HIKARU KOBORI
2c6a85832c Fix typo in admin/status_controller.rb (#4679) 2017-08-24 04:03:52 +02:00
m4sk1n
829e2e8c5d Update Polish translation (#4674)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-23 17:45:29 +02:00
m4sk1n
8a716c9e96 Introduce CODEOWNERS file (#4670)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-23 15:21:00 +02:00
nullkal
80393a23d0 Use checkboxes for application scope setting (#4671) 2017-08-23 15:16:20 +02:00
Yamagishi Kazutoshi
8d23667536 Add Japanese translations for #2758, #4506, #4521, #4600 and #4664 (#4665)
* Add Japanese translations for #2758, #4506, #4521, #4600 and #4664

* Do not translate Inbox URL and Outbox URL

* Remove "あなたの"

* Remove "あなたの"
2017-08-23 14:14:22 +02:00
unarist
9846806cb5 Fix Japanese translation (#4669) 2017-08-23 20:07:29 +09:00
unarist
760cfe328f Fix accessing to XML attribute in FetchAtomService (#4668) 2017-08-23 12:25:57 +02:00
Eugen Rochko
c1b086a538 Fix up the applications area (#4664)
- Section it into "Development" area
- Improve UI of application form, index, and details
2017-08-23 00:59:35 +02:00
Daigo 3 Dango
696c2c6f2f Add Mastodon::Source.url (#4643)
* Add Mastodon::Source.url

* Update spec

* Refactor

Move things frmo Mastodon::Source to Mastodon::Version
2017-08-22 22:54:19 +02:00
unarist
5927b43c0f Ignore empty response in ActivityPub::FetchRemoteStatusService (#4661)
* Ignore empty response in ActivityPub::FetchRemoteStatusService

This fixes `NoMethodError: undefined method `[]' for nil:NilClass` error.

* Check json.nil? in JsonLdHelper#supported_context?
2017-08-22 20:00:49 +02:00
Colin Mitchell
871c0d251a Application prefs section (#2758)
* Add code for creating/managing apps to settings section

* Add specs for app changes

* Fix controller spec

* Fix view file I pasted over by mistake

* Add locale strings. Add 'my apps' to nav

* Add Client ID/Secret to App page. Add some visual separation

* Fix rubocop warnings

* Fix embarrassing typo

I lost an `end` statement while fixing a merge conflict.

* Add code for creating/managing apps to settings section

- Add specs for app changes
- Add locale strings. Add 'my apps' to nav
- Add Client ID/Secret to App page. Add some visual separation
- Fix some bugs/warnings

* Update to match code standards

* Trigger notification

* Add warning about not sharing API secrets

* Tweak spec a bit

* Cleanup fixture creation by using let!

* Remove unused key

* Add foreign key for application<->user
2017-08-22 18:33:57 +02:00
Yamagishi Kazutoshi
11a7507318 Add delete account link for French (#4659) 2017-08-22 18:31:42 +02:00
unarist
d63de55ef8 Fix bugs which OStatus accounts may detected as ActivityPub ready (#4662)
* Fallback to OStatus in FetchAtomService

* Skip activity+json link if that activity is Person without inbox
* If unsupported activity was detected and all other URLs failed, retry with ActivityPub-less Accept header

* Allow mention to OStatus account in ActivityPub

* Don't update profile with inbox-less Person object
2017-08-22 18:30:15 +02:00
Eugen Rochko
72bb3e03fd Support more variations of ActivityPub keyId in signature (#4630)
- Tries to avoid performing HTTP request if the keyId is an actor URI
- Likewise if the URI is a fragment URI on top of actor URI
- Resolves public key, returns owner if the owner links back to the key
2017-08-21 22:57:34 +02:00
Eugen Rochko
f391a4673a Periodically remove expired PuSH subscribers (#4654) 2017-08-21 22:56:33 +02:00
Lynx Kotoura
143b77e10d Increase contrast in landing pages (#4567)
* Increase contrast in about and about/more page

* Lighten em color in landing pages

* Increase contrast in landing pages


Fix about.scss
2017-08-21 21:59:03 +02:00
Eugen Rochko
4cbb638604 Fix visual line-break glitch with .invisible parts of links (#4655) 2017-08-21 17:59:34 +02:00
Eugen Rochko
3534e115e5 Do not try to re-subscribe to unsubscribed accounts (#4653) 2017-08-21 17:32:41 +02:00
abcang
ea958cae7f Refactoring streaming connections (#4645) 2017-08-21 15:04:34 +02:00
Yamagishi Kazutoshi
10e9a9a3f9 Use URI.join even when S3 enabled (#4652) 2017-08-21 12:42:16 +02:00
Eugen Rochko
6e9eda5331 ActivityPub migration procedure (#4617)
* ActivityPub migration procedure

Once one account is detected as going from OStatus to ActivityPub,
invalidate WebFinger cache for other accounts from the same domain

* Unsubscribe from PuSH updates once we receive an ActivityPub payload

* Re-subscribe to PuSH unless already unsubscribed, regardless of protocol
2017-08-21 01:14:40 +02:00
m4sk1n
4c23544714 i18n: Minor changes in Polish translation (#4649)
* i18n: Minor changes in Polish translation

* i18n: pl
2017-08-21 07:57:28 +09:00
Eugen Rochko
74e5078795 Fix #4637 - Re-add missing doorkeeper_authorize for /api/v1/verify_credentials (#4650) 2017-08-21 00:41:08 +02:00
Yamagishi Kazutoshi
110227ac5e Remove status from favorites list when unfavorited (#4597) 2017-08-20 23:23:05 +02:00
unarist
f26758dc01 Fix .information-board style for Safari (#4602)
flex-basis: 0 allows make flexbox smaller than its contents on Safari <10.

https://github.com/philipwalton/flexbugs#1-minimum-content-sizing-of-flex-items-not-honored
2017-08-20 20:45:44 +02:00
abcang
23792f5a7c Fix hasUnread on HashtagTimeline (#4644) 2017-08-20 17:12:06 +02:00
Eugen Rochko
fe5b66aa08 Handle duplicate ActivityPub activities (#4639)
* Handle duplicate ActivityPub activities

Only perform side-effects when record processed for the first time

* Fast-forward repeat follow requests
2017-08-20 16:53:47 +02:00
Quent-in
93d4192a67 l10n update OC : Redesign public profiles (#4608) (#4646)
New strings added to be shown on the new profile page
2017-08-20 21:49:12 +09:00
takayamaki
d5acf4275f Improve about ja translation standalone.public_title (#4641) 2017-08-20 20:27:14 +09:00
Eugen Rochko
412ea87306 Improve ActivityPub/OStatus compatibility (#4632)
*Note: OStatus URIs are invalid for ActivityPub. But we have them for
as long as we want to keep old OStatus-sourced content and as long as
we remain OStatus-compatible.*

- In Announce handling, if object URI is not a URL, fallback to object URL
- Do not use specialized ThreadResolveWorker, rely on generalized handling
- When serializing notes, if parent's URI is not a URL, use parent's URL
2017-08-19 18:44:48 +02:00
Eugen Rochko
774b8661bc Revert #4616 (#4638) 2017-08-19 02:23:47 +02:00
Eugen Rochko
c7d2619ab1 Parse OStatus tag URIs in ActivityPub handlers when those are local (#4631) 2017-08-18 11:24:44 +02:00
Yamagishi Kazutoshi
2edfdab6e6 Don't send Link header when don't know prev and next links (#4633) 2017-08-18 10:42:59 +02:00
Eugen Rochko
4edf9d849f Make ActivityPub::TagManager#local_uri? recognize local URIs with ports (#4628) 2017-08-18 03:21:59 +02:00
Eugen Rochko
10489b4e4a If url attribute not present in Note, fallback to id attribute (#4629) 2017-08-18 02:29:12 +02:00
Eugen Rochko
40c45f5dd9 Put ActivityPub alternate link into Atom, prefer it when processing Atom (#4623) 2017-08-18 01:03:18 +02:00
nightpool
efec02f153 use existing inflections instead of custom helper (#4624)
* use existing inflections instead of custom helper

* use ActiveSupport versions
2017-08-17 23:20:50 +02:00
Eugen Rochko
116b8a6363 Fix #4607 - Accept/reject activities use FollowRequest, which has inverse relations (#4616) 2017-08-17 22:15:37 +02:00
Eugen Rochko
ad892dbc0c Add _:atomUri property for deduplicating OStatus/ActivityPub legacy records (#4593) 2017-08-17 21:35:00 +02:00
nullkal
075d6a1e13 Show what protocol is used for accounts in admin/accounts#index (#4622)
* Show what protocol used for in admin/accounts#index

* Add frozen_string_literal
2017-08-17 17:52:40 +02:00
nullkal
54a04e3658 Update charlock_holmes to 0.7.5 (#4620) 2017-08-17 14:46:53 +02:00
Naoki Kosaka
462c30e26c Update Japanese Translation. (Redesign public profiles) (#4612) 2017-08-17 06:19:37 +09:00
m4sk1n
2a04bdc87a i18n: Update Polish translation (#4613)
* i18n: Update Polish translation

* Update pl.json
2017-08-16 22:14:23 +02:00
Eugen Rochko
ca7ea1aba9 Redesign public profiles (#4608)
* Redesign public profiles

* Responsive design

* Change public profile status filtering defaults and add options

- No longer displays private/direct toots even if you are permitted access
- By default omits replies
- "With replies" option
- "Media only" option

* Redesign account grid cards

* Fix style issues
2017-08-16 17:12:58 +02:00
Clworld
f814661fca Make share intent modal to make "signed in as" shown. (#4611)
* Make share intent modal to make "signed in as" shown.

* fix glitch on mobile.
2017-08-16 16:48:44 +02:00
Quent-in
e33c28a6d8 Update ActivityPub (#4600) (#4609)
Update: new string + more translations for the time in words
2017-08-16 17:21:34 +09:00
abcang
e120d09c98 Fix require_user! behavior when not logged in (#4604) 2017-08-15 14:14:12 +02:00
Eugen Rochko
4fcbb1f838 Re-add missing transaction around status-from-OStatus creation (#4603) 2017-08-14 21:37:21 +02:00
unarist
a855956185 Fix ActivityPub follow interaction and add more specs (#4601) 2017-08-14 16:57:46 +02:00
unarist
5b9ae7981e Update /admin/accounts/:id view for ActivityPub (#4600)
* Add protocol field
* Switch protocol specific information according to active protocol
* Hide PuSH subscription related buttons if ActivityPub is active
2017-08-14 14:09:00 +02:00
Yamagishi Kazutoshi
5f22c0189d Add support for searching AP users (#4599)
* Add support for searching AP users

* use JsonLdHelper
2017-08-14 14:08:34 +02:00
Eugen Rochko
26d26644ac Require "inbox" to be set on actor to be ActivityPub-ready (#4595) 2017-08-14 11:27:25 +02:00
Eugen Rochko
3c6503038e Add protocol handler. Handle follow intents (#4511)
* Add protocol handler. Handle follow intents

* Add share intent

* Improve code in intents controller

* Adjust share form CSS
2017-08-14 04:53:31 +02:00
Yamagishi Kazutoshi
96e9ed13de Fix search (regression from #4589) (#4594) 2017-08-14 04:50:56 +02:00
Eugen Rochko
6df8bd277b Set correct content-type for ActivityPub JSON (#4592) 2017-08-14 04:16:43 +02:00
Eugen Rochko
4e75f0d889 Hook up URL-based resource look-up to ActivityPub (#4589) 2017-08-14 02:29:36 +02:00
Eugen Rochko
a2aeacbfee Add alternate links to ActivityPub resources from HTML/HEAD variants (#4586) 2017-08-13 00:45:04 +02:00
Eugen Rochko
b7370ac8ba ActivityPub delivery (#4566)
* Deliver ActivityPub Like

* Deliver ActivityPub Undo-Like

* Deliver ActivityPub Create/Announce activities

* Deliver ActivityPub creates from mentions

* Deliver ActivityPub Block/Undo-Block

* Deliver ActivityPub Accept/Reject-Follow

* Deliver ActivityPub Undo-Follow

* Deliver ActivityPub Follow

* Deliver ActivityPub Delete activities

Incidentally fix #889

* Adjust BatchedRemoveStatusService for ActivityPub

* Add tests for ActivityPub workers

* Add tests for FollowService

* Add tests for FavouriteService, UnfollowService and PostStatusService

* Add tests for ReblogService, BlockService, UnblockService, ProcessMentionsService

* Add tests for AuthorizeFollowService, RejectFollowService, RemoveStatusService

* Add tests for BatchedRemoveStatusService

* Deliver updates to a local account to ActivityPub followers

* Minor adjustments
2017-08-13 00:44:41 +02:00
Eugen Rochko
ccdd5a9576 Add serializing/unserializing of "locked" actor attribute (#4585) 2017-08-12 17:41:03 +02:00
Eugen Rochko
40be4ea239 Extend Devise remember_me longevity to 1 year instead of 2 weeks (#4587)
Force SSL only cookies for remember_me, adjust confirmation
expiration time to fit with the user cleanup scheduler
2017-08-12 16:30:59 +02:00
Eugen Rochko
3d47154c20 Only PuSH-resubscribe to OStatus accounts (#4583) 2017-08-12 02:54:54 +02:00
Sylvhem
d0a217eb92 Minor fixes in the French translation (#4580)
* Ajout de traductions manquantes

Ajoute des traductions pour les chaînes n’en ayant pas en version 1.5.1.

Add translations for the strings that are missing them in 1.5.1.

* Remplace « ' » par « ’ »

Retire de la traduction les apostrophes droites « ' » (U+0027) au profit des apostrophes typographiques « ’ » (U+2019).
En typographie française, les apostrophes typographiques sont utilisées à la place des apostrophes droites. La traduction était incohérente et utilisait les deux.

Remove from the translation all the vertical apostrophes (U+0027) in favor of the curly ones (U+2019).
In French typography, typographic apostrophes are used instead of vertical ones. The translation was incoherent and used both.

* Ajout d’espaces insécables

Ajoute des espaces insécables suivant les régles nécessaires en typographie française.

Add non-breaking spaces following rules of French typography.

* Remplace « status » par « statut »

Remplace le mot anglais « status » par sa traduction française « statut ».

Replace the English word "status" by its French translation "statut".

* Correction de la politique de confidentialité

Apporte diverses corrections à la traduction de la politique de confidentialité.

Add various fixes to the privacy policy's translation.

* Remplace « mentionné » par « mentionné·e »

Harmonise la traduction en remplaçant « mentionné » par sa forme épicène.

Harmonize the translation by replacing "mentionné" (sure) by its epicene form.

* Remplace « Coup d’œil » par « Jeter un coup d’œil… »

Remplace la première traduction par une forme plus proche de la version originelle.

Replace the first translation by something closer to the original version.

* Remplace « Bon Appétoot ! » par « Bon appouetit ! »

Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot.

Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun.

* Remplace « Bon Appétoot ! » par « Bon appouetit ! » (2)

Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot.

Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun.f

* Corrections

Corrige des fautes d’orthographe et change « appouetit » pour « appouétit ».

Correct some mistakes and change "appouetit" to "appouétit".
2017-08-12 01:33:30 +02:00
Eugen Rochko
81c1303cd6 Handle ActivityPub follows correctly (#4571)
* Handle ActivityPub follows correctly

ActivityPub follows are follow-requests. Always require an Accept.
If account is not locked, auto-accept.

* Handle ActivityPub Accept/Reject-Follow

* Fix wrong method

* Fix wrong class
2017-08-10 22:33:12 +02:00
Quent-in
4b8e4dca26 l10n Update OC #4521 (#4577)
* l10n Update OC #4521

Link => token
provider => provesidor
+ more generalized way of using present participle

* Update oc.yml
2017-08-10 22:15:26 +02:00
spla
10cdad3e7d Added new catalan strings (#4574)
* Add Catalan language

* Add Catalan language

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update settings_helper.rb

* Update mastodon.js

* Update index.js

* Update application.rb

* Update ca.yml

* removed extra spaces at line 225

* Catalan translation update

added activerecord.ca.yml

* Update activerecord.ca.yml

Done

* Updated activerecord.ca.yml

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Update ca.json

Removed :

<<<<<<< HEAD
  "getting_started.support": "{faq} • {userguide} • {apps}",
=======
>>>>>>> upstream/master

* Syncing to master

* Added new Catalan strings

* removed config.secret_key line

* Corrected <sotrong> tag to <strong>

Line 515

* Removed extra line

* Reverted

* yarn.lock reverted
2017-08-10 21:52:40 +09:00
Yamagishi Kazutoshi
d9a1fb134a Fix emoji picker scrollbar style (#4572) 2017-08-10 13:41:12 +02:00
Eugen Rochko
fdea173237 Add Digest header to requests with body, handle acct and URI keyId (#4565) 2017-08-09 23:54:14 +02:00
m4sk1n
4e1bf082ce i18n: Improve admin panel translation (pl) (#4559)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-09 07:46:21 +09:00
Ondřej Hruška
b1c8a702a4 Add favourited toot to favourites column (#4562)
* Add faved toot to faves column

* renamed append to prepend for clarity
2017-08-09 00:22:26 +02:00
Ondřej Hruška
820099813f add scrollTop to ui/components/column (#4563) 2017-08-09 00:21:58 +02:00
Gergely Nagy
2ebe4ff568 api/instances: Include the stats from the /about/more page (#4074)
To be able to pull some basic statistics from a Mastodon instance, include the
user, status and connected domain counters in the `/api/v1/instance` response.

Fixes #3570.

Signed-off-by: Gergely Nagy <algernon@madhouse-project.org>
2017-08-08 22:18:12 +02:00
Ondřej Hruška
61bfce5aa9 add missing @ to the onboarding modal (#4560) 2017-08-08 22:13:04 +02:00
Eugen Rochko
dd7ef0dc41 Add ActivityPub inbox (#4216)
* Add ActivityPub inbox

* Handle ActivityPub deletes

* Handle ActivityPub creates

* Handle ActivityPub announces

* Stubs for handling all activities that need to be handled

* Add ActivityPub actor resolving

* Handle conversation URI passing in ActivityPub

* Handle content language in ActivityPub

* Send accept header when fetching actor, handle JSON parse errors

* Test for ActivityPub::FetchRemoteAccountService

* Handle public key and icon/image when embedded/as array/as resolvable URI

* Implement ActivityPub::FetchRemoteStatusService

* Add stubs for more interactions

* Undo activities implemented

* Handle out of order activities

* Hook up ActivityPub to ResolveRemoteAccountService, handle
Update Account activities

* Add fragment IDs to all transient activity serializers

* Add tests and fixes

* Add stubs for missing tests

* Add more tests

* Add more tests
2017-08-08 21:52:15 +02:00
Lynx Kotoura
dcbc1af38a Fix short description in about/more page (#4554) 2017-08-08 15:49:32 +02:00
Yamagishi Kazutoshi
81c41d8681 Add coalesce option to avatar and header convert processor (#4552)
Resolve #3199
2017-08-08 15:49:14 +02:00
雨宮美羽
ec3be87a2b improve zh-CN translations (#4557) 2017-08-08 15:48:19 +02:00
unarist
b42c018bb8 Add Content-Type header on throttled response to fix mojibake (#4558)
application/json only allows Unicode, so this prevents from wrong charset detection.
2017-08-08 15:47:35 +02:00
TheInventrix
c9fd6f386c unify short description styling (#4548)
apply same style class to the About description on both the landing page
and the about/more page
2017-08-08 01:50:15 +02:00
Yamagishi Kazutoshi
1b5d26735e Revert "Set false to animated options for thumbnail processor" (#4550)
* Revert "Adjust tags and accounts page (#4534)"

This reverts commit a3e53bd442.

* Revert "feat: Cache status height to avoid expensive renders (#4439)"

This reverts commit 8eb6d171e6.

* Revert "Refactor Avatar and AvatarOverlay to have 'account' as prop instead of src and staticSrc (#4526)"

This reverts commit 5942347407.

* Revert "Update dependencies for Ruby (#4543)"

This reverts commit 22db947225.

* Revert "[Docker] Add multicore support to "make" and "bundler" (#4544)"

This reverts commit 5d408fd9aa.

* Revert "It makes no sense to try using invalid or expired link again (#4521)"

This reverts commit 47579ec58c.

* Revert "i18n: Update Polish translation (#4545)"

This reverts commit 3363a05539.

* Revert "Set false to animated options for thumbnail processor (#4547)"

This reverts commit 87f10d476c.
2017-08-08 01:49:56 +02:00
476 changed files with 8524 additions and 10430 deletions

View File

@@ -49,6 +49,7 @@ rules:
- warn
- allow:
- error
- warn
no-fallthrough: error
no-irregular-whitespace: error
no-mixed-spaces-and-tabs: warn

View File

@@ -10,6 +10,7 @@ AllCops:
- 'node_modules/**/*'
- 'Vagrantfile'
- 'vendor/**/*'
- 'lib/json_ld/*'
Bundler/OrderedGems:
Enabled: false

15
CODEOWNERS Normal file
View File

@@ -0,0 +1,15 @@
# CODEOWNERS for tootsuite/mastodon
# Translators
# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address.
# /app/javascript/mastodon/locales/fr.json @żelipapą
# /app/views/user_mailer/*.fr.html.erb @żelipapą
# /app/views/user_mailer/*.fr.text.erb @żelipapą
# /config/locales/*.fr.yml @żelipapą
# /config/locales/fr.yml @żelipapą
/app/javascript/mastodon/locales/pl.json @m4sk1n
/app/views/user_mailer/*.pl.html.erb @m4sk1n
/app/views/user_mailer/*.pl.text.erb @m4sk1n
/config/locales/*.pl.yml @m4sk1n
/config/locales/pl.yml @m4sk1n

View File

@@ -1,36 +1,3 @@
# Contributing to Mastodon Glitch Edition #
Thank you for your interest in contributing to the `glitch-soc` project!
Here are some guidelines, and ways you can help.
> (This document is a bit of a work-in-progress, so please bear with us.
> If you don't see what you're looking for here, please don't hesitate to reach out!)
## Planning ##
Right now a lot of the planning for this project takes place in our development Discord, or through GitHub Issues and Projects.
We're working on ways to improve the planning structure and better solicit feedback, and if you feel like you can help in this respect, feel free to give us a holler.
## Documentation ##
The documentation for this repository is available at [`glitch-soc/docs`](https://github.com/glitch-soc/docs) (online at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/)).
Right now, we've mostly focused on the features that make this fork different from upstream in some manner.
Adding screenshots, improving descriptions, and so forth are all ways to help contribute to the project even if you don't know any code.
## Frontend Development ##
Check out [the documentation here](https://glitch-soc.github.io/docs/contributing/frontend/) for more information.
## Backend Development ##
See the guidelines below.
- - -
You should also try to follow the guidelines set out in the original `CONTRIBUTING.md` from `tootsuite/mastodon`, reproduced below.
<blockquote>
CONTRIBUTING
============
@@ -82,5 +49,3 @@ It is expected that you have a working development environment set up (see back-
* If you are introducing new strings, they must be using localization methods
If the JavaScript or CSS assets won't compile due to a syntax error, it's a good sign that the pull request isn't ready for submission yet.
</blockquote>

View File

@@ -1,4 +1,4 @@
FROM ruby:2.4.1-alpine
FROM ruby:2.4.1-alpine3.6
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server"
@@ -14,9 +14,7 @@ EXPOSE 3000 4000
WORKDIR /mastodon
RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \
&& echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
&& apk -U upgrade \
RUN apk -U upgrade \
&& apk add -t build-dependencies \
build-base \
icu-dev \
@@ -31,15 +29,15 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit
file \
git \
icu-libs \
imagemagick@edge \
imagemagick \
libidn \
libpq \
nodejs-npm@edge \
nodejs@edge \
nodejs-npm \
nodejs \
protobuf \
su-exec \
tini \
yarn@edge \
yarn \
&& update-ca-certificates \
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \

View File

@@ -22,7 +22,7 @@ gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5'
gem 'bootsnap'
gem 'browser'
gem 'charlock_holmes', '~> 0.7.3'
gem 'charlock_holmes', '~> 0.7.5'
gem 'cld3', '~> 3.1'
gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0'
@@ -68,6 +68,9 @@ gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0'
gem 'webpush'
gem 'json-ld-preloaded', '~> 2.2.1'
gem 'rdf-normalize', '~> 0.3.1'
group :development, :test do
gem 'fabrication', '~> 2.16'
gem 'fuubar', '~> 2.2'

View File

@@ -44,8 +44,8 @@ GEM
i18n (~> 0.7)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.1)
public_suffix (~> 2.0, >= 2.0.2)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.2)
@@ -74,13 +74,13 @@ GEM
debug_inspector (>= 0.0.1)
bootsnap (1.1.2)
msgpack (~> 1.0)
brakeman (3.6.2)
brakeman (3.7.2)
browser (2.4.0)
builder (3.2.3)
bullet (5.5.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
bundler-audit (0.5.0)
bundler-audit (0.6.0)
bundler (~> 1.2)
thor (~> 0.18)
capistrano (3.8.2)
@@ -108,7 +108,7 @@ GEM
xpath (~> 2.0)
case_transform (0.2)
activesupport
charlock_holmes (0.7.3)
charlock_holmes (0.7.5)
chunky_png (1.3.8)
cld3 (3.1.3)
ffi (>= 1.1.0, < 1.10.0)
@@ -179,6 +179,8 @@ GEM
activesupport (>= 4.0.1)
hamlit (>= 1.2.0)
railties (>= 4.0.1)
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.3.5)
highline (1.7.8)
hiredis (0.6.1)
@@ -211,6 +213,13 @@ GEM
idn-ruby (0.1.0)
jmespath (1.3.1)
json (2.1.0)
json-ld (2.1.5)
multi_json (~> 1.12)
rdf (~> 2.2)
json-ld-preloaded (2.2.1)
json-ld (~> 2.1, >= 2.1.5)
multi_json (~> 1.11)
rdf (~> 2.2)
jsonapi-renderer (0.1.3)
jwt (1.5.6)
kaminari (1.0.1)
@@ -298,7 +307,7 @@ GEM
slop (~> 3.4)
pry-rails (0.3.6)
pry (>= 0.10.4)
public_suffix (2.0.5)
public_suffix (3.0.0)
puma (3.9.1)
pundit (1.1.0)
activesupport (>= 3.0.0)
@@ -348,6 +357,11 @@ GEM
rainbow (2.2.2)
rake
rake (12.0.0)
rdf (2.2.8)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.2)
rdf (~> 2.0)
redis (3.3.3)
redis-actionpack (5.0.1)
actionpack (>= 4.0, < 6)
@@ -454,7 +468,7 @@ GEM
temple (0.8.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
thor (0.19.4)
thor (0.20.0)
thread (0.2.2)
thread_safe (0.3.6)
tilt (2.0.8)
@@ -511,7 +525,7 @@ DEPENDENCIES
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 2.14)
charlock_holmes (~> 0.7.3)
charlock_holmes (~> 0.7.5)
cld3 (~> 3.1)
climate_control (~> 0.2)
devise (~> 4.2)
@@ -531,6 +545,7 @@ DEPENDENCIES
httplog (~> 0.99)
i18n-tasks (~> 0.9)
idn-ruby
json-ld-preloaded (~> 2.2.1)
kaminari (~> 1.0)
letter_opener (~> 1.4)
letter_opener_web (~> 1.3)
@@ -560,6 +575,7 @@ DEPENDENCIES
rails-controller-testing (~> 1.0)
rails-i18n (~> 5.0)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.3.1)
redis (~> 3.3)
redis-namespace (~> 1.5)
redis-rails (~> 5.0)
@@ -590,4 +606,4 @@ RUBY VERSION
ruby 2.4.1p111
BUNDLED WITH
1.15.3
1.15.4

View File

@@ -1,10 +1,70 @@
# Mastodon Glitch Edition #
![Mastodon](https://i.imgur.com/NhZc40l.png)
========
> Now with automated deploys!
[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?
Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)).
Click on the screenshot to watch a demo of the UI:
[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
[patreon]: https://www.patreon.com/user?u=619786
## Resources
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
## Features
- **Fully interoperable with GNU social and any OStatus platform**
Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network
- **Real-time timeline updates**
See the updates of people you're following appear in real-time in the UI via WebSockets
- **Federated thread resolving**
If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
- **Media attachments like images and WebM**
Upload and view images and WebM videos attached to the updates
- **OAuth2 and a straightforward REST API**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple
- **Background processing for long-running tasks**
Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are
- **Deployable via Docker**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
## Development
Please follow the [development guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Development-guide.md) from the documentation repository.
## Deployment
There are guides in the documentation repository for [deploying on various platforms](https://github.com/tootsuite/documentation#running-mastodon).
## Contributing
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. [Here are the guidelines for code contributions](CONTRIBUTING.md)
**IRC channel**: #mastodon on irc.freenode.net
## Extra credits
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
![Mastodon error image](https://mastodon.social/oops.png)

View File

@@ -7,8 +7,17 @@ class AccountsController < ApplicationController
def show
respond_to do |format|
format.html do
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
@pinned_statuses = []
if current_account && @account.blocking?(current_account)
@statuses = []
return
end
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) unless media_requested?
@statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
@next_url = next_url unless @statuses.empty?
end
format.atom do
@@ -17,14 +26,55 @@ class AccountsController < ApplicationController
end
format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
end
end
private
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if media_requested?
statuses.merge!(no_replies_scope) unless replies_requested?
end
end
def default_statuses
@account.statuses.where(visibility: [:public, :unlisted])
end
def only_media_scope
Status.where(id: account_media_status_ids)
end
def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end
def no_replies_scope
Status.without_replies
end
def set_account
@account = Account.find_local!(params[:username])
end
def next_url
if media_requested?
short_account_media_url(@account, max_id: @statuses.last.id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: @statuses.last.id)
else
short_account_url(@account, max_id: @statuses.last.id)
end
end
def media_requested?
request.path.ends_with?('/media')
end
def replies_requested?
request.path.ends_with?('/with_replies')
end
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification
before_action :set_account
def create
if signed_request_account
upgrade_account
process_payload
head 201
else
head 202
end
end
private
def set_account
@account = Account.find_local!(params[:account_username]) if params[:account_username]
end
def body
@body ||= request.body.read
end
def upgrade_account
return unless signed_request_account.subscribed?
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id)
end
def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
end
end

View File

@@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private

View File

@@ -17,7 +17,7 @@ module Admin
end
def unsubscribe
UnsubscribeService.new.call(@account)
Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
redirect_to admin_account_path(@account.id)
end

View File

@@ -9,7 +9,7 @@ module Admin
before_action :set_account
before_action :set_status, only: [:update, :destroy]
PAR_PAGE = 20
PER_PAGE = 20
def index
@statuses = @account.statuses
@@ -17,7 +17,7 @@ module Admin
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
@statuses.merge!(Status.where(id: account_media_status_ids))
end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE)
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new
end

View File

@@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController
links = []
links << [next_path, [%w(rel next)]] if next_path
links << [prev_path, [%w(rel prev)]] if prev_path
response.headers['Link'] = LinkHeader.new(links)
response.headers['Link'] = LinkHeader.new(links) unless links.empty?
end
def limit_param(default_limit)
@@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController
end
def require_user!
current_resource_owner
set_user_activity
rescue ActiveRecord::RecordNotFound
render json: { error: 'This method requires an authenticated user' }, status: 422
if current_user
set_user_activity
else
render json: { error: 'This method requires an authenticated user' }, status: 422
end
end
def render_empty

View File

@@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController
respond_to :json
def show
@stream_entry = find_stream_entry.stream_entry
render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
@status = status_finder.status
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
end
private
def find_stream_entry
StreamEntryFinder.new(params[:url])
def status_finder
StatusFinder.new(params[:url])
end
def maxwidth_or_default

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Api::V1::Accounts::CredentialsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }, except: [:update]
before_action -> { doorkeeper_authorize! :write }, only: [:update]
before_action :require_user!
@@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
end
def update
current_account.update!(account_params)
@account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
render json: @account, serializer: REST::CredentialAccountSerializer
end

View File

@@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def account_statuses
default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if params[:only_media]
statuses.merge!(pinned_scope) if params[:pinned]
statuses.merge!(no_replies_scope) if params[:exclude_replies]
end
end
@@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end
def pinned_scope
@account.pinned_statuses
end
def no_replies_scope
Status.without_replies
end

View File

@@ -24,20 +24,11 @@ class Api::V1::NotificationsController < Api::BaseController
render_empty
end
def destroy
dismiss
end
def dismiss
current_account.notifications.find_by!(id: params[:id]).destroy!
render_empty
end
def destroy_multiple
current_account.notifications.where(id: params[:ids]).destroy_all
render_empty
end
private
def load_notifications

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::SearchController < Api::BaseController
RESULTS_LIMIT = 10
RESULTS_LIMIT = 5
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Api::V1::Statuses::PinsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write }
before_action :require_user!
before_action :set_status
respond_to :json
def create
StatusPin.create!(account: current_account, status: @status)
render json: @status, serializer: REST::StatusSerializer
end
def destroy
pin = StatusPin.find_by(account: current_account, status: @status)
pin&.destroy!
render json: @status, serializer: REST::StatusSerializer
end
private
def set_status
@status = Status.find(params[:status_id])
end
end

View File

@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
end
def card
@card = PreviewCard.find_by(status: @status)
@card = @status.preview_cards.first
if @card.nil?
render_empty

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class Api::Web::EmbedsController < Api::BaseController
respond_to :json
before_action :require_user!
def create
status = StatusFinder.new(params[:url]).status
render json: status, serializer: OEmbedSerializer, width: 400
rescue ActiveRecord::RecordNotFound
oembed = OEmbed::Providers.get(params[:url])
render json: Oj.dump(oembed.fields)
rescue OEmbed::NotFound
render json: {}, status: :not_found
end
end

View File

@@ -23,6 +23,7 @@ module AccountControllerConcern
[
webfinger_account_link,
atom_account_url_link,
actor_url_link,
]
)
end
@@ -41,6 +42,13 @@ module AccountControllerConcern
]
end
def actor_url_link
[
ActivityPub::TagManager.instance.uri_for(@account),
[%w(rel alternate), %w(type application/activity+json)],
]
end
def webfinger_account_url
webfinger_url(resource: @account.to_webfinger_s)
end

View File

@@ -31,7 +31,7 @@ module SignatureVerification
return
end
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
account = account_from_key_id(signature_params['keyId'])
if account.nil?
@signed_request_account = nil
@@ -49,6 +49,10 @@ module SignatureVerification
end
end
def request_body
@request_body ||= request.raw_post
end
private
def build_signed_string(signed_headers)
@@ -57,6 +61,8 @@ module SignatureVerification
signed_headers.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end
@@ -73,6 +79,10 @@ module SignatureVerification
(Time.now.utc - time_sent).abs <= 30
end
def body_digest
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
end
def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-')
end
@@ -81,7 +91,16 @@ module SignatureVerification
signature_params['keyId'].blank? ||
signature_params['signature'].blank? ||
signature_params['algorithm'].blank? ||
signature_params['algorithm'] != 'rsa-sha256' ||
!signature_params['keyId'].start_with?('acct:')
signature_params['algorithm'] != 'rsa-sha256'
end
def account_from_key_id(key_id)
if key_id.start_with?('acct:')
ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id)
account
end
end
end

View File

@@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
end
end

View File

@@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
end
end

View File

@@ -6,7 +6,6 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
@frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
end
private

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class IntentsController < ApplicationController
def show
uri = Addressable::URI.parse(params[:uri])
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end
end
not_found
end
end

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
class Settings::ApplicationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_application, only: [:show, :update, :destroy, :regenerate]
before_action :prepare_scopes, only: [:create, :update]
def index
@applications = current_user.applications.page(params[:page])
end
def new
@application = Doorkeeper::Application.new(
redirect_uri: Doorkeeper.configuration.native_redirect_uri,
scopes: 'read write follow'
)
end
def show; end
def create
@application = current_user.applications.build(application_params)
if @application.save
redirect_to settings_applications_path, notice: I18n.t('applications.created')
else
render :new
end
end
def update
if @application.update(application_params)
redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
def destroy
@application.destroy
redirect_to settings_applications_path, notice: I18n.t('applications.destroyed')
end
def regenerate
@access_token = current_user.token_for_app(@application)
@access_token.destroy
redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated')
end
private
def set_application
@application = current_user.applications.find(params[:id])
end
def application_params
params.require(:doorkeeper_application).permit(
:name,
:redirect_uri,
:scopes,
:website
)
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
end
end

View File

@@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController
def show; end
def update
if @account.update(account_params)
if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
class SharesController < ApplicationController
layout 'modal'
before_action :authenticate_user!
before_action :set_body_classes
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
private
def initial_state_params
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
text: params[:text],
}
end
def set_body_classes
@body_classes = 'compose-standalone'
end
end

View File

@@ -9,6 +9,7 @@ class StatusesController < ApplicationController
before_action :set_status
before_action :set_link_headers
before_action :check_account_suspension
before_action :redirect_to_original, only: [:show]
def show
respond_to do |format|
@@ -20,13 +21,18 @@ class StatusesController < ApplicationController
end
format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
end
end
def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
def embed
response.headers['X-Frame-Options'] = 'ALLOWALL'
render 'stream_entries/embed', layout: 'embedded'
end
private
@@ -36,7 +42,12 @@ class StatusesController < ApplicationController
end
def set_link_headers
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]],
]
)
end
def set_status
@@ -53,4 +64,8 @@ class StatusesController < ApplicationController
def check_account_suspension
gone if @account.suspended?
end
def redirect_to_original
redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog?
end
end

View File

@@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController
end
def embed
response.headers['X-Frame-Options'] = 'ALLOWALL'
return gone if @stream_entry.activity.nil?
render layout: 'embedded'
redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301
end
private
@@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController
end
def set_link_headers
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]],
]
)
end
def set_stream_entry

View File

@@ -12,7 +12,7 @@ class TagsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
end
end

View File

@@ -5,6 +5,10 @@ module ApplicationHelper
current_page?(path) ? 'active' : ''
end
def active_link_to(label, path, options = {})
link_to label, path, options.merge(class: active_nav_class(path))
end
def show_landing_strip?
!user_signed_in? && !single_user_mode?
end

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
module JsonLdHelper
def equals_or_includes?(haystack, needle)
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
end
def first_of_value(value)
value.is_a?(Array) ? value.first : value
end
def value_or_id(value)
value.is_a?(String) || value.nil? ? value : value['id']
end
def supported_context?(json)
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end
def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
graph.dump(:normalize)
end
def fetch_resource(uri)
response = build_request(uri).perform
return if response.code != 200
body_to_json(response.to_s)
end
def body_to_json(body)
body.is_a?(String) ? Oj.load(body, mode: :strict) : body
rescue Oj::ParseError
nil
end
def merge_context(context, new_context)
if context.is_a?(Array)
context << new_context
else
[context, new_context]
end
end
private
def build_request(uri)
request = Request.new(:get, uri)
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
request
end
end

View File

@@ -12,6 +12,8 @@ module RoutingHelper
end
def full_asset_url(source, options = {})
Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s
source = ActionController::Base.helpers.asset_url(source, options) unless Rails.configuration.x.use_s3
URI.join(root_url, source).to_s
end
end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
module StreamEntriesHelper
EMBEDDED_CONTROLLER = 'stream_entries'
EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed'
def display_name(account)

View File

@@ -1,93 +0,0 @@
/*
`actions/local_settings`
========================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides our Redux actions related to local settings. It
consists of the following:
- __`changesLocalSetting(key, value)` :__
Changes the local setting with the given `key` to the given
`value`. `key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
- __`saveLocalSettings()` :__
Saves the local settings to `localStorage` as a JSON object. We
shouldn't ever need to call this ourselves.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Constants:
----------
We provide the following constants:
- __`LOCAL_SETTING_CHANGE` :__
This string constant is used to dispatch a setting change to our
reducer in `reducers/local_settings`, where the setting is
actually changed.
*/
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`changeLocalSetting(key, value)`:
---------------------------------
Changes the local setting with the given `key` to the given `value`.
`key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our
reducer in `reducers/local_settings`.
*/
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`saveLocalSettings()`:
----------------------
Saves the local settings to `localStorage` as a JSON object.
`changeLocalSetting()` calls this whenever it changes a setting. We
shouldn't ever need to call this ourselves.
> __TODO :__
> Right now `saveLocalSettings()` doesn't keep track of which user
> is currently signed in, but it might be better to give each user
> their *own* local settings.
*/
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@@ -1,237 +0,0 @@
/*
`<AccountHeader>`
=================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. We've expanded it in order to handle user bio
frontmatter.
The `<AccountHeader>` component provides the header for account
timelines. It is a fairly simple component which mostly just consists
of a `render()` method.
__Props:__
- __`account` (`ImmutablePropTypes.map`) :__
The account to render a header for.
- __`me` (`PropTypes.number.isRequired`) :__
The id of the currently-signed-in account.
- __`onFollow` (`PropTypes.func.isRequired`) :__
The function to call when the user clicks the "follow" button.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
// Our imports //
import { processBio } from '../../util/bio_metadata';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `unfollow`, `follow`, and
`requested` messages used in the `title` of our buttons.
*/
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
@injectIntl
export default class AccountHeader extends ImmutablePureComponent {
static propTypes = {
account : ImmutablePropTypes.map,
me : PropTypes.number.isRequired,
onFollow : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
/*
### `render()`
The `render()` function is used to render our component.
*/
render () {
const { account, me, intl } = this.props;
/*
If no `account` is provided, then we can't render a header. Otherwise,
we get the `displayName` for the account, if available. If it's blank,
then we set the `displayName` to just be the `username` of the account.
*/
if (!account) {
return null;
}
let displayName = account.get('display_name');
let info = '';
let actionBtn = '';
let following = false;
if (displayName.length === 0) {
displayName = account.get('username');
}
/*
Next, we handle the account relationships. If the account follows the
user, then we add an `info` message. If the user has requested a
follow, then we disable the `actionBtn` and display an hourglass.
Otherwise, if the account isn't blocked, we set the `actionBtn` to the
appropriate icon.
*/
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'followed_by'])) {
info = (
<span className='account--follows-info'>
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
</span>
);
}
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
following = account.getIn(['relationship', 'following']);
actionBtn = (
<div className='account--action-button'>
<IconButton
size={26}
icon={following ? 'user-times' : 'user-plus'}
active={following}
title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
onClick={this.props.onFollow}
/>
</div>
);
}
}
/*
`displayNameHTML` processes the `displayName` and prepares it for
insertion into the document. Meanwhile, we extract the `text` and
`metadata` from our account's `note` using `processBio()`.
*/
const displayNameHTML = {
__html : emojify(escapeTextContentForBrowser(displayName)),
};
const { text, metadata } = processBio(account.get('note'));
/*
Here, we render our component using all the things we've defined above.
*/
return (
<div className='account__header__wrapper'>
<div
className='account__header'
style={{ backgroundImage: `url(${account.get('header')})` }}
>
<div>
<a href={account.get('url')} target='_blank' rel='noopener'>
<span className='account__header__avatar'>
<Avatar account={account} size={90} />
</span>
<span
className='account__header__display-name'
dangerouslySetInnerHTML={displayNameHTML}
/>
</a>
<span className='account__header__username'>
@{account.get('acct')}
{account.get('locked') ? <i className='fa fa-lock' /> : null}
</span>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
{info}
{actionBtn}
</div>
</div>
{metadata.length && (
<table className='account__metadata'>
<tbody>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<tr key={i}>
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
</tr>
);
}
return data;
})()}
</tbody>
</table>
) || null}
</div>
);
}
}

View File

@@ -1,113 +0,0 @@
// <CommonAvatar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/avatar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonAvatar extends React.PureComponent {
// Props and state.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
circular: PropTypes.bool,
className: PropTypes.string,
comrade: ImmutablePropTypes.map,
}
state = {
hovering: false,
}
// Starts or stops animation on hover.
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
}
// Renders the component.
render () {
const {
handleMouseEnter,
handleMouseLeave,
} = this;
const {
account,
animate,
circular,
className,
comrade,
...others
} = this.props;
const { hovering } = this.state;
const computedClass = classNames('glitch', 'glitch__common__avatar', {
_circular: circular,
}, className);
// We store the image srcs here for later.
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const comradeSrc = comrade ? comrade.get('avatar') : null;
const comradeStaticSrc = comrade ? comrade.get('avatar_static') : null;
// Avatars are a straightforward div with image(s) inside.
return comrade ? (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\main'
src={hovering || animate ? src : staticSrc}
alt=''
/>
<img
className='avatar\comrade'
src={hovering || animate ? comradeSrc : comradeStaticSrc}
alt=''
/>
</div>
) : (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\solo'
src={hovering || animate ? src : staticSrc}
alt=''
/>
</div>
);
}
}

View File

@@ -1,41 +0,0 @@
@import 'variables';
.glitch.glitch__common__avatar {
display: inline-block;
position: relative;
& > img {
display: block;
position: static;
margin: 0;
border-radius: $ui-avatar-border-size;
width: 100%;
height: 100%;
&.avatar\\comrade {
position: absolute;
right: 0;
bottom: 0;
width: 50%;
height: 50%;
}
&.avatar\\main {
margin: 0 30% 30% 0;
width: 70%;
height: 70%;
}
}
&._circular {
& > img {
transition: border-radius ($glitch-animation-speed * .3s);
}
&:not(:hover) {
& > img {
border-radius: 50%;
}
}
}
}

View File

@@ -1,146 +0,0 @@
// <CommonButton>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/button
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonIcon from 'glitch/components/common/icon';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonButton extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
animate: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
disabled: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
onClick: PropTypes.func,
showTitle: PropTypes.bool,
title: PropTypes.string,
}
state = {
loaded: false,
}
// The `loaded` state property activates our animations. We wait
// until an activation change in order to prevent unsightly
// animations when the component first mounts.
componentWillReceiveProps (nextProps) {
const { active } = this.props;
// The double "not"s here cast both arguments to booleans.
if (!nextProps.active !== !active) this.setState({ loaded: true });
}
handleClick = (e) => {
const { onClick } = this.props;
if (!onClick) return;
onClick(e);
e.preventDefault();
}
// Rendering the component.
render () {
const { handleClick } = this;
const {
active,
animate,
children,
className,
disabled,
href,
icon,
onClick,
showTitle,
title,
...others
} = this.props;
const { loaded } = this.state;
const computedClass = classNames('glitch', 'glitch__common__button', className, {
_active: active && !href, // Links can't be active
_animated: animate && loaded,
_disabled: disabled,
_link: href,
_star: icon === 'star',
'_with-text': children || title && showTitle,
});
let conditionalProps = {};
// If href is provided, we render a link.
if (href) {
if (!disabled && href) conditionalProps.href = href;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick) {
if (!disabled) conditionalProps.onClick = handleClick;
else conditionalProps['aria-disabled'] = true;
conditionalProps.role = 'button';
conditionalProps.tabIndex = 0;
}
return (
<CommonLink
className={computedClass}
{...conditionalProps}
{...others}
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</CommonLink>
);
// Otherwise, we render a button.
} else {
if (active !== void 0) conditionalProps['aria-pressed'] = active;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick && !disabled) {
conditionalProps.onClick = handleClick;
}
return (
<button
className={computedClass}
{...conditionalProps}
disabled={disabled}
{...others}
tabIndex='0'
type='button'
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</button>
);
}
};
}

View File

@@ -1,134 +0,0 @@
@import 'variables';
.glitch.glitch__common__button {
display: inline-block;
border: none;
padding: 0;
color: $ui-base-lighter-color;
background: transparent;
outline: thin transparent dotted;
font-size: inherit;
text-decoration: none;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
&._animated .button\\icon {
animation-name: glitch__common__button__deactivate;
animation-duration: .9s;
animation-timing-function: ease-in-out;
@keyframes glitch__common__button__deactivate {
from {
transform: rotate(360deg);
}
57% {
transform: rotate(-60deg);
}
86% {
transform: rotate(30deg);
}
to {
transform: rotate(0deg);
}
}
}
&._active {
.button\\icon {
color: $ui-highlight-color;
}
&._animated .button\\icon {
animation-name: glitch__common__button__activate;
@keyframes glitch__common__button__activate {
from {
transform: rotate(0deg);
}
57% {
transform: rotate(420deg); // Blazin' 😎
}
86% {
transform: rotate(330deg);
}
to {
transform: rotate(360deg);
}
}
}
/*
The special `._star` class is given to buttons which have a star
icon (see JS). When they are active, we give them a gold star ⭐️.
*/
&._star .button\\icon {
color: $gold-star;
}
}
/*
For links, we consider them disabled if they don't have an `href`
attribute (see JS).
*/
&._disabled {
opacity: $glitch-disabled-opacity;
cursor: default;
}
/*
This is confusing becuase of the names, but the `color .3 ease-out`
transition is actually used when easing *in* to a hovering/active/
focusing state, and the default transition is used when leaving. Our
buttons are a little slower to glow than they are to fade.
*/
&:active,
&:focus,
&:hover {
color: $glitch-lighter-color;
transition: color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
&:focus {
outline-color: currentColor;
}
/*
Buttons with text have a number of different styling rules and an
overall different appearance.
*/
&._with-text {
display: inline-block;
border: none;
border-radius: .35em;
padding: 0 .5em;
color: $glitch-texture-color;
background: $ui-base-lighter-color;
font-size: .75em;
font-weight: inherit;
text-transform: uppercase;
line-height: 1.6;
cursor: pointer;
vertical-align: baseline;
transition: background-color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
.button\\icon {
display: inline-block;
font-size: 1.25em;
vertical-align: -.1em;
}
& > *:not(:first-child) {
margin: 0 0 0 .4em;
border-left: 1px solid currentColor;
padding: 0 0 0 .3em;
}
&:active,
&:hover,
&:focus {
color: $glitch-texture-color;
background: $glitch-lighter-color;
transition: background-color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
}
}

View File

@@ -1,59 +0,0 @@
// <CommonIcon>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/icon
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonIcon = ({
className,
name,
proportional,
title,
...others
}) => name ? (
<span
className={classNames('glitch', 'glitch__common__icon', className)}
{...others}
>
<span
aria-hidden
className={`fa ${proportional ? '' : 'fa-fw'} fa-${name} icon\fa`}
{...(title ? { title } : {})}
/>
{title ? (
<span className='_for-screenreader'>{title}</span>
) : null}
</span>
) : null;
// Props.
CommonIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.string,
proportional: PropTypes.bool,
title: PropTypes.string,
};
// Export.
export default CommonIcon;

View File

@@ -1,14 +0,0 @@
@import 'variables';
.glitch.glitch__common__icon {
display: inline-block;
._for-screenreader {
position: absolute;
margin: -1px -1px;
width: 1px;
height: 1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
}
}

View File

@@ -1,74 +0,0 @@
// <CommonLink>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/link
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonLink extends React.PureComponent {
// Props.
static propTypes = {
children: PropTypes.node,
className: PropTypes.string,
destination: PropTypes.string,
history: PropTypes.object,
href: PropTypes.string,
};
// We only reroute the link if it is an unadorned click, we have
// access to the router, and there is somewhere to reroute it *to*.
handleClick = (e) => {
const { destination, history } = this.props;
if (!history || !destination || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
history.push(destination);
e.preventDefault();
}
// Rendering.
render () {
const { handleClick } = this;
const { children, className, destination, history, href, ...others } = this.props;
const computedClass = classNames('glitch', 'glitch__common__link', className);
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = handleClick;
} else if (destination) {
conditionalProps.onClick = handleClick;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else conditionalProps.role = 'presentation';
return (
<a
className={computedClass}
{...conditionalProps}
{...others}
rel='noopener'
target='_blank'
>{children}</a>
);
}
}

View File

@@ -1,11 +0,0 @@
@import 'variables';
/*
Most link styling happens elsewhere but we disable text-decoration
here.
*/
.glitch.glitch__common__link {
display: inline;
color: $ui-secondary-color;
text-decoration: none;
}

View File

@@ -1,49 +0,0 @@
// <CommonSeparator>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/separator
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonSeparator = ({
className,
visible,
...others
}) => visible ? (
<span
className={
classNames('glitch', 'glitch__common__separator', className)
}
{...others}
role='separator'
/> // Contents provided via CSS.
) : null;
// Props.
CommonSeparator.propTypes = {
className: PropTypes.string,
visible: PropTypes.bool,
};
// Export.
export default CommonSeparator;

View File

@@ -1,15 +0,0 @@
@import 'variables';
/*
The default contents for a separator is an interpunct, surrounded by
spaces. However, this can be changed using CSS selectors.
*/
.glitch.glitch__common__separator {
display: inline-block;
&::after {
display: inline-block;
padding: 0 .3em;
content: "·";
}
}

View File

@@ -1,66 +0,0 @@
/*
`<ComposeAdvancedOptionsContainer>`
===================================
This container connects `<ComposeAdvancedOptions>` to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose';
// Our imports //
import ComposeAdvancedOptions from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. The only property we care about is
`compose.advanced_options`.
*/
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We just need to provide a dispatch for
when an advanced option toggle changes.
*/
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(toggleComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

@@ -1,241 +0,0 @@
/*
`<ComposeAdvancedOptions>`
==========================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This adds an advanced options dropdown to the toot compose box, for
toggles that don't necessarily fit elsewhere.
__Props:__
- __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__
An Immutable map with the following values:
- __`do_not_federate` (`PropTypes.bool.isRequired`) :__
Specifies whether or not to federate the status.
- __`onChange` (`PropTypes.func.isRequired`) :__
The function to call when a toggle is changed. We pass this from
our container to the toggle.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
__State:__
- __`open` :__
This tells whether the dropdown is currently open or closed.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import ComposeAdvancedOptionsToggle from './toggle';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. These are the various titles and labels on our
toggles.
`iconStyle` styles the icon used for the dropdown button.
*/
const messages = defineMessages({
local_only_short :
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long :
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title :
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
const iconStyle = {
height : null,
lineHeight : '27px',
};
/*
Implementation:
---------------
*/
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values : ImmutablePropTypes.contains({
do_not_federate : PropTypes.bool.isRequired,
}).isRequired,
onChange : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
state = {
open: false,
};
/*
### `onToggleDropdown()`
This function toggles the opening and closing of the advanced options
dropdown.
*/
onToggleDropdown = () => {
this.setState({ open: !this.state.open });
};
/*
### `onGlobalClick(e)`
This function closes the advanced options dropdown if you click
anywhere else on the screen.
*/
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
}
/*
### `componentDidMount()`, `componentWillUnmount()`
This function closes the advanced options dropdown if you click
anywhere else on the screen.
*/
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
/*
### `setRef(c)`
`setRef()` stores a reference to the dropdown's `<div> in `this.node`.
*/
setRef = (c) => {
this.node = c;
}
/*
### `render()`
`render()` actually puts our component on the screen.
*/
render () {
const { open } = this.state;
const { intl, values } = this.props;
/*
The `options` array provides all of the available advanced options
alongside their icon, text, and name.
*/
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
];
/*
`anyEnabled` tells us if any of our advanced options have been enabled.
*/
const anyEnabled = values.some((enabled) => enabled);
/*
`optionElems` takes our `options` and creates
`<ComposeAdvancedOptionsToggle>`s out of them. We use the `name` of the
toggle as its `key` so that React can keep track of it.
*/
const optionElems = options.map((option) => {
return (
<ComposeAdvancedOptionsToggle
onChange={this.props.onChange}
active={values.get(option.name)}
key={option.name}
name={option.name}
shortText={intl.formatMessage(option.shortText)}
longText={intl.formatMessage(option.longText)}
/>
);
});
/*
Finally, we can render our component.
*/
return (
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className='advanced-options-dropdown__value'
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='ellipsis-h' active={open || anyEnabled}
size={18}
style={iconStyle}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{optionElems}
</div>
</div>
);
}
}

View File

@@ -1,103 +0,0 @@
/*
`<ComposeAdvancedOptionsToggle>`
================================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This creates the toggle used by `<ComposeAdvancedOptions>`.
__Props:__
- __`onChange` (`PropTypes.func`) :__
This provides the function to call when the toggle is
(de-?)activated.
- __`active` (`PropTypes.bool`) :__
This prop controls whether the toggle is currently active or not.
- __`name` (`PropTypes.string`) :__
This identifies the toggle, and is sent to `onChange()` when it is
called.
- __`shortText` (`PropTypes.string`) :__
This is a short string used as the title of the toggle.
- __`longText` (`PropTypes.string`) :__
This is a longer string used as a subtitle for the toggle.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import Toggle from 'react-toggle';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
shortText: PropTypes.string.isRequired,
longText: PropTypes.string.isRequired,
}
/*
### `onToggle()`
The `onToggle()` function simply calls the `onChange()` prop with the
toggle's `name`.
*/
onToggle = () => {
this.props.onChange(this.props.name);
}
/*
### `render()`
The `render()` function is used to render our component. We just render
a `<Toggle>` and place next to it our text.
*/
render() {
const { active, shortText, longText } = this.props;
return (
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.onToggle} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{shortText}</strong>
{longText}
</div>
</div>
);
}
}

View File

@@ -1,52 +0,0 @@
// <ListConversationContainer>
// =================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation/container
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import { connect } from 'react-redux';
// Mastodon imports.
import { fetchContext } from 'mastodon/actions/statuses';
// Our imports.
import ListConversation from '.';
// * * * * * * * //
// State mapping
// -------------
const mapStateToProps = (state, { id }) => {
return {
ancestors : state.getIn(['contexts', 'ancestors', id]),
descendants : state.getIn(['contexts', 'descendants', id]),
};
};
// * * * * * * * //
// Dispatch mapping
// ----------------
const mapDispatchToProps = (dispatch) => ({
fetch (id) {
dispatch(fetchContext(id));
},
});
// * * * * * * * //
// Connecting
// ----------
export default connect(mapStateToProps, mapDispatchToProps)(ListConversation);

View File

@@ -1,80 +0,0 @@
// <ListConversation>
// ====================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ScrollContainer from 'react-router-scroll';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import StatusContainer from 'glitch/components/status/container';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class ListConversation extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
ancestors: ImmutablePropTypes.list,
descendants: ImmutablePropTypes.list,
fetch: PropTypes.func.isRequired,
}
// If this is a detailed status, we should fetch its contents and
// context upon mounting.
componentWillMount () {
const { id, fetch } = this.props;
fetch(id);
}
// Similarly, if the component receives new props, we need to fetch
// the new status.
componentWillReceiveProps (nextProps) {
const { id, fetch } = this.props;
if (nextProps.id !== id) fetch(nextProps.id);
}
// We just render our status inside a column with its
// ancestors and decendants.
render () {
const { id, ancestors, descendants } = this.props;
return (
<ScrollContainer scrollKey='thread'>
<div className='glitch glitch__list__conversation scrollable'>
{ancestors && ancestors.size > 0 ? (
ancestors.map(
ancestor => <StatusContainer key={ancestor} id={ancestor} route />
)
) : null}
<StatusContainer key={id} id={id} detailed route />
{descendants && descendants.size > 0 ? (
descendants.map(
descendant => <StatusContainer key={descendant} id={descendant} route />
)
) : null}
</div>
</ScrollContainer>
);
}
};

View File

@@ -1,80 +0,0 @@
/*
`<NotificationPurgeButtonsContainer>`
=========================
This container connects `<NotificationPurgeButtons>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Our imports //
import NotificationPurgeButtons from './notification_purge_buttons';
import {
deleteMarkedNotifications,
enterNotificationClearingMode,
markAllNotifications,
} from '../../../../mastodon/actions/notifications';
import { defineMessages, injectIntl } from 'react-intl';
import { openModal } from '../../../../mastodon/actions/modal';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We only need to provide a dispatch for
deleting notifications.
*/
const messages = defineMessages({
clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
onDeleteMarked() {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(deleteMarkedNotifications()),
}));
},
onMarkAll() {
dispatch(markAllNotifications(true));
},
onMarkNone() {
dispatch(markAllNotifications(false));
},
onInvert() {
dispatch(markAllNotifications(null));
},
});
const mapStateToProps = state => ({
markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));

View File

@@ -1,62 +0,0 @@
/**
* Buttons widget for controlling the notification clearing mode.
* In idle state, the cleaning mode button is shown. When the mode is active,
* a Confirm and Abort buttons are shown in its place.
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
});
@injectIntl
export default class NotificationPurgeButtons extends ImmutablePureComponent {
static propTypes = {
onDeleteMarked : PropTypes.func.isRequired,
onMarkAll : PropTypes.func.isRequired,
onMarkNone : PropTypes.func.isRequired,
onInvert : PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
markNewForDelete: PropTypes.bool,
};
render () {
const { intl, markNewForDelete } = this.props;
//className='active'
return (
<div className='column-header__notif-cleaning-buttons'>
<button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnAll)}
</button>
<button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnNone)}
</button>
<button onClick={this.props.onInvert}>
<b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
</button>
<button onClick={this.props.onDeleteMarked}>
<i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)}
</button>
</div>
);
}
}

View File

@@ -1,24 +0,0 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { closeModal } from 'mastodon/actions/modal';
// Our imports //
import { changeLocalSetting } from 'glitch/actions/local_settings';
import LocalSettings from '.';
const mapStateToProps = state => ({
settings: state.get('local_settings'),
});
const mapDispatchToProps = dispatch => ({
onChange (setting, value) {
dispatch(changeLocalSetting(setting, value));
},
onClose () {
dispatch(closeModal());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);

View File

@@ -1,50 +0,0 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Our imports
import LocalSettingsPage from './page';
import LocalSettingsNavigation from './navigation';
// Stylesheet imports
import './style';
export default class LocalSettings extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
state = {
currentIndex: 0,
};
navigateTo = (index) =>
this.setState({ currentIndex: +index });
render () {
const { navigateTo } = this;
const { onChange, onClose, settings } = this.props;
const { currentIndex } = this.state;
return (
<div className='glitch modal-root__modal local-settings'>
<LocalSettingsNavigation
index={currentIndex}
onClose={onClose}
onNavigate={navigateTo}
/>
<LocalSettingsPage
index={currentIndex}
onChange={onChange}
settings={settings}
/>
</div>
);
}
}

View File

@@ -1,74 +0,0 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports
import LocalSettingsNavigationItem from './item';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
general: { id: 'settings.general', defaultMessage: 'General' },
collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
media: { id: 'settings.media', defaultMessage: 'Media' },
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
close: { id: 'settings.close', defaultMessage: 'Close' },
});
@injectIntl
export default class LocalSettingsNavigation extends React.PureComponent {
static propTypes = {
index : PropTypes.number,
intl : PropTypes.object.isRequired,
onClose : PropTypes.func.isRequired,
onNavigate : PropTypes.func.isRequired,
};
render () {
const { index, intl, onClose, onNavigate } = this.props;
return (
<nav className='glitch local-settings__navigation'>
<LocalSettingsNavigationItem
active={index === 0}
index={0}
onNavigate={onNavigate}
title={intl.formatMessage(messages.general)}
/>
<LocalSettingsNavigationItem
active={index === 1}
index={1}
onNavigate={onNavigate}
title={intl.formatMessage(messages.collapsed)}
/>
<LocalSettingsNavigationItem
active={index === 2}
index={2}
onNavigate={onNavigate}
title={intl.formatMessage(messages.media)}
/>
<LocalSettingsNavigationItem
active={index === 3}
href='/settings/preferences'
index={3}
icon='cog'
title={intl.formatMessage(messages.preferences)}
/>
<LocalSettingsNavigationItem
active={index === 4}
className='close'
index={4}
onNavigate={onClose}
title={intl.formatMessage(messages.close)}
/>
</nav>
);
}
}

View File

@@ -1,69 +0,0 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default class LocalSettingsPage extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
className: PropTypes.string,
href: PropTypes.string,
icon: PropTypes.string,
index: PropTypes.number.isRequired,
onNavigate: PropTypes.func,
title: PropTypes.string,
};
handleClick = (e) => {
const { index, onNavigate } = this.props;
if (onNavigate) {
onNavigate(index);
e.preventDefault();
}
}
render () {
const { handleClick } = this;
const {
active,
className,
href,
icon,
onNavigate,
title,
} = this.props;
const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
active,
}, className);
const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null;
if (href) return (
<a
href={href}
className={finalClassName}
>
{iconElem} {title}
</a>
);
else if (onNavigate) return (
<a
onClick={handleClick}
role='button'
tabIndex='0'
className={finalClassName}
>
{iconElem} {title}
</a>
);
else return null;
}
}

View File

@@ -1,27 +0,0 @@
@import 'variables';
.glitch.local-settings__navigation__item {
display: block;
padding: 15px 20px;
color: inherit;
background: $primary-text-color;
border-bottom: 1px $ui-primary-color solid;
cursor: pointer;
text-decoration: none;
outline: none;
transition: background .3s;
&:hover {
background: $ui-secondary-color;
}
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
}
&.close, &.close:hover {
background: $error-value-color;
color: $primary-text-color;
}
}

View File

@@ -1,10 +0,0 @@
@import 'variables';
.glitch.local-settings__navigation {
background: $primary-text-color;
color: $ui-base-color;
width: 200px;
font-size: 15px;
line-height: 20px;
overflow-y: auto;
}

View File

@@ -1,183 +0,0 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
// Our imports
import LocalSettingsPageItem from './item';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
});
@injectIntl
export default class LocalSettingsPage extends React.PureComponent {
static propTypes = {
index : PropTypes.number,
intl : PropTypes.object.isRequired,
onChange : PropTypes.func.isRequired,
settings : ImmutablePropTypes.map.isRequired,
};
pages = [
({ intl, onChange, settings }) => (
<div className='glitch local-settings__page general'>
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
onChange={onChange}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={onChange}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['navbar_under']}
id='mastodon-settings--navbar_under'
onChange={onChange}
>
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
</LocalSettingsPageItem>
</div>
),
({ onChange, settings }) => (
<div className='glitch local-settings__page collapsed'>
<h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'enabled']}
id='mastodon-settings--collapsed-enabled'
onChange={onChange}
>
<FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
</LocalSettingsPageItem>
<section>
<h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'all']}
id='mastodon-settings--collapsed-auto-all'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'notifications']}
id='mastodon-settings--collapsed-auto-notifications'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'lengthy']}
id='mastodon-settings--collapsed-auto-lengthy'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'replies']}
id='mastodon-settings--collapsed-auto-replies'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'media']}
id='mastodon-settings--collapsed-auto-media'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
</LocalSettingsPageItem>
</section>
<section>
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'backgrounds', 'user_backgrounds']}
id='mastodon-settings--collapsed-user-backgrouns'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'backgrounds', 'preview_images']}
id='mastodon-settings--collapsed-preview-images'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
</LocalSettingsPageItem>
</section>
</div>
),
({ onChange, settings }) => (
<div className='glitch local-settings__page media'>
<h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['media', 'letterbox']}
id='mastodon-settings--media-letterbox'
onChange={onChange}
>
<FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['media', 'fullwidth']}
id='mastodon-settings--media-fullwidth'
onChange={onChange}
>
<FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
</LocalSettingsPageItem>
</div>
),
];
render () {
const { pages } = this;
const { index, intl, onChange, settings } = this.props;
const CurrentPage = pages[index] || pages[0];
return <CurrentPage intl={intl} onChange={onChange} settings={settings} />;
}
}

View File

@@ -1,90 +0,0 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports
import './style';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default class LocalSettingsPageItem extends React.PureComponent {
static propTypes = {
children: PropTypes.element.isRequired,
dependsOn: PropTypes.array,
dependsOnNot: PropTypes.array,
id: PropTypes.string.isRequired,
item: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
})),
settings: ImmutablePropTypes.map.isRequired,
};
handleChange = e => {
const { target } = e;
const { item, onChange, options } = this.props;
if (options && options.length > 0) onChange(item, target.value);
else onChange(item, target.checked);
}
render () {
const { handleChange } = this;
const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
let enabled = true;
if (dependsOn) {
for (let i = 0; i < dependsOn.length; i++) {
enabled = enabled && settings.getIn(dependsOn[i]);
}
}
if (dependsOnNot) {
for (let i = 0; i < dependsOnNot.length; i++) {
enabled = enabled && !settings.getIn(dependsOnNot[i]);
}
}
if (options && options.length > 0) {
const currentValue = settings.getIn(item);
const optionElems = options && options.length > 0 && options.map((opt) => (
<option
key={opt.value}
value={opt.value}
>
{opt.message}
</option>
));
return (
<label className='glitch local-settings__page__item' htmlFor={id}>
<p>{children}</p>
<p>
<select
id={id}
disabled={!enabled}
onBlur={handleChange}
onChange={handleChange}
value={currentValue}
>
{optionElems}
</select>
</p>
</label>
);
} else return (
<label className='glitch local-settings__page__item' htmlFor={id}>
<input
id={id}
type='checkbox'
checked={settings.getIn(item)}
onChange={handleChange}
disabled={!enabled}
/>
{children}
</label>
);
}
}

View File

@@ -1,7 +0,0 @@
@import 'variables';
.glitch.local-settings__page__item {
select {
margin-bottom: 5px;
}
}

View File

@@ -1,9 +0,0 @@
@import 'variables';
.glitch.local-settings__page {
display: block;
flex: auto;
padding: 15px 20px 15px 20px;
width: 360px;
overflow-y: auto;
}

View File

@@ -1,34 +0,0 @@
@import 'variables';
.glitch.local-settings {
position: relative;
display: flex;
flex-direction: row;
background: $ui-secondary-color;
color: $ui-base-color;
border-radius: 8px;
height: 80vh;
width: 80vw;
max-width: 740px;
max-height: 450px;
overflow: hidden;
label {
display: block;
}
h1 {
font-size: 18px;
font-weight: 500;
line-height: 24px;
margin-bottom: 20px;
}
h2 {
font-size: 15px;
font-weight: 500;
line-height: 20px;
margin-top: 20px;
margin-bottom: 10px;
}
}

View File

@@ -1,56 +0,0 @@
/*
`<NotificationContainer>`
=========================
This container connects `<Notification>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { makeGetNotification } from '../../../mastodon/selectors';
// Our imports //
import Notification from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in `makeMapStateToProps()` so that
we only have to call `makeGetNotification()` once instead of every
time.
*/
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const mapStateToProps = (state, props) => ({
notification: getNotification(state, props.notification, props.accountId),
settings: state.get('local_settings'),
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
});
return mapStateToProps;
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default connect(makeMapStateToProps)(Notification);

View File

@@ -1,124 +0,0 @@
/*
`<NotificationFollow>`
======================
This component renders a follow notification.
__Props:__
- __`id` (`PropTypes.number.isRequired`) :__
This is the id of the notification.
- __`onDeleteNotification` (`PropTypes.func.isRequired`) :__
The function to call when a notification should be
dismissed/deleted.
- __`account` (`PropTypes.object.isRequired`) :__
The account associated with the follow notification, ie the account
which followed the user.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import Permalink from '../../../mastodon/components/permalink';
import AccountContainer from '../../../mastodon/containers/account_container';
// Our imports //
import NotificationOverlayContainer from '../notification/overlay/container';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
export default class NotificationFollow extends ImmutablePureComponent {
static propTypes = {
id : PropTypes.number.isRequired,
account : ImmutablePropTypes.map.isRequired,
notification : ImmutablePropTypes.map.isRequired,
};
/*
### `render()`
This actually renders the component.
*/
render () {
const { account, notification } = this.props;
/*
`link` is a container for the account's `displayName`, which links to
the account timeline using a `<Permalink>`.
*/
const displayName = account.get('display_name') || account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = (
<Permalink
className='notification__display-name'
href={account.get('url')}
title={account.get('acct')}
to={`/accounts/${account.get('id')}`}
dangerouslySetInnerHTML={displayNameHTML}
/>
);
/*
We can now render our component.
*/
return (
<div className='notification notification-follow'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={{ name: link }}
/>
</div>
<AccountContainer id={account.get('id')} withNote={false} />
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@@ -1,90 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
// Our imports //
import StatusContainer from '../status/container';
import NotificationFollow from './follow';
import NotificationOverlayContainer from './overlay/container';
export default class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
renderFollow (notification) {
return (
<NotificationFollow
id={notification.get('id')}
account={notification.get('account')}
notification={notification}
/>
);
}
renderMention (notification) {
return (
<StatusContainer
id={notification.get('status')}
notification={notification}
withDismiss
/>
);
}
renderFavourite (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='favourite'
muted
notification={notification}
withDismiss
/>
);
}
renderReblog (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='reblog'
muted
notification={notification}
withDismiss
/>
);
}
render () {
const { notification } = this.props;
return (
<div class='status'>
{(() => {
switch (notification.get('type')) {
case 'follow':
return this.renderFollow(notification);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification);
case 'reblog':
return this.renderReblog(notification);
default:
return null;
}
})()}
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@@ -1,39 +0,0 @@
// <NotificationOverlayContainer>
// ==============================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/notification/overlay/container
// * * * * * * * //
// Imports
// -------
// Package imports.
import { connect } from 'react-redux';
// Mastodon imports.
import { markNotificationForDelete } from 'mastodon/actions/notifications';
// Our imports.
import NotificationOverlay from './notification_overlay';
// State mapping
// -------------
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
// Dispatch mapping
// ----------------
const mapDispatchToProps = dispatch => ({
onMarkForDelete(id, yes) {
dispatch(markNotificationForDelete(id, yes));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View File

@@ -1,57 +0,0 @@
/**
* Notification overlay
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
});
@injectIntl
export default class NotificationOverlay extends ImmutablePureComponent {
static propTypes = {
notification : ImmutablePropTypes.map.isRequired,
onMarkForDelete : PropTypes.func.isRequired,
show : PropTypes.bool.isRequired,
intl : PropTypes.object.isRequired,
};
onToggleMark = () => {
const mark = !this.props.notification.get('markedForDelete');
const id = this.props.notification.get('id');
this.props.onMarkForDelete(id, mark);
}
render () {
const { notification, show, intl } = this.props;
const active = notification.get('markedForDelete');
const label = intl.formatMessage(messages.markForDeletion);
return show ? (
<div
aria-label={label}
role='checkbox'
aria-checked={active}
tabIndex={0}
className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
onClick={this.onToggleMark}
>
<div className='wrappy'>
<div className='ckbox' aria-hidden='true' title={label}>
{active ? (<i className='fa fa-check' />) : ''}
</div>
</div>
</div>
) : null;
}
}

View File

@@ -1,268 +0,0 @@
// <StatusActionBar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/action_bar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
delete:
{ id: 'status.delete', defaultMessage: 'Delete' },
mention:
{ id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute:
{ id: 'account.mute', defaultMessage: 'Mute @{name}' },
block:
{ id: 'account.block', defaultMessage: 'Block @{name}' },
reply:
{ id: 'status.reply', defaultMessage: 'Reply' },
replyAll:
{ id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog:
{ id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog:
{ id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite:
{ id: 'status.favourite', defaultMessage: 'Favourite' },
open:
{ id: 'status.open', defaultMessage: 'Expand this status' },
report:
{ id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation:
{ id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation:
{ id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
share:
{ id: 'status.share', defaultMessage: 'Share' },
more:
{ id: 'status.more', defaultMessage: 'More' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusActionBar extends ImmutablePureComponent {
// Props.
static propTypes = {
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
me: PropTypes.number,
status: ImmutablePropTypes.map.isRequired,
};
// These handle all of our actions.
handleReplyClick = () => {
const { handler, history, status } = this.props;
handler.reply(status, { history }); // hack
}
handleFavouriteClick = () => {
const { handler, status } = this.props;
handler.favourite(status);
}
handleReblogClick = (e) => {
const { handler, status } = this.props;
handler.reblog(status, e.shiftKey);
}
handleDeleteClick = () => {
const { handler, status } = this.props;
handler.delete(status);
}
handleMentionClick = () => {
const { handler, history, status } = this.props;
handler.mention(status.get('account'), { history }); // hack
}
handleMuteClick = () => {
const { handler, status } = this.props;
handler.mute(status.get('account'));
}
handleBlockClick = () => {
const { handler, status } = this.props;
handler.block(status.get('account'));
}
handleOpen = () => {
const { history, status } = this.props;
history.push(`/statuses/${status.get('id')}`);
}
handleReport = () => {
const { handler, status } = this.props;
handler.report(status);
}
handleShare = () => {
const { status } = this.props;
navigator.share({
text: status.get('search_index'),
url: status.get('url'),
});
}
handleConversationMuteClick = () => {
const { handler, status } = this.props;
handler.muteConversation(status);
}
// Renders our component.
render () {
const {
handleBlockClick,
handleConversationMuteClick,
handleDeleteClick,
handleFavouriteClick,
handleMentionClick,
handleMuteClick,
handleOpen,
handleReblogClick,
handleReplyClick,
handleReport,
handleShare,
} = this;
const { detailed, intl, me, status } = this.props;
const account = status.get('account');
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const reblogTitle = reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog);
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
let menu = [];
let replyIcon;
let replyTitle;
// This builds our menu.
if (!detailed) {
menu.push({
text: intl.formatMessage(messages.open),
action: handleOpen,
});
menu.push(null);
}
menu.push({
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
action: handleConversationMuteClick,
});
menu.push(null);
if (account.get('id') === me) {
menu.push({
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
});
} else {
menu.push({
text: intl.formatMessage(messages.mention, {
name: account.get('username'),
}),
action: handleMentionClick,
});
menu.push(null);
menu.push({
text: intl.formatMessage(messages.mute, {
name: account.get('username'),
}),
action: handleMuteClick,
});
menu.push({
text: intl.formatMessage(messages.block, {
name: account.get('username'),
}),
action: handleBlockClick,
});
menu.push({
text: intl.formatMessage(messages.report, {
name: account.get('username'),
}),
action: handleReport,
});
}
// This selects our reply icon.
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
// Now we can render the component.
return (
<div className='glitch glitch__status__action-bar'>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
title={replyTitle}
icon={replyIcon}
onClick={handleReplyClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess || reblogDisabled}
active={status.get('reblogged')}
title={reblogTitle}
icon='retweet'
onClick={handleReblogClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
animate
active={status.get('favourited')}
title={intl.formatMessage(messages.favourite)}
icon='star'
onClick={handleFavouriteClick}
/>
{
'share' in navigator ? (
<CommonButton
className='action-bar\button'
disabled={status.get('visibility') !== 'public'}
title={intl.formatMessage(messages.share)}
icon='share-alt'
onClick={handleShare}
/>
) : null
}
<div className='action-bar\button'>
<DropdownMenuContainer
items={menu}
disabled={anonymousAccess}
icon='ellipsis-h'
size={18}
direction='right'
aria-label={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
}
}

View File

@@ -1,28 +0,0 @@
@import 'variables';
.glitch.glitch__status__action-bar {
display: block;
height: 1.25em;
font-size: 1.25em;
line-height: 1;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
// Dropdown style override for centering on the icon
.dropdown--active {
position: relative;
.dropdown__content.dropdown__right {
left: calc(50% + 3px);
right: initial;
transform: translate(-50%, 0);
top: 22px;
}
&::after {
right: 1px;
bottom: -2px;
}
}
}

View File

@@ -1,212 +0,0 @@
// <StatusContainer>
// =================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/container
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { createStructuredSelector } from 'reselect';
// Mastodon imports.
import { blockAccount, muteAccount } from 'mastodon/actions/accounts';
import {
replyCompose,
mentionCompose,
} from 'mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
} from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { initReport } from 'mastodon/actions/reports';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from 'mastodon/actions/statuses';
import { fetchStatusCard } from 'mastodon/actions/cards';
// Our imports.
import Status from '.';
import makeStatusSelector from 'glitch/selectors/status';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
},
deleteConfirm : {
id : 'confirmations.delete.confirm',
defaultMessage : 'Delete',
},
deleteMessage : {
id : 'confirmations.delete.message',
defaultMessage : 'Are you sure you want to delete this status?',
},
muteConfirm : {
id : 'confirmations.mute.confirm',
defaultMessage : 'Mute',
},
});
// * * * * * * * //
// State mapping
// -------------
// We wrap our `mapStateToProps()` function in a
// `makeMapStateToProps()` to give us a closure and preserve
// `makeGetStatus()`'s value.
const makeMapStateToProps = () => {
const statusSelector = makeStatusSelector();
// State mapping.
return (state, ownProps) => {
let status = statusSelector(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let comrade = undefined;
let prepend = undefined;
// Processes reblogs and generates their prepend.
if (reblogStatus !== null && typeof reblogStatus === 'object') {
comrade = status.get('account');
status = reblogStatus;
prepend = 'reblogged';
}
// This is what we pass to <Status>.
return {
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
comrade: comrade || ownProps.comrade,
deleteModal: state.getIn(['meta', 'delete_modal']),
me: state.getIn(['meta', 'me']),
prepend: prepend || ownProps.prepend,
reblogModal: state.getIn(['meta', 'boost_modal']),
settings: state.get('local_settings'),
status: status,
};
};
};
// * * * * * * * //
// Dispatch mapping
// ----------------
const makeMapDispatchToProps = (dispatch) => {
const dispatchSelector = createStructuredSelector({
handler: ({ intl }) => ({
block (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
delete (status) {
if (!this.deleteModal) { // TODO: THIS IS BORKN (this refers to handler)
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
},
favourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
fetchCard (status) {
dispatch(fetchStatusCard(status.get('id')));
},
mention (account, router) {
dispatch(mentionCompose(account, router));
},
modalReblog (status) {
dispatch(reblog(status));
},
mute (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id'))),
}));
},
muteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
openMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
openVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
reblog (status, withShift) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (withShift || !this.reblogModal) { // TODO: THIS IS BORKN (this refers to handler)
this.modalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.modalReblog }));
}
}
},
reply (status, router) {
dispatch(replyCompose(status, router));
},
report (status) {
dispatch(initReport(status.get('account'), status));
},
}),
});
return (_, ownProps) => dispatchSelector(ownProps);
};
// * * * * * * * //
// Connecting
// ----------
// `connect` will only update when its resultant props change. So
// `withRouter` won't get called unless an update is already planned.
// This is intended behaviour because we only care about the (mutable)
// `history` object.
export default injectIntl(
connect(makeMapStateToProps, makeMapDispatchToProps)(
withRouter(Status)
)
);

View File

@@ -1,190 +0,0 @@
// <StatusContentCard>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/card
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import punycode from 'punycode';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import emojify from 'mastodon/emoji';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Reliably gets the hostname from a URL.
const getHostname = url => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
// * * * * * * * //
// The component
// -------------
export default class Card extends ImmutablePureComponent {
// Props.
static propTypes = {
card: ImmutablePropTypes.map.isRequired,
fullwidth: PropTypes.bool,
letterbox: PropTypes.bool,
}
// Rendering.
render () {
const { card, fullwidth, letterbox } = this.props;
let media = null;
let text = null;
let author = null;
let provider = null;
let caption = null;
// This gets all of our card properties.
const authorName = card.get('author_name');
const authorUrl = card.get('author_url');
const description = card.get('description');
const html = card.get('html');
const image = card.get('image');
const providerName = card.get('provider_name');
const providerUrl = card.get('provider_url');
const title = card.get('title');
const type = card.get('type');
const url = card.get('url');
// Sets our class.
const computedClass = classNames('glitch', 'glitch__status__content__card', type, {
_fullwidth: fullwidth,
_letterbox: letterbox,
});
// A card is required to render.
if (!card) return null;
// This generates our card media (image or video).
switch(type) {
case 'photo':
media = (
<CommonLink
className='card\media card\photo'
href={url}
>
<img
alt={title}
src={image}
/>
</CommonLink>
);
break;
case 'video':
media = (
<div
className='card\media card\video'
dangerouslySetInnerHTML={{ __html: html }}
/>
);
break;
}
// If we have at least a title or a description, then we can
// render some textual contents.
if (title || description) {
text = (
<CommonLink
className='card\description'
href={url}
>
{type === 'link' && image ? (
<div className='card\thumbnail'>
<img
alt=''
className='card\image'
src={image}
/>
</div>
) : null}
{title ? (
<h1 className='card\title'>{title}</h1>
) : null}
{emojify(description)}
</CommonLink>
);
}
// This creates links or spans (depending on whether a URL was
// provided) for the card author and provider.
if (authorUrl) {
author = (
<CommonLink
className='card\author card\link'
href={authorUrl}
>
{authorName ? authorName : punycode.toUnicode(getHostname(authorUrl))}
</CommonLink>
);
} else if (authorName) {
author = <span className='card\author'>{authorName}</span>;
}
if (providerUrl) {
provider = (
<CommonLink
className='card\provider card\link'
href={providerUrl}
>
{providerName ? providerName : punycode.toUnicode(getHostname(providerUrl))}
</CommonLink>
);
} else if (providerName) {
provider = <span className='card\provider'>{providerName}</span>;
}
// If we have either the author or the provider, then we can
// render an attachment.
if (author || provider) {
caption = (
<figcaption className='card\caption'>
{author}
<CommonSeparator
className='card\separator'
visible={author && provider}
/>
{provider}
</figcaption>
);
}
// Putting the pieces together and returning.
return (
<figure className={computedClass}>
{media}
{text}
{caption}
</figure>
);
}
}

View File

@@ -1,123 +0,0 @@
@import 'variables';
.glitch.glitch__content__card {
display: block;
border: thin $glitch-texture-color solid;
border-radius: .35em;
background: $glitch-darker-color;
.card\\caption {
color: $ui-primary-color;
background: $glitch-texture-color;
font-size: (1.25em / 1.35); // approx. .925em
.card\\link { // caption links
color: inherit;
&:hover {
text-decoration: underline;
}
}
}
.card\\media {
display: block;
position: relative;
width: 100%;
height: 13.5em;
/*
Our fallback styles letterbox the media, but we'll expand it to
fill the container if supported. This won't do anything for
`<iframe>`s, but we'll just have to trust them to manage their
own content.
*/
& > * {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.card\\description {
color: $ui-secondary-color;
background: $ui-base-color;
.card\\thumbnail {
position: relative;
float: left;
width: 6.75em;
height: 100%;
background: $glitch-darker-color;
& > img {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
/*
We have to divide the bottom margin of titles by their font-size to
get them to match what we use elsewhere.
*/
.card\\title {
margin-bottom: (.75em * 1.35 / 1.5);
font-size: 1.5em;
line-height: 1.125; // = 1.35 * (1.25 / 1.5)
}
}
&._fullwidth {
margin-left: -.75em;
width: calc(100% + 1.5em);
}
/*
If `letterbox` is specified, then we don't need object-fit (since
we essentially just do a scale-down).
*/
&._letterbox {
.card\\description .card\\thumbnail {
& > img {
width: auto;
height: auto;
object-fit: fill;
}
}
.card\\media {
& > * {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -1,191 +0,0 @@
// <StatusContentGallery>
// ======================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Our imports.
import StatusContentGalleryItem from './item';
import StatusContentGalleryPlayer from './player';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
hide: { id: 'media_gallery.hide_media', defaultMessage: 'Hide media' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGallery extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
autoPlayGif: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
};
state = {
visible: !this.props.sensitive,
};
// Handles media clicks.
handleMediaClick = index => {
const { attachments, onOpenMedia, standalone } = this.props;
if (standalone) return;
onOpenMedia(attachments, index);
}
// Handles showing and hiding.
handleToggle = () => {
this.setState({ visible: !this.state.visible });
}
// Handles video clicks.
handleVideoClick = time => {
const { attachments, onOpenVideo, standalone } = this.props;
if (standalone) return;
onOpenVideo(attachments.get(0), time);
}
// Renders.
render () {
const { handleMediaClick, handleToggle, handleVideoClick } = this;
const {
attachments,
autoPlayGif,
fullwidth,
intl,
letterbox,
sensitive,
} = this.props;
const { visible } = this.state;
const computedClass = classNames('glitch', 'glitch__status__content__gallery', {
_fullwidth: fullwidth,
});
const useableAttachments = attachments.take(4);
let button;
let children;
let size;
// This handles hidden media
if (!this.state.visible) {
button = (
<CommonButton
active
className='gallery\sensitive gallery\curtain'
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
>
<span className='gallery\message'>
<strong className='gallery\warning'>
{sensitive ? (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
) : (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
)}
</strong>
<FormattedMessage
defaultMessage='Click to view'
id='status.sensitive_toggle'
/>
</span>
</CommonButton>
); // No children with hidden media
// If our media is visible, then we render it alongside the
// "eyeball" button.
} else {
button = (
<CommonButton
className='gallery\sensitive gallery\button'
icon={visible ? 'eye' : 'eye-slash'}
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
/>
);
// If our first item is a video, we render a player. Otherwise,
// we render our images.
if (attachments.getIn([0, 'type']) === 'video') {
size = 1;
children = (
<StatusContentGalleryPlayer
attachment={attachments.get(0)}
autoPlayGif={autoPlayGif}
intl={intl}
letterbox={letterbox}
onClick={handleVideoClick}
/>
);
} else {
size = useableAttachments.size;
children = useableAttachments.map(
(attachment, index) => (
<StatusContentGalleryItem
attachment={attachment}
autoPlayGif={autoPlayGif}
gallerySize={size}
index={index}
intl={intl}
key={attachment.get('id')}
letterbox={letterbox}
onClick={handleMediaClick}
/>
)
);
}
}
// Renders the gallery.
return (
<div
className={computedClass}
style={{ height: `${this.props.height}px` }}
>
{button}
{children}
</div>
);
}
}

View File

@@ -1,141 +0,0 @@
// <StatusContentGalleryItem>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/item
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
expand: { id: 'media_gallery.expand', defaultMessage: 'Expand image' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryItem extends ImmutablePureComponent {
// Props.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
gallerySize: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};
// Click handling.
handleClick = this.props.onClick.bind(this, this.props.index);
// Item rendering.
render () {
const { handleClick } = this;
const {
attachment,
autoPlayGif,
gallerySize,
intl,
letterbox,
} = this.props;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let thumbnail = '';
const computedClass = classNames('glitch', 'glitch__status__content__gallery__item', {
_letterbox: letterbox,
});
// If our gallery has more than one item, our images only take up
// half the width. We need this for image `sizes` calculations.
let multiplier = gallerySize === 1 ? 1 : .5;
// Image attachments
if (attachment.get('type') === 'image') {
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
// This lets the browser conditionally select the preview or
// original image depending on what the rendered size ends up
// being. We, of course, have no way of knowing what the width
// of the gallery will be postCSS, but we conservatively roll
// with 400px. (Note: Upstream Mastodon used media queries here,
// but because our page layout is user-configurable, we don't
// bother.)
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `${(400 * multiplier) >> 0}px`;
// The image.
thumbnail = (
<img
alt=''
className='item\image'
sizes={sizes}
src={previewUrl}
srcSet={srcSet}
/>
);
// Gifv attachments.
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<video
autoPlay={autoPlay}
className='item\gifv'
loop
muted
poster={previewUrl}
src={originalUrl}
/>
);
}
// Rendering. We render the item inside of a button+link, which
// provides the original. (We can do this for gifvs because we
// don't show the controls.)
return (
<CommonButton
className={computedClass}
data-gallery-size={gallerySize}
href={remoteUrl || originalUrl}
key={attachment.get('id')}
onClick={handleClick}
title={intl.formatMessage(messages.expand)}
>{thumbnail}</CommonButton>
);
}
}

View File

@@ -1,88 +0,0 @@
@import 'variables';
.glitch.glitch__status__content__gallery__item {
display: inline-block;
position: relative;
width: 100%;
height: 100%;
cursor: zoom-in;
.item\\image,
.item\\gifv {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.letterbox {
.item\\image,
.item\\gifv {
width: auto;
height: auto;
object-fit: fill;
}
}
&[data-gallery-size="2"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:last-child {
margin: .375em .375em .375em .1875em;
}
}
&[data-gallery-size="3"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:nth-last-child(2) {
float: right;
height: calc(50% - .5625em);
margin: .375em .375em .1875em .1875em;
}
&:last-child {
float: right;
height: calc(50% - .5625em);
margin: .1875em .375em .1875em .375em;
}
}
&[data-gallery-size="4"] {
width: calc(50% - .5625em);
height: calc(50% - .5625em);
margin: .375em .1875em .1875em .375em;
&:nth-last-child(3) {
margin: .375em .375em .1875em .1875em;
}
&:nth-last-child(2) {
margin: .1875em .1875em .375em .375em;
}
&:last-child {
margin: .1875em .375em .375em .1875em;
}
}
}
// add GIF label in CSS

View File

@@ -1,233 +0,0 @@
// <StatusContentGalleryPlayer>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/player
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
mute: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
open: { id: 'video_player.open', defaultMessage: 'Open video' },
play: { id: 'video_player.play', defaultMessage: 'Play video' },
pause: { id: 'video_player.pause', defaultMessage: 'Pause video' },
expand: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryPlayer extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
}
state = {
hasAudio: true,
muted: true,
preview: !isIOS() && this.props.autoPlayGif,
videoError: false,
}
// Basic video controls.
handleMute = () => {
this.setState({ muted: !this.state.muted });
}
handlePlayPause = () => {
const { video } = this;
if (video.paused) {
video.play();
} else {
video.pause();
}
}
// When clicking we either open (de-preview) the video or we
// expand it, depending. Note that when we de-preview the video will
// also begin playing (except on iOS) due to its `autoplay`
// attribute.
handleClick = () => {
const { setState, video } = this;
const { onClick } = this.props;
const { preview } = this.state;
if (preview) setState({ preview: false });
else {
video.pause();
onClick(video.currentTime);
}
}
// Loading and errors. We have to do some hacks in order to check if
// the video has audio imo. There's probably a better way to do this
// but that's how upstream has it.
handleLoadedData = () => {
const { video } = this;
if (('WebkitAppearance' in document.documentElement.style && video.audioTracks.length === 0) || video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
// On mounting or update, we ensure our video has the needed event
// listeners. We can't necessarily do this right away because there
// might be a preview up.
componentDidMount () {
this.componentDidUpdate();
}
componentDidUpdate () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('error', handleVideoError);
}
// On unmounting, we remove the listeners from the video element.
componentWillUnmount () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('error', handleVideoError);
}
// Getting a reference to our video.
setRef = (c) => {
this.video = c;
}
// Rendering.
render () {
const {
handleClick,
handleMute,
handlePlayPause,
setRef,
video,
} = this;
const {
attachment,
letterbox,
intl,
} = this.props;
const {
hasAudio,
muted,
preview,
videoError,
} = this.state;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let content = null;
const computedClass = classNames('glitch', 'glitch__status__content__gallery__player', {
_letterbox: letterbox,
});
// This gets our content: either a preview image, an error
// message, or the video.
switch (true) {
case preview:
content = (
<img
alt=''
className='player\preview'
src={previewUrl}
/>
);
break;
case videoError:
content = (
<span className='player\error'>
<FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' />
</span>
);
break;
default:
content = (
<video
autoPlay={!isIOS()}
className='player\video'
loop
muted={muted}
poster={previewUrl}
ref={setRef}
src={originalUrl}
/>
);
break;
}
// Everything goes inside of a button because everything is a
// button. This is okay wrt the video element because it doesn't
// have controls.
return (
<div className={computedClass}>
<CommonButton
className='player\box'
href={remoteUrl || originalUrl}
key='box'
onClick={handleClick}
title={intl.formatMessage(preview ? messages.open : messages.expand)}
>{content}</CommonButton>
{!preview ? (
<CommonButton
active={!video.paused}
className='player\play-pause player\button'
icon={video.paused ? 'play' : 'pause'}
key='play'
onClick={handlePlayPause}
title={intl.formatMessage(messages.play)}
/>
) : null}
{!preview && hasAudio ? (
<CommonButton
active={!muted}
className='player\mute player\button'
icon={muted ? 'volume-off' : 'volume-up'}
key='mute'
onClick={handleMute}
title={intl.formatMessage(messages.mute)}
/>
) : null}
</div>
);
}
}

View File

@@ -1,71 +0,0 @@
@import 'variables';
.glitch.glitch__status__content__gallery__player {
display: block;
padding: (1.5em * 1.35) 0; // Creates black bars at the bottom/top
width: 100%;
height: calc(100% - (1.5em * 1.35 * 2));
cursor: zoom-in;
.player\\box {
display: block;
position: relative;
width: 100%;
height: 100%;
& > img,
& > video {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.player\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&.player\\play-pause {
bottom: 0;
left: 0;
}
&.player\\mute {
bottom: 0;
right: 0;
}
}
&._letterbox {
.player\\box {
& > img,
& > video {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -1,74 +0,0 @@
@import 'variables';
.glitch.glitch__status__content__gallery {
display: block;
position: relative;
color: $ui-primary-color;
background: $base-shadow-color;
.gallery\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&:hover {
opacity: 1;
}
&.gallery\\sensitive {
top: 0;
left: 0;
}
}
.gallery\\curtain.gallery\\sensitive {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
padding: 0;
color: $ui-secondary-color;
background: $base-overlay-background;
font-size: (1.25em / 1.35); // approx. .925em
line-height: 1.35;
text-align: center;
white-space: nowrap;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in;
.gallery\\message {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 2.6em;
margin: auto;
.gallery\\warning {
display: block;
font-size: (1.35em / 1.25);
line-height: 1.35;
}
}
&:active,
&:focus,
&:hover {
color: $primary-text-color;
background: $base-overlay-background; // No change
transition: color ($glitch-animation-speed * .3s) ease-out;
}
}
}

View File

@@ -1,520 +0,0 @@
// <StatusContent>
// ===============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isRtl } from 'mastodon/rtl';
// Our imports.
import StatusContentCard from './card';
import StatusContentGallery from './gallery';
import StatusContentUnknown from './unknown';
import CommonButton from 'glitch/components/common/button';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
card_link :
{ id: 'status.card', defaultMessage: 'Card' },
video_link :
{ id: 'status.video', defaultMessage: 'Video' },
image_link :
{ id: 'status.image', defaultMessage: 'Image' },
unknown_link :
{ id: 'status.unknown_attachment', defaultMessage: 'Unknown attachment' },
hashtag :
{ id: 'status.hashtag', defaultMessage: 'Hashtag @{name}' },
show_more :
{ id: 'status.show_more', defaultMessage: 'Show more' },
show_less :
{ id: 'status.show_less', defaultMessage: 'Show less' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContent extends ImmutablePureComponent {
// Props and state.
static propTypes = {
autoPlayGif: PropTypes.bool,
detailed: PropTypes.bool,
expanded: PropTypes.oneOf([true, false, null]),
handler: PropTypes.object.isRequired,
hideMedia: PropTypes.bool,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func,
onHeightUpdate: PropTypes.func,
setExpansion: PropTypes.func,
status: ImmutablePropTypes.map.isRequired,
}
state = {
hidden: true,
}
// Variables.
text = null
// Our constructor preprocesses our status content and turns it into
// an array of React elements, stored in `this.text`.
constructor (props) {
super(props);
const { intl, history, status } = props;
// This creates a document fragment with the DOM contents of our
// status's text and a TreeWalker to walk them.
const range = document.createRange();
range.selectNode(document.body);
const walker = document.createTreeWalker(
range.createContextualFragment(status.get('contentHtml')),
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{ acceptNode (node) {
const name = node.nodeName;
switch (true) {
case node.parentElement && node.parentElement.nodeName.toUpperCase() === 'A':
return NodeFilter.FILTER_REJECT; // No link children
case node.nodeType === Node.TEXT_NODE:
case name.toUpperCase() === 'A':
case name.toUpperCase() === 'P':
case name.toUpperCase() === 'BR':
case name.toUpperCase() === 'IMG': // Emoji
return NodeFilter.FILTER_ACCEPT;
default:
return NodeFilter.FILTER_SKIP;
}
} },
);
const attachments = status.get('attachments');
const card = (!attachments || !attachments.size) && status.get('card');
this.text = [];
let currentP = [];
// This walks the contents of our status.
while (walker.nextNode()) {
const node = walker.currentNode;
const nodeName = node.nodeName.toUpperCase();
switch (nodeName) {
// If our element is a link, then we process it here.
case 'A':
currentP.push((() => {
// Here we detect what kind of link we're dealing with.
let mention = status.get('mentions') ? status.get('mentions').find(
item => node.href === item.get('url')
) : null;
let tag = status.get('tags') ? status.get('tags').find(
item => node.href === item.get('url')
) : null;
let attachment = attachments ? attachments.find(
item => node.href === item.get('url') || node.href === item.get('text_url') || node.href === item.get('remote_url')
) : null;
let text = node.textContent;
let icon = '';
let type = '';
// We use a switch to select our link type.
switch (true) {
// This handles cards.
case card && node.href === card.get('url'):
text = card.get('title') || intl.formatMessage(messages.card);
icon = 'id-card-o';
return (
<CommonButton
className={'content\card content\button'}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles mentions.
case mention && (text.replace(/^@/, '') === mention.get('username') || text.replace(/^@/, '') === mention.get('acct')):
icon = text[0] === '@' ? '@' : '';
text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={node.href}
key={currentP.length}
title={'@' + mention.get('acct')}
>
{icon ? <span className='content\at'>{icon}</span> : null}
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
// This handles attachment links.
case !!attachment:
type = attachment.get('type');
switch (type) {
case 'unknown':
text = intl.formatMessage(messages.unknown_attachment);
icon = 'question';
break;
case 'video':
text = intl.formatMessage(messages.video);
icon = 'video-camera';
break;
default:
text = intl.formatMessage(messages.image);
icon = 'picture-o';
break;
}
return (
<CommonButton
className={`content\\${type} content\\button`}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles hashtag links.
case !!tag && (text.replace(/^#/, '') === tag.get('name')):
icon = text[0] === '#' ? '#' : '';
text = tag.get('name');
return (
<CommonLink
className='content\tag content\link'
destination={`/timelines/tag/${tag.get('name')}`}
history={history}
href={node.href}
key={currentP.length}
title={intl.formatMessage(messages.hashtag, { name: tag.get('name') })}
>
{icon ? <span className='content\hash'>{icon}</span> : null}
<span className='content\tagname'>{text}</span>
</CommonLink>
);
// This handles all other links.
default:
if (text === node.href && text.length > 23) {
text = text.substr(0, 22) + '…';
}
return (
<CommonLink
className='content\link'
href={node.href}
key={currentP.length}
title={node.href}
>{text}</CommonLink>
);
}
})());
break;
// If our element is an IMG, we only render it if it's an emoji.
case 'IMG':
if (!node.classList.contains('emojione')) break;
currentP.push(
<img
alt={node.alt}
className={'content\emojione'}
draggable={false}
key={currentP.length}
src={node.src}
{...(node.title ? { title: node.title } : {})}
/>
);
break;
// If our element is a BR, we pass it along.
case 'BR':
currentP.push(<br key={currentP.length} />);
break;
// If our element is a P, then we need to start a new paragraph.
// If our paragraph has content, we need to push it first.
case 'P':
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
currentP = [];
break;
// Otherwise we just push the text.
default:
currentP.push(node.textContent);
}
}
// If there is unpushed paragraph content after walking the entire
// status contents, we push it here.
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
}
// When our content changes, we need to update the height of the
// status.
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
// When the mouse is pressed down, we grab its position.
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
// When the mouse is raised, we handle the click if it wasn't a part
// of a drag.
handleMouseUp = (e) => {
const { startXY } = this;
const { onClick } = this.props;
const { button, clientX, clientY, target } = e;
// This gets the change in mouse position. If `startXY` isn't set,
// it means that the click originated elsewhere.
if (!startXY) return;
const [ deltaX, deltaY ] = [clientX - startXY[0], clientY - startXY[1]];
// This switch prevents an overly lengthy if.
switch (true) {
// If the button being released isn't the main mouse button, or if
// we don't have a click parsing function, or if the mouse has
// moved more than 5px, OR if the target of the mouse event is a
// button or a link, we do nothing.
case button !== 0:
case !onClick:
case Math.sqrt(deltaX ** 2 + deltaY ** 2) >= 5:
case (
target.matches || target.msMatchesSelector || target.webkitMatchesSelector || (() => void 0)
).call(target, 'button, button *, a, a *'):
break;
// Otherwise, we parse the click.
default:
onClick(e);
break;
}
// This resets our mouse location.
this.startXY = null;
}
// This expands and collapses our spoiler.
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
// Renders our component.
render () {
const {
handleMouseDown,
handleMouseUp,
handleSpoilerClick,
text,
} = this;
const {
autoPlayGif,
detailed,
expanded,
handler,
hideMedia,
intl,
letterbox,
onClick,
setExpansion,
status,
} = this.props;
const attachments = status.get('attachments');
const card = status.get('card');
const hidden = setExpansion ? !expanded : this.state.hidden;
const computedClass = classNames('glitch', 'glitch__status__content', {
_actionable: !detailed && onClick,
_rtl: isRtl(status.get('search_index')),
});
let media = null;
let mediaIcon = '';
// This defines our media.
if (!hideMedia) {
// If there aren't any attachments, we try showing a card.
if ((!attachments || !attachments.size) && card) {
media = (
<StatusContentCard
card={card}
className='content\attachments content\card'
fullwidth={detailed}
letterbox={letterbox}
/>
);
mediaIcon = 'id-card-o';
// If any of the attachments are of unknown type, we render an
// unknown attachments list.
} else if (attachments && attachments.some(
(item) => item.get('type') === 'unknown'
)) {
media = (
<StatusContentUnknown
attachments={attachments}
className='content\attachments content\unknown'
fullwidth={detailed}
/>
);
mediaIcon = 'question';
// Otherwise, we display the gallery.
} else if (attachments) {
media = (
<StatusContentGallery
attachments={attachments}
autoPlayGif={autoPlayGif}
className='content\attachments content\gallery'
fullwidth={detailed}
intl={intl}
letterbox={letterbox}
onOpenMedia={handler.openMedia}
onOpenVideo={handler.openVideo}
sensitive={status.get('sensitive')}
standalone={!history}
/>
);
mediaIcon = attachments.getIn([0, 'type']) === 'video' ? 'film' : 'picture-o';
}
}
// Spoiler stuff.
if (status.get('spoiler_text').length > 0) {
// This gets our list of mentions.
const mentionLinks = status.get('mentions').map(mention => {
const text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={mention.get('url')}
key={mention.get('id')}
title={'@' + mention.get('acct')}
>
<span className='content\at'>@</span>
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
}).reduce((aggregate, item) => [...aggregate, ' ', item], []);
// Component rendering.
return (
<div className={computedClass}>
<div
className='content\spoiler'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>
<p>
<span
className='content\warning'
dangerouslySetInnerHTML={status.get('spoilerHtml')}
/>
{' '}
<CommonButton
active={!hidden}
className='content\showmore'
icon={hidden && mediaIcon}
onClick={handleSpoilerClick}
showTitle={hidden}
title={intl.formatMessage(messages.show_more)}
>
{hidden ? null : (
<FormattedMessage {...messages.show_less} />
)}
</CommonButton>
</p>
</div>
{hidden ? mentionLinks : null}
<div className='content\contents' hidden={hidden}>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
// Non-spoiler statuses.
} else {
return (
<div className={computedClass}>
<div className='content\contents'>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
}
}
}

View File

@@ -1,101 +0,0 @@
@import 'variables';
.glitch.glitch__status__content {
position: relative;
padding: (.75em * 1.35) .75em;
color: $primary-text-color;
direction: ltr; // but see `&.rtl` below
word-wrap: break-word;
overflow: visible;
white-space: pre-wrap;
.content\\contents {
.content\\attachments {
.content\\text + & {
margin-top: (.75em * 1.35);
}
}
&[hidden] {
display: none;
}
.content\\spoiler + & {
margin-top: (.75em * 1.35);
}
}
.content\\emojione {
width: 1.2em;
height: 1.2em;
}
.content\\spoiler,
.content\\text { // text-containing elements
p {
margin-bottom: (.75em * 1.35);
&:last-child {
margin-bottom: 0;
}
}
.content\\link {
color: $ui-secondary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
/*
For mentions, we only underline the username and instance (not
the @'s).
*/
&.content\\mention {
.content\\at {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\instance,
.content\\username {
text-decoration: underline;
}
}
}
/*
Similarly, for tags, we only underline the tag name (not the
hash).
*/
&.content\\tag {
.content\\hash {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\tagname {
text-decoration: underline;
}
}
}
}
}
&._actionable {
.content\\text,
.content\\spoiler {
cursor: pointer;
}
}
&._rtl {
direction: rtl;
}
}

View File

@@ -1,70 +0,0 @@
// <StatusContentUnknown>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/unknown
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusContentUnknown extends ImmutablePureComponent {
// Props.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
fullwidth: PropTypes.bool,
}
render () {
const { attachments, fullwidth } = this.props;
const computedClass = classNames('glitch', 'glitch__status__content__unknown', {
_fullwidth: fullwidth,
});
return (
<ul className={computedClass}>
{attachments.map(attachment => (
<li
className='unknown\attachment'
key={attachment.get('id')}
>
<CommonLink
className='unknown\link'
href={attachment.get('remote_url')}
>
<CommonIcon
className='unknown\icon'
name='link'
/>
{attachment.get('title') || attachment.get('remote_url')}
</CommonLink>
</li>
))}
</ul>
);
}
}

View File

@@ -1,141 +0,0 @@
// <StatusFooter>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/footer
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedDate } from 'react-intl';
// Mastodon imports.
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
public :
{ id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted :
{ id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private :
{ id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct :
{ id: 'privacy.direct.short', defaultMessage: 'Direct' },
permalink:
{ id: 'status.permalink', defaultMessage: 'Permalink' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusFooter extends ImmutablePureComponent {
// Props.
static propTypes = {
application: ImmutablePropTypes.map.isRequired,
datetime: PropTypes.string,
detailed: PropTypes.bool,
href: PropTypes.string,
intl: PropTypes.object.isRequired,
visibility: PropTypes.string,
}
// Rendering.
render () {
const { application, datetime, detailed, href, intl, visibility } = this.props;
const visibilityIcon = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
const computedClass = classNames('glitch', 'glitch__status__footer', {
_detailed: detailed,
});
// If our status isn't detailed, our footer only contains the
// relative timestamp and visibility information.
if (!detailed) return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp footer\link'
href={href}
title={intl.formatMessage(messages.permalink)}
><RelativeTimestamp timestamp={datetime} /></CommonLink>
<CommonSeparator className='footer\separator' visible />
<CommonIcon
className='footer\icon'
name={visibilityIcon}
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
// Otherwise, we give the full timestamp and include a link to the
// application which posted the status if applicable.
return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp'
href={href}
title={intl.formatMessage(messages.permalink)}
>
<FormattedDate
value={new Date(datetime)}
hour12={false}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</CommonLink>
<CommonSeparator className='footer\separator' visible={!!application} />
{
application ? (
<CommonLink
className='footer\application footer\link'
href={application.get('website')}
>{application.get('name')}</CommonLink>
) : null
}
<CommonSeparator className='footer\separator' visible />
<CommonIcon
name={visibilityIcon}
className='footer\icon'
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
}
}

View File

@@ -1,18 +0,0 @@
@import 'variables';
.glitch.glitch__status__footer {
display: block;
height: 1.25em;
font-size: (1.25em / 1.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.footer\\link {
color: inherit;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,76 +0,0 @@
// <StatusHeader>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonAvatar from 'glitch/components/common/avatar';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component:
// --------------
export default class StatusHeader extends ImmutablePureComponent {
// Props.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
comrade: ImmutablePropTypes.map,
history: PropTypes.object,
};
// Renders our component.
render () {
const {
account,
comrade,
history,
} = this.props;
// This displays our header.
return (
<header className='glitch glitch__status__header'>
<CommonLink
className='header\link'
destination={`/accounts/${account.get('id')}`}
history={history}
href={account.get('url')}
>
<CommonAvatar
account={account}
className='header\avatar'
comrade={comrade}
/>
</CommonLink>
<b
className='header\display-name'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
<code className='header\account'>@{account.get('acct')}</code>
</header>
);
}
}

View File

@@ -1,45 +0,0 @@
@import 'variables';
.glitch.glitch__status__header {
display: block;
height: 3.35em;
/*
Note that the computed value of `em` changes for `.account`, since it
has a different font-size.
*/
.header\\account,
.header\\display-name {
display: block;
border: none; // masto compat.
padding: 0; // masto compat.
max-width: none; // masto compat.
height: 1.35em;
overflow: hidden;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
This means that the heights of the account and display name together
are 2.6em.
*/
.header\\account {
font-size: (1.25em / 1.35); // approx. .925em
}
.header\\avatar {
float: left;
margin-right: .75em;
width: 3.35em;
height: 3.35em;
}
.header\\display-name {
padding-top: .75em;
}
}

View File

@@ -1,392 +0,0 @@
// <Status>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
// Our imports.
import StatusActionBar from './action_bar';
import StatusContent from './content';
import StatusFooter from './footer';
import StatusHeader from './header';
import StatusMissing from './missing';
import StatusNav from './nav';
import StatusPrepend from './prepend';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
detailed:
{ id: 'status.detailed', defaultMessage: 'Detailed view' },
});
// * * * * * * * //
// The component
// -------------
export default class Status extends ImmutablePureComponent {
// Props, and state.
static propTypes = {
autoPlayGif: PropTypes.bool,
comrade: ImmutablePropTypes.map,
deleteModal: PropTypes.bool,
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
index: PropTypes.number,
id: PropTypes.number,
listLength: PropTypes.number,
me: PropTypes.number,
muted: PropTypes.bool,
prepend: PropTypes.string,
reblogModal: PropTypes.bool,
setDetail: PropTypes.func,
settings: ImmutablePropTypes.map,
status: ImmutablePropTypes.map,
intersectionObserverWrapper: PropTypes.object,
intl : PropTypes.object,
}
state = {
isExpanded: null,
isIntersecting: true,
isHidden: false,
}
// Instance variables.
componentMounted = false;
// Prior to mounting, we fetch the status's card if this is a
// detailed status and we don't already have it.
componentWillMount () {
const { detailed, handler, status } = this.props;
if (!status.get('card') && detailed) handler.fetchCard(status);
}
// On mounting, we start up our intersection observer.
// `componentMounted` tells us everything worked out OK.
componentDidMount () {
const { handleIntersection, node } = this;
const { id, intersectionObserverWrapper } = this.props;
if (!intersectionObserverWrapper) return;
else intersectionObserverWrapper.observe(
id,
node,
handleIntersection
);
this.componentMounted = true;
}
// If the status is about to be both offscreen (not intersecting)
// and hidden, then we don't bother updating unless it's not already
// that way currently. Alternatively, if we're moving from offscreen
// to onscreen, we *have* to re-render. As a default case we just
// rely on `updateOnProps` and `updateOnStates` via the
// built-in `shouldComponentUpdate()` function.
shouldComponentUpdate (nextProps, nextState) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
switch (true) {
case this.state.isIntersecting:
case !this.state.isHidden:
case nextProps.listLength !== this.props.listLength:
case nextProps.index !== this.props.index:
return true;
default:
return false;
}
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
return super.shouldComponentUpdate(nextProps, nextState);
}
}
// If our component is about to update and is detailed, we request
// its card if we don't have it.
componentWillUpdate (nextProps) {
const { detailed, handler, status } = this.props;
if (!status.get('card') && nextProps.detailed && !detailed) {
handler.fetchCard(status);
}
}
// If the component is updated for any reason we save the height.
componentDidUpdate () {
const { isHidden, isIntersecting } = this.state;
if (isIntersecting || !isHidden) this.saveHeight();
}
// If our component is about to unmount, we'd better unset
// `componentMounted` lol.
componentWillUnmount () {
const { node } = this;
const { id, intersectionObserverWrapper } = this.props;
intersectionObserverWrapper.unobserve(id, node);
this.componentMounted = false;
}
// Doesn't quite work on Edge 15 but it gets close. This tells us if
// our status is onscreen, and if not we hide it at the next
// available opportunity. This isn't a huge deal (but it saves some
// rendering cycles if we don't have as much DOM) so we schedule
// it using `scheduleIdleTask`.
handleIntersection = (entry) => {
const isIntersecting = (
typeof entry.isIntersecting === 'boolean' ?
entry.isIntersecting :
entry.intersectionRect.height > 0
);
this.setState((prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting,
isHidden: false,
};
});
}
// Because we scheduled toot-hiding as an idle task (see above), we
// *do* need to ensure that it's still not intersecting before we
// hide it lol.
hideIfNotIntersecting = () => {
if (!this.componentMounted) return;
this.setState((prevState) => ({
isHidden: !prevState.isIntersecting,
}));
}
// `saveHeight()` saves the status height so that we preserve its
// dimensions when it's being hidden.
saveHeight = () => {
if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height;
}
}
// `setExpansion` handles expanding and collapsing statuses. Note
// that `isExpanded` is a *trinary* value:
setExpansion = (value) => {
const { detailed } = this.props;
switch (true) {
// A value of `null` or `undefined` means the status should be
// neither expanded or collapsed.
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
// A value of `false` means that the status should be collapsed.
case !value:
if (!detailed) this.setState({ isExpanded: false });
else this.setState({ isExpanded: null }); // fallback
break;
// A value of `true` means that the status should be expanded.
case !!value:
this.setState({ isExpanded: true });
break;
}
}
// Stores our node and saves its height.
handleRef = (node) => {
this.node = node;
this.saveHeight();
}
// `handleClick()` handles all clicking stuff. We route links and
// make our status detailed if it isn't already.
handleClick = (e) => {
const { detailed, history, id, setDetail, status } = this.props;
if (!history || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
if (setDetail) setDetail(detailed ? null : id);
else history.push(`/statuses/${status.get('id')}`);
e.preventDefault();
}
// Puts our element on the screen.
render () {
const {
handleRef,
handleClick,
saveHeight,
setExpansion,
} = this;
const {
autoPlayGif,
comrade,
detailed,
handler,
history,
index,
intl,
listLength,
me,
muted,
prepend,
setDetail,
settings,
status,
} = this.props;
const {
isExpanded,
isHidden,
isIntersecting,
} = this.state;
let account = status.get('account');
let computedClass = 'glitch glitch__status';
let conditionalProps = {};
let selectorAttribs = {};
// If there's no status, we can't render lol.
if (status === null) {
return <StatusMissing />;
}
// Here are extra data-* attributes for use with CSS selectors.
// We don't use these but users can via UserStyles.
selectorAttribs = {
'data-status-by': `@${account.get('acct')}`,
};
if (prepend && comrade) {
selectorAttribs[`data-${prepend === 'favourite' ? 'favourited' : 'boosted'}-by`] = `@${comrade.get('acct')}`;
}
// If our index and list length have been set, we can set the
// corresponding ARIA attributes.
if (isFinite(index) && isFinite(listLength)) conditionalProps = {
'aria-posinset': index,
'aria-setsize': listLength,
};
// This sets our class names.
computedClass = classNames('glitch', 'glitch__status', {
_detailed: detailed,
_muted: muted,
}, `_${status.get('visibility')}`);
// If our status is offscreen and hidden, we render an empty div.
if (!isIntersecting && isHidden) {
return (
<article
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
style={{
height: `${this.height}px`,
opacity: 0,
overflow: 'hidden',
visibility: 'hidden',
}}
tabIndex='0'
>
<div hidden>
{account.get('display_name') || account.get('username')}
{' '}
{status.get('content')}
</div>
</article>
);
}
// Otherwise, we can render our status!
return (
<article
className={computedClass}
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
{...selectorAttribs}
tabIndex='0'
>
{prepend && comrade ? (
<StatusPrepend
comrade={comrade}
history={history}
type={prepend}
/>
) : null}
{setDetail ? (
<CommonButton
active={detailed}
className='status\detail status\button'
icon={detailed ? 'minus' : 'plus'}
onClick={handleClick}
title={intl.formatMessage(messages.detailed)}
/>
) : null}
<StatusHeader
account={account}
comrade={comrade}
history={history}
/>
<StatusContent
autoPlayGif={autoPlayGif}
detailed={detailed}
expanded={isExpanded}
handler={handler}
hideMedia={muted}
history={history}
intl={intl}
letterbox={settings.getIn(['media', 'letterbox'])}
onClick={handleClick}
onHeightUpdate={saveHeight}
setExpansion={setExpansion}
status={status}
/>
<StatusFooter
application={status.get('application')}
datetime={status.get('created_at')}
detailed={detailed}
href={status.get('url')}
intl={intl}
visibility={status.get('visibility')}
/>
<StatusActionBar
detailed={detailed}
handler={handler}
history={history}
intl={intl}
me={me}
status={status}
/>
{detailed ? (
<StatusNav id={status.get('id')} intl={intl} />
) : null}
</article>
);
}
}

View File

@@ -1,33 +0,0 @@
// <StatusMissing>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/missing
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import { FormattedMessage } from 'react-intl';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const StatusMissing = () => (
<div className='glitch glitch__status__missing'>
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
</div>
);
export default StatusMissing;

View File

@@ -1,95 +0,0 @@
// <StatusNav>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/nav
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
conversation:
{ id : 'status.view_conversation', defaultMessage : 'View conversation' },
reblogs:
{ id : 'status.view_reblogs', defaultMessage : 'View reblogs' },
favourites:
{ id : 'status.view_favourites', defaultMessage : 'View favourites' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusNav extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
}
// Rendering.
render () {
const { id, intl } = this.props;
return (
<nav className='glitch glitch__status__nav'>
<CommonLink
className='nav\conversation'
destination={`/statuses/${id}`}
title={intl.formatMessage(messages.conversation)}
>
<CommonIcon
className='nav\icon'
name='comments-o'
/>
</CommonLink>
<CommonLink
className='nav\reblogs'
destination={`/statuses/${id}/reblogs`}
title={intl.formatMessage(messages.reblogs)}
>
<CommonIcon
className='nav\icon'
name='retweet'
/>
</CommonLink>
<CommonLink
className='nav\favourites'
destination={`/statuses/${id}/favourites`}
title={intl.formatMessage(messages.favourites)}
>
<CommonIcon
className='nav\icon'
name='star'
/>
</CommonLink>
</nav>
);
}
}

View File

@@ -1,99 +0,0 @@
// <StatusPrepend>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusPrepend extends React.PureComponent {
// Props.
static propTypes = {
comrade: ImmutablePropTypes.map.isRequired,
history: PropTypes.object,
type: PropTypes.string.isRequired,
};
// This is a quick functional React component to get the prepend
// message.
Message = () => {
const { comrade, history, type } = this.props;
let link = (
<CommonLink
className='prepend\comrade'
destination={`/accounts/${comrade.get('id')}`}
history={history}
href={comrade.get('url')}
>
{comrade.get('display_name_html') || comrade.get('username')}
</CommonLink>
);
switch (type) {
case 'favourite':
return (
<FormattedMessage
defaultMessage='{name} favourited your status'
id='notification.favourite'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
defaultMessage='{name} boosted your status'
id='notification.reblog'
values={{ name : link }}
/>
);
case 'reblogged':
return (
<FormattedMessage
defaultMessage='{name} boosted'
id='status.reblogged_by'
values={{ name : link }}
/>
);
}
return null;
}
// This renders the prepend icon and the prepend message in sequence.
render () {
const { Message } = this;
const { type } = this.props;
return type ? (
<aside className='glitch glitch__status__prepend'>
<CommonIcon
className={`prepend\\icon prepend\\${type}`}
name={type === 'favourite' ? 'star' : 'retweet'}
/>
<Message />
</aside>
) : null;
}
}

View File

@@ -1,33 +0,0 @@
@import 'variables';
.glitch.glitch__status__prepend {
display: block;
position: relative;
margin: 0 0 1em;
color: $ui-base-lighter-color;
padding: 0 0 0 (3.35em * .7);
.prepend\\icon {
display: block;
position: absolute;
margin: auto;
top: 0;
left: 0;
width: (3.35em * .7);
height: 1.35em;
text-align: center;
&.prepend\\reblog,
&.prepend\\reblogged {
color: $ui-highlight-color;
}
&.prepend\\favourite {
color: $gold-star;
}
}
.prepend\\comrade {
color: $glitch-lighter-color;
}
}

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