mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-12 06:06:22 +01:00
Merge commit 'b9f59ebcc68e9da0a7158741a1a2ef3564e1321e' into merging-upstream
This commit is contained in:
commit
83bda6c1a8
@ -1,5 +1,6 @@
|
||||
# Service dependencies
|
||||
# You may set REDIS_URL instead for more advanced options
|
||||
# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
# You may set DATABASE_URL instead for more advanced options
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@ public/system
|
||||
public/assets
|
||||
public/packs
|
||||
public/packs-test
|
||||
public/500.html
|
||||
.env
|
||||
.env.production
|
||||
node_modules/
|
||||
|
16
Dockerfile
16
Dockerfile
@ -7,6 +7,8 @@ ENV UID=991 GID=991 \
|
||||
RAILS_SERVE_STATIC_FILES=true \
|
||||
RAILS_ENV=production NODE_ENV=production
|
||||
|
||||
ARG YARN_VERSION=1.1.0
|
||||
ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3
|
||||
ARG LIBICONV_VERSION=1.15
|
||||
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
|
||||
|
||||
@ -19,6 +21,7 @@ RUN apk -U upgrade \
|
||||
build-base \
|
||||
icu-dev \
|
||||
libidn-dev \
|
||||
libressl \
|
||||
libtool \
|
||||
postgresql-dev \
|
||||
protobuf-dev \
|
||||
@ -32,16 +35,21 @@ RUN apk -U upgrade \
|
||||
imagemagick \
|
||||
libidn \
|
||||
libpq \
|
||||
nodejs-npm \
|
||||
nodejs \
|
||||
nodejs-npm \
|
||||
protobuf \
|
||||
su-exec \
|
||||
tini \
|
||||
yarn \
|
||||
&& update-ca-certificates \
|
||||
&& mkdir -p /tmp/src /opt \
|
||||
&& wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
|
||||
&& echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf yarn.tar.gz -C /tmp/src \
|
||||
&& rm yarn.tar.gz \
|
||||
&& mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \
|
||||
&& ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
|
||||
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
|
||||
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
|
||||
&& mkdir -p /tmp/src \
|
||||
&& tar -xzf libiconv.tar.gz -C /tmp/src \
|
||||
&& rm libiconv.tar.gz \
|
||||
&& cd /tmp/src/libiconv-$LIBICONV_VERSION \
|
||||
@ -56,7 +64,7 @@ COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/
|
||||
|
||||
RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
|
||||
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
|
||||
&& yarn --ignore-optional --pure-lockfile
|
||||
&& yarn --pure-lockfile
|
||||
|
||||
COPY . /mastodon
|
||||
|
||||
|
7
Gemfile
7
Gemfile
@ -67,7 +67,7 @@ gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
||||
gem 'statsd-instrument', '~> 2.1'
|
||||
gem 'twitter-text', '~> 1.14'
|
||||
gem 'tzinfo-data', '~> 1.2017'
|
||||
gem 'webpacker', '~> 2.0'
|
||||
gem 'webpacker', '~> 3.0'
|
||||
gem 'webpush'
|
||||
|
||||
gem 'json-ld-preloaded', '~> 2.2.1'
|
||||
@ -102,9 +102,10 @@ group :development do
|
||||
gem 'letter_opener', '~> 1.4'
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'rubocop', require: false
|
||||
gem 'brakeman', '~> 3.6', require: false
|
||||
gem 'bundler-audit', '~> 0.5', require: false
|
||||
gem 'brakeman', '~> 4.0', require: false
|
||||
gem 'bundler-audit', '~> 0.6', require: false
|
||||
gem 'scss_lint', '~> 0.53', require: false
|
||||
gem 'strong_migrations'
|
||||
|
||||
gem 'capistrano', '~> 3.8'
|
||||
gem 'capistrano-rails', '~> 1.2'
|
||||
|
17
Gemfile.lock
17
Gemfile.lock
@ -74,7 +74,7 @@ GEM
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (1.1.3)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (3.7.2)
|
||||
brakeman (4.0.1)
|
||||
browser (2.5.1)
|
||||
builder (3.2.3)
|
||||
bullet (5.6.1)
|
||||
@ -335,6 +335,8 @@ GEM
|
||||
rack-cors (0.4.1)
|
||||
rack-protection (2.0.0)
|
||||
rack
|
||||
rack-proxy (0.6.2)
|
||||
rack
|
||||
rack-test (0.7.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-timeout (0.4.2)
|
||||
@ -482,6 +484,8 @@ GEM
|
||||
net-scp (>= 1.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
statsd-instrument (2.1.4)
|
||||
strong_migrations (0.1.9)
|
||||
activerecord (>= 3.2.0)
|
||||
temple (0.8.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
@ -508,9 +512,9 @@ GEM
|
||||
addressable (>= 2.3.6)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff
|
||||
webpacker (2.0)
|
||||
webpacker (3.0.1)
|
||||
activesupport (>= 4.2)
|
||||
multi_json (~> 1.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 4.2)
|
||||
webpush (0.3.2)
|
||||
hkdf (~> 0.2)
|
||||
@ -533,10 +537,10 @@ DEPENDENCIES
|
||||
better_errors (~> 2.1)
|
||||
binding_of_caller (~> 0.7)
|
||||
bootsnap
|
||||
brakeman (~> 3.6)
|
||||
brakeman (~> 4.0)
|
||||
browser
|
||||
bullet (~> 5.5)
|
||||
bundler-audit (~> 0.5)
|
||||
bundler-audit (~> 0.6)
|
||||
capistrano (~> 3.8)
|
||||
capistrano-rails (~> 1.2)
|
||||
capistrano-rbenv (~> 2.1)
|
||||
@ -614,11 +618,12 @@ DEPENDENCIES
|
||||
simplecov (~> 0.14)
|
||||
sprockets-rails (~> 3.2)
|
||||
statsd-instrument (~> 2.1)
|
||||
strong_migrations
|
||||
twitter-text (~> 1.14)
|
||||
tzinfo-data (~> 1.2017)
|
||||
uglifier (~> 3.2)
|
||||
webmock (~> 3.0)
|
||||
webpacker (~> 2.0)
|
||||
webpacker (~> 3.0)
|
||||
webpush
|
||||
|
||||
RUBY VERSION
|
||||
|
@ -1,4 +1,4 @@
|
||||
web: PORT=3000 bundle exec puma -C config/puma.rb
|
||||
sidekiq: PORT=3000 bundle exec sidekiq
|
||||
stream: PORT=4000 yarn run start
|
||||
webpack: ./bin/webpack-dev-server --host 0.0.0.0
|
||||
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0
|
||||
|
@ -3,7 +3,7 @@
|
||||
module Admin
|
||||
class CustomEmojisController < BaseController
|
||||
def index
|
||||
@custom_emojis = CustomEmoji.where(domain: nil)
|
||||
@custom_emojis = CustomEmoji.local
|
||||
end
|
||||
|
||||
def new
|
||||
|
9
app/controllers/api/v1/custom_emojis_controller.rb
Normal file
9
app/controllers/api/v1/custom_emojis_controller.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::CustomEmojisController < Api::BaseController
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer
|
||||
end
|
||||
end
|
@ -17,12 +17,29 @@ class FollowerAccountsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def page_url(page)
|
||||
account_followers_url(@account, page: page) unless page.nil?
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
size: @account.followers_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
|
||||
part_of: account_followers_url(@account),
|
||||
next: page_url(@follows.next_page),
|
||||
prev: page_url(@follows.prev_page)
|
||||
)
|
||||
if params[:page].present?
|
||||
page
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.followers_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
|
||||
first: page
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -17,12 +17,29 @@ class FollowingAccountsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def page_url(page)
|
||||
account_following_index_url(@account, page: page) unless page.nil?
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
|
||||
part_of: account_following_index_url(@account),
|
||||
next: page_url(@follows.next_page),
|
||||
prev: page_url(@follows.prev_page)
|
||||
)
|
||||
if params[:page].present?
|
||||
page
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
|
||||
first: page
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1,24 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EmojiHelper
|
||||
def emojify(text)
|
||||
return text if text.blank?
|
||||
|
||||
text.gsub(emoji_pattern) do |match|
|
||||
emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
|
||||
|
||||
if emoji
|
||||
emoji
|
||||
else
|
||||
match
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def emoji_pattern
|
||||
@emoji_pattern ||=
|
||||
/(?<=[^[:alnum:]:]|\n|^)
|
||||
(#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
|
||||
(?=[^[:alnum:]:]|$)/x
|
||||
end
|
||||
end
|
@ -1,4 +1,5 @@
|
||||
import api from '../api';
|
||||
import { emojiIndex } from 'emoji-mart';
|
||||
|
||||
import {
|
||||
updateTimeline,
|
||||
@ -213,19 +214,33 @@ export function clearComposeSuggestions() {
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
if (token[0] === ':') {
|
||||
const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
|
||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||
return;
|
||||
}
|
||||
|
||||
api(getState).get('/api/v1/accounts/search', {
|
||||
params: {
|
||||
q: token,
|
||||
q: token.slice(1),
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
},
|
||||
}).then(response => {
|
||||
dispatch(readyComposeSuggestions(token, response.data));
|
||||
dispatch(readyComposeSuggestionsAccounts(token, response.data));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestions(token, accounts) {
|
||||
export function readyComposeSuggestionsEmojis(token, emojis) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
token,
|
||||
emojis,
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestionsAccounts(token, accounts) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
token,
|
||||
@ -233,13 +248,21 @@ export function readyComposeSuggestions(token, accounts) {
|
||||
};
|
||||
};
|
||||
|
||||
export function selectComposeSuggestion(position, token, accountId) {
|
||||
export function selectComposeSuggestion(position, token, suggestion) {
|
||||
return (dispatch, getState) => {
|
||||
const completion = getState().getIn(['accounts', accountId, 'acct']);
|
||||
let completion, startPosition;
|
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
startPosition = position - 1;
|
||||
} else {
|
||||
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
||||
startPosition = position;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SUGGESTION_SELECT,
|
||||
position,
|
||||
position: startPosition,
|
||||
token,
|
||||
completion,
|
||||
});
|
||||
|
@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
||||
|
||||
const convertState = rawState =>
|
||||
fromJS(rawState, (k, v) =>
|
||||
Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
|
||||
Number.isNaN(x * 1) ? x : x * 1));
|
||||
Iterable.isIndexed(v) ? v.toList() : v.toMap());
|
||||
|
||||
export function hydrateStore(rawState) {
|
||||
const state = convertState(rawState);
|
||||
|
@ -23,7 +23,7 @@ export default class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
|
37
app/javascript/mastodon/components/autosuggest_emoji.js
Normal file
37
app/javascript/mastodon/components/autosuggest_emoji.js
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { unicodeMapping } from '../emojione_light';
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
export default class AutosuggestEmoji extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const [ filename ] = unicodeMapping[emoji.native];
|
||||
url = `${assetHost}/emoji/${filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from '../rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
||||
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase().slice(1);
|
||||
word = word.trim().toLowerCase();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
@ -151,9 +153,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
||||
const { suggestionsHidden, selectedSuggestion } = this.state;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
inputRef={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
@ -181,18 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
key={suggestion}
|
||||
data-index={suggestion}
|
||||
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
<AutosuggestAccountContainer id={suggestion} />
|
||||
</div>
|
||||
))}
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,53 +1,58 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import IconButton from './icon_button';
|
||||
import { Overlay } from 'react-overlays';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
|
||||
export default class DropdownMenu extends React.PureComponent {
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
class DropdownMenu extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onModalOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
direction: PropTypes.string,
|
||||
status: ImmutablePropTypes.map,
|
||||
ariaLabel: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
ariaLabel: 'Menu',
|
||||
isModalOpen: false,
|
||||
isUserTouching: () => false,
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
direction: 'left',
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.dropdown = c;
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
if (this.props.isModalOpen) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
// Don't call e.preventDefault() when the item uses 'href' property.
|
||||
// ex. "Edit profile" on the account action bar
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
@ -56,46 +61,18 @@ export default class DropdownMenu extends React.PureComponent {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
|
||||
this.dropdown.hide();
|
||||
}
|
||||
|
||||
handleShow = () => {
|
||||
if (this.props.isUserTouching()) {
|
||||
this.props.onModalOpen({
|
||||
status: this.props.status,
|
||||
actions: this.props.items,
|
||||
onClick: this.handleClick,
|
||||
});
|
||||
} else {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
renderItem (option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
handleHide = () => this.setState({ expanded: false })
|
||||
|
||||
handleToggle = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (this.props.isUserTouching()) {
|
||||
this.handleShow();
|
||||
} else {
|
||||
this.setState({ expanded: !this.state.expanded });
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
this.setState({ expanded: false });
|
||||
}
|
||||
}
|
||||
|
||||
renderItem = (item, i) => {
|
||||
if (item === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown__sep' />;
|
||||
}
|
||||
|
||||
const { text, href = '#' } = item;
|
||||
const { text, href = '#' } = option;
|
||||
|
||||
return (
|
||||
<li className='dropdown__content-list-item' key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
|
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
@ -103,44 +80,131 @@ export default class DropdownMenu extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const isUserTouching = this.props.isUserTouching();
|
||||
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
|
||||
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
|
||||
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
|
||||
<i className={iconClassname} aria-hidden />
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class Dropdown extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
ariaLabel: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onModalOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
ariaLabel: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
|
||||
const { status, items } = this.props;
|
||||
|
||||
this.props.onModalOpen({
|
||||
status,
|
||||
actions: items,
|
||||
onClick: this.handleItemClick,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ expanded: !this.state.expanded });
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
if (this.props.onModalClose) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
this.setState({ expanded: false });
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'Enter':
|
||||
this.handleClick();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, ariaLabel, disabled } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
||||
return (
|
||||
<div onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
title={ariaLabel}
|
||||
active={expanded}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
ref={this.setTargetRef}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
|
||||
<Overlay show={expanded} placement='bottom' target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dropdownItems = expanded && (
|
||||
<ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
|
||||
{items.map(this.renderItem)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
// No need to render the actual dropdown if we use the modal. If we
|
||||
// don't render anything <Dropdow /> breaks, so we just put an empty div.
|
||||
const dropdownContent = !isUserTouching ? (
|
||||
<DropdownContent className={directionClass} >
|
||||
{dropdownItems}
|
||||
</DropdownContent>
|
||||
) : <div />;
|
||||
|
||||
return (
|
||||
<Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
|
||||
<DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}>
|
||||
<i className={iconClassname} aria-hidden />
|
||||
</DropdownTrigger>
|
||||
|
||||
{dropdownContent}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
|
||||
import { is } from 'immutable';
|
||||
|
||||
export default class IntersectionObserverArticle extends ImmutablePureComponent {
|
||||
// Diff these props in the "rendered" state
|
||||
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
||||
export default class IntersectionObserverArticle extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
@ -22,18 +27,15 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
if (!nextState.isIntersecting && nextState.isHidden) {
|
||||
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
|
||||
// that either "isIntersecting" or "isHidden" matter, and then they're
|
||||
// the only things that matter (and updated ARIA attributes).
|
||||
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
|
||||
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
|
||||
// If we're going from a non-intersecting state to an intersecting state,
|
||||
// (i.e. offscreen to onscreen), then we definitely need to re-render
|
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
|
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
|
||||
if (!!isUnrendered !== !!willBeUnrendered) {
|
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
return true;
|
||||
}
|
||||
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
|
||||
return super.shouldComponentUpdate(nextProps, nextState);
|
||||
// Otherwise, diff based on props
|
||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
|
||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
|
@ -4,9 +4,12 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { is } from 'immutable';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import sizeMe from 'react-sizeme';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
@ -20,6 +23,7 @@ class Item extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
standalone: PropTypes.bool,
|
||||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
@ -28,6 +32,9 @@ class Item extends React.PureComponent {
|
||||
|
||||
static defaultProps = {
|
||||
autoPlayGif: false,
|
||||
standalone: false,
|
||||
index: 0,
|
||||
size: 1,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
@ -60,7 +67,7 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size } = this.props;
|
||||
const { attachment, index, size, standalone } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
@ -139,7 +146,7 @@ class Item extends React.PureComponent {
|
||||
const autoPlay = !isIOS() && this.props.autoPlayGif;
|
||||
|
||||
thumbnail = (
|
||||
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
role='application'
|
||||
@ -158,7 +165,7 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
@ -167,11 +174,14 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
@sizeMe({})
|
||||
export default class MediaGallery extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
sensitive: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
size: PropTypes.object,
|
||||
height: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
@ -180,6 +190,7 @@ export default class MediaGallery extends React.PureComponent {
|
||||
|
||||
static defaultProps = {
|
||||
autoPlayGif: false,
|
||||
standalone: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -187,7 +198,7 @@ export default class MediaGallery extends React.PureComponent {
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.sensitive !== this.props.sensitive) {
|
||||
if (!is(nextProps.media, this.props.media)) {
|
||||
this.setState({ visible: !nextProps.sensitive });
|
||||
}
|
||||
}
|
||||
@ -201,10 +212,19 @@ export default class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, sensitive } = this.props;
|
||||
const { media, intl, sensitive, height, standalone, size } = this.props;
|
||||
|
||||
let children;
|
||||
|
||||
const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
const style = {};
|
||||
|
||||
if (standaloneEligible) {
|
||||
style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
} else {
|
||||
style.height = height;
|
||||
}
|
||||
|
||||
if (!this.state.visible) {
|
||||
let warning;
|
||||
|
||||
@ -215,19 +235,24 @@ export default class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
|
||||
children = (
|
||||
<button className='media-spoiler' onClick={this.handleOpen}>
|
||||
<button className='media-spoiler' onClick={this.handleOpen} style={style}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (standaloneEligible) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={{ height: `${this.props.height}px` }}>
|
||||
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
|
||||
<div className='media-gallery' style={style}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
</div>
|
||||
|
||||
|
@ -37,7 +37,7 @@ export default class Status extends ImmutablePureComponent {
|
||||
onBlock: PropTypes.func,
|
||||
onEmbed: PropTypes.func,
|
||||
onHeightChange: PropTypes.func,
|
||||
me: PropTypes.number,
|
||||
me: PropTypes.string,
|
||||
boostModal: PropTypes.bool,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
muted: PropTypes.bool,
|
||||
@ -73,7 +73,7 @@ export default class Status extends ImmutablePureComponent {
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
const id = Number(e.currentTarget.getAttribute('data-id'));
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
||||
onEmbed: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
me: PropTypes.number,
|
||||
me: PropTypes.string,
|
||||
withDismiss: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
@ -3,48 +3,70 @@ import Trie from 'substring-trie';
|
||||
|
||||
const trie = new Trie(Object.keys(unicodeMapping));
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
const emojify = (str, customEmojis = {}) => {
|
||||
// This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
|
||||
// and replacing valid unicode strings
|
||||
// that _aren't_ within tags with an <img> version.
|
||||
// The goal is to be the same as an emojione.regUnicode replacement, but faster.
|
||||
let i = -1;
|
||||
let insideTag = false;
|
||||
let insideShortname = false;
|
||||
let shortnameStartIndex = -1;
|
||||
let match;
|
||||
while (++i < str.length) {
|
||||
const char = str.charAt(i);
|
||||
if (insideShortname && char === ':') {
|
||||
const shortname = str.substring(shortnameStartIndex, i + 1);
|
||||
let rtn = '';
|
||||
for (;;) {
|
||||
let match, i = 0, tag;
|
||||
while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
|
||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||
}
|
||||
if (i === str.length)
|
||||
break;
|
||||
else if (tag >= 0) {
|
||||
const tagend = str.indexOf('>;'[tag], i + 1) + 1;
|
||||
if (!tagend)
|
||||
break;
|
||||
rtn += str.slice(0, tagend);
|
||||
str = str.slice(tagend);
|
||||
} else if (str[i] === ':') {
|
||||
try {
|
||||
// if replacing :shortname: succeed, exit this block with "continue"
|
||||
const closeColon = str.indexOf(':', i + 1) + 1;
|
||||
if (!closeColon) throw null; // no pair of ':'
|
||||
const lt = str.indexOf('<', i + 1);
|
||||
if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':'
|
||||
const shortname = str.slice(i, closeColon);
|
||||
if (shortname in customEmojis) {
|
||||
const replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
|
||||
str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1);
|
||||
i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string
|
||||
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
|
||||
str = str.slice(closeColon);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {}
|
||||
// replacing :shortname: failed
|
||||
rtn += str.slice(0, i + 1);
|
||||
str = str.slice(i + 1);
|
||||
} else {
|
||||
i--;
|
||||
}
|
||||
insideShortname = false;
|
||||
} else if (insideTag && char === '>') {
|
||||
insideTag = false;
|
||||
} else if (char === '<') {
|
||||
insideTag = true;
|
||||
insideShortname = false;
|
||||
} else if (!insideTag && char === ':') {
|
||||
insideShortname = true;
|
||||
shortnameStartIndex = i;
|
||||
} else if (!insideTag && (match = trie.search(str.substring(i)))) {
|
||||
const unicodeStr = match;
|
||||
if (unicodeStr in unicodeMapping) {
|
||||
const [filename, shortCode] = unicodeMapping[unicodeStr];
|
||||
const alt = unicodeStr;
|
||||
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
|
||||
str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
|
||||
i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
|
||||
const [filename, shortCode] = unicodeMapping[match];
|
||||
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`;
|
||||
str = str.slice(i + match.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str;
|
||||
return rtn + str;
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = customEmojis => {
|
||||
const emojis = [];
|
||||
|
||||
customEmojis.forEach(emoji => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = emoji.get('url');
|
||||
const name = shortcode.replace(':', '');
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
short_names: [name],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: [name],
|
||||
imageUrl: url,
|
||||
custom: true,
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
1
app/javascript/mastodon/emoji_map.json
Normal file
1
app/javascript/mastodon/emoji_map.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,13 +1,38 @@
|
||||
// @preval
|
||||
// Force tree shaking on emojione by exposing just a subset of its functionality
|
||||
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
||||
|
||||
const emojione = require('emojione');
|
||||
|
||||
const mappedUnicode = emojione.mapUnicodeToShort();
|
||||
const emojis = require('./emoji_map.json');
|
||||
const { emojiIndex } = require('emoji-mart');
|
||||
const excluded = ['®', '©', '™'];
|
||||
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
|
||||
const shortcodeMap = {};
|
||||
|
||||
module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
|
||||
.filter(c => !excluded.includes(c))
|
||||
.map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
|
||||
.map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
|
||||
.reduce((x, y) => Object.assign(x, y), { });
|
||||
Object.keys(emojiIndex.emojis).forEach(key => {
|
||||
shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
|
||||
});
|
||||
|
||||
const stripModifiers = unicode => {
|
||||
skins.forEach(tone => {
|
||||
unicode = unicode.replace(tone, '');
|
||||
});
|
||||
|
||||
return unicode;
|
||||
};
|
||||
|
||||
Object.keys(emojis).forEach(key => {
|
||||
if (excluded.includes(key)) {
|
||||
delete emojis[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedKey = stripModifiers(key);
|
||||
let shortcode = shortcodeMap[normalizedKey];
|
||||
|
||||
if (!shortcode) {
|
||||
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
|
||||
}
|
||||
|
||||
emojis[key] = [emojis[key], shortcode];
|
||||
});
|
||||
|
||||
module.exports.unicodeMapping = emojis;
|
||||
|
@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
|
@ -80,7 +80,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
autoPlayGif: PropTypes.bool.isRequired,
|
||||
|
@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll';
|
||||
import LoadMore from '../../components/load_more';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
medias: getAccountGallery(state, Number(props.params.accountId)),
|
||||
isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']),
|
||||
medias: getAccountGallery(state, props.params.accountId),
|
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
|
||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
||||
});
|
||||
|
||||
@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleScrollToBottom = () => {
|
||||
if (this.props.hasMore) {
|
||||
this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
|
@ -26,7 +26,7 @@ const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: getAccount(state, Number(accountId)),
|
||||
account: getAccount(state, accountId),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
unfollowModal: state.getIn(['meta', 'unfollow_modal']),
|
||||
});
|
||||
|
@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()),
|
||||
isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']),
|
||||
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
|
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
});
|
||||
|
||||
@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleScrollToBottom = () => {
|
||||
if (!this.props.isLoading && this.props.hasMore) {
|
||||
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
|
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import EmojiPickerDropdown from './emoji_picker_dropdown';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { isMobile } from '../../../is_mobile';
|
||||
@ -46,7 +46,7 @@ export default class ComposeForm extends ImmutablePureComponent {
|
||||
preselectDate: PropTypes.instanceOf(Date),
|
||||
is_submitting: PropTypes.bool,
|
||||
is_uploading: PropTypes.bool,
|
||||
me: PropTypes.number,
|
||||
me: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onClearSuggestions: PropTypes.func.isRequired,
|
||||
@ -150,7 +150,7 @@ export default class ComposeForm extends ImmutablePureComponent {
|
||||
|
||||
handleEmojiPick = (data) => {
|
||||
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||
const emojiChar = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
|
||||
const emojiChar = data.native;
|
||||
this._restoreCaret = position + emojiChar.length + 1;
|
||||
this.props.onPickEmoji(position, data);
|
||||
}
|
||||
|
@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
import { Picker, Emoji } from 'emoji-mart';
|
||||
import { Overlay } from 'react-overlays';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
@ -17,48 +24,250 @@ const messages = defineMessages({
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
const settings = {
|
||||
imageType: 'png',
|
||||
sprites: false,
|
||||
imagePathPNG: '/emoji/',
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
let EmojiPicker; // load asynchronously
|
||||
handleClick = (e) => {
|
||||
const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
|
||||
this.props.onSelect(modifier);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.active) {
|
||||
this.attachListeners();
|
||||
} else {
|
||||
this.removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.removeListeners();
|
||||
}
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
removeListeners () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { active } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ModifierPicker extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
modifier: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onOpen: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (this.props.active) {
|
||||
this.props.onClose();
|
||||
} else {
|
||||
this.props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
handleSelect = modifier => {
|
||||
this.props.onChange(modifier);
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { active, modifier } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers'>
|
||||
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
|
||||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
class EmojiPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onPick: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
modifierOpen: false,
|
||||
modifier: 1,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
getI18n = () => {
|
||||
const { intl } = this.props;
|
||||
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
recent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleClick = emoji => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
}
|
||||
|
||||
this.props.onClose();
|
||||
this.props.onPick(emoji);
|
||||
}
|
||||
|
||||
handleModifierOpen = () => {
|
||||
this.setState({ modifierOpen: true });
|
||||
}
|
||||
|
||||
handleModifierClose = () => {
|
||||
this.setState({ modifierOpen: false });
|
||||
}
|
||||
|
||||
handleModifierChange = modifier => {
|
||||
if (modifier !== this.state.modifier) {
|
||||
this.setState({ modifier });
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { style, intl } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { modifierOpen, modifier } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<Picker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={this.getI18n()}
|
||||
onClick={this.handleClick}
|
||||
skin={modifier}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={modifier}
|
||||
onOpen={this.handleModifierOpen}
|
||||
onClose={this.handleModifierClose}
|
||||
onChange={this.handleModifierChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.dropdown = c;
|
||||
}
|
||||
|
||||
handleChange = (data) => {
|
||||
this.dropdown.hide();
|
||||
this.props.onPickEmoji(data);
|
||||
}
|
||||
|
||||
onShowDropdown = () => {
|
||||
this.setState({ active: true });
|
||||
if (!EmojiPicker) {
|
||||
this.setState({ loading: true });
|
||||
EmojiPickerAsync().then(TheEmojiPicker => {
|
||||
EmojiPicker = TheEmojiPicker.default;
|
||||
this.setState({ loading: false });
|
||||
}).catch(() => {
|
||||
// TODO: show the user an error?
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHideDropdown = () => {
|
||||
@ -66,7 +275,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (!e.key || e.key === 'Enter') {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
@ -75,70 +284,43 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onEmojiPickerKeyDown = (e) => {
|
||||
handleKeyDown = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.onHideDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
const categories = {
|
||||
people: {
|
||||
title: intl.formatMessage(messages.people),
|
||||
emoji: 'smile',
|
||||
},
|
||||
nature: {
|
||||
title: intl.formatMessage(messages.nature),
|
||||
emoji: 'hamster',
|
||||
},
|
||||
food: {
|
||||
title: intl.formatMessage(messages.food),
|
||||
emoji: 'pizza',
|
||||
},
|
||||
activity: {
|
||||
title: intl.formatMessage(messages.activity),
|
||||
emoji: 'soccer',
|
||||
},
|
||||
travel: {
|
||||
title: intl.formatMessage(messages.travel),
|
||||
emoji: 'earth_americas',
|
||||
},
|
||||
objects: {
|
||||
title: intl.formatMessage(messages.objects),
|
||||
emoji: 'bulb',
|
||||
},
|
||||
symbols: {
|
||||
title: intl.formatMessage(messages.symbols),
|
||||
emoji: 'clock9',
|
||||
},
|
||||
flags: {
|
||||
title: intl.formatMessage(messages.flags),
|
||||
emoji: 'flag_gb',
|
||||
},
|
||||
};
|
||||
|
||||
const { active, loading } = this.state;
|
||||
const { intl, onPickEmoji } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active } = this.state;
|
||||
|
||||
return (
|
||||
<Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
|
||||
<DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} >
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||
<img
|
||||
className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
|
||||
className='emojione'
|
||||
alt='🙂'
|
||||
src='/emoji/1f602.svg'
|
||||
src={`${assetHost}/emoji/1f602.svg`}
|
||||
/>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
|
||||
<DropdownContent className='dropdown__left'>
|
||||
{
|
||||
this.state.active && !this.state.loading &&
|
||||
(<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />)
|
||||
}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<Overlay show={active} placement='bottom' target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
custom_emojis={this.props.custom_emojis}
|
||||
onClose={this.onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
@ -89,12 +90,12 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('click', this.onGlobalClick);
|
||||
window.addEventListener('touchstart', this.onGlobalClick);
|
||||
window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('click', this.onGlobalClick);
|
||||
window.removeEventListener('touchstart', this.onGlobalClick);
|
||||
window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
|
@ -21,7 +21,7 @@ export default class UploadForm extends React.PureComponent {
|
||||
};
|
||||
|
||||
onRemoveFile = (e) => {
|
||||
const id = Number(e.currentTarget.parentElement.getAttribute('data-id'));
|
||||
const id = e.currentTarget.parentElement.getAttribute('data-id');
|
||||
this.props.onRemoveFile(id);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { connect } from 'react-redux';
|
||||
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
custom_emojis: state.get('custom_emojis'),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(EmojiPickerDropdown);
|
@ -1,51 +1,23 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import Warning from '../components/warning';
|
||||
import { createSelector } from 'reselect';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { OrderedSet } from 'immutable';
|
||||
|
||||
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
|
||||
|
||||
const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
|
||||
return OrderedSet(mentionedUsernamesWithDomains !== null ? mentionedUsernamesWithDomains.map(item => item.split('@')[2]) : []);
|
||||
const mapStateToProps = state => ({
|
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const mentionedUsernames = getMentionedUsernames(state);
|
||||
const mentionedUsernamesWithDomains = getMentionedDomains(state);
|
||||
|
||||
return {
|
||||
needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
|
||||
mentionedDomains: mentionedUsernamesWithDomains,
|
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
|
||||
};
|
||||
};
|
||||
|
||||
const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
|
||||
const WarningWrapper = ({ needsLockWarning }) => {
|
||||
if (needsLockWarning) {
|
||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||
} else if (needsLeakWarning) {
|
||||
return (
|
||||
<Warning
|
||||
message={<FormattedMessage
|
||||
id='compose_form.privacy_disclaimer'
|
||||
defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
|
||||
values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.size }}
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
WarningWrapper.propTypes = {
|
||||
needsLeakWarning: PropTypes.bool,
|
||||
needsLockWarning: PropTypes.bool,
|
||||
mentionedDomains: ImmutablePropTypes.orderedSet.isRequired,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(WarningWrapper);
|
||||
|
@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]),
|
||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@ -24,12 +24,12 @@ export default class Favourites extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
|
||||
this.props.dispatch(fetchFavourites(this.props.params.statusId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
|
||||
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']),
|
||||
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@ -32,14 +32,14 @@ export default class Followers extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(fetchFollowers(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(fetchFollowers(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,13 +47,13 @@ export default class Followers extends ImmutablePureComponent {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
|
||||
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandFollowers(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandFollowers(this.props.params.accountId));
|
||||
}
|
||||
|
||||
render () {
|
||||
|
@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']),
|
||||
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@ -32,14 +32,14 @@ export default class Following extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(fetchFollowing(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(fetchFollowing(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,13 +47,13 @@ export default class Following extends ImmutablePureComponent {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
|
||||
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandFollowing(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
|
||||
this.props.dispatch(expandFollowing(this.props.params.accountId));
|
||||
}
|
||||
|
||||
render () {
|
||||
|
@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]),
|
||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@ -24,12 +24,12 @@ export default class Reblogs extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
|
||||
this.props.dispatch(fetchReblogs(this.props.params.statusId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
|
||||
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,7 +36,7 @@ export default class ActionBar extends React.PureComponent {
|
||||
onReport: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onEmbed: PropTypes.func,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.string.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -38,10 +38,10 @@ const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, Number(props.params.statusId)),
|
||||
status: getStatus(state, props.params.statusId),
|
||||
settings: state.get('local_settings'),
|
||||
ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]),
|
||||
descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]),
|
||||
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
|
||||
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
boostModal: state.getIn(['meta', 'boost_modal']),
|
||||
deleteModal: state.getIn(['meta', 'delete_modal']),
|
||||
@ -66,7 +66,7 @@ export default class Status extends ImmutablePureComponent {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
ancestorsIds: ImmutablePropTypes.list,
|
||||
descendantsIds: ImmutablePropTypes.list,
|
||||
me: PropTypes.number,
|
||||
me: PropTypes.string,
|
||||
boostModal: PropTypes.bool,
|
||||
deleteModal: PropTypes.bool,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
@ -74,12 +74,12 @@ export default class Status extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
|
||||
this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
|
||||
this.props.dispatch(fetchStatus(nextProps.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,32 +1,35 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import RelativeTimestamp from '../../../components/relative_timestamp';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class ActionsModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
actions: PropTypes.array,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
renderAction = (action, i) => {
|
||||
if (action === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown__sep' />;
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { icon = null, text, meta = null, active = false, href = '#' } = action;
|
||||
|
||||
return (
|
||||
<li key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
|
||||
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
|
||||
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
|
||||
<div>
|
||||
<div>{text}</div>
|
||||
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||
<div>{meta}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -3,17 +3,28 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Column from '../../../components/column';
|
||||
import ColumnHeader from '../../../components/column_header';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const ColumnLoading = ({ title = '', icon = ' ' }) => (
|
||||
export default class ColumnLoading extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
icon: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
title: '',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
render() {
|
||||
let { title, icon } = this.props;
|
||||
return (
|
||||
<Column>
|
||||
<ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
|
||||
<div className='scrollable' />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
ColumnLoading.propTypes = {
|
||||
title: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ColumnLoading;
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export default class UI extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
}
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
@ -193,14 +193,18 @@ export default class UI extends React.PureComponent {
|
||||
document.removeEventListener('dragend', this.handleDragEnd);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setColumnsAreaRef = (c) => {
|
||||
setColumnsAreaRef = c => {
|
||||
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
||||
}
|
||||
|
||||
setOverlayRef = c => {
|
||||
this.overlay = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { width, draggingOver } = this.state;
|
||||
const { children, layout, isWide, navbarUnder } = this.props;
|
||||
|
@ -1,7 +1,3 @@
|
||||
export function EmojiPicker () {
|
||||
return import(/* webpackChunkName: "emojione_picker" */'emojione-picker');
|
||||
}
|
||||
|
||||
export function Compose () {
|
||||
return import(/* webpackChunkName: "features/compose" */'../../compose');
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
const LAYOUT_BREAKPOINT = 1024;
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
|
||||
const LAYOUT_BREAKPOINT = 630;
|
||||
|
||||
export function isMobile(width, columns) {
|
||||
switch (columns) {
|
||||
@ -12,11 +14,16 @@ export function isMobile(width, columns) {
|
||||
};
|
||||
|
||||
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
let userTouching = false;
|
||||
|
||||
window.addEventListener('touchstart', () => {
|
||||
let userTouching = false;
|
||||
let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
function touchListener() {
|
||||
userTouching = true;
|
||||
}, { once: true });
|
||||
window.removeEventListener('touchstart', touchListener, listenerOptions);
|
||||
}
|
||||
|
||||
window.addEventListener('touchstart', touchListener, listenerOptions);
|
||||
|
||||
export function isUserTouching() {
|
||||
return userTouching;
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
|
||||
"compose_form.lock_disclaimer.lock": "مقفل",
|
||||
"compose_form.placeholder": "فيمَ تفكّر؟",
|
||||
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||
"compose_form.publish": "بوّق",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "الأنشطة",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "الأعلام",
|
||||
"emoji_button.food": "الطعام والشراب",
|
||||
"emoji_button.label": "أدرج إيموجي",
|
||||
"emoji_button.nature": "الطبيعة",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "أشياء",
|
||||
"emoji_button.people": "الناس",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "ابحث...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "رموز",
|
||||
"emoji_button.travel": "أماكن و أسفار",
|
||||
"empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Какво си мислиш?",
|
||||
"compose_form.privacy_disclaimer": "Поверителни публикации ще бъдат изпратени до споменатите потребители на {domains}. Доверяваш ли се на {domainsCount, plural, one {that server} other {those servers}}, че няма да издаде твоята публикация?",
|
||||
"compose_form.publish": "Раздумай",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Отбележи съдържанието като деликатно",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
|
||||
"compose_form.lock_disclaimer.lock": "bloquejat",
|
||||
"compose_form.placeholder": "En què estàs pensant?",
|
||||
"compose_form.privacy_disclaimer": "El teu missatge serà lliurat als usuaris esmentats en els dominis {domains}. Confies en {domainsCount, plural, one {that server} other {those servers}}? Els missatges privats només funcionen en instàncies Mastodon. Si {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, res indicarà que el teu missatge no es públic i pot ser impulsat (boosted) o ser visible per destinataris no desitjats.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Marcar multimèdia com a sensible",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activitat",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Menjar i Beure",
|
||||
"emoji_button.label": "Inserir emoji",
|
||||
"emoji_button.nature": "Natura",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objectes",
|
||||
"emoji_button.people": "Gent",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Cercar...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Símbols",
|
||||
"emoji_button.travel": "Viatges i Llocs",
|
||||
"empty_column.community": "La línia de temps local és buida. Escriu alguna cosa públicament per fer rodar la pilota!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
|
||||
"compose_form.lock_disclaimer.lock": "gesperrt",
|
||||
"compose_form.placeholder": "Worüber möchtest du schreiben?",
|
||||
"compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Profile auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
|
||||
"compose_form.publish": "Tröt",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Medien als heikel markieren",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
|
||||
"embed.preview": "So wird es aussehen:",
|
||||
"emoji_button.activity": "Aktivitäten",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flaggen",
|
||||
"emoji_button.food": "Essen und Trinken",
|
||||
"emoji_button.label": "Emoji einfügen",
|
||||
"emoji_button.nature": "Natur",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Dinge",
|
||||
"emoji_button.people": "Leute",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Suche…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbole",
|
||||
"emoji_button.travel": "Reise und Orte",
|
||||
"empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
|
||||
|
@ -516,6 +516,22 @@
|
||||
"defaultMessage": "Search...",
|
||||
"id": "emoji_button.search"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"id": "emoji_button.not_found"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Custom",
|
||||
"id": "emoji_button.custom"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Frequently used",
|
||||
"id": "emoji_button.recent"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Search results",
|
||||
"id": "emoji_button.search_results"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "People",
|
||||
"id": "emoji_button.people"
|
||||
@ -682,10 +698,6 @@
|
||||
{
|
||||
"defaultMessage": "locked",
|
||||
"id": "compose_form.lock_disclaimer.lock"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||
"id": "compose_form.privacy_disclaimer"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
|
||||
@ -1331,15 +1343,6 @@
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/ui/components/upload_area.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Close",
|
||||
"id": "lightbox.close"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/ui/components/video_modal.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "What is on your mind?",
|
||||
"compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Mark media as sensitive",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Pri kio vi pensas?",
|
||||
"compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.",
|
||||
"compose_form.publish": "Hup",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Marki ke la enhavo estas tikla",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Tu cuenta no está bloqueada. Todos pueden seguirte para ver tus toots solo para seguidores.",
|
||||
"compose_form.lock_disclaimer.lock": "bloqueado",
|
||||
"compose_form.placeholder": "¿En qué estás pensando?",
|
||||
"compose_form.privacy_disclaimer": "Tu toot privado será enviado a usuario/s mencionados de {domains}. ¿Confías en {domainsCount, plural, one {ese servidor} other {esos servidores}}? La privacidad del toot funcionará solamente en instancias de Mastodon. Si {domains} {domainsCount, plural, one {no es una instancia de Mastodon} other {no son instancias de Mastodon}}, no habrá indicación de que tu toot es privado, y puede hacerse visible a remitentes inesperados.",
|
||||
"compose_form.publish": "Tootear",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Marcar contenido como sensible",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Añade este toot a tu sitio web con el siguiente código.",
|
||||
"embed.preview": "Así es como se verá:",
|
||||
"emoji_button.activity": "Actividad",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Marcas",
|
||||
"emoji_button.food": "Comida y bebida",
|
||||
"emoji_button.label": "Insertar emoji",
|
||||
"emoji_button.nature": "Naturaleza",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objetos",
|
||||
"emoji_button.people": "Gente",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Buscar…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viajes y lugares",
|
||||
"empty_column.community": "La línea de tiempo local está vacía. ¡Escribe algo para empezar la fiesta!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "حساب شما {locked} نیست. هر کسی میتواند پیگیر شما شود و نوشتههای ویژهٔ پیگیران شما را ببیند.",
|
||||
"compose_form.lock_disclaimer.lock": "قفل",
|
||||
"compose_form.placeholder": "تازه چه خبر؟",
|
||||
"compose_form.privacy_disclaimer": "نوشتهٔ خصوصی شما به کاربران نامبردهشده در {domains} فرستاده میشود. آیا به {domainsCount, plural, one {آن سرور} other {آن سرورها}} اعتماد دارید؟ تنظیمات حریم خصوصی نوشتهها تنها در سرورهای ماستدون کار میکند. اگر {domains} {domainsCount, plural, one {یک سرور ماستدون نباشد} other {سرورهای ماستدون نباشند}}، اشارهای به خصوصیبودن نوشتهٔ شما نخواهد شد و شاید نوشتهٔ شما همرسان شود یا برای کاربرانی که نمیخواهید نمایش یابد.",
|
||||
"compose_form.publish": "بوق",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "تصاویر حساس هستند",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "برای جاگذاری این نوشته در سایت خودتان، کد زیر را کپی کنید.",
|
||||
"embed.preview": "نوشتهٔ جاگذاریشده این گونه به نظر خواهد رسید:",
|
||||
"emoji_button.activity": "فعالیت",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "پرچمها",
|
||||
"emoji_button.food": "غذا و نوشیدنی",
|
||||
"emoji_button.label": "افزودن شکلک",
|
||||
"emoji_button.nature": "طبیعت",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "اشیا",
|
||||
"emoji_button.people": "مردم",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "جستجو...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "نمادها",
|
||||
"emoji_button.travel": "سفر و مکان",
|
||||
"empty_column.community": "فهرست نوشتههای محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Mitä sinulla on mielessä?",
|
||||
"compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Merkitse media herkäksi",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
|
||||
"compose_form.lock_disclaimer.lock": "verrouillé",
|
||||
"compose_form.placeholder": "Qu’avez-vous en tête ?",
|
||||
"compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.",
|
||||
"compose_form.publish": "Pouet ",
|
||||
"compose_form.publish_loud": "{publish} !",
|
||||
"compose_form.sensitive": "Marquer le média comme sensible",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.",
|
||||
"embed.preview": "Il apparaîtra comme cela : ",
|
||||
"emoji_button.activity": "Activités",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Drapeaux",
|
||||
"emoji_button.food": "Boire et manger",
|
||||
"emoji_button.label": "Insérer un emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objets",
|
||||
"emoji_button.people": "Personnages",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Recherche…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symboles",
|
||||
"emoji_button.travel": "Lieux et voyages",
|
||||
"empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
|
||||
"compose_form.lock_disclaimer.lock": "נעול",
|
||||
"compose_form.placeholder": "מה עובר לך בראש?",
|
||||
"compose_form.privacy_disclaimer": "הודעתך הפרטית תשלח למשתמשים על {domains}. האם ניתן לסמוך על {domainsCount, plural, one {שרת זה} other {שרתים אלו}}? פרטיות ההודעה קיימת רק על שרתי מסטודון. אם {domains} {domainsCount, plural, one {הוא לא שרת מסטודון} other {הם לא שרתי מסטודון}}, לא יהיה שום סימן שההודעה פרטית, והוא עשוי להיות מקודם או להחשף למשתמשים שלא ברשימת היעד.",
|
||||
"compose_form.publish": "ללחוש",
|
||||
"compose_form.publish_loud": "לחצרץ!",
|
||||
"compose_form.sensitive": "סימון תוכן כרגיש",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "פעילות",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "דגלים",
|
||||
"emoji_button.food": "אוכל ושתיה",
|
||||
"emoji_button.label": "הוספת אמוג'י",
|
||||
"emoji_button.nature": "טבע",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "חפצים",
|
||||
"emoji_button.people": "אנשים",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "חיפוש...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "סמלים",
|
||||
"emoji_button.travel": "טיולים ואתרים",
|
||||
"empty_column.community": "טור הסביבה ריק. יש לפרסם משהו כדי שדברים יתרחילו להתגלגל!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.",
|
||||
"compose_form.lock_disclaimer.lock": "zaključan",
|
||||
"compose_form.placeholder": "Što ti je na umu?",
|
||||
"compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bi biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Označi media sadržaj kao osjetljiv",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Aktivnost",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Zastave",
|
||||
"emoji_button.food": "Hrana & Piće",
|
||||
"emoji_button.label": "Umetni smajlije",
|
||||
"emoji_button.nature": "Priroda",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objekti",
|
||||
"emoji_button.people": "Ljudi",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Traži...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Simboli",
|
||||
"emoji_button.travel": "Putovanja & Mjesta",
|
||||
"empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Mire gondolsz?",
|
||||
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||
"compose_form.publish": "Tülk!",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Tartalom érzékenynek jelölése",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.",
|
||||
"compose_form.lock_disclaimer.lock": "dikunci",
|
||||
"compose_form.placeholder": "Apa yang ada di pikiran anda?",
|
||||
"compose_form.privacy_disclaimer": "Status pribadi anda akan dikirim ke pengguna yang disebut dalam {domains}. Apa anda mempercayai {domainsCount, plural, one {server tersebut} other {server tersebut}}? Privasi postingan hanya bekerja dalam server Mastodon. Jika {domains} {domainsCount, plural, one {bukan server Mastodon} other {bukan server Mastodon}}, akan ada indikasi bahwa postingan anda adalah postingan pribadi, dan dapat di-boost atau dapat dilihat oleh orang lain.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Tandai media sensitif",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Aktivitas",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Bendera",
|
||||
"emoji_button.food": "Makanan & Minuman",
|
||||
"emoji_button.label": "Tambahkan emoji",
|
||||
"emoji_button.nature": "Alam",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Benda-benda",
|
||||
"emoji_button.people": "Orang",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Cari...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Simbol",
|
||||
"emoji_button.travel": "Tempat Wisata",
|
||||
"empty_column.community": "Linimasa lokal masih kosong. Tulis sesuatu secara publik dan buat roda berputar!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Quo esas en tua spirito?",
|
||||
"compose_form.privacy_disclaimer": "Tua privata mesajo livresos a mencionata uzeri en {domains}. Ka tu fidas {domainsCount, plural, one {ta servero} other {ta serveri}}? Privateso di mesaji funcionas nur en instaluri di Mastodon. Se {domains} {domainsCount, plural, one {ne esas instaluro di Mastodon} other {ne esas instaluri di Mastodon}}, esos nula indiko, ke tua mesajo esas privata, ed ol povos repetesar od altre divenar videbla da nedezirinda recevanti.",
|
||||
"compose_form.publish": "Siflar",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Markizar kontenajo kom trubliva",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insertar emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "La lokala tempolineo esas vakua. Skribez ulo publike por iniciar la agiveso!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "A cosa stai pensando?",
|
||||
"compose_form.privacy_disclaimer": "Il tuo status privato verrà condiviso con gli utenti menzionati su {domains}. Ti fidi di {domainsCount, plural, one {quel server} other {quei server}}? Le impostazioni sulla privacy valgono solo su server Mastodon. Se {domains} {domainsCount, plural, one {non è un server Mastodon} other {non sono server Mastodon}}, non ci saranno indicazioni sulla privacy del tuo status, e potrebbe essere condiviso o reso visibile a destinatari indesiderati.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Segnala file come sensibile",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Inserisci emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
|
||||
"compose_form.lock_disclaimer.lock": "非公開",
|
||||
"compose_form.placeholder": "今なにしてる?",
|
||||
"compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先ユーザーが所属する{domains}に送信されます。{domainsCount, plural, one {このサーバー} other {これらのサーバー}}は信頼できますか? 投稿のプライバシー保護はMastodonサーバー内でのみ有効です。{domains}がMastodonインスタンスでない場合、あなたの投稿がプライベートなものとして扱われず、ブーストされたり予期しないユーザーに見られる可能性があります。",
|
||||
"compose_form.publish": "トゥート",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "メディアを閲覧注意としてマークする",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
|
||||
"embed.preview": "表示例:",
|
||||
"emoji_button.activity": "活動",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "国旗",
|
||||
"emoji_button.food": "食べ物",
|
||||
"emoji_button.label": "絵文字を追加",
|
||||
"emoji_button.nature": "自然",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "物",
|
||||
"emoji_button.people": "人々",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "検索...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "記号",
|
||||
"emoji_button.travel": "旅行と場所",
|
||||
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
|
||||
"compose_form.lock_disclaimer.lock": "비공개",
|
||||
"compose_form.placeholder": "지금 무엇을 하고 있나요?",
|
||||
"compose_form.privacy_disclaimer": "이 계정의 비공개 포스트는 멘션된 사용자가 소속된 {domains}으로 전송됩니다. {domainsCount, plural, one {이 서버를} other {이 서버들을}} 신뢰할 수 있습니까? 포스팅의 프라이버시 보호는 Mastodon 서버에서만 유효합니다. {domains}가 Mastodon 인스턴스가 아닐 경우, 이 투고가 사적인 것으로 취급되지 않은 채 부스트 되거나 원하지 않는 사용자에게 보여질 가능성이 있습니다.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "이 미디어를 민감한 미디어로 취급",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "활동",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "국기",
|
||||
"emoji_button.food": "음식",
|
||||
"emoji_button.label": "emoji를 추가",
|
||||
"emoji_button.nature": "자연",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "물건",
|
||||
"emoji_button.people": "사람들",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "검색...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "기호",
|
||||
"emoji_button.travel": "여행과 장소",
|
||||
"empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!",
|
||||
|
@ -33,9 +33,8 @@
|
||||
"column.home": "Start",
|
||||
"column.mutes": "Genegeerde gebruikers",
|
||||
"column.notifications": "Meldingen",
|
||||
"column.pins": "Pinned toot",
|
||||
"column.public": "Globale tijdlijn",
|
||||
"column.pins": "Vastgezette toots",
|
||||
"column.public": "Globale tijdlijn",
|
||||
"column_back_button.label": "terug",
|
||||
"column_header.hide_settings": "Instellingen verbergen",
|
||||
"column_header.moveLeft_settings": "Kolom naar links verplaatsen",
|
||||
@ -48,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en toots zien die je alleen aan volgers hebt gericht.",
|
||||
"compose_form.lock_disclaimer.lock": "besloten",
|
||||
"compose_form.placeholder": "Wat wil je kwijt?",
|
||||
"compose_form.privacy_disclaimer": "Jouw privétoot wordt afgeleverd aan de vermelde gebruikers op {domains}. Vertrouw jij {domainsCount, plural, one {die server} other {die servers}}? Het privé plaatsen van toots werkt alleen op Mastodon-servers. Wanneer {domains} {domainsCount, plural, one {geen Mastodon-server is} other {geen Mastodon-servers zijn}}, dan wordt er niet aangegeven dat de toot privé is, waardoor het kan worden geboost of op een andere manier zichtbaar wordt gemaakt voor mensen waarvoor het niet was bedoeld.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Media als gevoelig markeren (nsfw)",
|
||||
@ -68,13 +66,17 @@
|
||||
"embed.instructions": "Embed deze toot op jouw website, door de onderstaande code te kopiëren.",
|
||||
"embed.preview": "Zo komt het eruit te zien:",
|
||||
"emoji_button.activity": "Activiteiten",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Vlaggen",
|
||||
"emoji_button.food": "Eten en drinken",
|
||||
"emoji_button.label": "Emoji toevoegen",
|
||||
"emoji_button.nature": "Natuur",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Voorwerpen",
|
||||
"emoji_button.people": "Mensen",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Zoeken...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbolen",
|
||||
"emoji_button.travel": "Reizen en plekken",
|
||||
"empty_column.community": "De lokale tijdlijn is nog leeg. Toot iets in het openbaar om de bal aan het rollen te krijgen!",
|
||||
@ -87,7 +89,6 @@
|
||||
"follow_request.authorize": "Goedkeuren",
|
||||
"follow_request.reject": "Afkeuren",
|
||||
"getting_started.appsshort": "Apps",
|
||||
"getting_started.donate": "Doneren",
|
||||
"getting_started.faq": "FAQ",
|
||||
"getting_started.heading": "Beginnen",
|
||||
"getting_started.open_source_notice": "Mastodon is open-sourcesoftware. Je kunt bijdragen of problemen melden op GitHub via {github}.",
|
||||
@ -112,10 +113,9 @@
|
||||
"navigation_bar.info": "Uitgebreide informatie",
|
||||
"navigation_bar.logout": "Afmelden",
|
||||
"navigation_bar.mutes": "Genegeerde gebruikers",
|
||||
"navigation_bar.pins": "Pinned toots",
|
||||
"navigation_bar.pins": "Vastgezette toots",
|
||||
"navigation_bar.preferences": "Instellingen",
|
||||
"navigation_bar.public_timeline": "Globale tijdlijn",
|
||||
"navigation_bar.pins": "Vastgezette toots",
|
||||
"notification.favourite": "{name} markeerde jouw toot als favoriet",
|
||||
"notification.follow": "{name} volgt jou nu",
|
||||
"notification.mention": "{name} vermeldde jou",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Din konto er ikke {locked}. Hvem som helst kan følge deg og se dine private poster.",
|
||||
"compose_form.lock_disclaimer.lock": "låst",
|
||||
"compose_form.placeholder": "Hva har du på hjertet?",
|
||||
"compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ikke er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
|
||||
"compose_form.publish": "Tut",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Merk media som følsomt",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Aktivitet",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flagg",
|
||||
"emoji_button.food": "Mat og drikke",
|
||||
"emoji_button.label": "Sett inn emoji",
|
||||
"emoji_button.nature": "Natur",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objekter",
|
||||
"emoji_button.people": "Mennesker",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Søk...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symboler",
|
||||
"emoji_button.travel": "Reise & steder",
|
||||
"empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
|
||||
"compose_form.lock_disclaimer.lock": "clavat",
|
||||
"compose_form.placeholder": "A de qué pensatz ?",
|
||||
"compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
|
||||
"compose_form.publish": "Tut",
|
||||
"compose_form.publish_loud": "{publish} !",
|
||||
"compose_form.sensitive": "Marcar lo mèdia coma sensible",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embarcar aqueste estatut per lo far veire sus un site Internet en copiar lo còdi çai-jos.",
|
||||
"embed.preview": "Semblarà aquò : ",
|
||||
"emoji_button.activity": "Activitats",
|
||||
"emoji_button.custom": "Personalizats",
|
||||
"emoji_button.flags": "Drapèus",
|
||||
"emoji_button.food": "Beure e manjar",
|
||||
"emoji_button.label": "Inserir un emoji",
|
||||
"emoji_button.nature": "Natura",
|
||||
"emoji_button.not_found": "Cap emoji ! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objèctes",
|
||||
"emoji_button.people": "Gents",
|
||||
"emoji_button.recent": "Sovent utilizats",
|
||||
"emoji_button.search": "Cercar…",
|
||||
"emoji_button.search_results": "Resultat de recèrca",
|
||||
"emoji_button.symbols": "Simbòls",
|
||||
"emoji_button.travel": "Viatges & lòcs",
|
||||
"empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
|
||||
"compose_form.lock_disclaimer.lock": "zablokowane",
|
||||
"compose_form.placeholder": "Co Ci chodzi po głowie?",
|
||||
"compose_form.privacy_disclaimer": "Twój wpis zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność wpisów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, wpis może być widoczny dla niewłaściwych osób.",
|
||||
"compose_form.publish": "Wyślij",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Oznacz treści jako wrażliwe",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
|
||||
"embed.preview": "Tak będzie to wyglądać:",
|
||||
"emoji_button.activity": "Aktywność",
|
||||
"emoji_button.custom": "Niestandardowe",
|
||||
"emoji_button.flags": "Flagi",
|
||||
"emoji_button.food": "Żywność i napoje",
|
||||
"emoji_button.label": "Wstaw emoji",
|
||||
"emoji_button.nature": "Natura",
|
||||
"emoji_button.not_found": "Brak emoji!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objekty",
|
||||
"emoji_button.people": "Ludzie",
|
||||
"emoji_button.recent": "Najczęściej używane",
|
||||
"emoji_button.search": "Szukaj…",
|
||||
"emoji_button.search_results": "Wyniki wyszukiwania",
|
||||
"emoji_button.symbols": "Symbole",
|
||||
"emoji_button.travel": "Podróże i miejsca",
|
||||
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
|
||||
|
@ -35,7 +35,6 @@
|
||||
"column.notifications": "Notificações",
|
||||
"column.pins": "Postagens fixadas",
|
||||
"column.public": "Global",
|
||||
"column.pins": "Postagens fixadas",
|
||||
"column_back_button.label": "Voltar",
|
||||
"column_header.hide_settings": "Esconder configurações",
|
||||
"column_header.moveLeft_settings": "Mover coluna para a esquerda",
|
||||
@ -48,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
|
||||
"compose_form.lock_disclaimer.lock": "trancado",
|
||||
"compose_form.placeholder": "No que você está pensando?",
|
||||
"compose_form.privacy_disclaimer": "O seu conteúdo privado será compartilhado com os usuários de {domains}. Você confia {domainsCount, plural, one {neste servidor} other {nestes servidores}}? As configurações de privacidade só funcionam em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não há como garantir a privacidade de suas postagens, e elas podem ser compartilhadas com destinatários indesejados.",
|
||||
"compose_form.publish": "Publicar",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Marcar mídia como conteúdo sensível",
|
||||
@ -68,13 +66,17 @@
|
||||
"embed.instructions": "Incorpore esta postagem em seu site copiando o código abaixo:",
|
||||
"embed.preview": "Aqui está uma previsão de como ficará:",
|
||||
"emoji_button.activity": "Atividades",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Bandeiras",
|
||||
"emoji_button.food": "Comidas & Bebidas",
|
||||
"emoji_button.label": "Inserir Emoji",
|
||||
"emoji_button.nature": "Natureza",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objetos",
|
||||
"emoji_button.people": "Pessoas",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Buscar...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viagens & Lugares",
|
||||
"empty_column.community": "A timeline local está vazia. Escreva algo publicamente para começar!",
|
||||
@ -114,9 +116,6 @@
|
||||
"navigation_bar.pins": "Postagens fixadas",
|
||||
"navigation_bar.preferences": "Preferências",
|
||||
"navigation_bar.public_timeline": "Global",
|
||||
"navigation_bar.preferences": "Preferências",
|
||||
"navigation_bar.public_timeline": "Global",
|
||||
"navigation_bar.pins": "Postagens fixadas",
|
||||
"notification.favourite": "{name} adicionou a sua postagem aos favoritos",
|
||||
"notification.follow": "{name} te seguiu",
|
||||
"notification.mention": "{name} te mencionou",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "Em que estás a pensar?",
|
||||
"compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
|
||||
"compose_form.publish": "Publicar",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Marcar media como conteúdo sensível",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Inserir Emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "Ainda não existem conteúdo local para mostrar!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
|
||||
"compose_form.lock_disclaimer.lock": "закрыт",
|
||||
"compose_form.placeholder": "О чем Вы думаете?",
|
||||
"compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.",
|
||||
"compose_form.publish": "Трубить",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Отметить как чувствительный контент",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Занятия",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Флаги",
|
||||
"emoji_button.food": "Еда и напитки",
|
||||
"emoji_button.label": "Вставить эмодзи",
|
||||
"emoji_button.nature": "Природа",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Предметы",
|
||||
"emoji_button.people": "Люди",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Найти...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Символы",
|
||||
"emoji_button.travel": "Путешествия",
|
||||
"empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.placeholder": "What is on your mind?",
|
||||
"compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Mark media as sensitive",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"emoji_button.label": "Insert emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Hesabınız {locked} değil. Sadece takipçilerle paylaştığınız gönderileri görebilmek için sizi herhangi bir kullanıcı takip edebilir.",
|
||||
"compose_form.lock_disclaimer.lock": "kilitli",
|
||||
"compose_form.placeholder": "Ne düşünüyorsun?",
|
||||
"compose_form.privacy_disclaimer": "Gönderiniz {domains}’teki bahsettiğiniz kullanıcılara iletilecektir.{domainsCount, plural, one {bu sunucuya} other {bu sunuculara}} güveniyor musunuz? Gönderi gizliliği sadece Mastodon sunucularında çalışır. Eğer {domains} {domainsCount, plural, one {bir Mastodon sunucusu değilse} other {Mastodon sunucuları değilse}}, gönderinizin herkese açık bir gönderi olmadığına ilişkin bir gösterge bulunmayacaktır. Bu yüzden gönderiniz boost edilebilir veya istenmeyen alıcılara görünebilir.",
|
||||
"compose_form.publish": "Toot",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Görseli hassas olarak işaretle",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Aktivite",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Bayraklar",
|
||||
"emoji_button.food": "Yiyecek ve İçecek",
|
||||
"emoji_button.label": "Emoji ekle",
|
||||
"emoji_button.nature": "Doğa",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Nesneler",
|
||||
"emoji_button.people": "İnsanlar",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Emoji ara...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Semboller",
|
||||
"emoji_button.travel": "Seyahat ve Yerler",
|
||||
"empty_column.community": "Yerel zaman tüneliniz boş. Daha fazla eğlence için herkese açık bir gönderi paylaşın.",
|
||||
|
@ -47,7 +47,6 @@
|
||||
"compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
|
||||
"compose_form.lock_disclaimer.lock": "приватний",
|
||||
"compose_form.placeholder": "Що у Вас на думці?",
|
||||
"compose_form.privacy_disclaimer": "Ваш приватний допис буде доставлено до згаданих користувачів на доменах {domains}. Ви довіряєте {domainsCount, plural, one {цьому серверу} other {цим серверам}}? Приватність постів працює тільки на інстанціях Mastodon. Якщо {domains} {domainsCount, plural, one {не є інстанцією Mastodon} other {не є інстанціями Mastodon}}, приватність поста не буде активована, та він може бути передмухнутий або іншим чином показаний не позначеним Вами користувачам.",
|
||||
"compose_form.publish": "Дмухнути",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Відмітити як непристойний зміст",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"embed.preview": "Here is what it will look like:",
|
||||
"emoji_button.activity": "Заняття",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Прапори",
|
||||
"emoji_button.food": "Їжа та напої",
|
||||
"emoji_button.label": "Вставити емодзі",
|
||||
"emoji_button.nature": "Природа",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "Предмети",
|
||||
"emoji_button.people": "Люди",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Знайти...",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Символи",
|
||||
"emoji_button.travel": "Подорожі",
|
||||
"empty_column.community": "Локальна стрічка пуста. Напишіть щось, щоб розігріти народ!",
|
||||
|
@ -33,21 +33,20 @@
|
||||
"column.home": "主页",
|
||||
"column.mutes": "被静音的用户",
|
||||
"column.notifications": "通知",
|
||||
"column.pins": "Pinned toot",
|
||||
"column.pins": "置顶嘟文",
|
||||
"column.public": "跨站公共时间轴",
|
||||
"column_back_button.label": "返回",
|
||||
"column_header.hide_settings": "隐藏设置",
|
||||
"column_header.moveLeft_settings": "将栏左移",
|
||||
"column_header.moveRight_settings": "将栏右移",
|
||||
"column_header.pin": "置顶",
|
||||
"column_header.pin": "固定",
|
||||
"column_header.show_settings": "显示设置",
|
||||
"column_header.unpin": "撤顶",
|
||||
"column_header.unpin": "取下",
|
||||
"column_subheading.navigation": "导航",
|
||||
"column_subheading.settings": "设置",
|
||||
"compose_form.lock_disclaimer": "你的帐户没 {locked}. 任何人可以通过关注你来查看只有关注者可见的嘟文.",
|
||||
"compose_form.lock_disclaimer.lock": "被保护",
|
||||
"compose_form.placeholder": "在想啥?",
|
||||
"compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务器实例,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务器实例} other {之中有些不是 Mastodon 服务器实例}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。",
|
||||
"compose_form.publish": "嘟嘟",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "将媒体文件标示为“敏感内容”",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "要内嵌此嘟文,请将以下代码贴进你的网站。",
|
||||
"embed.preview": "到时大概长这样:",
|
||||
"emoji_button.activity": "活动",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "旗帜",
|
||||
"emoji_button.food": "食物和饮料",
|
||||
"emoji_button.label": "加入表情符号",
|
||||
"emoji_button.nature": "自然",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "物体",
|
||||
"emoji_button.people": "人物",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "搜索…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "符号",
|
||||
"emoji_button.travel": "旅途和地点",
|
||||
"empty_column.community": "本站时间轴暂时未有内容,快嘟几个来抢头香啊!",
|
||||
@ -196,9 +199,9 @@
|
||||
"upload_form.undo": "还原",
|
||||
"upload_progress.label": "上传中……",
|
||||
"video.close": "关闭影片",
|
||||
"video.exit_fullscreen": "退出全荧幕",
|
||||
"video.exit_fullscreen": "退出全屏",
|
||||
"video.expand": "展开影片",
|
||||
"video.fullscreen": "全荧幕",
|
||||
"video.fullscreen": "全屏",
|
||||
"video.hide": "隐藏影片",
|
||||
"video.mute": "静音",
|
||||
"video.pause": "暂停",
|
||||
|
@ -33,21 +33,20 @@
|
||||
"column.home": "主頁",
|
||||
"column.mutes": "靜音名單",
|
||||
"column.notifications": "通知",
|
||||
"column.pins": "Pinned toot",
|
||||
"column.pins": "置頂文章",
|
||||
"column.public": "跨站時間軸",
|
||||
"column_back_button.label": "返回",
|
||||
"column_header.hide_settings": "隱藏設定",
|
||||
"column_header.moveLeft_settings": "將欄左移",
|
||||
"column_header.moveRight_settings": "將欄右移",
|
||||
"column_header.pin": "置頂",
|
||||
"column_header.pin": "固定",
|
||||
"column_header.show_settings": "顯示設定",
|
||||
"column_header.unpin": "撤頂",
|
||||
"column_header.unpin": "取下",
|
||||
"column_subheading.navigation": "瀏覽",
|
||||
"column_subheading.settings": "設定",
|
||||
"compose_form.lock_disclaimer": "你的用戶狀態為「{locked}」,任何人都能立即關注你,然後看到「只有關注者能看」的文章。",
|
||||
"compose_form.lock_disclaimer.lock": "公共",
|
||||
"compose_form.placeholder": "你在想甚麼?",
|
||||
"compose_form.privacy_disclaimer": "你的私人文章,將被遞送至 {domains}。你是否信任{domainsCount, plural, one {這個網站} other {這些網站}}?請留意,文章私隱設定只適用於 Mastodon 服務站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服務站} other {之中有些不是 Mastodon 服務站}},對方將可無視文章的私隱設定,轉推文章給其他用戶閱讀。",
|
||||
"compose_form.publish": "發文",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "將媒體檔案標示為「敏感內容」",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "要內嵌此文章,請將以下代碼貼進你的網站。",
|
||||
"embed.preview": "看上去會是這樣:",
|
||||
"emoji_button.activity": "活動",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "旗幟",
|
||||
"emoji_button.food": "飲飲食食",
|
||||
"emoji_button.label": "加入表情符號",
|
||||
"emoji_button.nature": "自然",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "物品",
|
||||
"emoji_button.people": "人物",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "搜尋…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "符號",
|
||||
"emoji_button.travel": "旅遊景物",
|
||||
"empty_column.community": "本站時間軸暫時未有內容,快文章來搶頭香啊!",
|
||||
|
@ -39,15 +39,14 @@
|
||||
"column_header.hide_settings": "隱藏設定",
|
||||
"column_header.moveLeft_settings": "將欄左移",
|
||||
"column_header.moveRight_settings": "將欄右移",
|
||||
"column_header.pin": "置頂",
|
||||
"column_header.pin": "固定",
|
||||
"column_header.show_settings": "顯示設定",
|
||||
"column_header.unpin": "撤頂",
|
||||
"column_header.unpin": "取下",
|
||||
"column_subheading.navigation": "瀏覽",
|
||||
"column_subheading.settings": "設定",
|
||||
"compose_form.lock_disclaimer": "你的帳號沒有{locked}。任何人都可以關注你,看到發給關注者的貼文。",
|
||||
"compose_form.lock_disclaimer.lock": "上鎖",
|
||||
"compose_form.placeholder": "在想些什麼?",
|
||||
"compose_form.privacy_disclaimer": "你的貼文會被傳到 {domains} 上被提到的使用者。你信任 {domainsCount, plural, one {這個伺服器} other {這些伺服器}}嗎?貼文的隱私設定只會在 Mastodon 副本上生效。如果 {domains} {domainsCount, plural, one {不是一個 Mastodon 副本} other {都不是 Mastodon 副本}},就不會被標記為非公開貼文,而且可能會被轉推或是讓不預期的人看見。",
|
||||
"compose_form.publish": "貼掉",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "將此媒體標為敏感",
|
||||
@ -67,13 +66,17 @@
|
||||
"embed.instructions": "要內嵌此貼文,請將以下代碼貼進你的網站。",
|
||||
"embed.preview": "看上去會變成這樣:",
|
||||
"emoji_button.activity": "活動",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "旗幟",
|
||||
"emoji_button.food": "食物與飲料",
|
||||
"emoji_button.label": "插入表情符號",
|
||||
"emoji_button.nature": "自然",
|
||||
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "物件",
|
||||
"emoji_button.people": "人",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "搜尋…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "符號",
|
||||
"emoji_button.travel": "旅遊與地點",
|
||||
"empty_column.community": "本地時間軸是空的。公開寫點什麼吧!",
|
||||
|
@ -110,7 +110,7 @@ export default function accounts(state = initialState, action) {
|
||||
case BLOCKS_EXPAND_SUCCESS:
|
||||
case MUTES_FETCH_SUCCESS:
|
||||
case MUTES_EXPAND_SUCCESS:
|
||||
return normalizeAccounts(state, action.accounts);
|
||||
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
|
||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||
case SEARCH_FETCH_SUCCESS:
|
||||
|
@ -106,7 +106,7 @@ export default function accountsCounters(state = initialState, action) {
|
||||
case BLOCKS_EXPAND_SUCCESS:
|
||||
case MUTES_FETCH_SUCCESS:
|
||||
case MUTES_EXPAND_SUCCESS:
|
||||
return normalizeAccounts(state, action.accounts);
|
||||
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
|
||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||
case SEARCH_FETCH_SUCCESS:
|
||||
|
@ -128,7 +128,7 @@ const insertSuggestion = (state, position, token, completion) => {
|
||||
};
|
||||
|
||||
const insertEmoji = (state, position, emojiData) => {
|
||||
const emoji = emojiData.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
|
||||
const emoji = emojiData.native;
|
||||
|
||||
return state.withMutations(map => {
|
||||
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
|
||||
@ -262,7 +262,7 @@ export default function compose(state = initialState, action) {
|
||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
|
||||
case COMPOSE_SUGGESTIONS_READY:
|
||||
return state.set('suggestions', ImmutableList(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
|
||||
return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
|
||||
case COMPOSE_SUGGESTION_SELECT:
|
||||
return insertSuggestion(state, action.position, action.token, action.completion);
|
||||
case TIMELINE_DELETE:
|
||||
|
16
app/javascript/mastodon/reducers/custom_emojis.js
Normal file
16
app/javascript/mastodon/reducers/custom_emojis.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { STORE_HYDRATE } from '../actions/store';
|
||||
import { emojiIndex } from 'emoji-mart';
|
||||
import { buildCustomEmojis } from '../emoji';
|
||||
|
||||
const initialState = ImmutableList();
|
||||
|
||||
export default function custom_emojis(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
|
||||
return action.state.get('custom_emojis');
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
@ -22,6 +22,7 @@ import search from './search';
|
||||
import media_attachments from './media_attachments';
|
||||
import notifications from './notifications';
|
||||
import height_cache from './height_cache';
|
||||
import custom_emojis from './custom_emojis';
|
||||
|
||||
const reducers = {
|
||||
timelines,
|
||||
@ -47,6 +48,7 @@ const reducers = {
|
||||
media_attachments,
|
||||
notifications,
|
||||
height_cache,
|
||||
custom_emojis,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
@ -124,6 +124,7 @@
|
||||
box-sizing: border-box;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.header-wrapper {
|
||||
|
@ -13,6 +13,7 @@
|
||||
@import 'accounts';
|
||||
@import 'stream_entries';
|
||||
@import 'components';
|
||||
@import 'emoji_picker';
|
||||
@import 'about';
|
||||
@import 'tables';
|
||||
@import 'admin';
|
||||
|
@ -62,6 +62,26 @@ body {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
text-align: center;
|
||||
color: $ui-primary-color;
|
||||
padding: 20px;
|
||||
|
||||
.dialog img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 470px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dialog h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -6,7 +6,7 @@
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 10px;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
|
||||
@media screen and (max-width: 740px) {
|
||||
text-align: center;
|
||||
|
@ -222,12 +222,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dropdown--active .icon-button {
|
||||
color: $ui-highlight-color;
|
||||
}
|
||||
|
||||
.dropdown--active::after {
|
||||
@media screen and (min-width: 1025px) {
|
||||
@media screen and (min-width: 631px) {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
@ -395,17 +399,11 @@
|
||||
.compose-form__autosuggest-wrapper {
|
||||
position: relative;
|
||||
|
||||
.emoji-picker__dropdown {
|
||||
.emoji-picker-dropdown {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
|
||||
&.dropdown--active::after {
|
||||
border-color: transparent transparent $base-border-color;
|
||||
bottom: -1px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:hover,
|
||||
::-webkit-scrollbar-track:active {
|
||||
background-color: rgba($base-overlay-background, 0.3);
|
||||
@ -444,6 +442,7 @@
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
margin: -.2ex .15em .2ex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@ -809,8 +808,8 @@
|
||||
|
||||
.status__action-bar-dropdown {
|
||||
float: left;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
height: 23.15px;
|
||||
width: 23.15px;
|
||||
|
||||
// Dropdown style override for centering on the icon
|
||||
.dropdown--active {
|
||||
@ -836,26 +835,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.dropdown {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.dropdown--active {
|
||||
.dropdown__content.dropdown__left {
|
||||
left: 20px;
|
||||
right: initial;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: initial;
|
||||
margin-left: 7px;
|
||||
margin-top: -7px;
|
||||
right: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
@ -1131,7 +1110,7 @@
|
||||
}
|
||||
|
||||
.account__action-bar-dropdown {
|
||||
flex: 1 1 auto;
|
||||
flex: 0 1 calc(50% - 140px);
|
||||
padding: 10px;
|
||||
|
||||
.dropdown--active {
|
||||
@ -1158,7 +1137,7 @@
|
||||
.account__action-bar__tab {
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
width: 80px;
|
||||
flex: 0 1 80px;
|
||||
border-left: 1px solid lighten($ui-base-color, 8%);
|
||||
padding: 10px 5px;
|
||||
|
||||
@ -1445,10 +1424,80 @@
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dropdown__sep {
|
||||
.dropdown-menu__separator {
|
||||
border-bottom: 1px solid darken($ui-secondary-color, 8%);
|
||||
margin: 5px 7px 6px;
|
||||
padding-top: 1px;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: $ui-secondary-color;
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu__arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0 solid transparent;
|
||||
|
||||
&.left {
|
||||
right: -5px;
|
||||
margin-top: -5px;
|
||||
border-width: 5px 0 5px 5px;
|
||||
border-left-color: $ui-secondary-color;
|
||||
}
|
||||
|
||||
&.top {
|
||||
bottom: -5px;
|
||||
margin-left: -13px;
|
||||
border-width: 5px 7px 0;
|
||||
border-top-color: $ui-secondary-color;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
top: -5px;
|
||||
margin-left: -13px;
|
||||
border-width: 0 7px 5px;
|
||||
border-bottom-color: $ui-secondary-color;
|
||||
}
|
||||
|
||||
&.right {
|
||||
left: -5px;
|
||||
margin-top: -5px;
|
||||
border-width: 5px 5px 5px 0;
|
||||
border-right-color: $ui-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu__item {
|
||||
a {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
display: block;
|
||||
padding: 4px 14px;
|
||||
box-sizing: border-box;
|
||||
text-decoration: none;
|
||||
background: $ui-secondary-color;
|
||||
color: $ui-base-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active {
|
||||
background: $ui-highlight-color;
|
||||
color: $ui-secondary-color;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--active .dropdown__content {
|
||||
@ -1633,7 +1682,7 @@
|
||||
}
|
||||
|
||||
:root { // Overrides .wide stylings for mobile view
|
||||
@include single-column('screen and (max-width: 1024px)', $parent: null) {
|
||||
@include single-column('screen and (max-width: 630px)', $parent: null) {
|
||||
.column,
|
||||
.drawer {
|
||||
flex: auto;
|
||||
@ -1654,7 +1703,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
|
||||
@include multi-columns('screen and (min-width: 631px)', $parent: null) {
|
||||
.columns-area {
|
||||
padding: 0;
|
||||
}
|
||||
@ -1766,7 +1815,7 @@
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
@include multi-columns('screen and (min-width: 1025px)') {
|
||||
@include multi-columns('screen and (min-width: 631px)') {
|
||||
background: lighten($ui-base-color, 14%);
|
||||
transition: all 100ms linear;
|
||||
}
|
||||
@ -1786,7 +1835,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
|
||||
@include multi-columns('screen and (min-width: 631px)', $parent: null) {
|
||||
.tabs-bar {
|
||||
display: none;
|
||||
}
|
||||
@ -2043,15 +2092,18 @@
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions {
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
z-index: 99;
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
|
||||
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
|
||||
background: $ui-secondary-color;
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: $ui-base-color;
|
||||
font-size: 14px;
|
||||
padding: 6px;
|
||||
|
||||
&.autosuggest-textarea__suggestions--visible {
|
||||
display: block;
|
||||
@ -2061,34 +2113,36 @@
|
||||
.autosuggest-textarea__suggestions__item {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&.selected {
|
||||
background: darken($ui-secondary-color, 10%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $ui-highlight-color;
|
||||
color: $base-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.autosuggest-account {
|
||||
overflow: hidden;
|
||||
.autosuggest-account,
|
||||
.autosuggest-emoji {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
line-height: 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.autosuggest-account-icon {
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
.autosuggest-account-icon,
|
||||
.autosuggest-emoji img {
|
||||
display: block;
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.autosuggest-status {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.autosuggest-account .display-name__account {
|
||||
color: lighten($ui-base-color, 36%);
|
||||
}
|
||||
|
||||
.character-counter__wrapper {
|
||||
@ -2837,198 +2891,62 @@ button.icon-button.active i.fa-retweet {
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
width: 245px;
|
||||
height: 270px;
|
||||
.emoji-picker-dropdown__menu {
|
||||
background: $simple-background-color;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
|
||||
border-radius: 4px;
|
||||
margin-top: 5px;
|
||||
|
||||
.emoji-mart-scroll {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
&.selecting .emoji-mart-scroll {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-dropdown__modifiers {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji-picker-dropdown__modifiers__menu {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: -4px;
|
||||
left: -8px;
|
||||
background: $simple-background-color;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 0 8px rgba($base-shadow-color, 0.2);
|
||||
|
||||
.emojione {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.emoji-dialog-header {
|
||||
padding: 0 10px;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 5px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
.emoji {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img,
|
||||
svg {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: $ui-highlight-color;
|
||||
|
||||
img,
|
||||
svg {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
box-sizing: border-box;
|
||||
overflow-y: hidden;
|
||||
padding-left: 10px;
|
||||
|
||||
.emoji {
|
||||
display: inline-block;
|
||||
padding: 2.5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-category-header {
|
||||
box-sizing: border-box;
|
||||
overflow-y: hidden;
|
||||
padding: 10px 8px 10px 16px;
|
||||
display: table;
|
||||
|
||||
> * {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-category-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
color: darken($ui-secondary-color, 18%);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.emoji-category-heading-decoration {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modifiers {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
padding: 0 2px;
|
||||
|
||||
&:last-of-type {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modifier {
|
||||
display: inline-block;
|
||||
border-radius: 10px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.active::after {
|
||||
content: "";
|
||||
button {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid $base-border-color;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: rgba($ui-secondary-color, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-search-wrapper {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid lighten($ui-secondary-color, 4%);
|
||||
}
|
||||
|
||||
.emoji-search {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
padding: 7px 9px;
|
||||
font-family: inherit;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: rgba($ui-secondary-color, 0.3);
|
||||
color: darken($ui-secondary-color, 18%);
|
||||
border: 1px solid $ui-secondary-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.emoji-categories-wrapper {
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.emoji-search-wrapper + .emoji-categories-wrapper {
|
||||
top: 93px;
|
||||
}
|
||||
|
||||
.emoji-row .emoji {
|
||||
img,
|
||||
svg {
|
||||
transition: transform 60ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: lighten($ui-secondary-color, 3%);
|
||||
|
||||
img,
|
||||
svg {
|
||||
transform: translateZ(0) scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 22px;
|
||||
.emoji-mart-emoji {
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-emoji {
|
||||
span {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
@ -3314,8 +3232,6 @@ button.icon-button.active i.fa-retweet {
|
||||
}
|
||||
|
||||
.search__input {
|
||||
padding-right: 30px;
|
||||
color: $ui-secondary-color;
|
||||
outline: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
@ -3851,6 +3767,10 @@ button.icon-button.active i.fa-retweet {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.dropdown-menu__separator {
|
||||
border-bottom-color: $ui-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.boost-modal__container {
|
||||
@ -3929,6 +3849,10 @@ button.icon-button.active i.fa-retweet {
|
||||
max-height: 80vh;
|
||||
max-width: 80vw;
|
||||
|
||||
.actions-modal__item-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ul {
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
@ -3941,11 +3865,20 @@ button.icon-button.active i.fa-retweet {
|
||||
a {
|
||||
color: $ui-base-color;
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
&,
|
||||
button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
&,
|
||||
button {
|
||||
background: $ui-highlight-color;
|
||||
@ -4102,6 +4035,12 @@ button.icon-button.active i.fa-retweet {
|
||||
display: block;
|
||||
float: left;
|
||||
position: relative;
|
||||
|
||||
&.standalone {
|
||||
.media-gallery__item-gifv-thumbnail {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery__item-thumbnail {
|
||||
@ -4109,6 +4048,7 @@ button.icon-button.active i.fa-retweet {
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
line-height: 0;
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
@ -4417,12 +4357,14 @@ button.icon-button.active i.fa-retweet {
|
||||
.account-gallery__container {
|
||||
margin: -2px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-gallery__item {
|
||||
float: left;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
flex: 1 1 auto;
|
||||
width: calc(100% / 3 - 4px);
|
||||
height: 95px;
|
||||
margin: 2px;
|
||||
|
||||
a {
|
||||
@ -4433,6 +4375,14 @@ button.icon-button.active i.fa-retweet {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4502,7 +4452,7 @@ noscript {
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) and (max-height: 400px) {
|
||||
@media screen and (max-width: 630px) and (max-height: 400px) {
|
||||
$duration: 400ms;
|
||||
$delay: 100ms;
|
||||
|
||||
|
199
app/javascript/styles/emoji_picker.scss
Normal file
199
app/javascript/styles/emoji_picker.scss
Normal file
@ -0,0 +1,199 @@
|
||||
.emoji-mart {
|
||||
&,
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
color: $ui-base-color;
|
||||
|
||||
.emoji-mart-emoji {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-bar {
|
||||
border: 0 solid darken($ui-secondary-color, 8%);
|
||||
|
||||
&:first-child {
|
||||
border-bottom-width: 1px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
background: $ui-secondary-color;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-width: 1px;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-anchors {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
color: $ui-primary-color;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.emoji-mart-anchor {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
overflow: hidden;
|
||||
transition: color .1s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: darken($ui-primary-color, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-anchor-selected {
|
||||
color: darken($ui-highlight-color, 3%);
|
||||
|
||||
&:hover {
|
||||
color: darken($ui-highlight-color, 3%);
|
||||
}
|
||||
|
||||
.emoji-mart-anchor-bar {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-anchor-bar {
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: darken($ui-highlight-color, 3%);
|
||||
}
|
||||
|
||||
.emoji-mart-anchors {
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
max-width: 22px;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
max-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-scroll {
|
||||
overflow-y: scroll;
|
||||
height: 270px;
|
||||
max-height: 35vh;
|
||||
padding: 0 6px 6px;
|
||||
background: $simple-background-color;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.emoji-mart-search {
|
||||
padding: 10px;
|
||||
padding-right: 45px;
|
||||
background: $simple-background-color;
|
||||
|
||||
input {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
padding: 7px 9px;
|
||||
font-family: inherit;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: rgba($ui-secondary-color, 0.3);
|
||||
color: $ui-primary-color;
|
||||
border: 1px solid $ui-secondary-color;
|
||||
border-radius: 4px;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner,
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-category .emoji-mart-emoji {
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
z-index: 0;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($ui-secondary-color, 0.7);
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-category-label {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
padding: 5px 6px;
|
||||
background: $simple-background-color;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-emoji {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
|
||||
span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-no-results {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding-top: 70px;
|
||||
color: $ui-primary-color;
|
||||
|
||||
.emoji-mart-category-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.emoji-mart-no-results-label {
|
||||
margin-top: .2em;
|
||||
}
|
||||
|
||||
.emoji-mart-emoji:hover::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-mart-preview {
|
||||
display: none;
|
||||
}
|
@ -245,7 +245,7 @@ body.rtl {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1025px) {
|
||||
@media screen and (min-width: 631px) {
|
||||
.column,
|
||||
.drawer {
|
||||
padding-left: 5px;
|
||||
|
@ -25,6 +25,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||
|
||||
def fetch_remote_original_status
|
||||
if object_uri.start_with?('http')
|
||||
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
||||
|
||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri)
|
||||
elsif @object['url'].present?
|
||||
::FetchRemoteStatusService.new.call(@object['url'])
|
||||
|
@ -68,6 +68,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def process_hashtag(tag, status)
|
||||
return if tag['name'].blank?
|
||||
|
||||
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
||||
hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
|
||||
|
||||
@ -75,6 +77,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def process_mention(tag, status)
|
||||
return if tag['href'].blank?
|
||||
|
||||
account = account_from_uri(tag['href'])
|
||||
account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
|
||||
return if account.nil?
|
||||
@ -82,6 +86,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def process_emoji(tag, _status)
|
||||
return if tag['name'].blank? || tag['href'].blank?
|
||||
|
||||
shortcode = tag['name'].delete(':')
|
||||
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
|
||||
|
||||
@ -96,7 +102,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
return unless @object['attachment'].is_a?(Array)
|
||||
|
||||
@object['attachment'].each do |attachment|
|
||||
next if unsupported_media_type?(attachment['mediaType'])
|
||||
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
|
||||
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
|
||||
@ -106,6 +112,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
media_attachment.file_remote_url = href
|
||||
media_attachment.save
|
||||
end
|
||||
rescue Addressable::URI::InvalidURIError => e
|
||||
Rails.logger.debug e
|
||||
end
|
||||
|
||||
def resolve_thread(status)
|
||||
@ -115,8 +123,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
|
||||
def conversation_from_uri(uri)
|
||||
return nil if uri.nil?
|
||||
return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
|
||||
Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
|
||||
return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
|
||||
Conversation.find_by(uri: uri) || Conversation.create(uri: uri)
|
||||
end
|
||||
|
||||
def visibility_from_audience
|
||||
|
@ -98,8 +98,8 @@ class ActivityPub::TagManager
|
||||
else
|
||||
StatusFinder.new(uri).status
|
||||
end
|
||||
elsif ::TagManager.instance.local_id?(uri)
|
||||
klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
|
||||
elsif OStatus::TagManager.instance.local_id?(uri)
|
||||
klass.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
|
||||
else
|
||||
klass.find_by(uri: uri.split('#').first)
|
||||
end
|
||||
|
@ -1,40 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
|
||||
class Emoji
|
||||
include Singleton
|
||||
|
||||
def initialize
|
||||
data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json')))
|
||||
|
||||
@map = {}
|
||||
|
||||
data.each do |_, emoji|
|
||||
keys = [emoji['shortname']] + emoji['aliases']
|
||||
unicode = codepoint_to_unicode(emoji['unicode'])
|
||||
|
||||
keys.each do |key|
|
||||
@map[key] = unicode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unicode(shortcode)
|
||||
@map[shortcode]
|
||||
end
|
||||
|
||||
def names
|
||||
@map.keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def codepoint_to_unicode(codepoint)
|
||||
if codepoint.include?('-')
|
||||
codepoint.split('-').map(&:hex).pack('U*')
|
||||
else
|
||||
[codepoint.hex].pack('U')
|
||||
end
|
||||
end
|
||||
end
|
@ -22,7 +22,7 @@ class Formatter
|
||||
unless status.local?
|
||||
html = reformat(raw_content)
|
||||
html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
|
||||
return html
|
||||
return html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
linkable_accounts = status.mentions.map(&:account)
|
||||
@ -39,7 +39,7 @@ class Formatter
|
||||
end
|
||||
|
||||
def reformat(html)
|
||||
sanitize(html, Sanitize::Config::MASTODON_STRICT).html_safe # rubocop:disable Rails/OutputSafety
|
||||
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
||||
end
|
||||
|
||||
def plaintext(status)
|
||||
@ -63,6 +63,12 @@ class Formatter
|
||||
Sanitize.fragment(html, config)
|
||||
end
|
||||
|
||||
def format_spoiler(status)
|
||||
html = encode(status.spoiler_text)
|
||||
html = encode_custom_emojis(html, status.emojis)
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encode(html)
|
||||
|
@ -11,30 +11,30 @@ class OStatus::Activity::Base
|
||||
end
|
||||
|
||||
def verb
|
||||
raw = @xml.at_xpath('./activity:verb', activity: TagManager::AS_XMLNS).content
|
||||
TagManager::VERBS.key(raw)
|
||||
raw = @xml.at_xpath('./activity:verb', activity: OStatus::TagManager::AS_XMLNS).content
|
||||
OStatus::TagManager::VERBS.key(raw)
|
||||
rescue
|
||||
:post
|
||||
end
|
||||
|
||||
def type
|
||||
raw = @xml.at_xpath('./activity:object-type', activity: TagManager::AS_XMLNS).content
|
||||
TagManager::TYPES.key(raw)
|
||||
raw = @xml.at_xpath('./activity:object-type', activity: OStatus::TagManager::AS_XMLNS).content
|
||||
OStatus::TagManager::TYPES.key(raw)
|
||||
rescue
|
||||
:activity
|
||||
end
|
||||
|
||||
def id
|
||||
@xml.at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content
|
||||
@xml.at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def url
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
|
||||
link.nil? ? nil : link['href']
|
||||
end
|
||||
|
||||
def activitypub_uri
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
|
||||
link.nil? ? nil : link['href']
|
||||
end
|
||||
|
||||
@ -45,8 +45,8 @@ class OStatus::Activity::Base
|
||||
private
|
||||
|
||||
def find_status(uri)
|
||||
if TagManager.instance.local_id?(uri)
|
||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
|
||||
if OStatus::TagManager.instance.local_id?(uri)
|
||||
local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status')
|
||||
return Status.find_by(id: local_id)
|
||||
elsif ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
|
||||
|
@ -14,14 +14,22 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
return result if result.first.present?
|
||||
end
|
||||
|
||||
Rails.logger.debug "Creating remote status #{id}"
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
# Return early if status already exists in db
|
||||
status = find_status(id)
|
||||
@status = find_status(id)
|
||||
return [@status, false] unless @status.nil?
|
||||
@status = process_status
|
||||
end
|
||||
end
|
||||
|
||||
return [status, false] unless status.nil?
|
||||
[@status, true]
|
||||
end
|
||||
|
||||
def process_status
|
||||
Rails.logger.debug "Creating remote status #{id}"
|
||||
cached_reblog = reblog
|
||||
status = nil
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
status = Status.create!(
|
||||
@ -55,7 +63,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
|
||||
DistributionWorker.perform_async(status.id)
|
||||
|
||||
[status, true]
|
||||
status
|
||||
end
|
||||
|
||||
def perform_via_activitypub
|
||||
@ -63,42 +71,42 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
end
|
||||
|
||||
def content
|
||||
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
|
||||
@xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def content_language
|
||||
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS)['xml:lang']&.presence || 'en'
|
||||
@xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS)['xml:lang']&.presence || 'en'
|
||||
end
|
||||
|
||||
def content_warning
|
||||
@xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
|
||||
@xml.at_xpath('./xmlns:summary', xmlns: OStatus::TagManager::XMLNS)&.content || ''
|
||||
end
|
||||
|
||||
def visibility_scope
|
||||
@xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public
|
||||
@xml.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content&.to_sym || :public
|
||||
end
|
||||
|
||||
def published
|
||||
@xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
|
||||
@xml.at_xpath('./xmlns:published', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def thread?
|
||||
!@xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS).nil?
|
||||
!@xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS).nil?
|
||||
end
|
||||
|
||||
def thread
|
||||
thr = @xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS)
|
||||
thr = @xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS)
|
||||
[thr['ref'], thr['href']]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_conversation
|
||||
uri = @xml.at_xpath('./ostatus:conversation', ostatus: TagManager::OS_XMLNS)&.attribute('ref')&.content
|
||||
uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
|
||||
return if uri.nil?
|
||||
|
||||
if TagManager.instance.local_id?(uri)
|
||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
|
||||
if OStatus::TagManager.instance.local_id?(uri)
|
||||
local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
|
||||
return Conversation.find_by(id: local_id)
|
||||
end
|
||||
|
||||
@ -108,8 +116,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
def save_mentions(parent)
|
||||
processed_account_ids = []
|
||||
|
||||
@xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link|
|
||||
next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type']
|
||||
@xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
|
||||
next if [OStatus::TagManager::TYPES[:group], OStatus::TagManager::TYPES[:collection]].include? link['ostatus:object-type']
|
||||
|
||||
mentioned_account = account_from_href(link['href'])
|
||||
|
||||
@ -123,14 +131,14 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
end
|
||||
|
||||
def save_hashtags(parent)
|
||||
tags = @xml.xpath('./xmlns:category', xmlns: TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
|
||||
tags = @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
|
||||
ProcessHashtagsService.new.call(parent, tags)
|
||||
end
|
||||
|
||||
def save_media(parent)
|
||||
do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
|
||||
|
||||
@xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link|
|
||||
@xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
|
||||
next unless link['href']
|
||||
|
||||
media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
|
||||
@ -156,7 +164,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
|
||||
return if do_not_download
|
||||
|
||||
@xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: TagManager::XMLNS).each do |link|
|
||||
@xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
|
||||
next unless link['href'] && link['name']
|
||||
|
||||
shortcode = link['name'].delete(':')
|
||||
@ -179,4 +187,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href)
|
||||
end
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "create:#{id}" }
|
||||
end
|
||||
end
|
||||
|
@ -10,7 +10,7 @@ class OStatus::Activity::Share < OStatus::Activity::Creation
|
||||
end
|
||||
|
||||
def object
|
||||
@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS)
|
||||
@xml.at_xpath('.//activity:object', activity: OStatus::TagManager::AS_XMLNS)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -15,10 +15,10 @@ class OStatus::AtomSerializer
|
||||
def author(account)
|
||||
author = Ox::Element.new('author')
|
||||
|
||||
uri = TagManager.instance.uri_for(account)
|
||||
uri = OStatus::TagManager.instance.uri_for(account)
|
||||
|
||||
append_element(author, 'id', uri)
|
||||
append_element(author, 'activity:object-type', TagManager::TYPES[:person])
|
||||
append_element(author, 'activity:object-type', OStatus::TagManager::TYPES[:person])
|
||||
append_element(author, 'uri', uri)
|
||||
append_element(author, 'name', account.username)
|
||||
append_element(author, 'email', account.local? ? account.local_username_and_domain : account.acct)
|
||||
@ -65,15 +65,15 @@ class OStatus::AtomSerializer
|
||||
|
||||
add_namespaces(entry) if root
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.uri_for(stream_entry.status))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.uri_for(stream_entry.status))
|
||||
append_element(entry, 'published', stream_entry.created_at.iso8601)
|
||||
append_element(entry, 'updated', stream_entry.updated_at.iso8601)
|
||||
append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status")
|
||||
|
||||
entry << author(stream_entry.account) if root
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[stream_entry.object_type])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[stream_entry.verb])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[stream_entry.object_type])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[stream_entry.verb])
|
||||
|
||||
entry << object(stream_entry.target) if stream_entry.targeted?
|
||||
|
||||
@ -88,7 +88,7 @@ class OStatus::AtomSerializer
|
||||
|
||||
append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status))
|
||||
append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom'))
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
|
||||
append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil?
|
||||
|
||||
entry
|
||||
@ -97,20 +97,20 @@ class OStatus::AtomSerializer
|
||||
def object(status)
|
||||
object = Ox::Element.new('activity:object')
|
||||
|
||||
append_element(object, 'id', TagManager.instance.uri_for(status))
|
||||
append_element(object, 'id', OStatus::TagManager.instance.uri_for(status))
|
||||
append_element(object, 'published', status.created_at.iso8601)
|
||||
append_element(object, 'updated', status.updated_at.iso8601)
|
||||
append_element(object, 'title', status.title)
|
||||
|
||||
object << author(status.account)
|
||||
|
||||
append_element(object, 'activity:object-type', TagManager::TYPES[status.object_type])
|
||||
append_element(object, 'activity:verb', TagManager::VERBS[status.verb])
|
||||
append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[status.object_type])
|
||||
append_element(object, 'activity:verb', OStatus::TagManager::VERBS[status.verb])
|
||||
|
||||
serialize_status_attributes(object, status)
|
||||
|
||||
append_element(object, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(status))
|
||||
append_element(object, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
|
||||
append_element(object, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
|
||||
append_element(object, 'ostatus:conversation', nil, ref: conversation_uri(status.conversation)) unless status.conversation_id.nil?
|
||||
|
||||
object
|
||||
@ -122,14 +122,14 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{follow.account.acct} started following #{follow.target_account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
|
||||
append_element(entry, 'title', description)
|
||||
append_element(entry, 'content', description, type: :html)
|
||||
|
||||
entry << author(follow.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:follow])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:follow])
|
||||
|
||||
object = author(follow.target_account)
|
||||
object.value = 'activity:object'
|
||||
@ -142,13 +142,13 @@ class OStatus::AtomSerializer
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'title', "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}")
|
||||
|
||||
entry << author(follow_request.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:request_friend])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
|
||||
|
||||
object = author(follow_request.target_account)
|
||||
object.value = 'activity:object'
|
||||
@ -161,19 +161,19 @@ class OStatus::AtomSerializer
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'title', "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}")
|
||||
|
||||
entry << author(follow_request.target_account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:authorize])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:authorize])
|
||||
|
||||
object = Ox::Element.new('activity:object')
|
||||
object << author(follow_request.account)
|
||||
|
||||
append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
|
||||
append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
|
||||
|
||||
inner_object = author(follow_request.target_account)
|
||||
inner_object.value = 'activity:object'
|
||||
@ -187,19 +187,19 @@ class OStatus::AtomSerializer
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
|
||||
append_element(entry, 'title', "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}")
|
||||
|
||||
entry << author(follow_request.target_account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:reject])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:reject])
|
||||
|
||||
object = Ox::Element.new('activity:object')
|
||||
object << author(follow_request.account)
|
||||
|
||||
append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
|
||||
append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
|
||||
|
||||
inner_object = author(follow_request.target_account)
|
||||
inner_object.value = 'activity:object'
|
||||
@ -215,14 +215,14 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
|
||||
append_element(entry, 'title', description)
|
||||
append_element(entry, 'content', description, type: :html)
|
||||
|
||||
entry << author(follow.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:unfollow])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfollow])
|
||||
|
||||
object = author(follow.target_account)
|
||||
object.value = 'activity:object'
|
||||
@ -237,13 +237,13 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
|
||||
append_element(entry, 'title', description)
|
||||
|
||||
entry << author(block.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:block])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:block])
|
||||
|
||||
object = author(block.target_account)
|
||||
object.value = 'activity:object'
|
||||
@ -258,13 +258,13 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{block.account.acct} no longer blocks #{block.target_account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
|
||||
append_element(entry, 'title', description)
|
||||
|
||||
entry << author(block.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:unblock])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unblock])
|
||||
|
||||
object = author(block.target_account)
|
||||
object.value = 'activity:object'
|
||||
@ -279,18 +279,18 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
|
||||
append_element(entry, 'title', description)
|
||||
append_element(entry, 'content', description, type: :html)
|
||||
|
||||
entry << author(favourite.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:favorite])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:favorite])
|
||||
|
||||
entry << object(favourite.status)
|
||||
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
|
||||
|
||||
entry
|
||||
end
|
||||
@ -301,18 +301,18 @@ class OStatus::AtomSerializer
|
||||
|
||||
description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
|
||||
|
||||
append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
|
||||
append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
|
||||
append_element(entry, 'title', description)
|
||||
append_element(entry, 'content', description, type: :html)
|
||||
|
||||
entry << author(favourite.account)
|
||||
|
||||
append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', TagManager::VERBS[:unfavorite])
|
||||
append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfavorite])
|
||||
|
||||
entry << object(favourite.status)
|
||||
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
|
||||
append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
|
||||
|
||||
entry
|
||||
end
|
||||
@ -332,17 +332,17 @@ class OStatus::AtomSerializer
|
||||
|
||||
def conversation_uri(conversation)
|
||||
return conversation.uri if conversation.uri?
|
||||
TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
|
||||
OStatus::TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
|
||||
end
|
||||
|
||||
def add_namespaces(parent)
|
||||
parent['xmlns'] = TagManager::XMLNS
|
||||
parent['xmlns:thr'] = TagManager::THR_XMLNS
|
||||
parent['xmlns:activity'] = TagManager::AS_XMLNS
|
||||
parent['xmlns:poco'] = TagManager::POCO_XMLNS
|
||||
parent['xmlns:media'] = TagManager::MEDIA_XMLNS
|
||||
parent['xmlns:ostatus'] = TagManager::OS_XMLNS
|
||||
parent['xmlns:mastodon'] = TagManager::MTDN_XMLNS
|
||||
parent['xmlns'] = OStatus::TagManager::XMLNS
|
||||
parent['xmlns:thr'] = OStatus::TagManager::THR_XMLNS
|
||||
parent['xmlns:activity'] = OStatus::TagManager::AS_XMLNS
|
||||
parent['xmlns:poco'] = OStatus::TagManager::POCO_XMLNS
|
||||
parent['xmlns:media'] = OStatus::TagManager::MEDIA_XMLNS
|
||||
parent['xmlns:ostatus'] = OStatus::TagManager::OS_XMLNS
|
||||
parent['xmlns:mastodon'] = OStatus::TagManager::MTDN_XMLNS
|
||||
end
|
||||
|
||||
def serialize_status_attributes(entry, status)
|
||||
@ -352,10 +352,10 @@ class OStatus::AtomSerializer
|
||||
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
|
||||
|
||||
status.mentions.each do |mentioned|
|
||||
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account))
|
||||
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
|
||||
end
|
||||
|
||||
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:collection], href: TagManager::COLLECTIONS[:public]) if status.public_visibility?
|
||||
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:collection], href: OStatus::TagManager::COLLECTIONS[:public]) if status.public_visibility?
|
||||
|
||||
status.tags.each do |tag|
|
||||
append_element(entry, 'category', nil, term: tag.name)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user