Compare commits

...

73 Commits

Author SHA1 Message Date
kibigo!
35be02f21d Renamed glitch async chunks 2018-01-09 16:26:14 -08:00
beatrix
90e568413b Merge pull request #308 from KnzkDev/fix/list-editor
Fix list editor design
2018-01-08 13:08:11 -05:00
ncls7615
ef0b7d1e76 fix list editor scss 2018-01-09 02:50:24 +09:00
David Yip
65986b6f0b Merge remote-tracking branch 'personal/merge/tootsuite/master' into gs-master 2018-01-08 09:48:42 -06:00
David Yip
2dc4fbbd1a When pulling out max_toot_chars, handle nulls
flavours/glitch/util/initial_state is used in places where we want to
exhibit different behavior based on user preferences.  This means that
it's used in places where no preference is defined, i.e. on an
unauthenticated access.  All values exported from that module must
therefore expect that case; previously, the max chars value didn't.

Addresses #306.
2018-01-08 09:45:59 -06:00
Jenkins
f839ac694c Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-08 10:17:15 +00:00
Eugen Rochko
dbda87c31f Revert #5772 (#6221) 2018-01-08 10:57:52 +01:00
Jenkins
722b3f567f Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-08 04:17:11 +00:00
Eugen Rochko
e4a241abef Fix bad URL schemes being accepted (#6219)
* Fix actors accepting invalid URI schemes or different host between URI and URL

* Fix statuses accepting invalid URI scheme or different host to actor

* Adjust tests to new requirements

* Improve readability of mismatching_origin?/invalid_origin? methods
2018-01-08 05:00:23 +01:00
Eugen Rochko
93555182c3 Do not display elephant friend in single-column layout (#6222) 2018-01-08 03:50:53 +01:00
puckipedia
0eff42d688 Move Article from supported to converted types (#6218) 2018-01-08 00:21:14 +01:00
David Yip
f7c4d4464b Merge remote-tracking branch 'personal/merge/tootsuite/master' into gs-master 2018-01-07 13:30:52 -06:00
David Yip
70c99a9f34 Use error pack when rendering error pages. Fixes #305. 2018-01-07 13:30:17 -06:00
Jenkins
c2e1bfd9ae Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-07 15:17:13 +00:00
Yamagishi Kazutoshi
1d92b90be9 Fix force_ssl conditional (#6201) 2018-01-07 15:19:23 +01:00
Yamagishi Kazutoshi
da809f9eec Fix unintended cache (#6214) 2018-01-07 15:12:59 +01:00
SerCom_KC
c4d36d024c Update Simplified Chinese translations (#6215)
* i18n: (zh-CN) Add translations of #6125

* i18n: (zh-CN) Add translations of #6132

* i18n: (zh-CN) Add translations of #6099

* i18n: (zh-CN) Add translations of #6071

* i18n: (zh-CN) Improve translations
2018-01-07 17:32:50 +09:00
David Yip
5083311d64 Merge remote-tracking branch 'ykzts/fix-unintended-cache' into gs-master 2018-01-07 00:32:24 -06:00
Yamagishi Kazutoshi
2af307bce4 Fix unintended cache 2018-01-07 14:59:12 +09:00
Jenkins
bcbdd4f88d Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-07 02:17:10 +00:00
Jeong Arm
9e97fbf0af Translate Korean (#6212) 2018-01-07 11:13:42 +09:00
kibigo!
b5874c1428 Fixes to search dropdown 2018-01-06 15:34:01 -08:00
beatrix
61ef8d643e fix typo in vanilla names.yml 2018-01-06 16:49:53 -05:00
Ondřej Hruška
9f29fd31ba fixed ctrl enter 2018-01-06 19:58:04 +01:00
Ondřej Hruška
53caab0c0b Fix the always-threaded bug 2018-01-06 19:55:53 +01:00
beatrix-bitrot
b75a1ce326 tighten csp 2018-01-06 18:49:03 +00:00
beatrix
d442cfa65c Merge pull request #303 from KnzkDev/ja-for-thread-mode
Update ja.js for #296
2018-01-06 12:06:17 -05:00
ncls7615
f5a4201ad8 Update ja.js 2018-01-07 01:51:49 +09:00
beatrix
a251c42192 Merge pull request #296 from glitch-soc/thread-mode
Threaded mode~
2018-01-06 11:28:36 -05:00
beatrix
2ec9a75a1d Merge pull request #302 from KnzkDev/fix/search-popout
Fix search popout
2018-01-06 11:25:59 -05:00
beatrix
fa92e88fb2 appease eslint 2018-01-06 10:30:49 -05:00
ncls7615
da98c33161 Fix search popout 2018-01-06 21:50:11 +09:00
David Yip
2eed4ace11 Read max_toot_chars from root object. Fixes #297.
max_toot_chars is present in the root of the initial state object.
(Previously, we were trying to read it from the meta child object.)
2018-01-06 03:01:11 -06:00
kibigo!
c71d848855 my global .gitignore excluded this file ;_; 2018-01-05 21:40:02 -08:00
kibigo!
e4bc013d6f Threaded mode~ 2018-01-05 21:16:43 -08:00
kibigo!
6932b464e6 Fixed improper dropdown func binding for #293 + toot button spacing 2018-01-05 21:02:53 -08:00
kibigo!
ad10a80a99 Styling and autosuggest fixes for #293 2018-01-05 20:43:16 -08:00
kibigo!
8bf9d9362a Fixes composer mounting issue with #293 2018-01-05 18:30:06 -08:00
David Yip
03aeab857f Merge remote-tracking branch 'personal/merge/tootsuite/master' into gs-master 2018-01-05 17:31:56 -06:00
beatrix
f441770e50 Merge pull request #290 from chriswmartin/web-push-updates
Web push updates
2018-01-05 18:29:57 -05:00
beatrix
b4e667f86b Merge pull request #295 from chriswmartin/getting-started-key-fix
unique ColumnLink keys in getting_started
2018-01-05 18:29:40 -05:00
beatrix
faf20eeaa4 Merge pull request #293 from glitch-soc/compose-refactor
Compose refactor
2018-01-05 18:29:08 -05:00
Jenkins
f6adb409fd Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-05 22:17:12 +00:00
ThibG
10f6793fd0 Fix PuSH workers (#6200) 2018-01-05 23:04:35 +01:00
ThibG
a594139115 When fetching an ActivityPub-enabled status, do not re-request it as text/html (#6196) 2018-01-05 22:42:50 +01:00
TheKinrar
95bd85d9e8 Represent numbers by strings in instance activity API (#6198)
Fixes #6197.
2018-01-05 22:38:33 +01:00
Naoki Kosaka
8d51ce4290 Fix enforce HTTPS in production. (#6180) 2018-01-05 20:04:22 +01:00
beatrix
f41b33eb01 Merge pull request #243 from m4sk1n/glitch-pl
i18n: 🇵🇱
2018-01-05 12:36:53 -05:00
cwm
9fc08e4861 add key to lists div 2018-01-05 09:00:48 -06:00
cwm
6236577734 change how list ColumnLink keys are determined 2018-01-05 08:12:34 -06:00
Quenty31
06636c6eca l10n Occitan language: mailer update (#6193)
* Create email_changed.oc.html.erb

* Create email_changed.oc.text.erb

* Update email_changed.oc.html.erb

* Update email_changed.oc.html.erb

* Create reconfirmation_instructions.oc.html.erb

* Create reconfirmation_instructions.oc.text.erb

* Update confirmation_instructions.oc.html.erb

* Update confirmation_instructions.oc.text.erb

* Update confirmation_instructions.oc.html.erb

* Update reconfirmation_instructions.oc.html.erb

* Update reconfirmation_instructions.oc.text.erb

* Update reconfirmation_instructions.oc.html.erb
2018-01-05 18:59:43 +09:00
Eugen Rochko
e9822a4e4e Bump version to 2.1.2 2018-01-05 04:52:06 +01:00
Yamagishi Kazutoshi
9a61b0ef22 Fix RFC 5646 Regular Expression (#6190) 2018-01-05 04:43:50 +01:00
Jenkins
c69a23ae46 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-01-04 23:17:11 +00:00
Branko Kokanovic
d872902997 Small translation fixes for Serbian (and sr@Latn too) (#6188) 2018-01-05 00:16:06 +01:00
Patrick Figel
5ec25ff3e1 Fix email confirmation link not updating email (#6187)
A change introduced in #6125 prevents
`Devise::Models::Confirmable#confirm` from being called for existing
users, which in turn leads to `email` not being set to
`unconfirmed_email`, breaking email updates. This also adds a test
that would've caught this issue.
2018-01-05 00:15:35 +01:00
Lynx Kotoura
49e296e1b0 Fix overflowing audit logs (#6184) 2018-01-04 19:38:46 +01:00
unarist
7347d4f8bb Use disable_ddl_transaction! to prevent warnings on migration (#6183)
Migration is wrapped by transaction, so manual `commit_db_transaction` without transaction restarting causes "there is no transaction in progress" warnings. We should use `disable_ddl_transaction!` instead, if we can omit transaction completely.
2018-01-04 19:38:29 +01:00
Eugen Rochko
7571c37c99 Bump version to 2.1.1 (#6164) 2018-01-04 16:40:26 +01:00
Yamagishi Kazutoshi
3c18964256 Fallback default thumbnail in instance status API (#6177) 2018-01-04 15:36:55 +01:00
Marcin Mikołajczak
c61dd918a2 i18n: Update Polish translation (#6176)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2018-01-04 23:15:29 +09:00
Marcin Mikołajczak
0f69a90588 i18n: Update Polish translation
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2018-01-04 14:42:58 +01:00
Eugen Rochko
02ba03d6db Send one Delete of Actor in ActivityPub when account is suspended (#6172) 2018-01-04 14:40:49 +01:00
ThibG
3bee0996c5 Make sure private toots remain private and do not end up in HTTP caches (#6175) 2018-01-04 14:39:38 +01:00
muan
89daeb43a8 Improve Traditional Chinese translation (#6166)
* Improve Traditional Chinese translations

* Sort alphabetically
2018-01-04 05:00:50 +01:00
Eugen Rochko
7d4f4f9aab Fix FetchAtomService not finding alternatives if there's a Link header (#6170)
without them, such as is the case with GNU social

Fixes the ability to find GNU social accounts via URL in search and
when using remote follow function
2018-01-04 04:56:04 +01:00
Akihiko Odaki
256c2b1de0 Rearrange items in Getting Started navigation (#6126)
Though the subsections are representing features such as navigation and
settings, they are categorized by the ways how they are implemented
(internal navigation or external links.) They are irrelevant and some
arrangements were confusing because of that. (It is nonsense that instance
information is in settings subsection, for example.)

This fixes the issue by rearranging.
2018-01-04 10:56:54 +09:00
Eugen Rochko
02e3e1ec09 Fix nil error in log_target_from_history helper (#6173) 2018-01-04 10:56:23 +09:00
Eugen Rochko
ff924f95bb Fix OpenSSL dependency in ostatus2 (#6174) 2018-01-04 10:56:00 +09:00
Eugen Rochko
c10f4bdb03 Cache JSON of immutable ActivityPub representations (#6171) 2018-01-04 01:21:38 +01:00
cwm
72b99f6ee4 bug fix (tootsuite pr #6120) 2017-12-31 08:26:50 -06:00
cwm
4ce44ba470 remove unused 'saveSettings' from column_settings_container 2017-12-30 16:42:26 -06:00
cwm
0dce26b82b web push updates (tootsuite PRs #5879, #5941, #6047) 2017-12-30 11:45:01 -06:00
101 changed files with 1361 additions and 774 deletions

View File

@@ -299,13 +299,11 @@ GEM
sidekiq (>= 3.5.0) sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0) statsd-ruby (~> 1.2.0)
oj (3.3.9) oj (3.3.9)
openssl (2.0.6)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostatus2 (2.0.1) ostatus2 (2.0.2)
addressable (~> 2.4) addressable (~> 2.4)
http (~> 2.0) http (~> 2.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
openssl (~> 2.0)
ox (2.8.2) ox (2.8.2)
paperclip (5.1.0) paperclip (5.1.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)

View File

@@ -2,7 +2,8 @@
class AccountsController < ApplicationController class AccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
include SignatureVerification
before_action :set_cache_headers
def show def show
respond_to do |format| respond_to do |format|
@@ -27,10 +28,11 @@ class AccountsController < ApplicationController
end end
format.json do format.json do
render json: @account, skip_session!
serializer: ActivityPub::ActorSerializer,
adapter: ActivityPub::Adapter, render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do
content_type: 'application/activity+json' ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
end
end end
end end
end end

View File

@@ -1,18 +0,0 @@
# frozen_string_literal: true
class ActivityPub::FollowsController < Api::BaseController
include SignatureVerification
def show
render(
json: FollowRequest.includes(:account).references(:account).find_by!(
id: params.require(:id),
accounts: { domain: nil, username: params.require(:account_username) },
target_account: signed_request_account
),
serializer: ActivityPub::FollowSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
)
end
end

View File

@@ -21,9 +21,9 @@ class Api::V1::Instances::ActivityController < Api::BaseController
weeks << { weeks << {
week: week.to_time.to_i.to_s, week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || 0, statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
logins: Redis.current.pfcount("activity:logins:#{week_id}"), logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || 0, registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
} }
end end

View File

@@ -31,7 +31,7 @@ class ApplicationController < ActionController::Base
private private
def https_enabled? def https_enabled?
Rails.env.production? && ENV['LOCAL_HTTPS'] == 'true' Rails.env.production?
end end
def store_current_location def store_current_location
@@ -192,17 +192,31 @@ class ApplicationController < ActionController::Base
format.any { head code } format.any { head code }
format.html do format.html do
set_locale set_locale
use_pack 'error'
render "errors/#{code}", layout: 'error', status: code render "errors/#{code}", layout: 'error', status: code
end end
end end
end end
def render_cached_json(cache_key, **options) def render_cached_json(cache_key, **options)
options[:expires_in] ||= 3.minutes
cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable)
cache_public = options.key?(:public) ? options.delete(:public) : true
content_type = options.delete(:content_type) || 'application/json'
data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do
yield.to_json yield.to_json
end end
expires_in options[:expires_in], public: true expires_in options[:expires_in], public: cache_public
render json: data render json: data, content_type: content_type
end
def set_cache_headers
response.headers['Vary'] = 'Accept'
end
def skip_session!
request.session_options[:skip] = true
end end
end end

View File

@@ -2,14 +2,16 @@
class EmojisController < ApplicationController class EmojisController < ApplicationController
before_action :set_emoji before_action :set_emoji
before_action :set_cache_headers
def show def show
respond_to do |format| respond_to do |format|
format.json do format.json do
render json: @emoji, skip_session!
serializer: ActivityPub::EmojiSerializer,
adapter: ActivityPub::Adapter, render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do
content_type: 'application/activity+json' ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
end
end end
end end
end end

View File

@@ -10,7 +10,7 @@ class StatusesController < ApplicationController
before_action :set_link_headers before_action :set_link_headers
before_action :check_account_suspension before_action :check_account_suspension
before_action :redirect_to_original, only: [:show] before_action :redirect_to_original, only: [:show]
before_action { response.headers['Vary'] = 'Accept' } before_action :set_cache_headers
def show def show
respond_to do |format| respond_to do |format|
@@ -23,25 +23,21 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
render json: @status, skip_session! unless @stream_entry.hidden?
serializer: ActivityPub::NoteSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
# Allow HTTP caching for 3 minutes if the status is public render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
unless @stream_entry.hidden? ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
request.session_options[:skip] = true
expires_in(3.minutes, public: true)
end end
end end
end end
end end
def activity def activity
render json: @status, skip_session!
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter, render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
content_type: 'application/activity+json' ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
end
end end
def embed def embed

View File

@@ -34,7 +34,7 @@ module Admin::ActionLogsHelper
link_to attributes['domain'], "https://#{attributes['domain']}" link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status' when 'Status'
tmp_status = Status.new(attributes) tmp_status = Status.new(attributes)
link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status) link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
end end
end end

View File

@@ -39,6 +39,10 @@ module JsonLdHelper
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end end
def unsupported_uri_scheme?(uri)
!uri.start_with?('http://', 'https://')
end
def canonicalize(json) def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json) graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
graph.dump(:normalize) graph.dump(:normalize)

View File

@@ -4,6 +4,7 @@ module RoutingHelper
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Rails.application.routes.url_helpers include Rails.application.routes.url_helpers
include ActionView::Helpers::AssetTagHelper include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
included do included do
def default_url_options def default_url_options
@@ -17,6 +18,10 @@ module RoutingHelper
URI.join(root_url, source).to_s URI.join(root_url, source).to_s
end end
def full_pack_url(source, **options)
full_asset_url(asset_pack_path(source, options))
end
private private
def use_storage? def use_storage?

View File

@@ -61,7 +61,7 @@ export function replyCompose(status, router) {
status: status, status: status,
}); });
if (!getState().getIn(['compose', 'mounted'])) { if (router && !getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new'); router.push('/statuses/new');
} }
}; };
@@ -118,6 +118,11 @@ export function submitCompose() {
}).then(function (response) { }).then(function (response) {
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
// If the response has no data then we can't do anything else.
if (!response.data) {
return;
}
// To make the app more responsive, immediately get the status into the columns // To make the app more responsive, immediately get the status into the columns
const insertOrRefresh = (timelineId, refreshAction) => { const insertOrRefresh = (timelineId, refreshAction) => {
@@ -341,10 +346,11 @@ export function unmountCompose() {
}; };
}; };
export function toggleComposeAdvancedOption(option) { export function changeComposeAdvancedOption(option, value) {
return { return {
option,
type: COMPOSE_ADVANCED_OPTIONS_CHANGE, type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
option: option, value,
}; };
} }

View File

@@ -0,0 +1,23 @@
import {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
setAlerts,
} from './setter';
import { register, saveSettings } from './registerer';
export {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
register,
};
export function changeAlerts(key, value) {
return dispatch => {
dispatch(setAlerts(key, value));
dispatch(saveSettings());
};
}

View File

@@ -0,0 +1,149 @@
import axios from 'axios';
import { pushNotificationsSetting } from 'flavours/glitch/util/settings';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
// Taken from https://www.npmjs.com/package/web-push
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
const getRegistration = () => navigator.serviceWorker.ready;
const getPushSubscription = (registration) =>
registration.pushManager.getSubscription()
.then(subscription => ({ registration, subscription }));
const subscribe = (registration) =>
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
});
const unsubscribe = ({ registration, subscription }) =>
subscription ? subscription.unsubscribe().then(() => registration) : registration;
const sendSubscriptionToBackend = (subscription, me) => {
const params = { subscription };
if (me) {
const data = pushNotificationsSetting.get(me);
if (data) {
params.data = data;
}
}
return axios.post('/api/web/push_subscriptions', params).then(response => response.data);
};
// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
export function register () {
return (dispatch, getState) => {
dispatch(setBrowserSupport(supportsPushNotifications));
const me = getState().getIn(['meta', 'me']);
if (me && !pushNotificationsSetting.get(me)) {
const alerts = getState().getIn(['push_notifications', 'alerts']);
if (alerts) {
pushNotificationsSetting.set(me, { alerts: alerts });
}
}
if (supportsPushNotifications) {
if (!getApplicationServerKey()) {
console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
return;
}
getRegistration()
.then(getPushSubscription)
.then(({ registration, subscription }) => {
if (subscription !== null) {
// We have a subscription, check if it is still valid
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
// If the VAPID public key did not change and the endpoint corresponds
// to the endpoint saved in the backend, the subscription is valid
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
return subscription;
} else {
// Something went wrong, try to subscribe again
return unsubscribe({ registration, subscription }).then(subscribe).then(
subscription => sendSubscriptionToBackend(subscription, me));
}
}
// No subscription, try to subscribe
return subscribe(registration).then(
subscription => sendSubscriptionToBackend(subscription, me));
})
.then(subscription => {
// If we got a PushSubscription (and not a subscription object from the backend)
// it means that the backend subscription is valid (and was set during hydration)
if (!(subscription instanceof PushSubscription)) {
dispatch(setSubscription(subscription));
if (me) {
pushNotificationsSetting.set(me, { alerts: subscription.alerts });
}
}
})
.catch(error => {
if (error.code === 20 && error.name === 'AbortError') {
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
} else if (error.code === 5 && error.name === 'InvalidCharacterError') {
console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
}
// Clear alerts and hide UI settings
dispatch(clearSubscription());
if (me) {
pushNotificationsSetting.remove(me);
}
try {
getRegistration()
.then(getPushSubscription)
.then(unsubscribe);
} catch (e) {
}
});
} else {
console.warn('Your browser does not support Web Push Notifications.');
}
};
}
export function saveSettings() {
return (_, getState) => {
const state = getState().get('push_notifications');
const subscription = state.get('subscription');
const alerts = state.get('alerts');
const data = { alerts };
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data,
}).then(() => {
const me = getState().getIn(['meta', 'me']);
if (me) {
pushNotificationsSetting.set(me, data);
}
});
};
}

View File

@@ -1,9 +1,7 @@
import axios from 'axios';
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
export function setBrowserSupport (value) { export function setBrowserSupport (value) {
return { return {
@@ -25,28 +23,12 @@ export function clearSubscription () {
}; };
} }
export function changeAlerts(key, value) { export function setAlerts (key, value) {
return dispatch => { return dispatch => {
dispatch({ dispatch({
type: ALERTS_CHANGE, type: SET_ALERTS,
key, key,
value, value,
}); });
dispatch(saveSettings());
};
}
export function saveSettings() {
return (_, getState) => {
const state = getState().get('push_notifications');
const subscription = state.get('subscription');
const alerts = state.get('alerts');
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data: {
alerts,
},
});
}; };
} }

View File

@@ -105,10 +105,22 @@ export default class Account extends ImmutablePureComponent {
} }
return small ? ( return small ? (
<div className='account small'> <Permalink
<div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div> className='account small'
<DisplayName account={account} /> href={account.get('url')}
</div> to={`/accounts/${account.get('id')}`}
>
<div className='account__avatar-wrapper'>
<Avatar
account={account}
size={24}
/>
</div>
<DisplayName
account={account}
inline
/>
</Permalink>
) : ( ) : (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>

View File

@@ -1,28 +1,30 @@
// Package imports.
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
export default class DisplayName extends React.PureComponent { // The component.
export default function DisplayName ({
static propTypes = { account,
account: ImmutablePropTypes.map.isRequired, className,
className: PropTypes.string, inline,
}; }) {
const computedClass = classNames('display-name', { inline }, className);
render () {
const {
account,
className,
} = this.props;
const computedClass = classNames('display-name', className);
const displayNameHtml = { __html: account.get('display_name_html') };
return (
<span className={computedClass}>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span>
);
}
// The result.
return account ? (
<span className={computedClass}>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
{inline ? ' ' : null}
<span className='display-name__account'>@{account.get('acct')}</span>
</span>
) : null;
} }
// Props.
DisplayName.propTypes = {
account: ImmutablePropTypes.map,
className: PropTypes.string,
inline: PropTypes.bool,
};

View File

@@ -137,7 +137,7 @@ export default class Dropdown extends React.PureComponent {
(item, i) => item ? { (item, i) => item ? {
...item, ...item,
name: `${item.text}-${i}`, name: `${item.text}-${i}`,
onClick: this.handleItemClick.bind(i), onClick: this.handleItemClick.bind(this, i),
} : null } : null
), ),
}); });

View File

@@ -22,7 +22,13 @@ export default class Permalink extends React.PureComponent {
} }
render () { render () {
const { href, children, className, ...other } = this.props; const {
children,
className,
href,
to,
...other
} = this.props;
return ( return (
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>

View File

@@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { import {
cancelReplyCompose, cancelReplyCompose,
changeCompose, changeCompose,
changeComposeAdvancedOption,
changeComposeSensitivity, changeComposeSensitivity,
changeComposeSpoilerText, changeComposeSpoilerText,
changeComposeSpoilerness, changeComposeSpoilerness,
@@ -15,10 +16,11 @@ import {
clearComposeSuggestions, clearComposeSuggestions,
fetchComposeSuggestions, fetchComposeSuggestions,
insertEmojiCompose, insertEmojiCompose,
mountCompose,
selectComposeSuggestion, selectComposeSuggestion,
submitCompose, submitCompose,
toggleComposeAdvancedOption,
undoUploadCompose, undoUploadCompose,
unmountCompose,
uploadCompose, uploadCompose,
} from 'flavours/glitch/actions/compose'; } from 'flavours/glitch/actions/compose';
import { import {
@@ -47,8 +49,8 @@ function mapStateToProps (state) {
const inReplyTo = state.getIn(['compose', 'in_reply_to']); const inReplyTo = state.getIn(['compose', 'in_reply_to']);
return { return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
advancedOptions: state.getIn(['compose', 'advanced_options']),
amUnlocked: !state.getIn(['accounts', me, 'locked']), amUnlocked: !state.getIn(['accounts', me, 'locked']),
doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
focusDate: state.getIn(['compose', 'focusDate']), focusDate: state.getIn(['compose', 'focusDate']),
isSubmitting: state.getIn(['compose', 'is_submitting']), isSubmitting: state.getIn(['compose', 'is_submitting']),
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
@@ -57,7 +59,7 @@ function mapStateToProps (state) {
preselectDate: state.getIn(['compose', 'preselectDate']), preselectDate: state.getIn(['compose', 'preselectDate']),
privacy: state.getIn(['compose', 'privacy']), privacy: state.getIn(['compose', 'privacy']),
progress: state.getIn(['compose', 'progress']), progress: state.getIn(['compose', 'progress']),
replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null, replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null, replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
resetFileKey: state.getIn(['compose', 'resetFileKey']), resetFileKey: state.getIn(['compose', 'resetFileKey']),
sideArm: state.getIn(['local_settings', 'side_arm']), sideArm: state.getIn(['local_settings', 'side_arm']),
@@ -74,6 +76,7 @@ function mapStateToProps (state) {
// Dispatch mapping. // Dispatch mapping.
const mapDispatchToProps = { const mapDispatchToProps = {
onCancelReply: cancelReplyCompose, onCancelReply: cancelReplyCompose,
onChangeAdvancedOption: changeComposeAdvancedOption,
onChangeDescription: changeUploadCompose, onChangeDescription: changeUploadCompose,
onChangeSensitivity: changeComposeSensitivity, onChangeSensitivity: changeComposeSensitivity,
onChangeSpoilerText: changeComposeSpoilerText, onChangeSpoilerText: changeComposeSpoilerText,
@@ -84,12 +87,13 @@ const mapDispatchToProps = {
onCloseModal: closeModal, onCloseModal: closeModal,
onFetchSuggestions: fetchComposeSuggestions, onFetchSuggestions: fetchComposeSuggestions,
onInsertEmoji: insertEmojiCompose, onInsertEmoji: insertEmojiCompose,
onMount: mountCompose,
onOpenActionsModal: openModal.bind(null, 'ACTIONS'), onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }), onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
onSelectSuggestion: selectComposeSuggestion, onSelectSuggestion: selectComposeSuggestion,
onSubmit: submitCompose, onSubmit: submitCompose,
onToggleAdvancedOption: toggleComposeAdvancedOption,
onUndoUpload: undoUploadCompose, onUndoUpload: undoUploadCompose,
onUnmount: unmountCompose,
onUpload: uploadCompose, onUpload: uploadCompose,
}; };
@@ -188,6 +192,22 @@ class Composer extends React.Component {
} }
} }
// Tells our state the composer has been mounted.
componentDidMount () {
const { onMount } = this.props;
if (onMount) {
onMount();
}
}
// Tells our state the composer has been unmounted.
componentWillUnmount () {
const { onUnmount } = this.props;
if (onUnmount) {
onUnmount();
}
}
// This statement does several things: // This statement does several things:
// - If we're beginning a reply, and, // - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end // - Replying to zero or one users, places the cursor at the end
@@ -245,17 +265,17 @@ class Composer extends React.Component {
handleSubmit, handleSubmit,
handleRefTextarea, handleRefTextarea,
} = this.handlers; } = this.handlers;
const { history } = this.context;
const { const {
acceptContentTypes, acceptContentTypes,
advancedOptions,
amUnlocked, amUnlocked,
doNotFederate,
intl, intl,
isSubmitting, isSubmitting,
isUploading, isUploading,
layout, layout,
media, media,
onCancelReply, onCancelReply,
onChangeAdvancedOption,
onChangeDescription, onChangeDescription,
onChangeSensitivity, onChangeSensitivity,
onChangeSpoilerness, onChangeSpoilerness,
@@ -266,7 +286,6 @@ class Composer extends React.Component {
onFetchSuggestions, onFetchSuggestions,
onOpenActionsModal, onOpenActionsModal,
onOpenDoodleModal, onOpenDoodleModal,
onToggleAdvancedOption,
onUndoUpload, onUndoUpload,
onUpload, onUpload,
privacy, privacy,
@@ -297,12 +316,12 @@ class Composer extends React.Component {
<ComposerReply <ComposerReply
account={replyAccount} account={replyAccount}
content={replyContent} content={replyContent}
history={history}
intl={intl} intl={intl}
onCancel={onCancelReply} onCancel={onCancelReply}
/> />
) : null} ) : null}
<ComposerTextarea <ComposerTextarea
advancedOptions={advancedOptions}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
disabled={isSubmitting} disabled={isSubmitting}
intl={intl} intl={intl}
@@ -329,19 +348,19 @@ class Composer extends React.Component {
) : null} ) : null}
<ComposerOptions <ComposerOptions
acceptContentTypes={acceptContentTypes} acceptContentTypes={acceptContentTypes}
advancedOptions={advancedOptions}
disabled={isSubmitting} disabled={isSubmitting}
doNotFederate={doNotFederate}
full={media.size >= 4 || media.some( full={media.size >= 4 || media.some(
item => item.get('type') === 'video' item => item.get('type') === 'video'
)} )}
hasMedia={!!media.size} hasMedia={!!media.size}
intl={intl} intl={intl}
onChangeAdvancedOption={onChangeAdvancedOption}
onChangeSensitivity={onChangeSensitivity} onChangeSensitivity={onChangeSensitivity}
onChangeVisibility={onChangeVisibility} onChangeVisibility={onChangeVisibility}
onDoodleOpen={onOpenDoodleModal} onDoodleOpen={onOpenDoodleModal}
onModalClose={onCloseModal} onModalClose={onCloseModal}
onModalOpen={onOpenActionsModal} onModalOpen={onOpenActionsModal}
onToggleAdvancedOption={onToggleAdvancedOption}
onToggleSpoiler={onChangeSpoilerness} onToggleSpoiler={onChangeSpoilerness}
onUpload={onUpload} onUpload={onUpload}
privacy={privacy} privacy={privacy}
@@ -350,7 +369,7 @@ class Composer extends React.Component {
spoiler={spoiler} spoiler={spoiler}
/> />
<ComposerPublisher <ComposerPublisher
countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`} countText={`${spoilerText}${countableText(text)}${advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
disabled={isSubmitting || isUploading || !!text.length && !text.trim().length} disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
intl={intl} intl={intl}
onSecondarySubmit={handleSecondarySubmit} onSecondarySubmit={handleSecondarySubmit}
@@ -364,19 +383,14 @@ class Composer extends React.Component {
} }
// Context
Composer.contextTypes = {
history: PropTypes.object,
};
// Props. // Props.
Composer.propTypes = { Composer.propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
// State props. // State props.
acceptContentTypes: PropTypes.string, acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
amUnlocked: PropTypes.bool, amUnlocked: PropTypes.bool,
doNotFederate: PropTypes.bool,
focusDate: PropTypes.instanceOf(Date), focusDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool, isSubmitting: PropTypes.bool,
isUploading: PropTypes.bool, isUploading: PropTypes.bool,
@@ -385,7 +399,7 @@ Composer.propTypes = {
preselectDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date),
privacy: PropTypes.string, privacy: PropTypes.string,
progress: PropTypes.number, progress: PropTypes.number,
replyAccount: ImmutablePropTypes.map, replyAccount: PropTypes.string,
replyContent: PropTypes.string, replyContent: PropTypes.string,
resetFileKey: PropTypes.number, resetFileKey: PropTypes.number,
sideArm: PropTypes.string, sideArm: PropTypes.string,
@@ -399,6 +413,7 @@ Composer.propTypes = {
// Dispatch props. // Dispatch props.
onCancelReply: PropTypes.func, onCancelReply: PropTypes.func,
onChangeAdvancedOption: PropTypes.func,
onChangeDescription: PropTypes.func, onChangeDescription: PropTypes.func,
onChangeSensitivity: PropTypes.func, onChangeSensitivity: PropTypes.func,
onChangeSpoilerText: PropTypes.func, onChangeSpoilerText: PropTypes.func,
@@ -409,12 +424,13 @@ Composer.propTypes = {
onCloseModal: PropTypes.func, onCloseModal: PropTypes.func,
onFetchSuggestions: PropTypes.func, onFetchSuggestions: PropTypes.func,
onInsertEmoji: PropTypes.func, onInsertEmoji: PropTypes.func,
onMount: PropTypes.func,
onOpenActionsModal: PropTypes.func, onOpenActionsModal: PropTypes.func,
onOpenDoodleModal: PropTypes.func, onOpenDoodleModal: PropTypes.func,
onSelectSuggestion: PropTypes.func, onSelectSuggestion: PropTypes.func,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
onToggleAdvancedOption: PropTypes.func,
onUndoUpload: PropTypes.func, onUndoUpload: PropTypes.func,
onUnmount: PropTypes.func,
onUpload: PropTypes.func, onUpload: PropTypes.func,
}; };

View File

@@ -1,6 +1,7 @@
// Package imports. // Package imports.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { import {
FormattedMessage, FormattedMessage,
defineMessages, defineMessages,
@@ -47,11 +48,11 @@ const messages = defineMessages({
}, },
local_only_long: { local_only_long: {
defaultMessage: 'Do not post to other instances', defaultMessage: 'Do not post to other instances',
id: 'advanced-options.local-only.long', id: 'advanced_options.local-only.long',
}, },
local_only_short: { local_only_short: {
defaultMessage: 'Local-only', defaultMessage: 'Local-only',
id: 'advanced-options.local-only.short', id: 'advanced_options.local-only.short',
}, },
private_long: { private_long: {
defaultMessage: 'Post to followers only', defaultMessage: 'Post to followers only',
@@ -77,6 +78,14 @@ const messages = defineMessages({
defaultMessage: 'Hide text behind warning', defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler', id: 'compose_form.spoiler',
}, },
threaded_mode_long: {
defaultMessage: 'Automatically opens a reply on posting',
id: 'advanced_options.threaded_mode.long',
},
threaded_mode_short: {
defaultMessage: 'Threaded mode',
id: 'advanced_options.threaded_mode.short',
},
unlisted_long: { unlisted_long: {
defaultMessage: 'Do not show in public timelines', defaultMessage: 'Do not show in public timelines',
id: 'privacy.unlisted.long', id: 'privacy.unlisted.long',
@@ -149,16 +158,16 @@ export default class ComposerOptions extends React.PureComponent {
} = this.handlers; } = this.handlers;
const { const {
acceptContentTypes, acceptContentTypes,
advancedOptions,
disabled, disabled,
doNotFederate,
full, full,
hasMedia, hasMedia,
intl, intl,
onChangeAdvancedOption,
onChangeSensitivity, onChangeSensitivity,
onChangeVisibility, onChangeVisibility,
onModalClose, onModalClose,
onModalOpen, onModalOpen,
onToggleAdvancedOption,
onToggleSpoiler, onToggleSpoiler,
privacy, privacy,
resetFileKey, resetFileKey,
@@ -283,23 +292,31 @@ export default class ComposerOptions extends React.PureComponent {
onClick={onToggleSpoiler} onClick={onToggleSpoiler}
title={intl.formatMessage(messages.spoiler)} title={intl.formatMessage(messages.spoiler)}
/> />
<Dropdown {advancedOptions ? (
active={doNotFederate} <Dropdown
disabled={disabled} active={advancedOptions.some(value => !!value)}
icon='home' disabled={disabled}
items={[ icon='ellipsis-h'
{ items={[
meta: <FormattedMessage {...messages.local_only_long} />, {
name: 'do_not_federate', meta: <FormattedMessage {...messages.local_only_long} />,
on: doNotFederate, name: 'do_not_federate',
text: <FormattedMessage {...messages.local_only_short} />, on: advancedOptions.get('do_not_federate'),
}, text: <FormattedMessage {...messages.local_only_short} />,
]} },
onChange={onToggleAdvancedOption} {
onModalClose={onModalClose} meta: <FormattedMessage {...messages.threaded_mode_long} />,
onModalOpen={onModalOpen} name: 'threaded_mode',
title={intl.formatMessage(messages.advanced_options_icon_title)} on: advancedOptions.get('threaded_mode'),
/> text: <FormattedMessage {...messages.threaded_mode_short} />,
},
]}
onChange={onChangeAdvancedOption}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.advanced_options_icon_title)}
/>
) : null}
</div> </div>
); );
} }
@@ -309,17 +326,17 @@ export default class ComposerOptions extends React.PureComponent {
// Props. // Props.
ComposerOptions.propTypes = { ComposerOptions.propTypes = {
acceptContentTypes: PropTypes.string, acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
disabled: PropTypes.bool, disabled: PropTypes.bool,
doNotFederate: PropTypes.bool,
full: PropTypes.bool, full: PropTypes.bool,
hasMedia: PropTypes.bool, hasMedia: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func,
onChangeSensitivity: PropTypes.func, onChangeSensitivity: PropTypes.func,
onChangeVisibility: PropTypes.func, onChangeVisibility: PropTypes.func,
onDoodleOpen: PropTypes.func, onDoodleOpen: PropTypes.func,
onModalClose: PropTypes.func, onModalClose: PropTypes.func,
onModalOpen: PropTypes.func, onModalOpen: PropTypes.func,
onToggleAdvancedOption: PropTypes.func,
onToggleSpoiler: PropTypes.func, onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func, onUpload: PropTypes.func,
privacy: PropTypes.string, privacy: PropTypes.string,

View File

@@ -85,6 +85,7 @@ export default function ComposerPublisher ({
unlisted: 'unlock-alt', unlisted: 'unlock-alt',
}[privacy]} }[privacy]}
/> />
{' '}
<FormattedMessage {...messages.publish} /> <FormattedMessage {...messages.publish} />
</span> </span>
); );

View File

@@ -1,12 +1,10 @@
// Package imports. // Package imports.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
// Components. // Components.
import Avatar from 'flavours/glitch/components/avatar'; import AccountContainer from 'flavours/glitch/containers/account_container';
import DisplayName from 'flavours/glitch/components/display_name';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
// Utils. // Utils.
@@ -31,17 +29,6 @@ const handlers = {
onCancel(); onCancel();
} }
}, },
// Handles a click on the status's account.
handleClickAccount () {
const {
account,
history,
} = this.props;
if (history) {
history.push(`/accounts/${account.get('id')}`);
}
},
}; };
// The component. // The component.
@@ -55,10 +42,7 @@ export default class ComposerReply extends React.PureComponent {
// Rendering. // Rendering.
render () { render () {
const { const { handleClick } = this.handlers;
handleClick,
handleClickAccount,
} = this.handlers;
const { const {
account, account,
content, content,
@@ -76,21 +60,10 @@ export default class ComposerReply extends React.PureComponent {
title={intl.formatMessage(messages.cancel)} title={intl.formatMessage(messages.cancel)}
/> />
{account ? ( {account ? (
<a <AccountContainer
className='account' id={account}
href={account.get('url')} small
onClick={handleClickAccount} />
>
<Avatar
account={account}
className='avatar'
size={24}
/>
<DisplayName
account={account}
className='display_name'
/>
</a>
) : null} ) : null}
</header> </header>
<div <div
@@ -105,9 +78,8 @@ export default class ComposerReply extends React.PureComponent {
} }
ComposerReply.propTypes = { ComposerReply.propTypes = {
account: ImmutablePropTypes.map, account: PropTypes.string,
content: PropTypes.string, content: PropTypes.string,
history: PropTypes.object,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onCancel: PropTypes.func, onCancel: PropTypes.func,
}; };

View File

@@ -0,0 +1,60 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Messages.
const messages = defineMessages({
localOnly: {
defaultMessage: 'This post is local-only',
id: 'advanced_options.local-only.tooltip',
},
threadedMode: {
defaultMessage: 'Threaded mode enabled',
id: 'advanced_options.threaded_mode.tooltip',
},
});
// We use an array of tuples here instead of an object because it
// preserves order.
const iconMap = [
['do_not_federate', 'home', messages.localOnly],
['threaded_mode', 'comments', messages.threadedMode],
];
// The component.
export default function ComposerTextareaIcons ({
advancedOptions,
intl,
}) {
// The result. We just map every active option to its icon.
return (
<div className='composer--textarea--icons'>
{advancedOptions ? iconMap.map(
([key, icon, message]) => advancedOptions.get(key) ? (
<span
className='textarea_icon'
key={key}
title={intl.formatMessage(message)}
>
<Icon
fullwidth
icon={icon}
/>
</span>
) : null
) : null}
</div>
);
}
// Props.
ComposerTextareaIcons.propTypes = {
advancedOptions: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};

View File

@@ -10,6 +10,7 @@ import Textarea from 'react-textarea-autosize';
// Components. // Components.
import EmojiPicker from 'flavours/glitch/features/emoji_picker'; import EmojiPicker from 'flavours/glitch/features/emoji_picker';
import ComposerTextareaIcons from './icons';
import ComposerTextareaSuggestions from './suggestions'; import ComposerTextareaSuggestions from './suggestions';
// Utils. // Utils.
@@ -32,7 +33,7 @@ const handlers = {
// When blurring the textarea, suggestions are hidden. // When blurring the textarea, suggestions are hidden.
handleBlur () { handleBlur () {
//this.setState({ suggestionsHidden: true }); this.setState({ suggestionsHidden: true });
}, },
// When the contents of the textarea change, we have to pull up new // When the contents of the textarea change, we have to pull up new
@@ -57,7 +58,7 @@ const handlers = {
const right = value.slice(selectionStart).search(/[\s\u200B]/); const right = value.slice(selectionStart).search(/[\s\u200B]/);
const token = function () { const token = function () {
switch (true) { switch (true) {
case left < 0 || /[@:]/.test(!value[left]): case left < 0 || !/[@:]/.test(value[left]):
return null; return null;
case right < 0: case right < 0:
return value.slice(left); return value.slice(left);
@@ -127,6 +128,11 @@ const handlers = {
return; return;
} }
// We submit the status on control/meta + enter.
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
onSubmit();
}
// Switches over the pressed key. // Switches over the pressed key.
switch(e.key) { switch(e.key) {
@@ -156,11 +162,6 @@ const handlers = {
} }
return; return;
} }
// We submit the status on control/meta + enter.
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
onSubmit();
}
}, },
// When the escape key is released, we either close the suggestions // When the escape key is released, we either close the suggestions
@@ -232,6 +233,7 @@ export default class ComposerTextarea extends React.Component {
handleRefTextarea, handleRefTextarea,
} = this.handlers; } = this.handlers;
const { const {
advancedOptions,
autoFocus, autoFocus,
disabled, disabled,
intl, intl,
@@ -249,6 +251,10 @@ export default class ComposerTextarea extends React.Component {
<div className='composer--textarea'> <div className='composer--textarea'>
<label> <label>
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
<ComposerTextareaIcons
advancedOptions={advancedOptions}
intl={intl}
/>
<Textarea <Textarea
aria-autocomplete='list' aria-autocomplete='list'
autoFocus={autoFocus} autoFocus={autoFocus}
@@ -280,6 +286,7 @@ export default class ComposerTextarea extends React.Component {
// Props. // Props.
ComposerTextarea.propTypes = { ComposerTextarea.propTypes = {
advancedOptions: ImmutablePropTypes.map,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
disabled: PropTypes.bool, disabled: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View File

@@ -24,9 +24,16 @@ const handlers = {
} = this.props; } = this.props;
if (onClick) { if (onClick) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevents following account links
onClick(index); onClick(index);
} }
}, },
// This prevents the focus from changing, which would mess with
// our suggestion code.
handleMouseDown (e) {
e.preventDefault();
},
}; };
// The component. // The component.
@@ -40,7 +47,10 @@ export default class ComposerTextareaSuggestionsItem extends React.Component {
// Rendering. // Rendering.
render () { render () {
const { handleClick } = this.handlers; const {
handleMouseDown,
handleClick,
} = this.handlers;
const { const {
selected, selected,
suggestion, suggestion,
@@ -51,7 +61,8 @@ export default class ComposerTextareaSuggestionsItem extends React.Component {
return ( return (
<div <div
className={computedClass} className={computedClass}
onMouseDown={handleClick} onMouseDown={handleMouseDown}
onClickCapture={handleClick} // Jumps in front of contents
role='button' role='button'
tabIndex='0' tabIndex='0'
> >

View File

@@ -45,10 +45,10 @@ const handlers = {
const { const {
onClear, onClear,
submitted, submitted,
value: { length }, value,
} = this.props; } = this.props;
e.preventDefault(); // Prevents focus change ?? e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || length)) { if (onClear && (submitted || value && value.length)) {
onClear(); onClear();
} }
}, },
@@ -100,7 +100,8 @@ export default class DrawerSearch extends React.PureComponent {
value, value,
} = this.props; } = this.props;
const { expanded } = this.state; const { expanded } = this.state;
const computedClass = classNames('drawer--search', { active: value.length || submitted }); const active = value && value.length || submitted;
const computedClass = classNames('drawer--search', { active });
return ( return (
<div className={computedClass}> <div className={computedClass}>
@@ -126,11 +127,11 @@ export default class DrawerSearch extends React.PureComponent {
tabIndex='0' tabIndex='0'
> >
<Icon icon='search' /> <Icon icon='search' />
<Icon icon='fa-times-circle' /> <Icon icon='times-circle' />
</div> </div>
<Overlay <Overlay
placement='bottom' placement='bottom'
show={expanded && !(value || '').length && !submitted} show={expanded && !active}
target={this} target={this}
><DrawerSearchPopout /></Overlay> ><DrawerSearchPopout /></Overlay>
</div> </div>

View File

@@ -42,56 +42,61 @@ export default function DrawerSearchPopout ({ style }) {
// The result. // The result.
return ( return (
<Motion <div
defaultStyle={{ className='drawer--search--popout'
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{ style={{
opacity: motionSpring, ...style,
scaleX: motionSpring, position: 'absolute',
scaleY: motionSpring, width: 285,
}} }}
> >
{({ opacity, scaleX, scaleY }) => ( <Motion
<div defaultStyle={{
className='drawer--search--popout' opacity: 0,
style={{ scaleX: 0.85,
...style, scaleY: 0.75,
position: 'absolute', }}
width: 285, style={{
opacity: opacity, opacity: motionSpring,
transform: `scale(${scaleX}, ${scaleY})`, scaleX: motionSpring,
}} scaleY: motionSpring,
> }}
<h4><FormattedMessage {...messages.format} /></h4> >
<ul> {({ opacity, scaleX, scaleY }) => (
<li> <div
<em>#example</em> style={{
{' '} opacity: opacity,
<FormattedMessage {...messages.hashtag} /> transform: `scale(${scaleX}, ${scaleY})`,
</li> }}
<li> >
<em>@username@domain</em> <h4><FormattedMessage {...messages.format} /></h4>
{' '} <ul>
<FormattedMessage {...messages.user} /> <li>
</li> <em>#example</em>
<li> {' '}
<em>URL</em> <FormattedMessage {...messages.hashtag} />
{' '} </li>
<FormattedMessage {...messages.user} /> <li>
</li> <em>@username@domain</em>
<li> {' '}
<em>URL</em> <FormattedMessage {...messages.user} />
{' '} </li>
<FormattedMessage {...messages.status} /> <li>
</li> <em>URL</em>
</ul> {' '}
<FormattedMessage {...messages.text} /> <FormattedMessage {...messages.user} />
</div> </li>
)} <li>
</Motion> <em>URL</em>
{' '}
<FormattedMessage {...messages.status} />
</li>
</ul>
<FormattedMessage {...messages.text} />
</div>
)}
</Motion>
</div>
); );
} }

View File

@@ -111,10 +111,10 @@ export default class GettingStarted extends ImmutablePureComponent {
navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
listItems = listItems.concat([ listItems = listItems.concat([
<div> <div key='7'>
<ColumnLink key='7' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> <ColumnLink key='8' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
{lists.map(list => {lists.map(list =>
<ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> <ColumnLink key={(8 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
)} )}
</div>, </div>,
]); ]);

View File

@@ -11,7 +11,6 @@ export default class ColumnSettings extends React.PureComponent {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
pushSettings: ImmutablePropTypes.map.isRequired, pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
}; };

View File

@@ -1,9 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; import { changeSetting } from 'flavours/glitch/actions/settings';
import { clearNotifications } from 'flavours/glitch/actions/notifications'; import { clearNotifications } from 'flavours/glitch/actions/notifications';
import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from 'flavours/glitch/actions/push_notifications'; import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
const messages = defineMessages({ const messages = defineMessages({
@@ -26,11 +26,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onSave () {
dispatch(saveSettings());
dispatch(savePushNotificationSettings());
},
onClear () { onClear () {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.clearMessage), message: intl.formatMessage(messages.clearMessage),

View File

@@ -52,9 +52,13 @@ const messages = {
'compose.attach.doodle': 'Draw something', 'compose.attach.doodle': 'Draw something',
'compose.attach': 'Attach...', 'compose.attach': 'Attach...',
'advanced-options.local-only.short': 'Local-only', 'advanced_options.local-only.short': 'Local-only',
'advanced-options.local-only.long': 'Do not post to other instances', 'advanced_options.local-only.long': 'Do not post to other instances',
'advanced_options.local-only.tooltip': 'This post is local-only',
'advanced_options.icon_title': 'Advanced options', 'advanced_options.icon_title': 'Advanced options',
'advanced_options.threaded_mode.short': 'Threaded mode',
'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting',
'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled',
}; };
export default Object.assign({}, inherited, messages); export default Object.assign({}, inherited, messages);

View File

@@ -55,9 +55,13 @@ const messages = {
'compose.attach.doodle': '落書きをする', 'compose.attach.doodle': '落書きをする',
'compose.attach': 'アタッチ...', 'compose.attach': 'アタッチ...',
'advanced-options.local-only.short': 'ローカル限定', 'advanced_options.local-only.short': 'ローカル限定',
'advanced-options.local-only.long': '他のインスタンスには投稿されません', 'advanced_options.local-only.long': '他のインスタンスには投稿されません',
'advanced_options.local-only.tooltip': 'この投稿はローカル限定投稿です',
'advanced_options.icon_title': '高度な設定', 'advanced_options.icon_title': '高度な設定',
'advanced_options.threaded_mode.short': 'スレッドモード',
'advanced_options.threaded_mode.long': '投稿時に自動的に返信するように設定します',
'advanced_options.threaded_mode.tooltip': 'スレッドモードを有効にする',
}; };
export default Object.assign({}, inherited, messages); export default Object.assign({}, inherited, messages);

View File

@@ -28,12 +28,16 @@ const messages = {
'settings.media': 'Zawartość multimedialna', 'settings.media': 'Zawartość multimedialna',
'settings.media_letterbox': 'Letterbox media', 'settings.media_letterbox': 'Letterbox media',
'settings.media_fullwidth': 'Podgląd zawartości multimedialnej o pełnej szerokości', 'settings.media_fullwidth': 'Podgląd zawartości multimedialnej o pełnej szerokości',
'settings.preferences': 'Preferencje użyytkownika', 'settings.preferences': 'Preferencje użytkownika',
'settings.wide_view': 'Szeroki widok (tylko w trybie desktopowym)', 'settings.wide_view': 'Szeroki widok (tylko w trybie desktopowym)',
'settings.navbar_under': 'Pasek nawigacji na dole (tylko w trybie mobilnym)', 'settings.navbar_under': 'Pasek nawigacji na dole (tylko w trybie mobilnym)',
'status.collapse': 'Zwiń', 'status.collapse': 'Zwiń',
'status.uncollapse': 'Rozwiń', 'status.uncollapse': 'Rozwiń',
'favourite_modal.combo': 'Możesz nacisnąć {combo}, aby pominąć to następnym razem',
'home.column_settings.show_direct': 'Pokaż wiadomości bezpośrednie',
'notification.markForDeletion': 'Oznacz do usunięcia', 'notification.markForDeletion': 'Oznacz do usunięcia',
'notifications.clear': 'Wyczyść wszystkie powiadomienia', 'notifications.clear': 'Wyczyść wszystkie powiadomienia',
'notifications.marked_clear_confirmation': 'Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?', 'notifications.marked_clear_confirmation': 'Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?',
@@ -43,6 +47,14 @@ const messages = {
'notification_purge.btn_none': 'Odznacz\nwszystkie', 'notification_purge.btn_none': 'Odznacz\nwszystkie',
'notification_purge.btn_invert': 'Odwróć\nzaznaczenie', 'notification_purge.btn_invert': 'Odwróć\nzaznaczenie',
'notification_purge.btn_apply': 'Usuń\nzaznaczone', 'notification_purge.btn_apply': 'Usuń\nzaznaczone',
'compose.attach.upload': 'Wyślij plik',
'compose.attach.doodle': 'Narysuj coś',
'compose.attach': 'Załącz coś',
'advanced-options.local-only.short': 'Tylko lokalnie',
'advanced-options.local-only.long': 'Nie wysyłaj na inne instancje',
'advanced_options.icon_title': 'Ustawienia zaawansowane',
}; };
export default Object.assign({}, inherited, messages); export default Object.assign({}, inherited, messages);

View File

@@ -6,3 +6,10 @@ en:
skins: skins:
glitch: glitch:
default: Default default: Default
pl:
flavours:
glitch:
description: Domyślny motyw instancji GlitchSoc.
skins:
glitch:
default: Domyślny

View File

@@ -33,11 +33,13 @@ import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import uuid from 'flavours/glitch/util/uuid'; import uuid from 'flavours/glitch/util/uuid';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
import { overwrite } from 'flavours/glitch/util/js_helpers';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
mounted: false, mounted: false,
advanced_options: ImmutableMap({ advanced_options: ImmutableMap({
do_not_federate: false, do_not_federate: false,
threaded_mode: false,
}), }),
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
@@ -55,6 +57,7 @@ const initialState = ImmutableMap({
suggestions: ImmutableList(), suggestions: ImmutableList(),
default_advanced_options: ImmutableMap({ default_advanced_options: ImmutableMap({
do_not_federate: false, do_not_federate: false,
threaded_mode: null, // Do not reset
}), }),
default_privacy: 'public', default_privacy: 'public',
default_sensitive: false, default_sensitive: false,
@@ -83,6 +86,20 @@ function statusToTextMentions(state, status) {
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
}; };
function apiStatusToTextMentions (state, status) {
let set = ImmutableOrderedSet([]);
if (status.account.id !== me) {
set = set.add(`@${status.account.acct} `);
}
return set.union(status.mentions.filter(
mention => mention.id !== me
).map(
mention => `@${mention.acct} `
)).join('');
}
function clearAll(state) { function clearAll(state) {
return state.withMutations(map => { return state.withMutations(map => {
map.set('text', ''); map.set('text', '');
@@ -90,7 +107,10 @@ function clearAll(state) {
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('is_submitting', false); map.set('is_submitting', false);
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('advanced_options', state.get('default_advanced_options')); map.update(
'advanced_options',
map => map.mergeWith(overwrite, state.get('default_advanced_options'))
);
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false); map.set('sensitive', false);
map.update('media_attachments', list => list.clear()); map.update('media_attachments', list => list.clear());
@@ -98,6 +118,31 @@ function clearAll(state) {
}); });
}; };
function continueThread (state, status) {
return state.withMutations(function (map) {
map.set('text', apiStatusToTextMentions(state, status));
if (status.spoiler_text) {
map.set('spoiler', true);
map.set('spoiler_text', status.spoiler_text);
} else {
map.set('spoiler', false);
map.set('spoiler_text', '');
}
map.set('is_submitting', false);
map.set('in_reply_to', status.id);
map.update(
'advanced_options',
map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) }))
);
map.set('privacy', privacyPreference(status.visibility, state.get('default_privacy')));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
map.set('idempotencyKey', uuid());
map.set('focusDate', new Date());
map.set('preselectDate', new Date());
});
}
function appendMedia(state, media) { function appendMedia(state, media) {
const prevSize = state.get('media_attachments').size; const prevSize = state.get('media_attachments').size;
@@ -182,8 +227,7 @@ export default function compose(state = initialState, action) {
return state.set('mounted', false); return state.set('mounted', false);
case COMPOSE_ADVANCED_OPTIONS_CHANGE: case COMPOSE_ADVANCED_OPTIONS_CHANGE:
return state return state
.set('advanced_options', .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid());
case COMPOSE_SENSITIVITY_CHANGE: case COMPOSE_SENSITIVITY_CHANGE:
return state.withMutations(map => { return state.withMutations(map => {
@@ -220,9 +264,10 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status)); map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('advanced_options', new ImmutableMap({ map.update(
do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), 'advanced_options',
})); map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
);
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('preselectDate', new Date()); map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
@@ -243,14 +288,17 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('advanced_options', state.get('default_advanced_options')); map.update(
'advanced_options',
map => map.mergeWith(overwrite, state.get('default_advanced_options'))
);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); });
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:
case COMPOSE_UPLOAD_CHANGE_REQUEST: case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_submitting', true); return state.set('is_submitting', true);
case COMPOSE_SUBMIT_SUCCESS: case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state); return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
case COMPOSE_SUBMIT_FAIL: case COMPOSE_SUBMIT_FAIL:
case COMPOSE_UPLOAD_CHANGE_FAIL: case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_submitting', false); return state.set('is_submitting', false);

View File

@@ -1,5 +1,5 @@
import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from 'flavours/glitch/actions/push_notifications'; import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from 'flavours/glitch/actions/push_notifications';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
@@ -43,7 +43,7 @@ export default function push_subscriptions(state = initialState, action) {
return state.set('browserSupport', action.value); return state.set('browserSupport', action.value);
case CLEAR_SUBSCRIPTION: case CLEAR_SUBSCRIPTION:
return initialState; return initialState;
case ALERTS_CHANGE: case SET_ALERTS:
return state.setIn(action.key, action.value); return state.setIn(action.key, action.value);
default: default:
return state; return state;

View File

@@ -52,22 +52,7 @@
margin-bottom: 5px; margin-bottom: 5px;
overflow: hidden; overflow: hidden;
& > .account { & > .account.small { color: $ui-base-color }
& > .avatar {
float: left;
margin-right: 5px;
}
& > .display_name {
color: $ui-base-color;
display: block;
padding-right: 25px;
max-width: 100%;
line-height: 24px;
text-decoration: none;
overflow: hidden;
}
}
& > .cancel { & > .cancel {
float: right; float: right;
@@ -87,6 +72,27 @@
overflow: visible; overflow: visible;
white-space: pre-wrap; white-space: pre-wrap;
padding-top: 5px; padding-top: 5px;
p {
margin-bottom: 20px;
&:last-child { margin-bottom: 0 }
}
a {
color: lighten($ui-base-color, 20%);
text-decoration: none;
&:hover { text-decoration: underline }
&.mention {
&:hover {
text-decoration: none;
span { text-decoration: underline }
}
}
}
} }
.emojione { .emojione {
@@ -94,27 +100,6 @@
height: 20px; height: 20px;
margin: -5px 0 0; margin: -5px 0 0;
} }
p {
margin-bottom: 20px;
&:last-child { margin-bottom: 0 }
}
a {
color: lighten($ui-base-color, 20%);
text-decoration: none;
&:hover { text-decoration: underline }
&.mention {
&:hover {
text-decoration: none;
span { text-decoration: underline }
}
}
}
} }
.composer--textarea { .composer--textarea {
@@ -149,6 +134,27 @@
} }
} }
.composer--textarea--icons {
display: block;
position: absolute;
top: 29px;
right: 5px;
bottom: 5px;
overflow: hidden;
& > .textarea_icon {
display: block;
margin: 2px 0 0 2px;
width: 24px;
height: 24px;
color: darken($ui-primary-color, 24%);
font-size: 18px;
line-height: 24px;
text-align: center;
opacity: .8;
}
}
.composer--textarea--suggestions { .composer--textarea--suggestions {
display: block; display: block;
position: absolute; position: absolute;
@@ -175,6 +181,7 @@
padding: 10px; padding: 10px;
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
overflow: hidden;
cursor: pointer; cursor: pointer;
&:hover, &:hover,
@@ -191,6 +198,12 @@
height: 18px; height: 18px;
} }
} }
& > .account.small {
.display-name {
& > span { color: lighten($ui-base-color, 36%) }
}
}
} }
.composer--upload_form { .composer--upload_form {

View File

@@ -114,19 +114,27 @@
} }
& > .icon { & > .icon {
display: block;
position: absolute;
top: 10px;
right: 10px;
width: 18px;
height: 18px;
color: $ui-secondary-color;
font-size: 18px;
line-height: 18px;
z-index: 2;
.fa { .fa {
display: inline-block; display: inline-block;
position: absolute; position: absolute;
top: 10px; top: 0;
right: 10px; bottom: 0;
width: 18px; left: 0;
height: 18px; right: 0;
color: $ui-secondary-color;
font-size: 18px;
opacity: 0; opacity: 0;
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
z-index: 2;
transition: all 100ms linear; transition: all 100ms linear;
} }
@@ -136,14 +144,15 @@
} }
.fa-times-circle { .fa-times-circle {
top: 11px;
transform: rotate(-90deg); transform: rotate(-90deg);
cursor: pointer; cursor: pointer;
&:hover { color: $primary-text-color } &:hover { color: $primary-text-color }
} }
}
&.active { &.active {
& > .icon {
.fa-search { .fa-search {
opacity: 0; opacity: 0;
transform: rotate(90deg); transform: rotate(90deg);
@@ -158,6 +167,32 @@
} }
} }
.drawer--search--popout {
box-sizing: border-box;
margin-top: 10px;
border-radius: 4px;
padding: 10px 14px 14px 14px;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
color: $ui-primary-color;
background: $simple-background-color;
h4 {
margin-bottom: 10px;
color: $ui-primary-color;
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
}
ul { margin-bottom: 10px }
li { padding: 4px 0 }
em {
color: $ui-base-color;
font-weight: 500;
}
}
.drawer--account { .drawer--account {
padding: 10px; padding: 10px;
color: $ui-primary-color; color: $ui-primary-color;

View File

@@ -745,6 +745,8 @@
.account { .account {
padding: 10px; padding: 10px;
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
color: inherit;
text-decoration: none;
.account__display-name { .account__display-name {
flex: 1 1 auto; flex: 1 1 auto;
@@ -762,27 +764,8 @@
& > .account__avatar-wrapper { margin: 0 8px 0 0 } & > .account__avatar-wrapper { margin: 0 8px 0 0 }
& > .display-name { & > .display-name {
display: block; height: 24px;
padding: 0; line-height: 24px;
height: auto;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
& > strong {
display: inline;
font-size: inherit;
line-height: inherit;
}
& > span {
display: inline;
color: lighten($ui-base-color, 36%);
font-size: inherit;
line-height: inherit;
&::before { content: " " }
}
} }
} }
} }
@@ -1243,6 +1226,30 @@
text-decoration: underline; text-decoration: underline;
} }
} }
&.inline {
padding: 0;
height: 18px;
font-size: 15px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
strong {
display: inline;
height: auto;
font-size: inherit;
line-height: inherit;
}
span {
display: inline;
height: auto;
font-size: inherit;
line-height: inherit;
}
}
} }
.status__relative-time, .status__relative-time,
@@ -1561,6 +1568,39 @@
} }
} }
.drawer__pager {
box-sizing: border-box;
padding: 0;
flex-grow: 1;
position: relative;
overflow: hidden;
display: flex;
}
.drawer__inner {
position: absolute;
top: 0;
left: 0;
background: lighten($ui-base-color, 13%) url('~images/wave-drawer.png') no-repeat bottom / 100% auto;
box-sizing: border-box;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
width: 100%;
height: 100%;
&.darker {
background: $ui-base-color;
}
> .mastodon {
background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
flex: 1;
}
}
.pseudo-drawer { .pseudo-drawer {
background: lighten($ui-base-color, 13%); background: lighten($ui-base-color, 13%);
font-size: 13px; font-size: 13px;
@@ -2774,6 +2814,112 @@
filter: none; filter: none;
} }
.search {
position: relative;
}
.search__input {
outline: 0;
box-sizing: border-box;
display: block;
width: 100%;
border: none;
padding: 10px;
padding-right: 30px;
font-family: inherit;
background: $ui-base-color;
color: $ui-primary-color;
font-size: 14px;
margin: 0;
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
&:focus {
background: lighten($ui-base-color, 4%);
}
@media screen and (max-width: 600px) {
font-size: 16px;
}
}
.search__icon {
.fa {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
display: inline-block;
opacity: 0;
transition: all 100ms linear;
font-size: 18px;
width: 18px;
height: 18px;
color: $ui-secondary-color;
cursor: default;
pointer-events: none;
&.active {
pointer-events: auto;
opacity: 0.3;
}
}
.fa-search {
transform: rotate(90deg);
&.active {
pointer-events: none;
transform: rotate(0deg);
}
}
.fa-times-circle {
top: 11px;
transform: rotate(0deg);
cursor: pointer;
&.active {
transform: rotate(90deg);
}
&:hover {
color: $primary-text-color;
}
}
}
.search-results__header {
color: $ui-base-lighter-color;
background: lighten($ui-base-color, 2%);
border-bottom: 1px solid darken($ui-base-color, 4%);
padding: 15px 10px;
font-size: 14px;
font-weight: 500;
}
.search-results__hashtag {
display: block;
padding: 10px;
color: $ui-secondary-color;
text-decoration: none;
&:hover,
&:active,
&:focus {
color: lighten($ui-secondary-color, 4%);
text-decoration: underline;
}
}
.modal-root { .modal-root {
transition: opacity 0.3s linear; transition: opacity 0.3s linear;
will-change: opacity; will-change: opacity;
@@ -3911,37 +4057,6 @@
border-radius: 0; border-radius: 0;
} }
.search-popout {
background: $simple-background-color;
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $ui-primary-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $ui-primary-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $ui-base-color;
}
}
noscript { noscript {
text-align: center; text-align: center;

View File

@@ -27,15 +27,15 @@ export function HashtagTimeline () {
} }
export function ListTimeline () { export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'flavours/glitch/features/list_timeline'); return import(/* webpackChunkName: "flavours/glitch/async/list_timeline" */'flavours/glitch/features/list_timeline');
} }
export function Lists () { export function Lists () {
return import(/* webpackChunkName: "features/lists" */'flavours/glitch/features/lists'); return import(/* webpackChunkName: "flavours/glitch/async/lists" */'flavours/glitch/features/lists');
} }
export function ListEditor () { export function ListEditor () {
return import(/* webpackChunkName: "features/list_editor" */'flavours/glitch/features/list_editor'); return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor');
} }
export function DirectTimeline() { export function DirectTimeline() {
@@ -51,7 +51,7 @@ export function GettingStarted () {
} }
export function KeyboardShortcuts () { export function KeyboardShortcuts () {
return import(/* webpackChunkName: "features/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts'); return import(/* webpackChunkName: "flavours/glitch/async/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts');
} }
export function PinnedStatuses () { export function PinnedStatuses () {

View File

@@ -18,6 +18,6 @@ export const boostModal = getMeta('boost_modal');
export const favouriteModal = getMeta('favourite_modal'); export const favouriteModal = getMeta('favourite_modal');
export const deleteModal = getMeta('delete_modal'); export const deleteModal = getMeta('delete_modal');
export const me = getMeta('me'); export const me = getMeta('me');
export const maxChars = getMeta('max_toot_chars') || 500; export const maxChars = (initialState && initialState.max_toot_chars) || 500;
export default initialState; export default initialState;

View File

@@ -0,0 +1,5 @@
// This function returns the new value unless it is `null` or
// `undefined`, in which case it returns the old one.
export function overwrite (oldVal, newVal) {
return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal;
}

View File

@@ -1,5 +1,5 @@
import * as WebPushSubscription from './web_push_subscription'; import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
import Mastodon from 'flavours/glitch/containers/mastodon'; import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import ready from './ready'; import ready from './ready';
@@ -25,7 +25,7 @@ function main() {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
require('offline-plugin/runtime').install(); require('offline-plugin/runtime').install();
WebPushSubscription.register(); store.dispatch(registerPushNotifications.register());
} }
perf.stop('main()'); perf.stop('main()');

View File

@@ -0,0 +1,46 @@
export default class Settings {
constructor(keyBase = null) {
this.keyBase = keyBase;
}
generateKey(id) {
return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
}
set(id, data) {
const key = this.generateKey(id);
try {
const encodedData = JSON.stringify(data);
localStorage.setItem(key, encodedData);
return data;
} catch (e) {
return null;
}
}
get(id) {
const key = this.generateKey(id);
try {
const rawData = localStorage.getItem(key);
return JSON.parse(rawData);
} catch (e) {
return null;
}
}
remove(id) {
const data = this.get(id);
if (data) {
const key = this.generateKey(id);
try {
localStorage.removeItem(key);
} catch (e) {
}
}
return data;
}
}
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');

View File

@@ -1,105 +0,0 @@
import axios from 'axios';
import { store } from 'flavours/glitch/containers/mastodon';
import { setBrowserSupport, setSubscription, clearSubscription } from 'flavours/glitch/actions/push_notifications';
// Taken from https://www.npmjs.com/package/web-push
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
const getRegistration = () => navigator.serviceWorker.ready;
const getPushSubscription = (registration) =>
registration.pushManager.getSubscription()
.then(subscription => ({ registration, subscription }));
const subscribe = (registration) =>
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
});
const unsubscribe = ({ registration, subscription }) =>
subscription ? subscription.unsubscribe().then(() => registration) : registration;
const sendSubscriptionToBackend = (subscription) =>
axios.post('/api/web/push_subscriptions', {
subscription,
}).then(response => response.data);
// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
export function register () {
store.dispatch(setBrowserSupport(supportsPushNotifications));
if (supportsPushNotifications) {
if (!getApplicationServerKey()) {
console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
return;
}
getRegistration()
.then(getPushSubscription)
.then(({ registration, subscription }) => {
if (subscription !== null) {
// We have a subscription, check if it is still valid
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
// If the VAPID public key did not change and the endpoint corresponds
// to the endpoint saved in the backend, the subscription is valid
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
return subscription;
} else {
// Something went wrong, try to subscribe again
return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
}
}
// No subscription, try to subscribe
return subscribe(registration).then(sendSubscriptionToBackend);
})
.then(subscription => {
// If we got a PushSubscription (and not a subscription object from the backend)
// it means that the backend subscription is valid (and was set during hydration)
if (!(subscription instanceof PushSubscription)) {
store.dispatch(setSubscription(subscription));
}
})
.catch(error => {
if (error.code === 20 && error.name === 'AbortError') {
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
} else if (error.code === 5 && error.name === 'InvalidCharacterError') {
console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
}
// Clear alerts and hide UI settings
store.dispatch(clearSubscription());
try {
getRegistration()
.then(getPushSubscription)
.then(unsubscribe);
} catch (e) {
}
});
} else {
console.warn('Your browser does not support Web Push Notifications.');
}
}

View File

@@ -6,3 +6,11 @@ en:
skins: skins:
vanilla: vanilla:
default: Default default: Default
pl:
flavours:
vanilla:
description: Motyw używany przez instancje czystego Mastodona. Może nie obsługiwać wszystkich funkcji GlitchSoc.
name: Mastodon Vanilla
skins:
vanilla:
default: Domyślny

View File

@@ -94,7 +94,7 @@ export default class Compose extends React.PureComponent {
<div className='drawer__inner' onFocus={this.onFocus}> <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} /> <NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer /> <ComposeFormContainer />
<div className='mastodon' /> {multiColumn && <div className='mastodon' />}
</div> </div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>

View File

@@ -70,30 +70,28 @@ export default class GettingStarted extends ImmutablePureComponent {
navItems.push( navItems.push(
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, <ColumnLink key='5' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
<ColumnLink key='6' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
); );
if (myAccount.get('locked')) { if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
} }
navItems.push(
<ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
);
if (multiColumn) { if (multiColumn) {
navItems.push(<ColumnLink key='10' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />); navItems.push(<ColumnLink key='7' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
} }
navItems.push(<ColumnLink key='8' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />);
return ( return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
<div className='getting-started__wrapper'> <div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
{navItems} {navItems}
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnLink icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div> </div>

View File

@@ -25,11 +25,11 @@
"account.unmute_notifications": "@{name}의 알림 뮤트 해제", "account.unmute_notifications": "@{name}의 알림 뮤트 해제",
"account.view_full_profile": "전체 프로필 보기", "account.view_full_profile": "전체 프로필 보기",
"boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.", "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
"bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
"bundle_column_error.retry": "다시 시도", "bundle_column_error.retry": "다시 시도",
"bundle_column_error.title": "네트워크 에러", "bundle_column_error.title": "네트워크 에러",
"bundle_modal_error.close": "닫기", "bundle_modal_error.close": "닫기",
"bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.message": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
"bundle_modal_error.retry": "다시 시도", "bundle_modal_error.retry": "다시 시도",
"column.blocks": "차단 중인 사용자", "column.blocks": "차단 중인 사용자",
"column.community": "로컬 타임라인", "column.community": "로컬 타임라인",
@@ -50,7 +50,7 @@
"column_header.unpin": "고정 해제", "column_header.unpin": "고정 해제",
"column_subheading.navigation": "내비게이션", "column_subheading.navigation": "내비게이션",
"column_subheading.settings": "설정", "column_subheading.settings": "설정",
"compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", "compose_form.hashtag_warning": "이 툿은 어떤 해시태그로도 검색 되지 않습니다. 전체공개로 게시 된 툿만이 해시태그로 검색 될 수 있습니다.",
"compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.", "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
"compose_form.lock_disclaimer.lock": "비공개", "compose_form.lock_disclaimer.lock": "비공개",
"compose_form.placeholder": "지금 무엇을 하고 있나요?", "compose_form.placeholder": "지금 무엇을 하고 있나요?",
@@ -135,7 +135,7 @@
"lists.new.create": "리스트 추가", "lists.new.create": "리스트 추가",
"lists.new.title_placeholder": "새 리스트의 이름", "lists.new.title_placeholder": "새 리스트의 이름",
"lists.search": "팔로우 중인 사람들 중에서 찾기", "lists.search": "팔로우 중인 사람들 중에서 찾기",
"lists.subheading": "Your lists", "lists.subheading": "당신의 리스트",
"loading_indicator.label": "불러오는 중...", "loading_indicator.label": "불러오는 중...",
"media_gallery.toggle_visible": "표시 전환", "media_gallery.toggle_visible": "표시 전환",
"missing_indicator.label": "찾을 수 없습니다", "missing_indicator.label": "찾을 수 없습니다",
@@ -178,7 +178,7 @@
"onboarding.page_one.welcome": "Mastodon에 어서 오세요!", "onboarding.page_one.welcome": "Mastodon에 어서 오세요!",
"onboarding.page_six.admin": "이 인스턴스의 관리자는 {admin}입니다.", "onboarding.page_six.admin": "이 인스턴스의 관리자는 {admin}입니다.",
"onboarding.page_six.almost_done": "이상입니다.", "onboarding.page_six.almost_done": "이상입니다.",
"onboarding.page_six.appetoot": "Bon Appetoot!", "onboarding.page_six.appetoot": "본 아페툿!",
"onboarding.page_six.apps_available": "iOS、Android 또는 다른 플랫폼에서 사용할 수 있는 {apps}이 있습니다.", "onboarding.page_six.apps_available": "iOS、Android 또는 다른 플랫폼에서 사용할 수 있는 {apps}이 있습니다.",
"onboarding.page_six.github": "Mastodon는 오픈 소스 소프트웨어입니다. 버그 보고나 기능 추가 요청, 기여는 {github}에서 할 수 있습니다.", "onboarding.page_six.github": "Mastodon는 오픈 소스 소프트웨어입니다. 버그 보고나 기능 추가 요청, 기여는 {github}에서 할 수 있습니다.",
"onboarding.page_six.guidelines": "커뮤니티 가이드라인", "onboarding.page_six.guidelines": "커뮤니티 가이드라인",
@@ -213,7 +213,7 @@
"search_popout.tips.text": "단순한 텍스트 검색은 관계된 프로필 이름, 유저 이름 그리고 해시태그를 표시합니다", "search_popout.tips.text": "단순한 텍스트 검색은 관계된 프로필 이름, 유저 이름 그리고 해시태그를 표시합니다",
"search_popout.tips.user": "유저", "search_popout.tips.user": "유저",
"search_results.total": "{count, number}건의 결과", "search_results.total": "{count, number}건의 결과",
"standalone.public_title": "A look inside...", "standalone.public_title": "지금 이런 이야기를 하고 있습니다…",
"status.block": "@{name} 차단", "status.block": "@{name} 차단",
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
"status.delete": "삭제", "status.delete": "삭제",
@@ -247,7 +247,7 @@
"ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.", "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.",
"upload_area.title": "드래그 & 드롭으로 업로드", "upload_area.title": "드래그 & 드롭으로 업로드",
"upload_button.label": "미디어 추가", "upload_button.label": "미디어 추가",
"upload_form.description": "Describe for the visually impaired", "upload_form.description": "시각장애인을 위한 설명",
"upload_form.undo": "재시도", "upload_form.undo": "재시도",
"upload_progress.label": "업로드 중...", "upload_progress.label": "업로드 중...",
"video.close": "동영상 닫기", "video.close": "동영상 닫기",

View File

@@ -11,7 +11,7 @@
"account.media": "Mediji", "account.media": "Mediji",
"account.mention": "Pomeni korisnika @{name}", "account.mention": "Pomeni korisnika @{name}",
"account.moved_to": "{name} se pomerio na:", "account.moved_to": "{name} se pomerio na:",
"account.mute": "Mutiraj @{name}", "account.mute": "Ućutkaj korisnika @{name}",
"account.mute_notifications": "Isključi obaveštenja od korisnika @{name}", "account.mute_notifications": "Isključi obaveštenja od korisnika @{name}",
"account.posts": "Statusa", "account.posts": "Statusa",
"account.report": "Prijavi @{name}", "account.report": "Prijavi @{name}",
@@ -21,7 +21,7 @@
"account.unblock": "Odblokiraj korisnika @{name}", "account.unblock": "Odblokiraj korisnika @{name}",
"account.unblock_domain": "Odblokiraj domen {domain}", "account.unblock_domain": "Odblokiraj domen {domain}",
"account.unfollow": "Otprati", "account.unfollow": "Otprati",
"account.unmute": "Odmutiraj @{name}", "account.unmute": "Ukloni ućutkavanje korisniku @{name}",
"account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}", "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}",
"account.view_full_profile": "Vidi ceo profil", "account.view_full_profile": "Vidi ceo profil",
"boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put", "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put",
@@ -37,10 +37,10 @@
"column.follow_requests": "Zahtevi za praćenje", "column.follow_requests": "Zahtevi za praćenje",
"column.home": "Početna", "column.home": "Početna",
"column.lists": "Liste", "column.lists": "Liste",
"column.mutes": "Mutirani korisnici", "column.mutes": "Ućutkani korisnici",
"column.notifications": "Obaveštenja", "column.notifications": "Obaveštenja",
"column.pins": "Prikačeni tutovi", "column.pins": "Prikačeni tutovi",
"column.public": "Združena lajna", "column.public": "Federisana lajna",
"column_back_button.label": "Nazad", "column_back_button.label": "Nazad",
"column_header.hide_settings": "Sakrij postavke", "column_header.hide_settings": "Sakrij postavke",
"column_header.moveLeft_settings": "Pomeri kolonu ulevo", "column_header.moveLeft_settings": "Pomeri kolonu ulevo",
@@ -50,6 +50,7 @@
"column_header.unpin": "Otkači", "column_header.unpin": "Otkači",
"column_subheading.navigation": "Navigacija", "column_subheading.navigation": "Navigacija",
"column_subheading.settings": "Postavke", "column_subheading.settings": "Postavke",
"compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
"compose_form.lock_disclaimer": "Vaš nalog nije {locked}. Svako može da Vas zaprati i da vidi objave namenjene samo Vašim pratiocima.", "compose_form.lock_disclaimer": "Vaš nalog nije {locked}. Svako može da Vas zaprati i da vidi objave namenjene samo Vašim pratiocima.",
"compose_form.lock_disclaimer.lock": "zaključan", "compose_form.lock_disclaimer.lock": "zaključan",
"compose_form.placeholder": "Šta Vam je na umu?", "compose_form.placeholder": "Šta Vam je na umu?",
@@ -66,9 +67,9 @@
"confirmations.delete_list.confirm": "Obriši", "confirmations.delete_list.confirm": "Obriši",
"confirmations.delete_list.message": "Da li ste sigurni da želite da bespovratno obrišete ovu listu?", "confirmations.delete_list.message": "Da li ste sigurni da želite da bespovratno obrišete ovu listu?",
"confirmations.domain_block.confirm": "Sakrij ceo domen", "confirmations.domain_block.confirm": "Sakrij ceo domen",
"confirmations.domain_block.message": "Da li ste stvarno, stvarno sigurno da želite da blokirate ceo domen {domain}? U većini slučajeva, par dobrih blokiranja ili mutiranja su dovoljna i preporučljiva.", "confirmations.domain_block.message": "Da li ste stvarno, stvarno sigurno da želite da blokirate ceo domen {domain}? U većini slučajeva, par dobrih blokiranja ili ućutkavanja su dovoljna i preporučljiva.",
"confirmations.mute.confirm": "Mutiraj", "confirmations.mute.confirm": "Ućutkaj",
"confirmations.mute.message": "Da li stvarno želite da mutirate korisnika {name}?", "confirmations.mute.message": "Da li stvarno želite da ućutkate korisnika {name}?",
"confirmations.unfollow.confirm": "Otprati", "confirmations.unfollow.confirm": "Otprati",
"confirmations.unfollow.message": "Da li ste sigurni da želite da otpratite korisnika {name}?", "confirmations.unfollow.message": "Da li ste sigurni da želite da otpratite korisnika {name}?",
"embed.instructions": "Ugradi ovaj status na Vaš veb sajt kopiranjem koda ispod.", "embed.instructions": "Ugradi ovaj status na Vaš veb sajt kopiranjem koda ispod.",
@@ -148,10 +149,10 @@
"navigation_bar.keyboard_shortcuts": "Prečice na tastaturi", "navigation_bar.keyboard_shortcuts": "Prečice na tastaturi",
"navigation_bar.lists": "Liste", "navigation_bar.lists": "Liste",
"navigation_bar.logout": "Odjava", "navigation_bar.logout": "Odjava",
"navigation_bar.mutes": "Mutirani korisnici", "navigation_bar.mutes": "Ućutkani korisnici",
"navigation_bar.pins": "Prikačeni tutovi", "navigation_bar.pins": "Prikačeni tutovi",
"navigation_bar.preferences": "Podešavanja", "navigation_bar.preferences": "Podešavanja",
"navigation_bar.public_timeline": "Združena lajna", "navigation_bar.public_timeline": "Federisana lajna",
"notification.favourite": "{name} je stavio Vaš status kao omiljeni", "notification.favourite": "{name} je stavio Vaš status kao omiljeni",
"notification.follow": "{name} Vas je zapratio", "notification.follow": "{name} Vas je zapratio",
"notification.mention": "{name} Vas je pomenuo", "notification.mention": "{name} Vas je pomenuo",
@@ -169,7 +170,7 @@
"notifications.column_settings.sound": "Puštaj zvuk", "notifications.column_settings.sound": "Puštaj zvuk",
"onboarding.done": "Gotovo", "onboarding.done": "Gotovo",
"onboarding.next": "Sledeće", "onboarding.next": "Sledeće",
"onboarding.page_five.public_timelines": "Lokalna lajna prikazuje sve javne statuse od svih na domenu {domain}. Združena lajna prikazuje javne statuse od svih ljudi koje prate korisnici sa domena {domain}. Ovo su javne lajne, sjajan način da otkrijete nove ljude.", "onboarding.page_five.public_timelines": "Lokalna lajna prikazuje sve javne statuse od svih na domenu {domain}. Federisana lajna prikazuje javne statuse od svih ljudi koje prate korisnici sa domena {domain}. Ovo su javne lajne, sjajan način da otkrijete nove ljude.",
"onboarding.page_four.home": "Početna lajna prikazuje statuse ljudi koje Vi pratite.", "onboarding.page_four.home": "Početna lajna prikazuje statuse ljudi koje Vi pratite.",
"onboarding.page_four.notifications": "Kolona sa obaveštenjima Vam prikazuje kada neko priča sa Vama.", "onboarding.page_four.notifications": "Kolona sa obaveštenjima Vam prikazuje kada neko priča sa Vama.",
"onboarding.page_one.federation": "Mastodont je mreža nezavisnih servera koji se uvezuju da naprave jednu veću društvenu mrežu. Ove servere zovemo instancama.", "onboarding.page_one.federation": "Mastodont je mreža nezavisnih servera koji se uvezuju da naprave jednu veću društvenu mrežu. Ove servere zovemo instancama.",
@@ -213,6 +214,7 @@
"search_popout.tips.user": "korisnik", "search_popout.tips.user": "korisnik",
"search_results.total": "{count, number} {count, plural, one {rezultat} few {rezultata} other {rezultata}}", "search_results.total": "{count, number} {count, plural, one {rezultat} few {rezultata} other {rezultata}}",
"standalone.public_title": "Pogled iznutra...", "standalone.public_title": "Pogled iznutra...",
"status.block": "Block @{name}",
"status.cannot_reblog": "Ovaj status ne može da se podrži", "status.cannot_reblog": "Ovaj status ne može da se podrži",
"status.delete": "Obriši", "status.delete": "Obriši",
"status.embed": "Ugradi na sajt", "status.embed": "Ugradi na sajt",
@@ -221,7 +223,8 @@
"status.media_hidden": "Multimedija sakrivena", "status.media_hidden": "Multimedija sakrivena",
"status.mention": "Pomeni korisnika @{name}", "status.mention": "Pomeni korisnika @{name}",
"status.more": "Još", "status.more": "Još",
"status.mute_conversation": "Mutiraj prepisku", "status.mute": "Mute @{name}",
"status.mute_conversation": "Ućutkaj prepisku",
"status.open": "Proširi ovaj status", "status.open": "Proširi ovaj status",
"status.pin": "Prikači na profil", "status.pin": "Prikači na profil",
"status.reblog": "Podrži", "status.reblog": "Podrži",
@@ -237,7 +240,7 @@
"status.unmute_conversation": "Uključi prepisku", "status.unmute_conversation": "Uključi prepisku",
"status.unpin": "Otkači sa profila", "status.unpin": "Otkači sa profila",
"tabs_bar.compose": "Napiši", "tabs_bar.compose": "Napiši",
"tabs_bar.federated_timeline": "Združeno", "tabs_bar.federated_timeline": "Federisano",
"tabs_bar.home": "Početna", "tabs_bar.home": "Početna",
"tabs_bar.local_timeline": "Lokalno", "tabs_bar.local_timeline": "Lokalno",
"tabs_bar.notifications": "Obaveštenja", "tabs_bar.notifications": "Obaveštenja",

View File

@@ -11,7 +11,7 @@
"account.media": "Медији", "account.media": "Медији",
"account.mention": "Помени корисника @{name}", "account.mention": "Помени корисника @{name}",
"account.moved_to": "{name} се померио на:", "account.moved_to": "{name} се померио на:",
"account.mute": "Мутирај @{name}", "account.mute": "Ућуткај корисника @{name}",
"account.mute_notifications": "Искључи обавештења од корисника @{name}", "account.mute_notifications": "Искључи обавештења од корисника @{name}",
"account.posts": "Статуса", "account.posts": "Статуса",
"account.report": "Пријави @{name}", "account.report": "Пријави @{name}",
@@ -21,7 +21,7 @@
"account.unblock": "Одблокирај корисника @{name}", "account.unblock": "Одблокирај корисника @{name}",
"account.unblock_domain": "Одблокирај домен {domain}", "account.unblock_domain": "Одблокирај домен {domain}",
"account.unfollow": "Отпрати", "account.unfollow": "Отпрати",
"account.unmute": "Одмутирај @{name}", "account.unmute": "Уклони ућуткавање кориснику @{name}",
"account.unmute_notifications": "Укључи назад обавештења од корисника @{name}", "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}",
"account.view_full_profile": "Види цео профил", "account.view_full_profile": "Види цео профил",
"boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут", "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут",
@@ -37,10 +37,10 @@
"column.follow_requests": "Захтеви за праћење", "column.follow_requests": "Захтеви за праћење",
"column.home": "Почетна", "column.home": "Почетна",
"column.lists": "Листе", "column.lists": "Листе",
"column.mutes": "Мутирани корисници", "column.mutes": "Ућуткани корисници",
"column.notifications": "Обавештења", "column.notifications": "Обавештења",
"column.pins": "Прикачени тутови", "column.pins": "Прикачени тутови",
"column.public": "Здружена лајна", "column.public": "Федерисана лајна",
"column_back_button.label": "Назад", "column_back_button.label": "Назад",
"column_header.hide_settings": "Сакриј поставке", "column_header.hide_settings": "Сакриј поставке",
"column_header.moveLeft_settings": "Помери колону улево", "column_header.moveLeft_settings": "Помери колону улево",
@@ -67,9 +67,9 @@
"confirmations.delete_list.confirm": "Обриши", "confirmations.delete_list.confirm": "Обриши",
"confirmations.delete_list.message": "Да ли сте сигурни да желите да бесповратно обришете ову листу?", "confirmations.delete_list.message": "Да ли сте сигурни да желите да бесповратно обришете ову листу?",
"confirmations.domain_block.confirm": "Сакриј цео домен", "confirmations.domain_block.confirm": "Сакриј цео домен",
"confirmations.domain_block.message": "Да ли сте стварно, стварно сигурно да желите да блокирате цео домен {domain}? У већини случајева, пар добрих блокирања или мутирања су довољна и препоручљива.", "confirmations.domain_block.message": "Да ли сте стварно, стварно сигурно да желите да блокирате цео домен {domain}? У већини случајева, пар добрих блокирања или ућуткавања су довољна и препоручљива.",
"confirmations.mute.confirm": "Мутирај", "confirmations.mute.confirm": "Ућуткај",
"confirmations.mute.message": "Да ли стварно желите да мутирате корисника {name}?", "confirmations.mute.message": "Да ли стварно желите да ућуткате корисника {name}?",
"confirmations.unfollow.confirm": "Отпрати", "confirmations.unfollow.confirm": "Отпрати",
"confirmations.unfollow.message": "Да ли сте сигурни да желите да отпратите корисника {name}?", "confirmations.unfollow.message": "Да ли сте сигурни да желите да отпратите корисника {name}?",
"embed.instructions": "Угради овај статус на Ваш веб сајт копирањем кода испод.", "embed.instructions": "Угради овај статус на Ваш веб сајт копирањем кода испод.",
@@ -149,10 +149,10 @@
"navigation_bar.keyboard_shortcuts": "Пречице на тастатури", "navigation_bar.keyboard_shortcuts": "Пречице на тастатури",
"navigation_bar.lists": "Листе", "navigation_bar.lists": "Листе",
"navigation_bar.logout": "Одјава", "navigation_bar.logout": "Одјава",
"navigation_bar.mutes": "Мутирани корисници", "navigation_bar.mutes": "Ућуткани корисници",
"navigation_bar.pins": "Прикачени тутови", "navigation_bar.pins": "Прикачени тутови",
"navigation_bar.preferences": "Подешавања", "navigation_bar.preferences": "Подешавања",
"navigation_bar.public_timeline": "Здружена лајна", "navigation_bar.public_timeline": "Федерисана лајна",
"notification.favourite": "{name} је ставио Ваш статус као омиљени", "notification.favourite": "{name} је ставио Ваш статус као омиљени",
"notification.follow": "{name} Вас је запратио", "notification.follow": "{name} Вас је запратио",
"notification.mention": "{name} Вас је поменуо", "notification.mention": "{name} Вас је поменуо",
@@ -170,7 +170,7 @@
"notifications.column_settings.sound": "Пуштај звук", "notifications.column_settings.sound": "Пуштај звук",
"onboarding.done": "Готово", "onboarding.done": "Готово",
"onboarding.next": "Следеће", "onboarding.next": "Следеће",
"onboarding.page_five.public_timelines": "Локална лајна приказује све јавне статусе од свих на домену {domain}. Здружена лајна приказује јавне статусе од свих људи које прате корисници са домена {domain}. Ово су јавне лајне, сјајан начин да откријете нове људе.", "onboarding.page_five.public_timelines": "Локална лајна приказује све јавне статусе од свих на домену {domain}. Федерисана лајна приказује јавне статусе од свих људи које прате корисници са домена {domain}. Ово су јавне лајне, сјајан начин да откријете нове људе.",
"onboarding.page_four.home": "Почетна лајна приказује статусе људи које Ви пратите.", "onboarding.page_four.home": "Почетна лајна приказује статусе људи које Ви пратите.",
"onboarding.page_four.notifications": "Колона са обавештењима Вам приказује када неко прича са Вама.", "onboarding.page_four.notifications": "Колона са обавештењима Вам приказује када неко прича са Вама.",
"onboarding.page_one.federation": "Мастодонт је мрежа независних сервера који се увезују да направе једну већу друштвену мрежу. Ове сервере зовемо инстанцама.", "onboarding.page_one.federation": "Мастодонт је мрежа независних сервера који се увезују да направе једну већу друштвену мрежу. Ове сервере зовемо инстанцама.",
@@ -224,7 +224,7 @@
"status.mention": "Помени корисника @{name}", "status.mention": "Помени корисника @{name}",
"status.more": "Још", "status.more": "Још",
"status.mute": "Mute @{name}", "status.mute": "Mute @{name}",
"status.mute_conversation": "Мутирај преписку", "status.mute_conversation": "Ућуткај преписку",
"status.open": "Прошири овај статус", "status.open": "Прошири овај статус",
"status.pin": "Прикачи на профил", "status.pin": "Прикачи на профил",
"status.reblog": "Подржи", "status.reblog": "Подржи",
@@ -240,7 +240,7 @@
"status.unmute_conversation": "Укључи преписку", "status.unmute_conversation": "Укључи преписку",
"status.unpin": "Откачи са профила", "status.unpin": "Откачи са профила",
"tabs_bar.compose": "Напиши", "tabs_bar.compose": "Напиши",
"tabs_bar.federated_timeline": "Здружено", "tabs_bar.federated_timeline": "Федерисано",
"tabs_bar.home": "Почетна", "tabs_bar.home": "Почетна",
"tabs_bar.local_timeline": "Локално", "tabs_bar.local_timeline": "Локално",
"tabs_bar.notifications": "Обавештења", "tabs_bar.notifications": "Обавештења",

View File

@@ -0,0 +1,2 @@
[
]

View File

@@ -50,7 +50,7 @@
"column_header.unpin": "取消固定", "column_header.unpin": "取消固定",
"column_subheading.navigation": "导航", "column_subheading.navigation": "导航",
"column_subheading.settings": "设置", "column_subheading.settings": "设置",
"compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.", "compose_form.hashtag_warning": "这条嘟文被设置为“不公开”,因此它不会出现在任何话题标签的列表下。只有公开的嘟文才能通过话题标签进行搜索。",
"compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以在关注你后立即查看仅关注者可见的嘟文。", "compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以在关注你后立即查看仅关注者可见的嘟文。",
"compose_form.lock_disclaimer.lock": "开启保护", "compose_form.lock_disclaimer.lock": "开启保护",
"compose_form.placeholder": "在想啥?", "compose_form.placeholder": "在想啥?",
@@ -214,7 +214,7 @@
"search_popout.tips.user": "用户", "search_popout.tips.user": "用户",
"search_results.total": "共 {count, number} 个结果", "search_results.total": "共 {count, number} 个结果",
"standalone.public_title": "大家都在干啥?", "standalone.public_title": "大家都在干啥?",
"status.block": "Block @{name}", "status.block": "屏蔽 @{name}",
"status.cannot_reblog": "无法转嘟这条嘟文", "status.cannot_reblog": "无法转嘟这条嘟文",
"status.delete": "删除", "status.delete": "删除",
"status.embed": "嵌入", "status.embed": "嵌入",
@@ -223,7 +223,7 @@
"status.media_hidden": "隐藏媒体内容", "status.media_hidden": "隐藏媒体内容",
"status.mention": "提及 @{name}", "status.mention": "提及 @{name}",
"status.more": "更多", "status.more": "更多",
"status.mute": "Mute @{name}", "status.mute": "隐藏 @{name}",
"status.mute_conversation": "隐藏此对话", "status.mute_conversation": "隐藏此对话",
"status.open": "展开嘟文", "status.open": "展开嘟文",
"status.pin": "在个人资料页面置顶", "status.pin": "在个人资料页面置顶",

View File

@@ -64,8 +64,8 @@
"confirmations.block.message": "你確定要封鎖 {name} ", "confirmations.block.message": "你確定要封鎖 {name} ",
"confirmations.delete.confirm": "刪除", "confirmations.delete.confirm": "刪除",
"confirmations.delete.message": "你確定要刪除這個狀態?", "confirmations.delete.message": "你確定要刪除這個狀態?",
"confirmations.delete_list.confirm": "Delete", "confirmations.delete_list.confirm": "刪除",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", "confirmations.delete_list.message": "確定要永久性地刪除這個名單嗎?",
"confirmations.domain_block.confirm": "隱藏整個網域", "confirmations.domain_block.confirm": "隱藏整個網域",
"confirmations.domain_block.message": "你真的真的確定要隱藏整個 {domain} ?多數情況下,比較推薦封鎖或消音幾個特定目標就好。", "confirmations.domain_block.message": "你真的真的確定要隱藏整個 {domain} ?多數情況下,比較推薦封鎖或消音幾個特定目標就好。",
"confirmations.mute.confirm": "消音", "confirmations.mute.confirm": "消音",
@@ -128,14 +128,14 @@
"lightbox.close": "關閉", "lightbox.close": "關閉",
"lightbox.next": "繼續", "lightbox.next": "繼續",
"lightbox.previous": "回退", "lightbox.previous": "回退",
"lists.account.add": "Add to list", "lists.account.add": "加到名單裡",
"lists.account.remove": "Remove from list", "lists.account.remove": "從名單中移除",
"lists.delete": "Delete list", "lists.delete": "刪除名單",
"lists.edit": "Edit list", "lists.edit": "修改名單",
"lists.new.create": "Add list", "lists.new.create": "新增名單",
"lists.new.title_placeholder": "New list title", "lists.new.title_placeholder": "名單名稱",
"lists.search": "Search among people you follow", "lists.search": "搜尋您關注的使用者",
"lists.subheading": "Your lists", "lists.subheading": "您的名單",
"loading_indicator.label": "讀取中...", "loading_indicator.label": "讀取中...",
"media_gallery.toggle_visible": "切換可見性", "media_gallery.toggle_visible": "切換可見性",
"missing_indicator.label": "找不到", "missing_indicator.label": "找不到",
@@ -146,8 +146,8 @@
"navigation_bar.favourites": "最愛", "navigation_bar.favourites": "最愛",
"navigation_bar.follow_requests": "關注請求", "navigation_bar.follow_requests": "關注請求",
"navigation_bar.info": "關於本站", "navigation_bar.info": "關於本站",
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts", "navigation_bar.keyboard_shortcuts": "快速鍵",
"navigation_bar.lists": "Lists", "navigation_bar.lists": "名單",
"navigation_bar.logout": "登出", "navigation_bar.logout": "登出",
"navigation_bar.mutes": "消音的使用者", "navigation_bar.mutes": "消音的使用者",
"navigation_bar.pins": "置頂貼文", "navigation_bar.pins": "置頂貼文",

View File

@@ -398,10 +398,12 @@
} }
} }
&__content {
max-width: calc(100% - 90px);
}
&__title { &__title {
overflow: hidden; word-wrap: break-word;
text-overflow: ellipsis;
white-space: nowrap;
} }
&__timestamp { &__timestamp {
@@ -415,7 +417,7 @@
color: $ui-primary-color; color: $ui-primary-color;
font-family: 'mastodon-font-monospace', monospace; font-family: 'mastodon-font-monospace', monospace;
font-size: 12px; font-size: 12px;
white-space: nowrap; word-wrap: break-word;
min-height: 20px; min-height: 20px;
} }

View File

@@ -2,18 +2,16 @@
class ActivityPub::Activity::Accept < ActivityPub::Activity class ActivityPub::Activity::Accept < ActivityPub::Activity
def perform def perform
if @object.respond_to?(:[]) && case @object['type']
@object['type'] == 'Follow' && @object['actor'].present? when 'Follow'
accept_follow_from @object['actor'] accept_follow
else
accept_follow_object @object
end end
end end
private private
def accept_follow_from(actor) def accept_follow
target_account = account_from_uri(value_or_id(actor)) target_account = account_from_uri(target_uri)
return if target_account.nil? || !target_account.local? return if target_account.nil? || !target_account.local?
@@ -21,8 +19,7 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
follow_request&.authorize! follow_request&.authorize!
end end
def accept_follow_object(object) def target_uri
follow_request = ActivityPub::TagManager.instance.uri_to_resource(value_or_id(object), FollowRequest) @target_uri ||= value_or_id(@object['actor'])
follow_request&.authorize!
end end
end end

View File

@@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::Activity::Create < ActivityPub::Activity class ActivityPub::Activity::Create < ActivityPub::Activity
SUPPORTED_TYPES = %w(Article Note).freeze SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video).freeze CONVERTED_TYPES = %w(Image Video Article).freeze
def perform def perform
return if delete_arrived_first?(object_uri) || unsupported_object_type? return if delete_arrived_first?(object_uri) || unsupported_object_type? || invalid_origin?(@object['id'])
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@@ -213,7 +213,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def object_url def object_url
return if @object['url'].blank? return if @object['url'].blank?
url_to_href(@object['url'], 'text/html')
url_candidate = url_to_href(@object['url'], 'text/html')
if invalid_origin?(url_candidate)
nil
else
url_candidate
end
end end
def content_language_map? def content_language_map?
@@ -245,6 +252,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
end end
def invalid_origin?(url)
return true if unsupported_uri_scheme?(url)
needle = Addressable::URI.parse(url).host
haystack = Addressable::URI.parse(@account.uri).host
!haystack.casecmp(needle).zero?
end
def reply_to_local? def reply_to_local?
!replied_to_status.nil? && replied_to_status.account.local? !replied_to_status.nil? && replied_to_status.account.local?
end end

View File

@@ -28,8 +28,6 @@ class ActivityPub::TagManager
return target.uri if target.respond_to?(:local?) && !target.local? return target.uri if target.respond_to?(:local?) && !target.local?
case target.object_type case target.object_type
when :follow
account_follow_url(target.account.username, target)
when :person when :person
account_url(target) account_url(target)
when :note, :comment, :activity when :note, :comment, :activity
@@ -99,12 +97,6 @@ class ActivityPub::TagManager
case klass.name case klass.name
when 'Account' when 'Account'
klass.find_local(uri_to_local_id(uri, :username)) klass.find_local(uri_to_local_id(uri, :username))
when 'FollowRequest'
params = Rails.application.routes.recognize_path(uri)
klass.joins(:account).find_by!(
accounts: { domain: nil, username: params[:account_username] },
id: params[:id]
)
else else
StatusFinder.new(uri).status StatusFinder.new(uri).status
end end

View File

@@ -21,10 +21,6 @@ class FollowRequest < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id } validates :account_id, uniqueness: { scope: :target_account_id }
def object_type
:follow
end
def authorize! def authorize!
account.follow!(target_account, reblogs: show_reblogs) account.follow!(target_account, reblogs: show_reblogs)
MergeWorker.perform_async(target_account.id, account.id) MergeWorker.perform_async(target_account.id, account.id)

View File

@@ -126,18 +126,18 @@ class User < ApplicationRecord
end end
def confirm def confirm
return if confirmed? new_user = !confirmed?
super super
update_statistics! update_statistics! if new_user
end end
def confirm! def confirm!
return if confirmed? new_user = !confirmed?
skip_confirmation! skip_confirmation!
save! save!
update_statistics! update_statistics! if new_user
end end
def promote! def promote!

View File

@@ -0,0 +1,22 @@
# frozen_string_literal: true
class ActivityPub::DeleteActorSerializer < ActiveModel::Serializer
attributes :id, :type, :actor
attribute :virtual_object, key: :object
def id
[ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
end
def type
'Delete'
end
def actor
ActivityPub::TagManager.instance.uri_for(object)
end
def virtual_object
actor
end
end

View File

@@ -1,12 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::FollowSerializer < ActiveModel::Serializer class ActivityPub::FollowSerializer < ActiveModel::Serializer
attributes :type, :actor attributes :id, :type, :actor
attribute :id, if: :dereferencable?
attribute :virtual_object, key: :object attribute :virtual_object, key: :object
def id def id
ActivityPub::TagManager.instance.uri_for(object) [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
end end
def type def type
@@ -20,8 +19,4 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
def virtual_object def virtual_object
ActivityPub::TagManager.instance.uri_for(object.target_account) ActivityPub::TagManager.instance.uri_for(object.target_account)
end end
def dereferencable?
object.respond_to?(:object_type)
end
end end

View File

@@ -27,7 +27,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
end end
def thumbnail def thumbnail
full_asset_url(instance_presenter.thumbnail.file.url) if instance_presenter.thumbnail instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('preview.jpg')
end end
def max_toot_chars def max_toot_chars

View File

@@ -6,7 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService
# Should be called with confirmed valid JSON # Should be called with confirmed valid JSON
# and WebFinger-resolved username and domain # and WebFinger-resolved username and domain
def call(username, domain, json) def call(username, domain, json)
return if json['inbox'].blank? return if json['inbox'].blank? || unsupported_uri_scheme?(json['id'])
@json = json @json = json
@uri = @json['id'] @uri = @json['id']
@@ -107,7 +107,21 @@ class ActivityPub::ProcessAccountService < BaseService
def url def url
return if @json['url'].blank? return if @json['url'].blank?
url_to_href(@json['url'], 'text/html')
url_candidate = url_to_href(@json['url'], 'text/html')
if unsupported_uri_scheme?(url_candidate) || mismatching_origin?(url_candidate)
nil
else
url_candidate
end
end
def mismatching_origin?(url)
needle = Addressable::URI.parse(url).host
haystack = Addressable::URI.parse(@uri).host
!haystack.casecmp(needle).zero?
end end
def outbox_total_items def outbox_total_items

View File

@@ -17,9 +17,7 @@ class BatchedRemoveStatusService < BaseService
@stream_entry_batches = [] @stream_entry_batches = []
@salmon_batches = [] @salmon_batches = []
@activity_json_batches = []
@json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id.to_s)] }.to_h @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id.to_s)] }.to_h
@activity_json = {}
@activity_xml = {} @activity_xml = {}
# Ensure that rendered XML reflects destroyed state # Ensure that rendered XML reflects destroyed state
@@ -32,10 +30,7 @@ class BatchedRemoveStatusService < BaseService
unpush_from_home_timelines(account, account_statuses) unpush_from_home_timelines(account, account_statuses)
unpush_from_list_timelines(account, account_statuses) unpush_from_list_timelines(account, account_statuses)
if account.local? batch_stream_entries(account, account_statuses) if account.local?
batch_stream_entries(account, account_statuses)
batch_activity_json(account, account_statuses)
end
end end
# Cannot be batched # Cannot be batched
@@ -47,7 +42,6 @@ class BatchedRemoveStatusService < BaseService
Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
end end
private private
@@ -58,22 +52,6 @@ class BatchedRemoveStatusService < BaseService
end end
end end
def batch_activity_json(account, statuses)
account.followers.inboxes.each do |inbox_url|
statuses.each do |status|
@activity_json_batches << [build_json(status), account.id, inbox_url]
end
end
statuses.each do |status|
other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
other_recipients.each do |target_account|
@activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
end
end
end
def unpush_from_home_timelines(account, statuses) def unpush_from_home_timelines(account, statuses)
recipients = account.followers.local.to_a recipients = account.followers.local.to_a
@@ -134,23 +112,9 @@ class BatchedRemoveStatusService < BaseService
Redis.current Redis.current
end end
def build_json(status)
return @activity_json[status.id] if @activity_json.key?(status.id)
@activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
status,
serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
adapter: ActivityPub::Adapter
).as_json)
end
def build_xml(stream_entry) def build_xml(stream_entry)
return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
@activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry) @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
end end
def sign_json(status, json)
Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
end
end end

View File

@@ -46,11 +46,13 @@ class FetchAtomService < BaseService
json = body_to_json(@response.to_s) json = body_to_json(@response.to_s)
if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present?
[json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub]
elsif supported_context?(json) && json['type'] == 'Note'
[json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub]
else else
@unsupported_activity = true @unsupported_activity = true
nil nil
end end
elsif @response['Link'] && !terminal elsif @response['Link'] && !terminal && link_header.find_link(%w(rel alternate))
process_headers process_headers
elsif @response.mime_type == 'text/html' && !terminal elsif @response.mime_type == 'text/html' && !terminal
process_html process_html
@@ -70,8 +72,6 @@ class FetchAtomService < BaseService
end end
def process_headers def process_headers
link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
@@ -80,4 +80,8 @@ class FetchAtomService < BaseService
result result
end end
def link_header
@link_header ||= LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
end
end end

View File

@@ -22,6 +22,8 @@ class SuspendAccountService < BaseService
end end
def purge_content! def purge_content!
ActivityPub::RawDistributionWorker.perform_async(delete_actor_json, @account.id) if @account.local?
@account.statuses.reorder(nil).find_in_batches do |statuses| @account.statuses.reorder(nil).find_in_batches do |statuses|
BatchedRemoveStatusService.new.call(statuses) BatchedRemoveStatusService.new.call(statuses)
end end
@@ -54,4 +56,14 @@ class SuspendAccountService < BaseService
def destroy_all(association) def destroy_all(association)
association.in_batches.destroy_all association.in_batches.destroy_all
end end
def delete_actor_json
payload = ActiveModelSerializers::SerializableResource.new(
@account,
serializer: ActivityPub::DeleteActorSerializer,
adapter: ActivityPub::Adapter
).as_json
Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
end
end end

View File

@@ -9,7 +9,7 @@
= fa_icon 'user-times' = fa_icon 'user-times'
= t('accounts.unfollow') = t('accounts.unfollow')
- else - else
= link_to account_follows_path(account), data: { method: :post }, class: 'icon-button' do = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
= fa_icon 'user-plus' = fa_icon 'user-plus'
= t('accounts.follow') = t('accounts.follow')
- elsif !user_signed_in? - elsif !user_signed_in?

View File

@@ -7,7 +7,7 @@
<p>Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de laisina.</p> <p>Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de laisina.</p>
<p>Pensatz tanben de gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>.</p> <p>Pensatz tanben de gaitar nòstres <%= link_to 'tèrmes e condicions d\'utilizacion', terms_url %>.</p>
<p>Amistosament,</p> <p>Amistosament,</p>

View File

@@ -7,7 +7,7 @@ er confirmar vòstra inscripcion, mercés de clicar sul ligam seguent:
Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de laisina. Aprèp vòstra primièra connexion, poiretz accedir a la documentacion de laisina.
Pensatz tanben de gaitar nòstras <%= link_to 'conditions d\'utilisation', terms_url %>. Pensatz tanben de gaitar nòstres <%= link_to 'tèrmes e condicions d\'utilizacion', terms_url %>.
Amistosament, Amistosament,

View File

@@ -0,0 +1,15 @@
<p>Bonjorn <%= @resource.email %>!</p>
<% if @resource&.unconfirmed_email? %>
<p>Vos contactem per vos senhalar que ladreça quutilizatz per <%= @instance %> es cambiada per aquesta daquí <%= @resource.unconfirmed_email %>.</p>
<% else %>
<p>Vos contactem per vos senhalar que ladreça quutilizatz per <%= @instance %> es cambiada per aquesta daquí <%= @resource.email %>.</p>
<% end %>
<p>
Savètz pas demandat aqueste cambiament dadreça, poiriá arribar que qualquun mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator dinstància se laccès a vòstre compte vos es barrat.
</p>
<p>Amistosament,<p>
<p>La còla <%= @instance %></p>

View File

@@ -0,0 +1,13 @@
Bonjorn <%= @resource.email %>!
<% if @resource&.unconfirmed_email? %>
Vos contactem per vos senhalar que ladreça quutilizatz per <%= @instance %> es cambiada per aquesta daquí <%= @resource.unconfirmed_email %>.
<% else %>
Vos contactem per vos senhalar que ladreça quutilizatz per <%= @instance %> es cambiada per aquesta daquí <%= @resource.email %>.
<% end %>
Savètz pas demandat aqueste cambiament dadreça, poiriá arribar que qualquun mai aguèsse agut accès a vòstre compte. Mercés de cambiar sulpic vòstre senhal o de contactar vòstre administrator dinstància se laccès a vòstre compte vos es barrat.
Amistosament,
La còla <%= @instance %>

View File

@@ -0,0 +1,13 @@
<p><%= @resource.email %>,你好呀!</p>
<% if @resource&.unconfirmed_email? %>
<p>我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址即将变更为 <%= @resource.unconfirmed_email %>。</p>
<% else %>
<p>我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址已经变更为 <%= @resource.unconfirmed_email %>。</p>
<% end %>
<p>
如果你并没有请求更改你的电子邮件地址,则他人很有可能已经入侵你的帐户。请立即更改你的密码;如果你已经无法访问你的帐户,请联系实例的管理员请求协助。
</p>
<p>来自 <%= @instance %> 管理团队</p>

View File

@@ -0,0 +1,11 @@
<%= @resource.email %>,你好呀!
<% if @resource&.unconfirmed_email? %>
我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址即将变更为 <%= @resource.unconfirmed_email %>。
<% else %>
我们发送这封邮件是为了提醒你,你在 <%= @instance %> 上使用的电子邮件地址已经变更为 <%= @resource.unconfirmed_email %>。
<% end %>
如果你并没有请求更改你的电子邮件地址,则他人很有可能已经入侵你的帐户。请立即更改你的密码;如果你已经无法访问你的帐户,请联系实例的管理员请求协助。
来自 <%= @instance %> 管理团队

View File

@@ -0,0 +1,15 @@
<p>Bonjorn <%= @resource.unconfirmed_email %>!</p>
<p>Avètz demandat a cambiar vòstra adreça de corrièl quutilizatz per <%= @instance %>.</p>
<p>Per confirmar vòstra novèla adreça, mercés de clicar lo ligam seguent:<br>
<%= link_to 'Confirmar mon adreça', confirmation_url(@resource, confirmation_token: @token) %></p>
<p>Se lo ligam al dessús fonciona pas, copiatz e pegatz aquesta URL a la barra dadreça:<br>
<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
<p>Mercés de gaitar tanben nòstres <%= link_to 'terms and conditions', terms_url %>.</p>
<p>Amistosament,<p>
<p>La còla <%= @instance %></p>

View File

@@ -0,0 +1,12 @@
Bonjorn <%= @resource.unconfirmed_email %>!
Avètz demandat a cambiar vòstra adreça de corrièl quutilizatz per <%= @instance %>.
Per confirmar vòstra novèla adreça, mercés de clicar lo ligam seguent:
<%= confirmation_url(@resource, confirmation_token: @token) %>
Mercés tanben de gaitar nòstres <%= link_to 'terms and conditions', terms_url %>.
Amistosament,
La còla <%= @instance %>

View File

@@ -0,0 +1,13 @@
<p><%= @resource.email %>,你好呀!</p>
<p>你正在更改你在 <%= @instance %> 使用的电子邮件地址。</p>
<p>点击下面的链接以确认操作:<br>
<%= link_to '确认我的电子邮件地址', confirmation_url(@resource, confirmation_token: @token) %></p>
<p>上面的链接按不动?把下面的链接复制到地址栏再试试:<br>
<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
<p>记得读一读我们的<%= link_to '使用条款', terms_url %>哦。</p>
<p>来自 <%= @instance %> 管理团队</p>

View File

@@ -0,0 +1,10 @@
<%= @resource.email %>,你好呀!
你正在更改你在 <%= @instance %> 使用的电子邮件地址。
点击下面的链接以确认操作:
<%= confirmation_url(@resource, confirmation_token: @token) %>
记得读一读我们的使用条款哦:<%= terms_url %>
来自 <%= @instance %> 管理团队

View File

@@ -20,7 +20,7 @@ class Pubsubhubbub::SubscribeWorker
sidekiq_retries_exhausted do |msg, _e| sidekiq_retries_exhausted do |msg, _e|
account = Account.find(msg['args'].first) account = Account.find(msg['args'].first)
logger.error "PuSH subscription attempts for #{account.acct} exhausted. Unsubscribing" Sidekiq.logger.error "PuSH subscription attempts for #{account.acct} exhausted. Unsubscribing"
::UnsubscribeService.new.call(account) ::UnsubscribeService.new.call(account)
end end

View File

@@ -95,7 +95,7 @@ Rails.application.configure do
'X-Frame-Options' => 'DENY', 'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff', 'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block', 'X-XSS-Protection' => '1; mode=block',
'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social 'unsafe-inline'; base-uri 'none';" , 'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social ; base-uri 'none';" ,
'Referrer-Policy' => 'no-referrer, strict-origin-when-cross-origin', 'Referrer-Policy' => 'no-referrer, strict-origin-when-cross-origin',
'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload', 'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
'X-Clacks-Overhead' => 'GNU Natalie Nguyen' 'X-Clacks-Overhead' => 'GNU Natalie Nguyen'

View File

@@ -1,3 +1,3 @@
# Be sure to restart your server when you modify this file. # Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: '_mastodon_session', secure: (ENV['LOCAL_HTTPS'] == 'true') Rails.application.config.session_store :cookie_store, key: '_mastodon_session', secure: (Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true')

View File

@@ -17,15 +17,17 @@ zh-CN:
unconfirmed: 继续操作前请先确认你的帐户。 unconfirmed: 继续操作前请先确认你的帐户。
mailer: mailer:
confirmation_instructions: confirmation_instructions:
subject: Mastodon 帐户确认信息 subject: Mastodon:确认 %{instance} 帐户信息
email_changed: email_changed:
subject: Mastodon 电子邮件地址已被修改 subject: Mastodon电子邮件地址已被修改
password_change: password_change:
subject: Mastodon 密码已被重置 subject: Mastodon密码已被重置
reconfirmation_instructions:
subject: Mastodon确认 %{instance} 电子邮件地址
reset_password_instructions: reset_password_instructions:
subject: Mastodon 重置密码信息 subject: Mastodon重置密码信息
unlock_instructions: unlock_instructions:
subject: Mastodon 帐户解锁信息 subject: Mastodon帐户解锁信息
omniauth_callbacks: omniauth_callbacks:
failure: 由于%{reason},无法从%{kind}获得授权。 failure: 由于%{reason},无法从%{kind}获得授权。
success: 成功地从%{kind}获得授权。 success: 成功地从%{kind}获得授权。

View File

@@ -160,6 +160,7 @@ pl:
update_status: "%{name} zaktualizował wpis użytkownika %{target}" update_status: "%{name} zaktualizował wpis użytkownika %{target}"
title: Dziennik działań administracyjnych title: Dziennik działań administracyjnych
custom_emojis: custom_emojis:
by_domain: Według domeny
copied_msg: Pomyślnie utworzono lokalną kopię emoji copied_msg: Pomyślnie utworzono lokalną kopię emoji
copy: Kopiuj copy: Kopiuj
copy_failed_msg: Nie udało się utworzyć lokalnej kopii emoji copy_failed_msg: Nie udało się utworzyć lokalnej kopii emoji
@@ -603,8 +604,10 @@ pl:
development: Tworzenie aplikacji development: Tworzenie aplikacji
edit_profile: Edytuj profil edit_profile: Edytuj profil
export: Eksportowanie danych export: Eksportowanie danych
flavours: Motywy
followers: Autoryzowani śledzący followers: Autoryzowani śledzący
import: Importowanie danych import: Importowanie danych
keyword_mutes: Wyciszone słowa
migrate: Migracja konta migrate: Migracja konta
notifications: Powiadomienia notifications: Powiadomienia
preferences: Preferencje preferences: Preferencje
@@ -620,6 +623,7 @@ pl:
private: Nie możesz przypiąć niepublicznego wpisu private: Nie możesz przypiąć niepublicznego wpisu
reblog: Nie możesz przypiąć podbicia wpisu reblog: Nie możesz przypiąć podbicia wpisu
show_more: Pokaż więcej show_more: Pokaż więcej
title: '%{name}: "%{quote}"'
visibilities: visibilities:
private: Tylko dla śledzących private: Tylko dla śledzących
private_long: Widoczne tylko dla osób, które Cię śledzą private_long: Widoczne tylko dla osób, które Cię śledzą

View File

@@ -21,7 +21,7 @@ zh-TW:
data: 資料 data: 資料
display_name: 顯示名稱 display_name: 顯示名稱
email: 電子信箱 email: 電子信箱
filtered_languages: 封鎖下面言的文章 filtered_languages: 封鎖下面言的文章
header: 個人頁面頂部 header: 個人頁面頂部
locale: 語言 locale: 語言
locked: 將帳號轉為「私密」 locked: 將帳號轉為「私密」
@@ -29,7 +29,16 @@ zh-TW:
note: 簡介 note: 簡介
otp_attempt: 雙因子驗證碼 otp_attempt: 雙因子驗證碼
password: 密碼 password: 密碼
setting_auto_play_gif: 自動播放 GIFs
setting_boost_modal: 轉推前跳出確認視窗
setting_default_privacy: 文章預設隱私度 setting_default_privacy: 文章預設隱私度
setting_default_sensitive: 預設我的內容為敏感內容
setting_delete_modal: 刪推前跳出確認視窗
setting_noindex: 不被搜尋引擎檢索
setting_reduce_motion: 減低動畫效果
setting_system_font_ui: 使用系統預設字體
setting_theme: 網站主題
setting_unfollow_modal: 取消關注前跳出確認視窗
type: 匯入資料類型 type: 匯入資料類型
username: 使用者名稱 username: 使用者名稱
interactions: interactions:

View File

@@ -409,8 +409,8 @@ sr-Latn:
exports: exports:
blocks: Blokirali ste blocks: Blokirali ste
csv: CSV csv: CSV
follows: PRatite follows: Pratite
mutes: Mutirali ste mutes: Ućutkali ste
storage: Multimedijalno skladište storage: Multimedijalno skladište
followers: followers:
domain: Domen domain: Domen
@@ -441,7 +441,7 @@ sr-Latn:
types: types:
blocking: Lista blokiranja blocking: Lista blokiranja
following: Lista pratilaca following: Lista pratilaca
muting: Lista mutiranih muting: Lista ućutkanih
upload: Otpremi upload: Otpremi
in_memoriam_html: In Memoriam. in_memoriam_html: In Memoriam.
invites: invites:

View File

@@ -409,8 +409,8 @@ sr:
exports: exports:
blocks: Блокирали сте blocks: Блокирали сте
csv: CSV csv: CSV
follows: ПРатите follows: Пратите
mutes: Мутирали сте mutes: Ућуткали сте
storage: Мултимедијално складиште storage: Мултимедијално складиште
followers: followers:
domain: Домен domain: Домен
@@ -441,7 +441,7 @@ sr:
types: types:
blocking: Листа блокирања blocking: Листа блокирања
following: Листа пратилаца following: Листа пратилаца
muting: Листа мутираних muting: Листа ућутканих
upload: Отпреми upload: Отпреми
in_memoriam_html: In Memoriam. in_memoriam_html: In Memoriam.
invites: invites:

View File

@@ -263,12 +263,18 @@ zh-CN:
unresolved: 未处理 unresolved: 未处理
view: 查看 view: 查看
settings: settings:
activity_api_enabled:
desc_html: 本站用户发布的嘟文数,以及本站的活跃用户数和一周内新用户数
title: 公开用户活跃度的统计数据
bootstrap_timeline_accounts: bootstrap_timeline_accounts:
desc_html: 用半角逗号分隔多个用户名。只能添加来自本站且未开启保护的帐户。如果留空,则默认关注本站所有的管理员。 desc_html: 用半角逗号分隔多个用户名。只能添加来自本站且未开启保护的帐户。如果留空,则默认关注本站所有的管理员。
title: 新用户默认关注 title: 新用户默认关注
contact_information: contact_information:
email: 用于联系的公开电子邮件地址 email: 用于联系的公开电子邮件地址
username: 用于联系的公开用户名 username: 用于联系的公开用户名
peers_api_enabled:
desc_html: 截至目前本实例在网络中已发现的域名
title: 公开已知实例的列表
registrations: registrations:
closed_message: closed_message:
desc_html: 本站关闭注册期间的提示信息。可以使用 HTML 标签 desc_html: 本站关闭注册期间的提示信息。可以使用 HTML 标签

View File

@@ -55,7 +55,7 @@ zh-TW:
perform_full_suspension: 進行停權 perform_full_suspension: 進行停權
profile_url: 個人檔案網址 profile_url: 個人檔案網址
public: 公開 public: 公開
push_subscription_expires: PuSH 訂閱 push_subscription_expires: 推播訂閱
salmon_url: Salmon URL salmon_url: Salmon URL
silence: 靜音 silence: 靜音
statuses: 狀態 statuses: 狀態
@@ -133,12 +133,14 @@ zh-TW:
forgot_password: 忘記密碼? forgot_password: 忘記密碼?
login: 登入 login: 登入
logout: 登出 logout: 登出
migrate_account: 轉移到另一個帳號
migrate_account_html: 想要將這個帳號指向另一個帳號可到<a href="%{path}">到這裡設定</a>。
register: 註冊 register: 註冊
resend_confirmation: 重寄驗證信 resend_confirmation: 重寄驗證信
reset_password: 重設密碼 reset_password: 重設密碼
set_new_password: 設定新密碼 set_new_password: 設定新密碼
authorize_follow: authorize_follow:
error: 對不起,尋找這個跨站使用者的過程發生錯誤 error: 對不起,搜尋遠端使用者出現錯誤
follow: 關注 follow: 關注
title: 關注 %{acct} title: 關注 %{acct}
datetime: datetime:
@@ -165,7 +167,16 @@ zh-TW:
blocks: 您封鎖的使用者 blocks: 您封鎖的使用者
csv: CSV csv: CSV
follows: 您關注的使用者 follows: 您關注的使用者
mutes: 您靜音的使用者
storage: 儲存空間大小 storage: 儲存空間大小
followers:
domain: 網域
explanation_html: 為確保個人隱私,您必須知道有哪些使用者正關注你。<strong>您的私密內容會被發送到所有您有被關注的服務站上</strong>。如果您不信任這些服務站的管理者,您可以選擇檢查或刪除您的關注者。
followers_count: 關注者數
lock_link: 鎖住你的帳號
purge: 移除關注者
unlocked_warning_html: 所有人都可以關注並檢索你的隱藏狀態。%{lock_link}以檢查或拒絕關注。
unlocked_warning_title: 你的帳號是公開的
generic: generic:
changes_saved_msg: 已成功儲存修改 changes_saved_msg: 已成功儲存修改
powered_by: 網站由 %{link} 開發 powered_by: 網站由 %{link} 開發
@@ -179,6 +190,7 @@ zh-TW:
types: types:
blocking: 您封鎖的使用者名單 blocking: 您封鎖的使用者名單
following: 您關注的使用者名單 following: 您關注的使用者名單
muting: 您靜音的使用者名單
upload: 上傳 upload: 上傳
landing_strip_html: "<strong>%{name}</strong> 是一個在 %{link_to_root_path} 的使用者。只要您有任何 Mastodon 服務站、或者聯盟網站的帳號,便可以跨站關注此站使用者,或者與他們互動。" landing_strip_html: "<strong>%{name}</strong> 是一個在 %{link_to_root_path} 的使用者。只要您有任何 Mastodon 服務站、或者聯盟網站的帳號,便可以跨站關注此站使用者,或者與他們互動。"
landing_strip_signup_html: 如果您沒有這些帳號,歡迎在<a href="%{sign_up_path}">這裡註冊</a>。 landing_strip_signup_html: 如果您沒有這些帳號,歡迎在<a href="%{sign_up_path}">這裡註冊</a>。
@@ -231,15 +243,26 @@ zh-TW:
missing_resource: 無法找到資源 missing_resource: 無法找到資源
proceed: 下一步 proceed: 下一步
prompt: 您希望關注︰ prompt: 您希望關注︰
sessions:
activity: 最近活動
browser: 瀏覽器
current_session: 目前的 session
description: "%{platform} 上的 %{browser}"
explanation: 這些是現在正登入於你的 Mastodon 帳號的瀏覽器。
revoke: 取消
revoke_success: Session 取消成功。
settings: settings:
authorized_apps: 已授權應用程式 authorized_apps: 已授權應用程式
back: 回到 Mastodon back: 回到 Mastodon
development: 開發
edit_profile: 修改個人資料 edit_profile: 修改個人資料
export: 匯出 export: 匯出
followers: 授權追蹤者
import: 匯入 import: 匯入
notifications: 通知
preferences: 偏好設定 preferences: 偏好設定
settings: 設定 settings: 設定
two_factor_authentication: 雙因子認證 two_factor_authentication: 兩階段認證
statuses: statuses:
open_in_web: 以網頁開啟 open_in_web: 以網頁開啟
over_character_limit: 超過了 %{max} 字的限制 over_character_limit: 超過了 %{max} 字的限制
@@ -257,14 +280,14 @@ zh-TW:
default: "%Y年%-m月%d日 %H:%M" default: "%Y年%-m月%d日 %H:%M"
two_factor_authentication: two_factor_authentication:
code_hint: 請輸入您認證器產生的代碼,以進行認證 code_hint: 請輸入您認證器產生的代碼,以進行認證
description_html: 當您啟用<strong>雙因子認證</strong>後,登入時將需要使手機、或其他種類認證器產生的代碼。 description_html: 啟用<strong>兩階段認證</strong>後,登入時將需要使手機、或其他種類認證器產生的代碼。
disable: 停用 disable: 停用
enable: 啟用 enable: 啟用
enabled_success: 已成功啟用雙因子認證 enabled_success: 已成功啟用兩階段認證
instructions_html: "<strong>請用您手機的認證器應用程式(如 Google Authenticator、Authy掃描這裡的 QR 圖形碼</strong>。在雙因子認證啟用後,您登入時將須要使用此應用程式產生的認證碼。" instructions_html: "<strong>請用您手機的認證器應用程式(如 Google Authenticator、Authy掃描這裡的 QR 圖形碼</strong>。在兩階段認證啟用後,您登入時將須要使用此應用程式產生的認證碼。"
manual_instructions: 如果您無法掃描 QR 圖形碼,請手動輸入︰ manual_instructions: 如果您無法掃描 QR 圖形碼,請手動輸入︰
setup: 設定 setup: 設定
wrong_code: 您輸入的認證碼並不正確!可能伺服器時間和您手機不一致,請檢查您手機的時間,或與本站管理員聯絡。 wrong_code: 您輸入的認證碼並不正確!可能伺服器時間和您手機不一致,請檢查您手機的時間,或與本站管理員聯絡。
users: users:
invalid_email: 信箱地址格式不正確 invalid_email: 信箱地址格式不正確
invalid_otp_token: 雙因子認證碼不正確 invalid_otp_token: 兩階段認證碼不正確

View File

@@ -54,8 +54,7 @@ Rails.application.routes.draw do
resources :followers, only: [:index], controller: :follower_accounts resources :followers, only: [:index], controller: :follower_accounts
resources :following, only: [:index], controller: :following_accounts resources :following, only: [:index], controller: :following_accounts
resources :follows, only: [:show], module: :activitypub resource :follow, only: [:create], controller: :account_follow
resource :follow, only: [:create], controller: :account_follow, as: :follows
resource :unfollow, only: [:create], controller: :account_unfollow resource :unfollow, only: [:create], controller: :account_unfollow
resource :outbox, only: [:show], module: :activitypub resource :outbox, only: [:show], module: :activitypub
resource :inbox, only: [:create], module: :activitypub resource :inbox, only: [:create], module: :activitypub

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { default: manageTranslations } = require('react-intl-translations-manager'); const { default: manageTranslations } = require('react-intl-translations-manager');
const RFC5646_REGEXP = /^[a-z]{2,3}(?:|-[A-Z]+)$/; const RFC5646_REGEXP = /^[a-z]{2,3}(?:-(?:x|[A-Za-z]{2,4}))*$/;
const rootDirectory = path.resolve(__dirname, '..', '..'); const rootDirectory = path.resolve(__dirname, '..', '..');
const translationsDirectory = path.resolve(rootDirectory, 'app', 'javascript', 'mastodon', 'locales'); const translationsDirectory = path.resolve(rootDirectory, 'app', 'javascript', 'mastodon', 'locales');

View File

@@ -1,6 +1,7 @@
class AddIndexOnStreamEntries < ActiveRecord::Migration[5.1] class AddIndexOnStreamEntries < ActiveRecord::Migration[5.1]
disable_ddl_transaction!
def change def change
commit_db_transaction
add_index :stream_entries, [:account_id, :activity_type, :id], algorithm: :concurrently add_index :stream_entries, [:account_id, :activity_type, :id], algorithm: :concurrently
remove_index :stream_entries, name: :index_stream_entries_on_account_id remove_index :stream_entries, name: :index_stream_entries_on_account_id
end end

View File

@@ -1,6 +1,7 @@
class MoreFasterIndexOnNotifications < ActiveRecord::Migration[5.1] class MoreFasterIndexOnNotifications < ActiveRecord::Migration[5.1]
disable_ddl_transaction!
def change def change
commit_db_transaction
add_index :notifications, [:account_id, :id], order: { id: :desc }, algorithm: :concurrently add_index :notifications, [:account_id, :id], order: { id: :desc }, algorithm: :concurrently
remove_index :notifications, name: :index_notifications_on_id_and_account_id_and_activity_type remove_index :notifications, name: :index_notifications_on_id_and_account_id_and_activity_type
end end

View File

@@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
0 2
end end
def pre def pre

View File

@@ -1,43 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe ActivityPub::FollowsController, type: :controller do
let(:follow_request) { Fabricate(:follow_request, account: account) }
render_views
context 'with local account' do
let(:account) { Fabricate(:account, domain: nil) }
it 'returns follow request' do
signed_request = Request.new(:get, account_follow_url(account, follow_request))
signed_request.on_behalf_of(follow_request.target_account)
request.headers.merge! signed_request.headers
get :show, params: { id: follow_request, account_username: account.username }
expect(body_as_json[:id]).to eq ActivityPub::TagManager.instance.uri_for(follow_request)
expect(response).to have_http_status :success
end
it 'returns http 404 without signature' do
get :show, params: { id: follow_request, account_username: account.username }
expect(response).to have_http_status 404
end
end
context 'with remote account' do
let(:account) { Fabricate(:account, domain: Faker::Internet.domain_name) }
it 'returns http 404' do
signed_request = Request.new(:get, account_follow_url(account, follow_request))
signed_request.on_behalf_of(follow_request.target_account)
request.headers.merge! signed_request.headers
get :show, params: { id: follow_request, account_username: account.username }
expect(response).to have_http_status 404
end
end
end

View File

@@ -47,22 +47,18 @@ describe ApplicationController, type: :controller do
include_examples 'respond_with_error', 422 include_examples 'respond_with_error', 422
end end
it "does not force ssl if LOCAL_HTTPS is not 'true'" do it "does not force ssl if Rails.env.production? is not 'true'" do
routes.draw { get 'success' => 'anonymous#success' } routes.draw { get 'success' => 'anonymous#success' }
ClimateControl.modify LOCAL_HTTPS: '' do allow(Rails.env).to receive(:production?).and_return(false)
allow(Rails.env).to receive(:production?).and_return(true) get 'success'
get 'success' expect(response).to have_http_status(:success)
expect(response).to have_http_status(:success)
end
end end
it "forces ssl if LOCAL_HTTPS is 'true'" do it "forces ssl if Rails.env.production? is 'true'" do
routes.draw { get 'success' => 'anonymous#success' } routes.draw { get 'success' => 'anonymous#success' }
ClimateControl.modify LOCAL_HTTPS: 'true' do allow(Rails.env).to receive(:production?).and_return(true)
allow(Rails.env).to receive(:production?).and_return(true) get 'success'
get 'success' expect(response).to redirect_to('https://test.host/success')
expect(response).to redirect_to('https://test.host/success')
end
end end
describe 'helper_method :current_account' do describe 'helper_method :current_account' do

View File

@@ -12,20 +12,40 @@ describe Auth::ConfirmationsController, type: :controller do
end end
describe 'GET #show' do describe 'GET #show' do
let!(:user) { Fabricate(:user, confirmation_token: 'foobar', confirmed_at: nil) } context 'when user is unconfirmed' do
let!(:user) { Fabricate(:user, confirmation_token: 'foobar', confirmed_at: nil) }
before do before do
allow(BootstrapTimelineWorker).to receive(:perform_async) allow(BootstrapTimelineWorker).to receive(:perform_async)
@request.env['devise.mapping'] = Devise.mappings[:user] @request.env['devise.mapping'] = Devise.mappings[:user]
get :show, params: { confirmation_token: 'foobar' } get :show, params: { confirmation_token: 'foobar' }
end
it 'redirects to login' do
expect(response).to redirect_to(new_user_session_path)
end
it 'queues up bootstrapping of home timeline' do
expect(BootstrapTimelineWorker).to have_received(:perform_async).with(user.account_id)
end
end end
it 'redirects to login' do context 'when user is updating email' do
expect(response).to redirect_to(new_user_session_path) let!(:user) { Fabricate(:user, confirmation_token: 'foobar', unconfirmed_email: 'new-email@example.com') }
end
it 'queues up bootstrapping of home timeline' do before do
expect(BootstrapTimelineWorker).to have_received(:perform_async).with(user.account_id) allow(BootstrapTimelineWorker).to receive(:perform_async)
@request.env['devise.mapping'] = Devise.mappings[:user]
get :show, params: { confirmation_token: 'foobar' }
end
it 'redirects to login' do
expect(response).to redirect_to(new_user_session_path)
end
it 'does not queue up bootstrapping of home timeline' do
expect(BootstrapTimelineWorker).to_not have_received(:perform_async)
end
end end
end end
end end

View File

@@ -3,49 +3,36 @@ require 'rails_helper'
RSpec.describe ActivityPub::Activity::Accept do RSpec.describe ActivityPub::Activity::Accept do
let(:sender) { Fabricate(:account) } let(:sender) { Fabricate(:account) }
let(:recipient) { Fabricate(:account) } let(:recipient) { Fabricate(:account) }
let!(:follow_request) { Fabricate(:follow_request, account: recipient, target_account: sender) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
id: 'bar',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
end
describe '#perform' do describe '#perform' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
before do before do
Fabricate(:follow_request, account: recipient, target_account: sender)
subject.perform subject.perform
end end
context 'with concerete object representation' do it 'creates a follow relationship' do
let(:json) do expect(recipient.following?(sender)).to be true
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
end
it 'creates a follow relationship' do
expect(recipient.following?(sender)).to be true
end
end end
context 'with object represented by id' do it 'removes the follow request' do
let(:json) do expect(recipient.requested?(sender)).to be false
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(follow_request),
}.with_indifferent_access
end
it 'creates a follow relationship' do
expect(recipient.following?(sender)).to be true
end
end end
end end
end end

View File

@@ -6,7 +6,7 @@ RSpec.describe ActivityPub::Activity::Create do
let(:json) do let(:json) do
{ {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo', id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: object_json, object: object_json,
@@ -16,6 +16,8 @@ RSpec.describe ActivityPub::Activity::Create do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
before do before do
sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png'))
end end
@@ -28,7 +30,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'standalone' do context 'standalone' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
} }
@@ -52,7 +54,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'public' do context 'public' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
to: 'https://www.w3.org/ns/activitystreams#Public', to: 'https://www.w3.org/ns/activitystreams#Public',
@@ -70,7 +72,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'unlisted' do context 'unlisted' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
cc: 'https://www.w3.org/ns/activitystreams#Public', cc: 'https://www.w3.org/ns/activitystreams#Public',
@@ -88,7 +90,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'private' do context 'private' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
to: 'http://example.com/followers', to: 'http://example.com/followers',
@@ -108,7 +110,7 @@ RSpec.describe ActivityPub::Activity::Create do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient), to: ActivityPub::TagManager.instance.uri_for(recipient),
@@ -128,7 +130,7 @@ RSpec.describe ActivityPub::Activity::Create do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status),
@@ -151,7 +153,7 @@ RSpec.describe ActivityPub::Activity::Create do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
tag: [ tag: [
@@ -174,7 +176,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with mentions missing href' do context 'with mentions missing href' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
tag: [ tag: [
@@ -194,7 +196,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with media attachments' do context 'with media attachments' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
attachment: [ attachment: [
@@ -218,7 +220,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with media attachments missing url' do context 'with media attachments missing url' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
attachment: [ attachment: [
@@ -239,7 +241,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with hashtags' do context 'with hashtags' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
tag: [ tag: [
@@ -263,7 +265,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with hashtags missing name' do context 'with hashtags missing name' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
tag: [ tag: [
@@ -284,7 +286,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with emojis' do context 'with emojis' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum :tinking:', content: 'Lorem ipsum :tinking:',
tag: [ tag: [
@@ -310,7 +312,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with emojis missing name' do context 'with emojis missing name' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum :tinking:', content: 'Lorem ipsum :tinking:',
tag: [ tag: [
@@ -333,7 +335,7 @@ RSpec.describe ActivityPub::Activity::Create do
context 'with emojis missing icon' do context 'with emojis missing icon' do
let(:object_json) do let(:object_json) do
{ {
id: 'bar', id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum :tinking:', content: 'Lorem ipsum :tinking:',
tag: [ tag: [

View File

@@ -34,12 +34,4 @@ RSpec.describe FollowRequest, type: :model do
expect(follow_request.account.muting_reblogs?(target)).to be true expect(follow_request.account.muting_reblogs?(target)).to be true
end end
end end
describe '#object_type' do
let(:follow_request) { Fabricate(:follow_request) }
it 'equals to :follow' do
expect(follow_request.object_type).to eq :follow
end
end
end end

View File

@@ -148,6 +148,14 @@ RSpec.describe User, type: :model do
end end
end end
describe '#confirm' do
it 'sets email to unconfirmed_email' do
user = Fabricate.build(:user, confirmed_at: Time.now.utc, unconfirmed_email: 'new-email@example.com')
user.confirm
expect(user.email).to eq 'new-email@example.com'
end
end
describe '#disable_two_factor!' do describe '#disable_two_factor!' do
it 'saves false for otp_required_for_login' do it 'saves false for otp_required_for_login' do
user = Fabricate.build(:user, otp_required_for_login: true) user = Fabricate.build(:user, otp_required_for_login: true)

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