Compare commits

...

40 Commits

Author SHA1 Message Date
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
72 changed files with 943 additions and 492 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

@@ -4,15 +4,19 @@ class ActivityPub::FollowsController < Api::BaseController
include SignatureVerification include SignatureVerification
def show def show
render( render json: follow_request,
json: FollowRequest.includes(:account).references(:account).find_by!( serializer: ActivityPub::FollowSerializer,
id: params.require(:id), adapter: ActivityPub::Adapter,
accounts: { domain: nil, username: params.require(:account_username) }, content_type: 'application/activity+json'
target_account: signed_request_account end
),
serializer: ActivityPub::FollowSerializer, private
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json' def follow_request
FollowRequest.includes(:account).references(:account).find_by!(
id: params.require(:id),
accounts: { domain: nil, username: params.require(:account_username) },
target_account: signed_request_account
) )
end end
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

@@ -198,11 +198,24 @@ class ApplicationController < ActionController::Base
end end
def render_cached_json(cache_key, **options) def render_cached_json(cache_key, **options)
options[:expires_in] ||= 3.minutes
options[:public] ||= true
cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable)
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: options[: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

@@ -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);
@@ -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

@@ -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,8 +55,8 @@ 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.icon_title': '高度な設定', 'advanced_options.icon_title': '高度な設定',
}; };

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.get('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

@@ -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,

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
en:
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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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)

View File

@@ -46,7 +46,7 @@ RSpec.configure do |config|
config.include ActiveSupport::Testing::TimeHelpers config.include ActiveSupport::Testing::TimeHelpers
config.before :each, type: :feature do config.before :each, type: :feature do
https = ENV['LOCAL_HTTPS'] == 'true' https = Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'
Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}" Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}"
end end