Compare commits

..

58 Commits

Author SHA1 Message Date
kibigo!
bc8532359b Mastodon GO! -> v0.1.1 2017-11-06 20:00:03 -08:00
kibigo!
e0298d66f8 Autocollapse boosts option 2017-11-05 15:05:12 -08:00
beatrix
73bf0ea7d1 Merge pull request #204 from glitch-soc/with-mastodon-go
Introducing: Mastodon GO!
2017-11-04 09:14:08 -04:00
kibigo!
276432790a Introducing: Mastodon GO! 2017-11-04 05:48:42 -07:00
beatrix
254b74c71f add memorial to production.rb
in memory of Natalie Nguyen

let her name ring through the ether
2017-11-03 12:34:50 -04:00
David Yip
870d71b78b Merge branch 'master' into gs-master 2017-10-27 09:45:25 -05:00
nullkal
781105293c Feature: Unlisted custom emojis (#5485) 2017-10-27 16:11:30 +02:00
puckipedia
0cb329f63a Allow ActivityPub Note's tag and attachment to be single objects (#5534) 2017-10-27 16:10:36 +02:00
unarist
0129f5eada Optimize FixReblogsInFeeds migration (#5538)
We have changed how we store reblogs in the redis for bigint IDs. This process is done by 1) scan all entries in users feed, and 2) re-store reblogs by 3 write commands.

However, this operation is really slow for large instances. e.g. 1hrs on friends.nico (w/ 50k users). So I have tried below tweaks.

* It checked non-reblogs by `entry[0] == entry[1]`, but this condition won't work because `entry[0]` is String while `entry[1]` is Float. Changing `entry[0].to_i == entry[1]` seems work.
  -> about 4-20x faster (feed with less reblogs will be faster)
* Write operations can be batched by pipeline
  -> about 6x faster
* Wrap operation by Lua script and execute by EVALSHA command. This really reduces packets between Ruby and Redis.
  -> about 3x faster

I've taken Lua script way, though doing other optimizations may be enough.
2017-10-27 16:10:22 +02:00
Jenkins
656f5b6f87 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-10-26 15:08:34 +00:00
erin
22da775a85 Fix copying emojos: redirect to the page you were on (#5509) 2017-10-26 23:44:24 +09:00
David Yip
dd28b94cf0 Merge remote-tracking branch 'origin/master' into gs-master 2017-10-26 09:18:27 -05:00
りんすき
d556be2968 Fix column design broken with very long title (#5493)
* Fix #5314

* fix not beautiful code

* fix broken design with mobile view

* remove no longer needed code
2017-10-26 22:52:48 +09:00
unarist
4f337c020a Fix Cocaine::ExitStatusError when upload small non-animated GIF (#5489)
Looks like copied tempfile need to be flushed before further processing. This issue won't happen if the uploaded file has enough file size.
2017-10-26 22:48:35 +09:00
Nolan Lawson
02f7f3619a Remove translateZ(0) on modal overlay (#5478) 2017-10-26 22:46:50 +09:00
beatrix
a2612d0d38 Merge pull request #179 from glitch-soc/keyword-mute
Keyword muting
2017-10-25 17:37:48 -04:00
beatrix
31814ddda0 Merge pull request #198 from glitch-soc/gs-direct-timeline
Direct messages timeline from tootsuite/mastodon#4514
2017-10-25 17:37:06 -04:00
David Yip
42f2045c21 Merge remote-tracking branch 'STJrInuyasha/feature/direct-timeline' into gs-direct-timeline 2017-10-25 16:01:20 -05:00
Jenkins
5f0268ab84 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-10-25 16:17:14 +00:00
Ratmir Karabut
20fee786b1 Update Russian translation (#5517)
* Add Russian translation (ru)

* Fix a missing comma

* Fix the wording for better consistency

* Update Russian translation

* Arrange Russian setting alphabetically

* Fix syntax error

* Update Russian translation

* Fix formatting error

* Update Russian translation

* Update Russian translation

* Update ru.jsx

* Fix syntax error

* Remove two_factor_auth.warning (appears obsolete)

* Add missing strings in ru.yml

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

* Fix translation consistency

* Update Russian translation

* Update Russian translation (pluralizations)

* Update Russian translation

* Update Russian translation

* Update Russian translation (pin)

* Update Russian translation (account deletion)

* Fix extra line

* Update Russian translation (sessions)

* Update Russian translation

* Update Russian translation

* Fix merge conflicts (revert)

* Update Russian translation

* Update Russian translation (fix)

* Update Russian translation (fix quotes)

* Update Russian translation (fix quotes)

* Update Russian translation (fix)

* Update Russian translation

* Add quotes

* bundle exec i18n-tasks normalize
2017-10-26 00:21:58 +09:00
Anna e só
74777599cf l10n: PT-BR translation updated (#5530) 2017-10-25 23:11:03 +09:00
Olivier Nicole
1ba3725473 Complete Esperanto translation (#5520) 2017-10-25 22:38:37 +09:00
David Yip
e40fe4092d Remove nil check in Glitch::KeywordMute#=~.
@regex can no longer be nil, so we don't need to check it.
2017-10-24 19:03:59 -05:00
David Yip
d9485e6497 Assume Glitch::KeywordMute#destroy! works and error out if it doesn't.
There's nothing useful we can display if the destroy action messes up,
so might as well assert it does and complain loudly if it doesn't.
2017-10-24 18:56:57 -05:00
David Yip
d5c8ebe205 Use edit template for displaying errors in update. 2017-10-24 18:56:44 -05:00
David Yip
d03b48cea0 Also filter notifications containing muted keywords. 2017-10-24 18:51:27 -05:00
David Yip
9226257a1b Override Action View name inference in settings/keyword_mutes.
Glitch::KeywordMute's name is inferred as glitch_keyword_mutes, and in
templates this turns into e.g. settings/glitch/keyword_mutes.  Going
along with this convention means a lot of file movement, though, and for
a UI that's as temporary and awkward as this one I think it's less
effort to slap a bunch of as: options everywhere.

We'll do the Right Thing when we build out the API and frontend UI.
2017-10-24 18:40:28 -05:00
David Yip
641f90e73a Fix example description.
This example actually checks matches at the end of a string.
2017-10-24 18:33:02 -05:00
David Yip
f5a3283976 Switch to Regexp.union for building the mute expression.
Also make the keyword-building methods private: they always probably
should have been private, but now I have encoded enough fun and games
into them that it now seems wrong for them to *not* be private.
2017-10-24 18:31:34 -05:00
Ondřej Hruška
516eeeb43d option to add title to <Button>, use for toot buttons (#197) 2017-10-24 19:08:07 +02:00
David Yip
664c9aa708 Merge pull request #196 from glitch-soc/fix-imports
Added app/javascript for imports
2017-10-23 23:34:43 -05:00
kibigo!
119d477c8b Added app/javascript for imports 2017-10-23 20:22:48 -07:00
David Yip
8410d33b49 Only cache the regex text, not the regex itself.
It is possible to cache a Regexp object, but I'm not sure what happens
if e.g. that object remains in cache across two different Ruby versions.
Caching a string seems to raise fewer questions.
2017-10-23 19:31:59 -05:00
David Yip
4f01e6e8d5 Merge remote-tracking branch 'origin/master' into gs-master 2017-10-22 22:57:41 -05:00
Matthew Walsh
a76b024228 Changes to match other timelines in 2.0 2017-10-22 18:45:35 -07:00
Matthew Walsh
3db80f75a6 Added a timeline for Direct statuses
* Lists all Direct statuses you've sent and received
* Displayed in Getting Started
* Streaming server support for direct TL
2017-10-22 18:35:14 -07:00
David Yip
af8f06413e KeywordMute matcher: more closely mimic Regexp#=~ behavior.
Regexp#=~ returns nil if it does not match.  An empty mute set does not
match any status, so KeywordMute::Matcher#=~ ought to return nil also.
2017-10-22 01:12:21 -05:00
David Yip
1a60445a5f Address unused translation errors. 2017-10-22 01:05:56 -05:00
David Yip
4c84513e04 Use current_account from ApplicationController.
This avoids copy-pasting definitions of set_account.
2017-10-22 01:02:52 -05:00
David Yip
4b68e82a19 Don't add \b to whole-word keywords that don't start with word characters.
Ditto for ending with \b.

Consider muting the phrase "(hot take)".  I stipulate it is reasonable
to enter this with the default "match whole word" behavior.  Under the
old behavior, this would be encoded as

    \b\(hot\ take\)\b

However, if \b is before the first character in the string and the first
character in the string is not a word character, then the match will
fail.  Ditto for after.  In our example, "(" is not a word character, so
this will not match statuses containing "(hot take)", and that's a very
surprising behavior.

To address this, we only add leading and trailing \b to keywords that
start or end with word characters.
2017-10-22 00:38:54 -05:00
David Yip
19826774f0 keyword mutes: also check spoiler (CW) text and reblogged statuses. 2017-10-22 00:38:53 -05:00
Marcin Mikołajczak
fdb0848e08 i18n: Update Polish Translation (#5494) 2017-10-22 08:34:39 +09:00
David Yip
ad86c86fa8 Apply keyword mutes to reblogs. 2017-10-21 15:44:47 -05:00
David Yip
670e6a33f8 Move KeywordMute into Glitch namespace.
There are two motivations for this:

1. It looks like we're going to add other features that require
   server-side storage (e.g. user notes).

2. Namespacing glitchsoc modifications is a good idea anyway: even if we
   do not end up doing (1), if upstream introduces a keyword-mute feature
   that also uses a "KeywordMute" model, we can avoid some merge
   conflicts this way and work on the more interesting task of
   choosing which implementation to use.
2017-10-21 14:54:36 -05:00
David Yip
cd04e3df58 Fill in create, edit, update, and destroy for keyword mutes interface.
Also add a destroy-all action, which can be useful if you're flushing an
old list entirely to start a new one.
2017-10-21 14:54:36 -05:00
David Yip
4a64181461 Allow keywords to match either substrings or whole words.
Word-boundary matching only works as intended in English and languages
that use similar word-breaking characters; it doesn't work so well in
(say) Japanese, Chinese, or Thai.  It's unacceptable to have a feature
that doesn't work as intended for some languages.  (Moreso especially
considering that it's likely that the largest contingent on the Mastodon
bit of the fediverse speaks Japanese.)

There are rules specified in Unicode TR29[1] for word-breaking across
all languages supported by Unicode, but the rules deliberately do not
cover all cases.  In fact, TR29 states

    For example, reliable detection of word boundaries in languages such
    as Thai, Lao, Chinese, or Japanese requires the use of dictionary
    lookup, analogous to English hyphenation.

So we aren't going to be able to make word detection work with regexes
within Mastodon (or glitchsoc).  However, for a first pass (even if it's
kind of punting) we can allow the user to choose whether they want word
or substring detection and warn about the limitations of this
implementation in, say, docs.

[1]: https://unicode.org/reports/tr29/
     https://web.archive.org/web/20171001005125/https://unicode.org/reports/tr29/
2017-10-21 14:54:36 -05:00
David Yip
2e03a10059 Spike out index and new views for keyword mutes controller. 2017-10-21 14:54:36 -05:00
David Yip
4fa2f7e82d Set up /settings/keyword_mutes. #164.
This should eventually be accessible via the API and the web frontend,
but I find it easier to set up an editing interface using Rails
templates and the like.  We can always take it out if it turns out we
don't need it.
2017-10-21 14:54:36 -05:00
David Yip
b4b657eb1d Invalidate cached matcher objects on KeywordMute commit. #164. 2017-10-21 14:54:36 -05:00
David Yip
693c66dfde Use more idiomatic string concatentation. #164.
The intent of the previous concatenation was to minimize object
allocations, which can end up being a slow killer.  However, it turns
out that under MRI 2.4.x, the shove-strings-in-an-array-and-join method
is not only arguably more common but (in this particular case) actually
allocates *fewer* objects than the string concatenation.

Or, at least, that's what I gather by running this:

    words = %w(palmettoes nudged hibernation bullish stockade's tightened Hades
    Dixie's formalize superego's commissaries Zappa's viceroy's apothecaries
    tablespoonful's barons Chennai tollgate ticked expands)

    a = Account.first

    KeywordMute.transaction do
      words.each { |w| KeywordMute.create!(keyword: w, account: a) }

      GC.start

      s1 = GC.stat

      re = String.new.tap do |str|
        scoped = KeywordMute.where(account: a)
        keywords = scoped.select(:id, :keyword)
        count = scoped.count

        keywords.find_each.with_index do |kw, index|
          str << Regexp.escape(kw.keyword.strip)
          str << '|' if index < count - 1
        end
      end

      s2 = GC.stat

      puts s1.inspect, s2.inspect

      raise ActiveRecord::Rollback
    end

vs this:

    words = %w( palmettoes nudged hibernation bullish stockade's tightened Hades Dixie's
    formalize superego's commissaries Zappa's viceroy's apothecaries tablespoonful's
    barons Chennai tollgate ticked expands
    )

    a = Account.first

    KeywordMute.transaction do
      words.each { |w| KeywordMute.create!(keyword: w, account: a) }

      GC.start

      s1 = GC.stat

      re = [].tap do |arr|
        KeywordMute.where(account: a).select(:keyword, :id).find_each do |m|
          arr << Regexp.escape(m.keyword.strip)
        end
      end.join('|')

      s2 = GC.stat

      puts s1.inspect, s2.inspect

      raise ActiveRecord::Rollback
    end

Using rails r, here is a comparison of the total_allocated_objects and
malloc_increase_bytes GC stat data:

                 total_allocated_objects        malloc_increase_bytes
string concat    3200241 -> 3201428 (+1187)     1176 -> 45216 (44040)
array join       3200380 -> 3201299 (+919)      1176 -> 36448 (35272)
2017-10-21 14:54:36 -05:00
David Yip
a4851100fd Make use of the regex attr_reader. #164.
It would also have been valid to get rid of the attr_reader, but I like
being able to reach inside KeywordMute::Matcher without resorting to
instance_variable_get tomfoolery.
2017-10-21 14:54:36 -05:00
David Yip
9f609bc94e Fix case-insensitive match scenario; test some word ornamentation. #164. 2017-10-21 14:54:36 -05:00
David Yip
603cf02b70 Rework KeywordMute interface to use a matcher object; spec out matcher. #164.
A matcher object that builds a match from KeywordMute data and runs it
over text is, in my view, one of the easier ways to write examples for
this sort of thing.
2017-10-21 14:54:36 -05:00
David Yip
4745d6eeca Spec out KeywordMute interface. #164. 2017-10-21 14:54:21 -05:00
David Yip
9093e2de7a Add KeywordMute model.
Gist of the proposed keyword mute implementation:

Keyword mutes are represented server-side as one keyword per record.
For each account, there exists a keyword regex that is generated as one
big alternation of all keywords.  This regex is cached (in Redis, I
guess) so we can quickly get it when filtering in FeedManager.
2017-10-21 14:53:41 -05:00
Ondřej Hruška
d589dd7cd0 Compose buttons bar redesign + generalize dropdown (#194)
* Generalize compose dropdown for re-use

* wip stuffs

* new tootbox look and removed old doodle button files

* use the house icon for ...
2017-10-21 20:24:53 +02:00
Nolan Lawson
8392ddbf87 Remove unnecessary translateZ(0) when doing scale() (#5473) 2017-10-19 18:27:55 +02:00
masarakki
049381b284 remove-duplicated-jest-config (#5465) 2017-10-19 13:51:38 +02:00
78 changed files with 1347 additions and 330 deletions

View File

@@ -29,6 +29,11 @@ settings:
import/ignore:
- node_modules
- \\.(css|scss|json)$
import/resolver:
node:
moduleDirectory:
- node_modules
- app/javascript
rules:
brace-style: warn

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "app/javascript/themes/mastodon-go"]
path = app/javascript/themes/mastodon-go
url = https://github.com/marrus-sh/mastodon-go

2
Vagrantfile vendored
View File

@@ -83,7 +83,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
vb.customize ["modifyvm", :id, "--memory", "2048"]
vb.customize ["modifyvm", :id, "--memory", "4096"]
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
# https://github.com/mitchellh/vagrant/issues/1172

View File

@@ -22,6 +22,14 @@ module Admin
end
end
def update
if @custom_emoji.update(resource_params)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
else
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
end
end
def destroy
@custom_emoji.destroy
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
@@ -36,7 +44,7 @@ module Admin
flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
end
redirect_to admin_custom_emojis_path(params[:page])
redirect_to admin_custom_emojis_path(page: params[:page])
end
def enable
@@ -56,7 +64,7 @@ module Admin
end
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image)
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
end
def filtered_custom_emojis

View File

@@ -0,0 +1,60 @@
# frozen_string_literal: true
class Api::V1::Timelines::DirectController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }, only: [:show]
before_action :require_user!, only: [:show]
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
respond_to :json
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_direct_statuses
end
def cached_direct_statuses
cache_collection direct_statuses, Status
end
def direct_statuses
direct_timeline_statuses.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
end
def direct_timeline_statuses
Status.as_direct_timeline(current_account)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params)
end
def next_path
api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
end
def prev_path
api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
class Settings::KeywordMutesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :load_keyword_mute, only: [:edit, :update, :destroy]
def index
@keyword_mutes = paginated_keyword_mutes_for_account
end
def new
@keyword_mute = keyword_mutes_for_account.build
end
def create
@keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
if @keyword_mute.persisted?
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
else
render :new
end
end
def update
if @keyword_mute.update(keyword_mute_params)
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
else
render :edit
end
end
def destroy
@keyword_mute.destroy!
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
end
def destroy_all
keyword_mutes_for_account.delete_all
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
end
private
def keyword_mutes_for_account
Glitch::KeywordMute.where(account: current_account)
end
def load_keyword_mute
@keyword_mute = keyword_mutes_for_account.find(params[:id])
end
def keyword_mute_params
params.require(:keyword_mute).permit(:keyword, :whole_word)
end
def paginated_keyword_mutes_for_account
keyword_mutes_for_account.order(:keyword).page params[:page]
end
end

View File

@@ -9,6 +9,10 @@ module JsonLdHelper
value.is_a?(Array) ? value.first : value
end
def as_array(value)
value.is_a?(Array) ? value : [value]
end
def value_or_id(value)
value.is_a?(String) || value.nil? ? value : value['id']
end

View File

@@ -0,0 +1,2 @@
module Settings::KeywordMutesHelper
end

View File

@@ -124,6 +124,16 @@ export default class LocalSettingsPage extends React.PureComponent {
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'reblogs']}
id='mastodon-settings--collapsed-auto-reblogs'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'replies']}

View File

@@ -287,6 +287,7 @@ properly and our intersection observer is good to go.
muted,
id,
intersectionObserverWrapper,
prepend,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
@@ -299,6 +300,9 @@ properly and our intersection observer is good to go.
node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
)
) || (
autoCollapseSettings.get('reblogs') &&
prepend === 'reblogged_by'
) || (
autoCollapseSettings.get('replies') &&
status.get('in_reply_to_id', null) !== null

View File

@@ -14,6 +14,7 @@
"settings.auto_collapse_lengthy": "Lengthy toots",
"settings.auto_collapse_media": "Toots with media",
"settings.auto_collapse_notifications": "Notifications",
"settings.auto_collapse_reblogs": "Boosts",
"settings.auto_collapse_replies": "Replies",
"settings.close": "Close",
"settings.collapsed_statuses": "Collapsed toots",

View File

@@ -59,6 +59,7 @@ const initialState = ImmutableMap({
all : false,
notifications : true,
lengthy : true,
reblogs : false,
replies : false,
media : false,
}),

View File

@@ -8,6 +8,7 @@ import {
refreshHomeTimeline,
refreshCommunityTimeline,
refreshPublicTimeline,
refreshDirectTimeline,
} from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
@@ -133,6 +134,8 @@ export function submitCompose() {
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertOrRefresh('community', refreshCommunityTimeline);
insertOrRefresh('public', refreshPublicTimeline);
} else if (response.data.visibility === 'direct') {
insertOrRefresh('direct', refreshDirectTimeline);
}
}).catch(function (error) {
dispatch(submitComposeFail(error));

View File

@@ -92,3 +92,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
export const connectPublicStream = () => connectTimelineStream('public', 'public');
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');

View File

@@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct');
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
@@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) {
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct');
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);

View File

@@ -112,3 +112,19 @@ exports[`<Button /> renders the props.text instead of children 1`] = `
foo
</button>
`;
exports[`<Button /> renders title if props.title is given 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
title="foo"
/>
`;

View File

@@ -72,4 +72,11 @@ describe('<Button />', () => {
expect(tree).toMatchSnapshot();
});
it('renders title if props.title is given', () => {
const component = renderer.create(<Button title='foo' />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -14,6 +14,7 @@ export default class Button extends React.PureComponent {
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
title: PropTypes.string,
};
static defaultProps = {
@@ -35,26 +36,26 @@ export default class Button extends React.PureComponent {
}
render () {
const style = {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
let attrs = {
className: classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
}),
disabled: this.props.disabled,
onClick: this.handleClick,
ref: this.setRef,
style: {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
},
};
const className = classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
});
if (this.props.title) attrs.title = this.props.title;
return (
<button
className={className}
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
style={style}
>
<button {...attrs}>
{this.props.text || this.props.children}
</button>
);

View File

@@ -175,7 +175,9 @@ export default class ColumnHeader extends React.PureComponent {
<div className={wrapperClassName}>
<h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title}
<span className='column-header__title'>
{title}
</span>
<div className='column-header__buttons'>
{backButton}
{ notifCleaning ? (

View File

@@ -164,6 +164,8 @@ export default class ComposeForm extends ImmutablePureComponent {
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
const privacyIcons = {
none: '',
@@ -173,7 +175,10 @@ export default class ComposeForm extends ImmutablePureComponent {
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
@@ -185,13 +190,15 @@ export default class ComposeForm extends ImmutablePureComponent {
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`}
aria-label={title2}
/>
);
} else {
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
@@ -256,6 +263,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
@@ -263,6 +271,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>

View File

@@ -68,7 +68,7 @@ export default class Upload extends ImmutablePureComponent {
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
<div className={classNames('compose-form__upload-description', { active })}>

View File

@@ -46,7 +46,7 @@ const getFrequentlyUsedEmojis = createSelector([
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.sort((a, b) => {
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();

View File

@@ -47,7 +47,7 @@ class SensitiveButton extends React.PureComponent {
'compose-form__sensitive-button--visible': visible,
});
return (
<div className={className} style={{ transform: `translateZ(0) scale(${scale})` }}>
<div className={className} style={{ transform: `scale(${scale})` }}>
<IconButton
className='compose-form__sensitive-button__icon'
title={intl.formatMessage(messages.title)}

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ColumnSettings from '../../community_timeline/components/column_settings';
import { changeSetting } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'direct']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['direct', ...key], checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshDirectTimeline,
expandDirectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class DirectTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(refreshDirectTimeline());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandDirectTimeline());
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
loadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
</Column>
);
}
}

View File

@@ -17,6 +17,7 @@ const messages = defineMessages({
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@@ -78,18 +79,22 @@ export default class GettingStarted extends ImmutablePureComponent {
}
}
navItems = navItems.concat([
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (me.get('locked')) {
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
}
navItems = navItems.concat([
<ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
<ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (me.get('locked')) {
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
}
navItems = navItems.concat([
<ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
]);
return (

View File

@@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
@@ -23,6 +23,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
};

View File

@@ -40,7 +40,7 @@ export default class UploadArea extends React.PureComponent {
{({ backgroundOpacity, backgroundScale }) =>
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
<div className='upload-area__drop'>
<div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
</div>
</div>

View File

@@ -29,6 +29,7 @@ import {
Following,
Reblogs,
Favourites,
DirectTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@@ -71,6 +72,7 @@ const keyMap = {
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
@@ -302,6 +304,10 @@ export default class UI extends React.Component {
this.context.router.history.push('/timelines/public');
}
handleHotkeyGoToDirect = () => {
this.context.router.history.push('/timelines/direct');
}
handleHotkeyGoToStart = () => {
this.context.router.history.push('/getting-started');
}
@@ -357,6 +363,7 @@ export default class UI extends React.Component {
goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated,
goToDirect: this.handleHotkeyGoToDirect,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToPinned: this.handleHotkeyGoToPinned,
@@ -377,6 +384,7 @@ export default class UI extends React.Component {
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />

View File

@@ -26,6 +26,10 @@ export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}

View File

@@ -755,6 +755,19 @@
],
"path": "app/javascript/mastodon/features/compose/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Direct messages",
"id": "column.direct"
},
{
"defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"id": "empty_column.direct"
}
],
"path": "app/javascript/mastodon/features/direct_timeline/index.json"
},
{
"descriptors": [
{
@@ -816,6 +829,10 @@
"defaultMessage": "Local timeline",
"id": "navigation_bar.community_timeline"
},
{
"defaultMessage": "Direct messages",
"id": "navigation_bar.direct"
},
{
"defaultMessage": "Preferences",
"id": "navigation_bar.preferences"

View File

@@ -28,6 +28,7 @@
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.home": "Home",
@@ -80,6 +81,7 @@
"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!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
@@ -106,6 +108,7 @@
"missing_indicator.label": "Not found",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.direct": "Direct messages",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Favourites",
"navigation_bar.follow_requests": "Follow requests",

View File

@@ -1,221 +1,221 @@
{
"account.block": "Bloki @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
"account.block_domain": "Kaŝi ĉion el {domain}",
"account.disclaimer_full": "La ĉi-subaj informoj povas ne plene reflekti la profilon de la uzanto.",
"account.edit_profile": "Redakti la profilon",
"account.follow": "Sekvi",
"account.followers": "Sekvantoj",
"account.follows": "Sekvatoj",
"account.follows_you": "Sekvas vin",
"account.media": "Media",
"account.media": "Sonbildaĵoj",
"account.mention": "Mencii @{name}",
"account.mute": "Mute @{name}",
"account.mute": "Silentigi @{name}",
"account.posts": "Mesaĝoj",
"account.report": "Report @{name}",
"account.report": "Signali @{name}",
"account.requested": "Atendas aprobon",
"account.share": "Share @{name}'s profile",
"account.share": "Diskonigi la profilon de @{name}",
"account.unblock": "Malbloki @{name}",
"account.unblock_domain": "Unhide {domain}",
"account.unfollow": "Malsekvi",
"account.unmute": "Unmute @{name}",
"account.view_full_profile": "View full profile",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"account.unblock_domain": "Malkaŝi {domain}",
"account.unfollow": "Ne plus sekvi",
"account.unmute": "Malsilentigi @{name}",
"account.view_full_profile": "Vidi plenan profilon",
"boost_modal.combo": "La proksiman fojon, premu {combo} por pasigi",
"bundle_column_error.body": "Io malfunkciis ŝargante tiun ĉi komponanton.",
"bundle_column_error.retry": "Bonvolu reprovi",
"bundle_column_error.title": "Reta eraro",
"bundle_modal_error.close": "Fermi",
"bundle_modal_error.message": "Io malfunkciis ŝargante tiun ĉi komponanton.",
"bundle_modal_error.retry": "Bonvolu reprovi",
"column.blocks": "Blokitaj uzantoj",
"column.community": "Loka tempolinio",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.favourites": "Favoritoj",
"column.follow_requests": "Abonpetoj",
"column.home": "Hejmo",
"column.mutes": "Muted users",
"column.mutes": "Silentigitaj uzantoj",
"column.notifications": "Sciigoj",
"column.pins": "Pinned toot",
"column.pins": "Alpinglitaj pepoj",
"column.public": "Fratara tempolinio",
"column_back_button.label": "Reveni",
"column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left",
"column_header.moveRight_settings": "Move column to the right",
"column_header.pin": "Pin",
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_subheading.navigation": "Navigation",
"column_subheading.settings": "Settings",
"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",
"column_header.hide_settings": "Kaŝi agordojn",
"column_header.moveLeft_settings": "Movi kolumnon maldekstren",
"column_header.moveRight_settings": "Movi kolumnon dekstren",
"column_header.pin": "Alpingli",
"column_header.show_settings": "Malkaŝi agordojn",
"column_header.unpin": "Depingli",
"column_subheading.navigation": "Navigado",
"column_subheading.settings": "Agordoj",
"compose_form.lock_disclaimer": "Via konta ne estas ŝlosita. Iu ajn povas sekvi vin por vidi viajn privatajn pepojn.",
"compose_form.lock_disclaimer.lock": "ŝlosita",
"compose_form.placeholder": "Pri kio vi pensas?",
"compose_form.publish": "Hup",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "Marki ke la enhavo estas tikla",
"compose_form.spoiler": "Kaŝi la tekston malantaŭ averto",
"compose_form.spoiler_placeholder": "Content warning",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this status?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
"confirmations.mute.confirm": "Mute",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"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!",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"getting_started.appsshort": "Apps",
"getting_started.faq": "FAQ",
"compose_form.spoiler_placeholder": "Skribu tie vian averton",
"confirmation_modal.cancel": "Malfari",
"confirmations.block.confirm": "Bloki",
"confirmations.block.message": "Ĉu vi konfirmas la blokadon de {name}?",
"confirmations.delete.confirm": "Malaperigi",
"confirmations.delete.message": "Ĉu vi konfirmas la malaperigon de tiun pepon?",
"confirmations.domain_block.confirm": "Kaŝi la tutan reton",
"confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas bloki {domain} tute? Plej ofte, kelkaj celitaj blokadoj aŭ silentigoj estas sufiĉaj kaj preferindaj.",
"confirmations.mute.confirm": "Silentigi",
"confirmations.mute.message": "Ĉu vi konfirmas la silentigon de {name}?",
"confirmations.unfollow.confirm": "Ne plu sekvi",
"confirmations.unfollow.message": "Ĉu vi volas ĉesi sekvi {name}?",
"embed.instructions": "Enmetu tiun statkonigon ĉe vian retejon kopiante la ĉi-suban kodon.",
"embed.preview": "Ĝi aperos tiel:",
"emoji_button.activity": "Aktivecoj",
"emoji_button.custom": "Personaj",
"emoji_button.flags": "Flagoj",
"emoji_button.food": "Manĝi kaj trinki",
"emoji_button.label": "Enmeti mieneton",
"emoji_button.nature": "Naturo",
"emoji_button.not_found": "Neniuj mienetoj!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Objektoj",
"emoji_button.people": "Homoj",
"emoji_button.recent": "Ofte uzataj",
"emoji_button.search": "Serĉo…",
"emoji_button.search_results": "Rezultatoj de serĉo",
"emoji_button.symbols": "Simboloj",
"emoji_button.travel": "Vojaĝoj & lokoj",
"empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!",
"empty_column.hashtag": "Ĝise, neniu enhavo estas asociita kun tiu kradvorto.",
"empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
"empty_column.home.public_timeline": "la publika tempolinio",
"empty_column.notifications": "Vi dume ne havas sciigojn. Interagi kun aliajn uzantojn por komenci la konversacion.",
"empty_column.public": "Estas nenio ĉi tie! Publike skribu ion, aŭ mane sekvu uzantojn de aliaj instancoj por plenigi la publikan tempolinion.",
"follow_request.authorize": "Akcepti",
"follow_request.reject": "Rifuzi",
"getting_started.appsshort": "Aplikaĵoj",
"getting_started.faq": "Oftaj demandoj",
"getting_started.heading": "Por komenci",
"getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}.",
"getting_started.userguide": "User Guide",
"home.column_settings.advanced": "Advanced",
"home.column_settings.basic": "Basic",
"home.column_settings.filter_regex": "Filter out by regular expressions",
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
"home.settings": "Column settings",
"getting_started.open_source_notice": "Mastodono estas malfermkoda programo. Vi povas kontribui aŭ raporti problemojn en GitHub je {github}.",
"getting_started.userguide": "Gvidilo de uzo",
"home.column_settings.advanced": "Precizaj agordoj",
"home.column_settings.basic": "Bazaj agordoj",
"home.column_settings.filter_regex": "Forfiltri per regulesprimo",
"home.column_settings.show_reblogs": "Montri diskonigojn",
"home.column_settings.show_replies": "Montri respondojn",
"home.settings": "Agordoj de la kolumno",
"lightbox.close": "Fermi",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"loading_indicator.label": "Ŝarĝanta...",
"media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Not found",
"navigation_bar.blocks": "Blocked users",
"lightbox.next": "Malantaŭa",
"lightbox.previous": "Antaŭa",
"loading_indicator.label": "Ŝarganta",
"media_gallery.toggle_visible": "Baskuli videblecon",
"missing_indicator.label": "Ne trovita",
"navigation_bar.blocks": "Blokitaj uzantoj",
"navigation_bar.community_timeline": "Loka tempolinio",
"navigation_bar.edit_profile": "Redakti la profilon",
"navigation_bar.favourites": "Favourites",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "Extended information",
"navigation_bar.favourites": "Favoritaj",
"navigation_bar.follow_requests": "Abonpetoj",
"navigation_bar.info": "Plia informo",
"navigation_bar.logout": "Elsaluti",
"navigation_bar.mutes": "Muted users",
"navigation_bar.pins": "Pinned toots",
"navigation_bar.mutes": "Silentigitaj uzantoj",
"navigation_bar.pins": "Alpinglitaj pepoj",
"navigation_bar.preferences": "Preferoj",
"navigation_bar.public_timeline": "Fratara tempolinio",
"notification.favourite": "{name} favoris vian mesaĝon",
"notification.follow": "{name} sekvis vin",
"notification.mention": "{name} menciis vin",
"notification.reblog": "{name} diskonigis vian mesaĝon",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.clear": "Forviŝi la sciigojn",
"notifications.clear_confirmation": "Ĉu vi certe volas malaperigi ĉiujn viajn sciigojn?",
"notifications.column_settings.alert": "Retumilaj atentigoj",
"notifications.column_settings.favourite": "Favoroj:",
"notifications.column_settings.favourite": "Favoritoj:",
"notifications.column_settings.follow": "Novaj sekvantoj:",
"notifications.column_settings.mention": "Mencioj:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.push": "Puŝsciigoj",
"notifications.column_settings.push_meta": "Tiu ĉi aparato",
"notifications.column_settings.reblog": "Diskonigoj:",
"notifications.column_settings.show": "Montri en kolono",
"notifications.column_settings.sound": "Play sound",
"onboarding.done": "Done",
"onboarding.next": "Next",
"onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
"onboarding.page_four.home": "The home timeline shows posts from people you follow.",
"onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
"onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
"onboarding.page_one.welcome": "Welcome to Mastodon!",
"onboarding.page_six.admin": "Your instance's admin is {admin}.",
"onboarding.page_six.almost_done": "Almost done...",
"onboarding.page_six.appetoot": "Bon Appetoot!",
"onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
"onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "community guidelines",
"onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
"onboarding.page_six.various_app": "mobile apps",
"onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
"onboarding.skip": "Skip",
"privacy.change": "Adjust status privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",
"privacy.public.short": "Public",
"privacy.unlisted.long": "Do not show in public timelines",
"privacy.unlisted.short": "Unlisted",
"relative_time.days": "{number}d",
"notifications.column_settings.sound": "Eligi sonon",
"onboarding.done": "Farita",
"onboarding.next": "Malantaŭa",
"onboarding.page_five.public_timelines": "La loka tempolinio enhavas mesaĝojn de ĉiuj ĉe {domain}. La federacia tempolinio enhavas ĉiujn mesaĝojn de uzantoj, kiujn iu ĉe {domain} sekvas. Ambaŭ tre utilas por trovi novajn kunparolantojn.",
"onboarding.page_four.home": "La hejma tempolinio enhavas la mesaĝojn de ĉiuj uzantoj, kiuj vi sekvas.",
"onboarding.page_four.notifications": "La sciiga kolumno informas vin kiam iu interagas kun vi.",
"onboarding.page_one.federation": "Mastodono estas reto de nedependaj serviloj, unuiĝintaj por krei pligrandan socian retejon. Ni nomas tiujn servilojn instancoj.",
"onboarding.page_one.handle": "Vi estas ĉe {domain}, unu el la multaj instancoj de Mastodono. Via kompleta uznomo do estas {handle}",
"onboarding.page_one.welcome": "Bonvenon al Mastodono!",
"onboarding.page_six.admin": "Via instancestro estas {admin}.",
"onboarding.page_six.almost_done": "Estas preskaŭ finita…",
"onboarding.page_six.appetoot": "Bonan apepiton!",
"onboarding.page_six.apps_available": "{apps} estas elŝuteblaj por iOS, Androido kaj alioj. Kaj nun… bonan apepiton!",
"onboarding.page_six.github": "Mastodono estas libera, senpaga kaj malfermkoda programaro. Vi povas signali cimojn, proponi funkciojn aŭ kontribui al gîa kreskado ĉe {github}.",
"onboarding.page_six.guidelines": "komunreguloj",
"onboarding.page_six.read_guidelines": "Ni petas vin: ne forgesu legi la {guidelines}n de {domain}!",
"onboarding.page_six.various_app": "telefon-aplikaĵoj",
"onboarding.page_three.profile": "Redaktu vian profilon por ŝanĝi vian avataron, priskribon kaj vian nomon. Vi tie trovos ankoraŭ aliajn agordojn.",
"onboarding.page_three.search": "Uzu la serĉokampo por trovi uzantojn kaj esplori kradvortojn tiel ke {illustration} kaj {introductions}. Por trovi iun, kiu ne estas ĉe ĉi tiu instanco, uzu ĝian kompletan uznomon.",
"onboarding.page_two.compose": "Skribu pepojn en la verkkolumno. Vi povas aldoni bildojn, ŝanĝi la agordojn de privateco kaj aldoni tiklavertojn («content warning») dank' al la piktogramoj malsupre.",
"onboarding.skip": "Pasigi",
"privacy.change": "Austigi la privateco de la mesaĝo",
"privacy.direct.long": "Vidigi nur al la menciitaj personoj",
"privacy.direct.short": "Rekta",
"privacy.private.long": "Vidigi nur al viaj sekvantoj",
"privacy.private.short": "Nursekvanta",
"privacy.public.long": "Vidigi en publikaj tempolinioj",
"privacy.public.short": "Publika",
"privacy.unlisted.long": "Ne vidigi en publikaj tempolinioj",
"privacy.unlisted.short": "Nelistigita",
"relative_time.days": "{number}t",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.just_now": "nun",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"reply_indicator.cancel": "Rezigni",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting",
"reply_indicator.cancel": "Malfari",
"report.placeholder": "Pliaj komentoj",
"report.submit": "Sendi",
"report.target": "Signalaĵo",
"search.placeholder": "Serĉi",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"search_popout.search_format": "Detala serĉo",
"search_popout.tips.hashtag": "kradvorto",
"search_popout.tips.status": "statkonigo",
"search_popout.tips.text": "Simpla teksto eligas la kongruajn afiŝnomojn, uznomojn kaj kradvortojn.",
"search_popout.tips.user": "uzanto",
"search_results.total": "{count, number} {count, plural, one {rezultato} other {rezultatoj}}",
"standalone.public_title": "Rigardeti…",
"status.cannot_reblog": "Tiun publikaĵon oni ne povas diskonigi",
"status.delete": "Forigi",
"status.embed": "Embed",
"status.embed": "Enmeti",
"status.favourite": "Favori",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.load_more": "Ŝargi plie",
"status.media_hidden": "Sonbildaĵo kaŝita",
"status.mention": "Mencii @{name}",
"status.more": "More",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
"status.pin": "Pin on profile",
"status.more": "Pli",
"status.mute_conversation": "Silentigi konversacion",
"status.open": "Disfaldi statkonigon",
"status.pin": "Pingli al la profilo",
"status.reblog": "Diskonigi",
"status.reblogged_by": "{name} diskonigita",
"status.reblogged_by": "{name} diskonigis",
"status.reply": "Respondi",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
"status.replyAll": "Respondi al la fadeno",
"status.report": "Signali @{name}",
"status.sensitive_toggle": "Alklaki por vidi",
"status.sensitive_warning": "Tikla enhavo",
"status.share": "Share",
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"status.share": "Diskonigi",
"status.show_less": "Refaldi",
"status.show_more": "Disfaldi",
"status.unmute_conversation": "Malsilentigi konversacion",
"status.unpin": "Depingli de profilo",
"tabs_bar.compose": "Ekskribi",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.federated_timeline": "Federacia tempolinio",
"tabs_bar.home": "Hejmo",
"tabs_bar.local_timeline": "Local",
"tabs_bar.local_timeline": "Loka tempolinio",
"tabs_bar.notifications": "Sciigoj",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Aldoni enhavaĵon",
"upload_form.description": "Describe for the visually impaired",
"upload_area.title": "Algliti por alŝuti",
"upload_button.label": "Aldoni sonbildaĵon",
"upload_form.description": "Priskribi por la misvidantaj",
"upload_form.undo": "Malfari",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound"
"upload_progress.label": "Alŝutanta…",
"video.close": "Fermi videon",
"video.exit_fullscreen": "Eliri el plenekrano",
"video.expand": "Vastigi videon",
"video.fullscreen": "Igi plenekrane",
"video.hide": "Kaŝi videon",
"video.mute": "Silentigi",
"video.pause": "Paŭzi",
"video.play": "Legi",
"video.unmute": "Malsilentigi"
}

View File

@@ -159,11 +159,11 @@
"privacy.public.short": "Publiczny",
"privacy.unlisted.long": "Niewidoczny na publicznych osiach czasu",
"privacy.unlisted.short": "Niewidoczny",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.days": "{number} dni",
"relative_time.hours": "{number} godz.",
"relative_time.just_now": "teraz",
"relative_time.minutes": "{number} min.",
"relative_time.seconds": "{number} s.",
"reply_indicator.cancel": "Anuluj",
"report.placeholder": "Dodatkowe komentarze",
"report.submit": "Wyślij",

View File

@@ -63,20 +63,20 @@
"confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
"confirmations.unfollow.confirm": "Отписаться",
"confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
"embed.instructions": "Embed this status on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"embed.instructions": "Встройте этот статус на Вашем сайте, скопировав код внизу.",
"embed.preview": "Так это будет выглядеть:",
"emoji_button.activity": "Занятия",
"emoji_button.custom": "Custom",
"emoji_button.custom": "Собственные",
"emoji_button.flags": "Флаги",
"emoji_button.food": "Еда и напитки",
"emoji_button.label": "Вставить эмодзи",
"emoji_button.nature": "Природа",
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
"emoji_button.not_found": "Нет эмодзи!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Предметы",
"emoji_button.people": "Люди",
"emoji_button.recent": "Frequently used",
"emoji_button.recent": "Последние",
"emoji_button.search": "Найти...",
"emoji_button.search_results": "Search results",
"emoji_button.search_results": "Результаты поиска",
"emoji_button.symbols": "Символы",
"emoji_button.travel": "Путешествия",
"empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
@@ -159,34 +159,34 @@
"privacy.public.short": "Публичный",
"privacy.unlisted.long": "Не показывать в лентах",
"privacy.unlisted.short": "Скрытый",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.days": "{number}д",
"relative_time.hours": "{number}ч",
"relative_time.just_now": "только что",
"relative_time.minutes": "{number}м",
"relative_time.seconds": "{number}с",
"reply_indicator.cancel": "Отмена",
"report.placeholder": "Комментарий",
"report.submit": "Отправить",
"report.target": "Жалуемся на",
"search.placeholder": "Поиск",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_popout.search_format": "Продвинутый формат поиска",
"search_popout.tips.hashtag": "хэштег",
"search_popout.tips.status": "статус",
"search_popout.tips.text": "Простой ввод текста покажет совпадающие имена пользователей, отображаемые имена и хэштеги",
"search_popout.tips.user": "пользователь",
"search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
"standalone.public_title": "A look inside...",
"standalone.public_title": "Прямо сейчас",
"status.cannot_reblog": "Этот статус не может быть продвинут",
"status.delete": "Удалить",
"status.embed": "Embed",
"status.embed": "Встроить",
"status.favourite": "Нравится",
"status.load_more": "Показать еще",
"status.media_hidden": "Медиаконтент скрыт",
"status.mention": "Упомянуть @{name}",
"status.more": "More",
"status.more": "Больше",
"status.mute_conversation": "Заглушить тред",
"status.open": "Развернуть статус",
"status.pin": "Pin on profile",
"status.pin": "Закрепить в профиле",
"status.reblog": "Продвинуть",
"status.reblogged_by": "{name} продвинул(а)",
"status.reply": "Ответить",
@@ -194,11 +194,11 @@
"status.report": "Пожаловаться",
"status.sensitive_toggle": "Нажмите для просмотра",
"status.sensitive_warning": "Чувствительный контент",
"status.share": "Share",
"status.share": "Поделиться",
"status.show_less": "Свернуть",
"status.show_more": "Развернуть",
"status.unmute_conversation": "Снять глушение с треда",
"status.unpin": "Unpin from profile",
"status.unpin": "Открепить от профиля",
"tabs_bar.compose": "Написать",
"tabs_bar.federated_timeline": "Глобальная",
"tabs_bar.home": "Главная",
@@ -206,16 +206,16 @@
"tabs_bar.notifications": "Уведомления",
"upload_area.title": "Перетащите сюда, чтобы загрузить",
"upload_button.label": "Добавить медиаконтент",
"upload_form.description": "Describe for the visually impaired",
"upload_form.description": "Описать для людей с нарушениями зрения",
"upload_form.undo": "Отменить",
"upload_progress.label": "Загрузка...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound"
"video.close": "Закрыть видео",
"video.exit_fullscreen": "Покинуть полноэкранный режим",
"video.expand": "Развернуть видео",
"video.fullscreen": "Полноэкранный режим",
"video.hide": "Скрыть видео",
"video.mute": "Заглушить звук",
"video.pause": "Пауза",
"video.play": "Пуск",
"video.unmute": "Включить звук"
}

View File

@@ -58,6 +58,12 @@ const initialState = ImmutableMap({
body: '',
}),
}),
direct: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
});
const defaultColumns = fromJS([

View File

@@ -2503,6 +2503,7 @@ button.icon-button.active i.fa-retweet {
}
.column-header {
display: flex;
padding: 15px;
font-size: 16px;
background: lighten($ui-base-color, 4%);
@@ -2528,12 +2529,10 @@ button.icon-button.active i.fa-retweet {
}
.column-header__buttons {
position: absolute;
right: 0;
top: 0;
height: 100%;
display: flex;
height: 48px;
display: flex;
margin: -15px;
margin-left: 0;
}
.column-header__button {
@@ -2692,6 +2691,14 @@ button.icon-button.active i.fa-retweet {
}
}
.column-header__title {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex: 1;
}
.text-btn {
display: inline-block;
padding: 0;
@@ -3402,21 +3409,21 @@ button.icon-button.active i.fa-retweet {
}
.fa-search {
transform: translateZ(0) rotate(90deg);
transform: rotate(90deg);
&.active {
pointer-events: none;
transform: translateZ(0) rotate(0deg);
transform: rotate(0deg);
}
}
.fa-times-circle {
top: 11px;
transform: translateZ(0) rotate(0deg);
transform: rotate(0deg);
cursor: pointer;
&.active {
transform: translateZ(0) rotate(90deg);
transform: rotate(90deg);
}
&:hover {
@@ -3465,7 +3472,6 @@ button.icon-button.active i.fa-retweet {
right: 0;
bottom: 0;
background: rgba($base-overlay-background, 0.7);
transform: translateZ(0);
}
.modal-root__container {

View File

@@ -53,9 +53,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def process_tags(status)
return unless @object['tag'].is_a?(Array)
return if @object['tag'].nil?
@object['tag'].each do |tag|
as_array(@object['tag']).each do |tag|
case tag['type']
when 'Hashtag'
process_hashtag tag, status
@@ -103,9 +103,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def process_attachments(status)
return unless @object['attachment'].is_a?(Array)
return if @object['attachment'].nil?
@object['attachment'].each do |attachment|
as_array(@object['attachment']).each do |attachment|
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
href = Addressable::URI.parse(attachment['url']).normalize.to_s

View File

@@ -141,6 +141,8 @@ class FeedManager
return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
check_for_mutes = [status.account_id]
check_for_mutes.concat(status.mentions.pluck(:account_id))
check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
@@ -166,6 +168,18 @@ class FeedManager
false
end
def keyword_filter?(status, matcher)
should_filter = matcher =~ status.text
should_filter ||= matcher =~ status.spoiler_text
if status.reblog?
should_filter ||= matcher =~ status.reblog.text
should_filter ||= matcher =~ status.reblog.spoiler_text
end
!!should_filter
end
def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
@@ -175,6 +189,7 @@ class FeedManager
should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) # or if the mention contains a muted keyword
should_filter
end

View File

@@ -15,6 +15,7 @@
# disabled :boolean default(FALSE), not null
# uri :string
# image_remote_url :string
# visible_in_picker :boolean default(TRUE), not null
#
class CustomEmoji < ApplicationRecord

7
app/models/glitch.rb Normal file
View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
module Glitch
def self.table_name_prefix
'glitch_'
end
end

View File

@@ -0,0 +1,66 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: glitch_keyword_mutes
#
# id :integer not null, primary key
# account_id :integer not null
# keyword :string not null
# whole_word :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Glitch::KeywordMute < ApplicationRecord
belongs_to :account, required: true
validates_presence_of :keyword
after_commit :invalidate_cached_matcher
def self.matcher_for(account_id)
Matcher.new(account_id)
end
private
def invalidate_cached_matcher
Rails.cache.delete("keyword_mutes:regex:#{account_id}")
end
class Matcher
attr_reader :account_id
attr_reader :regex
def initialize(account_id)
@account_id = account_id
regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
@regex = /#{regex_text}/i
end
def =~(str)
regex =~ str
end
private
def keywords
Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
end
def regex_text_for_account
kws = keywords.find_each.with_object([]) do |kw, a|
a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
end
Regexp.union(kws).source
end
def boundary_regex_for_keyword(keyword)
sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
/#{sb}#{Regexp.escape(keyword)}#{eb}/
end
end
end

View File

@@ -154,6 +154,14 @@ class Status < ApplicationRecord
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
end
def as_direct_timeline(account)
query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
.where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
.where(visibility: [:direct])
apply_timeline_filters(query, account, false)
end
def as_public_timeline(account = nil, local_only = false)
query = timeline_scope(local_only).without_replies

View File

@@ -3,7 +3,7 @@
class REST::CustomEmojiSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :shortcode, :url, :static_url
attributes :shortcode, :url, :static_url, :visible_in_picker
def url
full_asset_url(object.image.url)

View File

@@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService
# Cannot be batched
statuses.each do |status|
unpush_from_public_timelines(status)
unpush_from_direct_timelines(status) if status.direct_visibility?
batch_salmon_slaps(status) if status.local?
end
@@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService
end
end
def unpush_from_direct_timelines(status)
payload = @json_payloads[status.id]
redis.pipelined do
@mentions[status.id].each do |mention|
redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local?
end
redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local?
end
end
def batch_salmon_slaps(status)
return if @mentions[status.id].empty?

View File

@@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local?
render_anonymous_payload(status)
if status.direct_visibility?
deliver_to_mentioned_followers(status)
deliver_to_direct_timelines(status)
else
deliver_to_followers(status)
end
return if status.account.silenced? || !status.public_visibility? || status.reblog?
render_anonymous_payload(status)
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id
@@ -73,4 +75,13 @@ class FanOutOnWriteService < BaseService
Redis.current.publish('timeline:public', @payload)
Redis.current.publish('timeline:public:local', @payload) if status.local?
end
def deliver_to_direct_timelines(status)
Rails.logger.debug "Delivering status #{status.id} to direct timelines"
status.mentions.includes(:account).each do |mention|
Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
end
Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
end
end

View File

@@ -18,6 +18,7 @@ class RemoveStatusService < BaseService
remove_reblogs
remove_from_hashtags
remove_from_public
remove_from_direct if status.direct_visibility?
@status.destroy!
@@ -121,6 +122,13 @@ class RemoveStatusService < BaseService
Redis.current.publish('timeline:public:local', @payload) if @status.local?
end
def remove_from_direct
@mentions.each do |mention|
Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
end
Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local?
end
def redis
Redis.current
end

View File

@@ -9,7 +9,12 @@
- else
= custom_emoji.domain
%td
- unless custom_emoji.local?
- if custom_emoji.local?
- if custom_emoji.visible_in_picker
= table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }), method: :patch
- else
= table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }), method: :patch
- else
= table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post
%td
- if custom_emoji.disabled?

View File

@@ -5,6 +5,7 @@
%link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
%link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
%link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
%link{ href: asset_pack_path('features/direct_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
%meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)

View File

@@ -0,0 +1,11 @@
.fields-group
= f.input :keyword
= f.check_box :whole_word
= f.label :whole_word, t('keyword_mutes.match_whole_word')
.actions
- if f.object.persisted?
= f.button :button, t('generic.save_changes'), type: :submit
= link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
- else
= f.button :button, t('keyword_mutes.add_keyword'), type: :submit

View File

@@ -0,0 +1,10 @@
%tr
%td
= keyword_mute.keyword
%td
- if keyword_mute.whole_word
%i.fa.fa-check
%td
= table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute)
%td
= table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View File

@@ -0,0 +1,6 @@
- content_for :page_title do
= t('keyword_mutes.edit_keyword')
= simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f|
= render 'shared/error_messages', object: @keyword_mute
= render 'fields', f: f

View File

@@ -0,0 +1,18 @@
- content_for :page_title do
= t('settings.keyword_mutes')
.table-wrapper
%table.table
%thead
%tr
%th= t('keyword_mutes.keyword')
%th= t('keyword_mutes.match_whole_word')
%th
%th
%tbody
= render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute
= paginate @keyword_mutes
.simple_form
= link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button'
= link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View File

@@ -0,0 +1,6 @@
- content_for :page_title do
= t('keyword_mutes.add_keyword')
= simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f|
= render 'shared/error_messages', object: @keyword_mute
= render 'fields', f: f

View File

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

View File

@@ -130,11 +130,15 @@ en:
enable: Enable
enabled_msg: Successfully enabled that emoji
image_hint: PNG up to 50KB
listed: Listed
new:
title: Add new custom emoji
shortcode: Shortcode
shortcode_hint: At least 2 characters, only alphanumeric characters and underscores
title: Custom emojis
unlisted: Unlisted
update_failed_msg: Could not update that emoji
updated_msg: Emoji successfully updated!
upload: Upload
domain_blocks:
add_new: Add new
@@ -373,6 +377,14 @@ en:
following: Following list
muting: Muting list
upload: Upload
keyword_mutes:
add_keyword: Add keyword
edit: Edit
edit_keyword: Edit keyword
keyword: Keyword
match_whole_word: Match whole word
remove: Remove
remove_all: Remove all
landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
media_attachments:
@@ -491,6 +503,7 @@ en:
export: Data export
followers: Authorized followers
import: Import
keyword_mutes: Muted keywords
notifications: Notifications
preferences: Preferences
settings: Settings

View File

@@ -1,39 +1,77 @@
---
ru:
about:
about_hashtag_html: Это публичные статусы, отмеченные хэштегом <strong>#%{hashtag}</strong>. Вы можете взаимодействовать с ними при наличии у Вас аккаунта в глобальной сети Mastodon.
about_mastodon_html: Mastodon - это <em>свободная</em> социальная сеть с <em>открытым исходным кодом</em>. Как <em>децентрализованная</em> альтернатива коммерческим платформам, Mastodon предотвращает риск монополизации Вашего общения одной компанией. Выберите сервер, которому Вы доверяете &mdash; что бы Вы ни выбрали, Вы сможете общаться со всеми остальными. Любой может запустить свой собственный узел Mastodon и участвовать в <em>социальной сети</em> совершенно бесшовно.
about_this: Об этом узле
closed_registrations: В данный момент регистрация на этом узле закрыта.
contact: Связаться
contact_missing: Не установлено
contact_unavailable: Недоступен
description_headline: Что такое %{domain}?
domain_count_after: другими узлами
domain_count_before: Связан с
extended_description_html: |
<h3>Хорошее место для правил</h3>
<p>Расширенное описание еще не настроено.</p>
features:
humane_approach_body: Наученный ошибками других проектов, Mastodon направлен на выбор этичных решений в борьбе со злоупотреблениями возможностями социальных сетей.
humane_approach_title: Человечный подход
not_a_product_body: Mastodon - не коммерческая сеть. Здесь нет рекламы, сбора данных, отгороженных мест. Здесь нет централизованного управления.
not_a_product_title: Вы - человек, а не продукт
real_conversation_body: С 500 символами в Вашем распоряжении и поддержкой предупреждений о содержании статусов Вы сможете выражать свои мысли так, как Вы этого хотите.
real_conversation_title: Создан для настоящего общения
within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно.
within_reach_title: Всегда под рукой
find_another_instance: Найти другой узел
generic_description: "%{domain} - один из серверов сети"
hosted_on: Mastodon размещен на %{domain}
learn_more: Узнать больше
other_instances: Другие узлы
source_code: Исходный код
status_count_after: статусов
status_count_before: Опубликовано
user_count_after: пользователей
user_count_before: Здесь живет
what_is_mastodon: Что такое Mastodon?
accounts:
follow: Подписаться
followers: Подписчики
following: Подписан(а)
media: Медиаконтент
nothing_here: Здесь ничего нет!
people_followed_by: Люди, на которых подписан(а) %{name}
people_who_follow: Подписчики %{name}
posts: Посты
posts_with_replies: Посты с ответами
remote_follow: Подписаться на удаленном узле
reserved_username: Имя пользователя зарезервировано
roles:
admin: Администратор
unfollow: Отписаться
admin:
account_moderation_notes:
account: Модератор
create: Создать
created_at: Дата
created_msg: Заметка модератора успешно создана!
delete: Удалить
destroyed_msg: Заметка модератора успешно удалена!
accounts:
are_you_sure: Вы уверены?
confirm: Подтвердить
confirmed: Подтверждено
disable_two_factor_authentication: Отключить 2FA
display_name: Отображаемое имя
domain: Домен
edit: Изменить
email: E-mail
feed_url: URL фида
followers: Подписчики
followers_url: URL подписчиков
follows: Подписки
inbox_url: URL входящих
ip: IP
location:
all: Все
local: Локальные
@@ -45,6 +83,7 @@ ru:
silenced: Заглушенные
suspended: Заблокированные
title: Модерация
moderation_notes: Заметки модератора
most_recent_activity: Последняя активность
most_recent_ip: Последний IP
not_subscribed: Не подписаны
@@ -52,19 +91,51 @@ ru:
alphabetic: По алфавиту
most_recent: По дате
title: Порядок
outbox_url: URL исходящих
perform_full_suspension: Полная блокировка
profile_url: URL профиля
protocol: Протокол
public: Публичный
push_subscription_expires: Подписка PuSH истекает
redownload: Обновить аватар
reset: Сбросить
reset_password: Сбросить пароль
resubscribe: Переподписаться
salmon_url: Salmon URL
search: Поиск
shared_inbox_url: URL общих входящих
show:
created_reports: Жалобы, отправленные этим аккаунтом
report: жалоба
targeted_reports: Жалобы на этот аккаунт
silence: Глушение
statuses: Статусы
subscribe: Подписаться
title: Аккаунты
undo_silenced: Снять глушение
undo_suspension: Снять блокировку
unsubscribe: Отписаться
username: Имя пользователя
web: WWW
custom_emojis:
copied_msg: Локальная копия эмодзи успешно создана
copy: Скопироват
copy_failed_msg: Не удалось создать локальную копию эмодзи
created_msg: Эмодзи успешно создано!
delete: Удалить
destroyed_msg: Эмодзи успешно удалено!
disable: Отключить
disabled_msg: Эмодзи успешно отключено
emoji: Эмодзи
enable: Включить
enabled_msg: Эмодзи успешно включено
image_hint: PNG до 50KB
new:
title: Добавить новое эмодзи
shortcode: Шорткод
shortcode_hint: Как минимум 2 символа, только алфавитно-цифровые символы и подчеркивания
title: Собственные эмодзи
upload: Загрузить
domain_blocks:
add_new: Добавить новую
created_msg: Блокировка домена обрабатывается
@@ -74,13 +145,15 @@ ru:
create: Создать блокировку
hint: Блокировка домена не предотвратит создание новых аккаунтов в базе данных, но ретроактивно и автоматически применит указанные методы модерации для этих аккаунтов.
severity:
desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля."
desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля. Используйте <strong>Ничего</strong>, если хотите только запретить медиаконтент."
noop: Ничего
silence: Глушение
suspend: Блокировка
title: Новая доменная блокировка
reject_media: Запретить медиаконтент
reject_media_hint: Удаляет локально хранимый медиаконтент и запрещает его загрузку в будущем. Не имеет значения в случае блокировки.
severities:
noop: Ничего
silence: Глушение
suspend: Блокировка
severity: Строгость
@@ -97,13 +170,34 @@ ru:
undo: Отменить
title: Доменные блокировки
undo: Отемнить
email_domain_blocks:
add_new: Добавить новую
created_msg: Доменная блокировка еmail успешно создана
delete: Удалить
destroyed_msg: Доменная блокировка еmail успешно удалена
domain: Домен
new:
create: Создать блокировку
title: Новая доменная блокировка еmail
title: Доменная блокировка email
instances:
account_count: Известных аккаунтов
domain_name: Домен
reset: Сбросить
search: Поиск
title: Известные узлы
reports:
action_taken_by: 'Действие предпринято:'
are_you_sure: Вы уверены?
comment:
label: Комментарий
none: Нет
delete: Удалить
id: ID
mark_as_resolved: Отметить как разрешенную
nsfw:
'false': Показать мультимедийные вложения
'true': Скрыть мультимедийные вложения
report: 'Жалоба #%{id}'
reported_account: Аккаунт нарушителя
reported_by: Отправитель жалобы
@@ -116,6 +210,9 @@ ru:
unresolved: Неразрешенные
view: Просмотреть
settings:
bootstrap_timeline_accounts:
desc_html: Разделяйте имена пользователей запятыми. Сработает только для локальных незакрытых аккаунтов. По умолчанию включены все локальные администраторы.
title: Подписки по умолчанию для новых пользователей
contact_information:
email: Введите публичный e-mail
username: Введите имя пользователя
@@ -123,7 +220,11 @@ ru:
closed_message:
desc_html: Отображается на титульной странице, когда закрыта регистрация<br>Можно использовать HTML-теги
title: Сообщение о закрытой регистрации
deletion:
desc_html: Позволяет всем удалять собственные аккаунты
title: Разрешить удаление аккаунтов
open:
desc_html: Позволяет любому создавать аккаунт
title: Открыть регистрацию
site_description:
desc_html: Отображается в качестве параграфа на титульной странице и используется в качестве мета-тега.<br>Можно использовать HTML-теги, в особенности <code>&lt;a&gt;</code> и <code>&lt;em&gt;</code>.
@@ -131,8 +232,32 @@ ru:
site_description_extended:
desc_html: Отображается на странице дополнительной информации<br>Можно использовать HTML-теги
title: Расширенное описание сайта
site_terms:
desc_html: Вы можете добавить сюда собственную политику конфиденциальности, пользовательское соглашение и другие документы. Можно использовать теги HTML.
title: Условия использования
site_title: Название сайта
thumbnail:
desc_html: Используется для предпросмотра с помощью OpenGraph и API. Рекомендуется разрешение 1200x630px
title: Картинка узла
timeline_preview:
desc_html: Показывать публичную ленту на целевой странице
title: Предпросмотр ленты
title: Настройки сайта
statuses:
back_to_account: Назад к странице аккаунта
batch:
delete: Удалить
nsfw_off: Выключить NSFW
nsfw_on: Включить NSFW
execute: Выполнить
failed_to_execute: Не удалось выполнить
media:
hide: Скрыть медиаконтент
show: Показать медиаконтент
title: Медиаконтент
no_media: Без медиаконтента
title: Статусы аккаунта
with_media: С медиаконтентом
subscriptions:
callback_url: Callback URL
confirmed: Подтверждено
@@ -141,18 +266,31 @@ ru:
title: WebSub
topic: Тема
title: Администрирование
admin_mailer:
new_report:
body: "%{reporter} подал(а) жалобу на %{target}"
subject: Новая жалоба, узел %{instance} (#%{id})
application_mailer:
salutation: "%{name},"
settings: 'Изменить настройки e-mail: %{link}'
signature: Уведомления Mastodon от %{instance}
view: 'Просмотр:'
applications:
created: Приложение успешно создано
destroyed: Приложение успешно удалено
invalid_url: Введенный URL неверен
regenerate_token: Повторно сгенерировать токен доступа
token_regenerated: Токен доступа успешно сгенерирован
warning: Будьте очень внимательны с этими данными. Не делитесь ими ни с кем!
your_token: Ваш токен доступа
auth:
agreement_html: Создавая аккаунт, вы соглашаетесь с <a href="%{rules_path}">нашими правилами поведения</a> и <a href="%{terms_path}">политикой конфиденциальности</a>.
change_password: Изменить пароль
delete_account: Удалить аккаунт
delete_account_html: Если Вы хотите удалить свой аккаунт, вы можете <a href="%{path}">перейти сюда</a>. У Вас будет запрошено подтверждение.
didnt_get_confirmation: Не получили инструкцию для подтверждения?
forgot_password: Забыли пароль?
invalid_reset_password_token: Токен сброса пароля неверен или устарел. Пожалуйста, запросите новый.
login: Войти
logout: Выйти
register: Зарегистрироваться
@@ -162,6 +300,12 @@ ru:
authorize_follow:
error: К сожалению, при поиске удаленного аккаунта возникла ошибка
follow: Подписаться
follow_request: 'Вы отправили запрос на подписку:'
following: 'Ура! Теперь Вы подписаны на:'
post_follow:
close: Или просто закрыть это окно.
return: Вернуться к профилю пользователя
web: Перейти к WWW
title: Подписаться на %{acct}
datetime:
distance_in_words:
@@ -193,7 +337,10 @@ ru:
content: Проверка безопасности не удалась. Возможно, Вы блокируете cookies?
title: Проверка безопасности не удалась.
'429': Слишком много запросов
noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript.
'500':
content: Приносим извинения, но на нашей стороне что-то пошло не так.
title: Страница неверна
noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript. Кроме того, вы можете использовать одно из <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">приложений</a> Mastodon для Вашей платформы.
exports:
blocks: Список блокировки
csv: CSV
@@ -265,23 +412,30 @@ ru:
number:
human:
decimal_units:
format: "%n%u"
format: "%n %u"
units:
billion: B
million: M
billion: млрд
million: млн
quadrillion: Q
thousand: K
trillion: T
thousand: тыс
trillion: трлн
unit: ''
pagination:
next: След
prev: Пред
truncate: "&hellip;"
preferences:
languages: Языки
other: Другое
publishing: Публикация
web: WWW
push_notifications:
favourite:
title: Ваш статус понравился %{name}
follow:
title: "%{name} теперь подписан(а) на Вас"
group:
title: "%{count} уведомлений"
mention:
action_boost: Продвинуть
action_expand: Развернуть
@@ -335,16 +489,24 @@ ru:
authorized_apps: Авторизованные приложения
back: Назад в Mastodon
delete: Удаление аккаунта
development: Разработка
edit_profile: Изменить профиль
export: Экспорт данных
followers: Авторизованные подписчики
import: Импорт
notifications: Уведомления
preferences: Настройки
settings: Опции
two_factor_authentication: Двухфакторная аутентификация
your_apps: Ваши приложения
statuses:
open_in_web: Открыть в WWW
over_character_limit: превышен лимит символов (%{max})
pin_errors:
limit: Слишком много закрепленных статусов
ownership: Нельзя закрепить чужой статус
private: Нельзя закрепить непубличный статус
reblog: Нельзя закрепить продвинутый статус
show_more: Подробнее
visibilities:
private: Для подписчиков
@@ -359,6 +521,8 @@ ru:
sensitive_content: Чувствительный контент
terms:
title: Условия обслуживания и политика конфиденциальности %{instance}
themes:
default: Mastodon
time:
formats:
default: "%b %d, %Y, %H:%M"
@@ -367,11 +531,13 @@ ru:
description_html: При включении <strong>двухфакторной аутентификации</strong>, вход потребует от Вас использования Вашего телефона, который сгенерирует входные токены.
disable: Отключить
enable: Включить
enabled: Двухфакторная аутентификация включена
enabled_success: Двухфакторная аутентификация успешно включена
generate_recovery_codes: Сгенерировать коды восстановления
instructions_html: "<strong>Отсканируйте этот QR-код с помощью Google Authenticator или другого подобного приложения на Вашем телефоне</strong>. С этого момента приложение будет генерировать токены, которые будет необходимо ввести для входа."
lost_recovery_codes: Коды восстановления позволяют вернуть доступ к аккаунту в случае утери телефона. Если Вы потеряли Ваши коды восстановления, вы можете заново сгенерировать их здесь. Ваши старые коды восстановления будут аннулированы.
manual_instructions: 'Если Вы не можете отсканировать QR-код и хотите ввести его вручную, секрет представлен здесь открытым текстом:'
recovery_codes: Коды восстановления
recovery_codes_regenerated: Коды восстановления успешно сгенерированы
recovery_instructions_html: В случае утери доступа к Вашему телефону Вы можете использовать один из кодов восстановления, указанных ниже, чтобы вернуть доступ к аккаунту. Держите коды восстановления в безопасности, например, распечатав их и храня с другими важными документами.
setup: Настроить
@@ -379,3 +545,4 @@ ru:
users:
invalid_email: Введенный e-mail неверен
invalid_otp_token: Введен неверный код
signed_in_as: 'Выполнен вход под именем:'

View File

@@ -4,6 +4,7 @@ pt-BR:
hints:
defaults:
avatar: PNG, GIF or JPG. Arquivos de até 2MB. Eles serão diminuídos para 120x120px
digest: Enviado após um longo período de inatividade com um resumo das menções que você recebeu em sua ausência.
display_name:
one: <span class="name-counter">1</span> caracter restante
other: <span class="name-counter">%{count}</span> caracteres restantes
@@ -13,6 +14,7 @@ pt-BR:
one: <span class="note-counter">1</span> caracter restante
other: <span class="note-counter">%{count}</span> caracteres restantes
setting_noindex: Afeta seu perfil público e as páginas de suas postagens
setting_theme: Afeta a aparência do Mastodon quando em sua conta em qualquer aparelho.
imports:
data: Arquivo CSV exportado de outra instância do Mastodon
sessions:
@@ -42,7 +44,9 @@ pt-BR:
setting_default_sensitive: Sempre marcar mídia como sensível
setting_delete_modal: Mostrar diálogo de confirmação antes de deletar uma postagem
setting_noindex: Não quero ser indexado por mecanismos de busca
setting_reduce_motion: Reduz movimento em animações
setting_system_font_ui: Usar a fonte padrão de seu sistema
setting_theme: Tema do site
setting_unfollow_modal: Mostrar diálogo de confirmação antes de deixar de seguir alguém
severity: Gravidade
type: Tipo de importação

View File

@@ -4,6 +4,7 @@ ru:
hints:
defaults:
avatar: PNG, GIF или JPG. Максимально 2MB. Будет уменьшено до 120x120px
digest: Отсылается после долгого периода неактивности с общей информацией упоминаний, полученных в Ваше отсутствие
display_name:
few: Осталось <span class="name-counter">%{count}</span> символа
many: Осталось <span class="name-counter">%{count}</span> символов
@@ -17,6 +18,7 @@ ru:
one: Остался <span class="name-counter">1</span> символ
other: Осталось <span class="name-counter">%{count}</span> символов
setting_noindex: Относится к Вашему публичному профилю и страницам статусов
setting_theme: Влияет на внешний вид Mastodon при выполненном входе в аккаунт.
imports:
data: Файл CSV, экспортированный с другого узла Mastodon
sessions:
@@ -46,6 +48,8 @@ ru:
setting_default_sensitive: Всегда отмечать медиаконтент как чувствительный
setting_delete_modal: Показывать диалог подтверждения перед удалением
setting_noindex: Отказаться от индексации в поисковых машинах
setting_reduce_motion: Уменьшить движение в анимации
setting_site_theme: Тема сайта
setting_system_font_ui: Использовать шрифт системы по умолчанию
setting_unfollow_modal: Показывать диалог подтверждения перед тем, как отписаться от аккаунта
severity: Строгость

View File

@@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation|
primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url
settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url
settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}

View File

@@ -66,6 +66,13 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [:show, :update]
resources :keyword_mutes do
collection do
delete :destroy_all
end
end
resource :preferences, only: [:show, :update]
resource :notifications, only: [:show, :update]
resource :import, only: [:show, :create]
@@ -140,7 +147,7 @@ Rails.application.routes.draw do
resource :two_factor_authentication, only: [:destroy]
end
resources :custom_emojis, only: [:index, :new, :create, :destroy] do
resources :custom_emojis, only: [:index, :new, :create, :update, :destroy] do
member do
post :copy
post :enable
@@ -193,6 +200,7 @@ Rails.application.routes.draw do
end
namespace :timelines do
resource :direct, only: :show, controller: :direct
resource :home, only: :show, controller: :home
resource :public, only: :show, controller: :public
resources :tag, only: :show

View File

@@ -3,48 +3,62 @@ class FixReblogsInFeeds < ActiveRecord::Migration[5.1]
redis = Redis.current
fm = FeedManager.instance
# Old scheme:
# Each user's feed zset had a series of score:value entries,
# where "regular" statuses had the same score and value (their
# ID). Reblogs had a score of the reblogging status' ID, and a
# value of the reblogged status' ID.
# New scheme:
# The feed contains only entries with the same score and value.
# Reblogs result in the reblogging status being added to the
# feed, with an entry in a reblog tracking zset (where the score
# is once again set to the reblogging status' ID, and the value
# is set to the reblogged status' ID). This is safe for Redis'
# float coersion because in this reblog tracking zset, we only
# need the rebloggging status' ID to be able to stop tracking
# entries after they have gotten too far down the feed, which
# does not require an exact value.
# This process reads all feeds and writes 3 times for each reblogs.
# So we use Lua script to avoid overhead between Ruby and Redis.
script = <<-LUA
local timeline_key = KEYS[1]
local reblog_key = KEYS[2]
-- So, first, we iterate over the user's feed to find any reblogs.
local items = redis.call('zrange', timeline_key, 0, -1, 'withscores')
for i = 1, #items, 2 do
local reblogged_id = items[i]
local reblogging_id = items[i + 1]
if (reblogged_id ~= reblogging_id) then
-- The score and value don't match, so this is a reblog.
-- (note that we're transitioning from IDs < 53 bits so we
-- don't have to worry about the loss of precision)
-- Remove the old entry
redis.call('zrem', timeline_key, reblogged_id)
-- Add a new one for the reblogging status
redis.call('zadd', timeline_key, reblogging_id, reblogging_id)
-- Track the fact that this was a reblog
redis.call('zadd', reblog_key, reblogging_id, reblogged_id)
end
end
LUA
script_hash = redis.script(:load, script)
# find_each is batched on the database side.
User.includes(:account).find_each do |user|
account = user.account
# Old scheme:
# Each user's feed zset had a series of score:value entries,
# where "regular" statuses had the same score and value (their
# ID). Reblogs had a score of the reblogging status' ID, and a
# value of the reblogged status' ID.
# New scheme:
# The feed contains only entries with the same score and value.
# Reblogs result in the reblogging status being added to the
# feed, with an entry in a reblog tracking zset (where the score
# is once again set to the reblogging status' ID, and the value
# is set to the reblogged status' ID). This is safe for Redis'
# float coersion because in this reblog tracking zset, we only
# need the rebloggging status' ID to be able to stop tracking
# entries after they have gotten too far down the feed, which
# does not require an exact value.
# So, first, we iterate over the user's feed to find any reblogs.
timeline_key = fm.key(:home, account.id)
reblog_key = fm.key(:home, account.id, 'reblogs')
redis.zrange(timeline_key, 0, -1, with_scores: true).each do |entry|
next if entry[0] == entry[1]
# The score and value don't match, so this is a reblog.
# (note that we're transitioning from IDs < 53 bits so we
# don't have to worry about the loss of precision)
reblogged_id, reblogging_id = entry
# Remove the old entry
redis.zrem(timeline_key, reblogged_id)
# Add a new one for the reblogging status
redis.zadd(timeline_key, reblogging_id, reblogging_id)
# Track the fact that this was a reblog
redis.zadd(reblog_key, reblogging_id, reblogged_id)
end
redis.evalsha(script_hash, [timeline_key, reblog_key])
end
end

View File

@@ -0,0 +1,12 @@
class CreateKeywordMutes < ActiveRecord::Migration[5.1]
def change
create_table :keyword_mutes do |t|
t.references :account, null: false
t.string :keyword, null: false
t.boolean :whole_word, null: false, default: true
t.timestamps
end
add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade
end
end

View File

@@ -0,0 +1,7 @@
class AddVisibleInPickerToCustomEmoji < ActiveRecord::Migration[5.1]
def change
safety_assured {
add_column :custom_emojis, :visible_in_picker, :boolean, default: true, null: false
}
end
end

View File

@@ -0,0 +1,7 @@
class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1]
def change
safety_assured do
rename_table :keyword_mutes, :glitch_keyword_mutes
end
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20171010025614) do
ActiveRecord::Schema.define(version: 20171021191900) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -111,6 +111,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do
t.boolean "disabled", default: false, null: false
t.string "uri"
t.string "image_remote_url"
t.boolean "visible_in_picker", default: true, null: false
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
end
@@ -155,6 +156,15 @@ ActiveRecord::Schema.define(version: 20171010025614) do
t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
end
create_table "glitch_keyword_mutes", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "keyword", null: false
t.boolean "whole_word", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id"
end
create_table "imports", force: :cascade do |t|
t.integer "type", null: false
t.boolean "approved", default: false, null: false
@@ -472,6 +482,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade
add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
add_foreign_key "media_attachments", "statuses", on_delete: :nullify

View File

@@ -10,6 +10,7 @@ module Paperclip
unless options[:style] == :original && num_frames > 1
tmp_file = Paperclip::TempfileFactory.new.generate(attachment.instance.file_file_name)
tmp_file << file.read
tmp_file.flush
return tmp_file
end

View File

@@ -135,22 +135,5 @@
},
"optionalDependencies": {
"fsevents": "*"
},
"jest": {
"projects": [
"<rootDir>/app/javascript/mastodon"
],
"testPathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/vendor/",
"<rootDir>/config/",
"<rootDir>/log/",
"<rootDir>/public/",
"<rootDir>/tmp/"
],
"setupFiles": [
"raf/polyfill"
],
"setupTestFrameworkScriptFile": "<rootDir>/app/javascript/mastodon/test_setup.js"
}
}

View File

@@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Settings::KeywordMutesController, type: :controller do
end

View File

@@ -0,0 +1,2 @@
Fabricator('Glitch::KeywordMute') do
end

BIN
spec/fixtures/files/mini-static.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the Settings::KeywordMutesHelper. For example:
#
# describe Settings::KeywordMutesHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe Settings::KeywordMutesHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -119,6 +119,44 @@ RSpec.describe FeedManager do
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end
it 'returns true for a status containing a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
status = Fabricate(:status, text: 'This is a hot take', account: bob)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end
it 'returns true for a reply containing a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
s1 = Fabricate(:status, text: 'Something', account: alice)
s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob)
expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true
end
it 'returns true for a status whose spoiler text contains a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end
it 'returns true for a reblog containing a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
status = Fabricate(:status, text: 'This is a hot take', account: bob)
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end
it 'returns true for a reblog whose spoiler text contains a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end
end
context 'for mentions feed' do
@@ -147,6 +185,13 @@ RSpec.describe FeedManager do
bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
end
it 'returns true for status that contains a muted keyword' do
Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take')
status = Fabricate(:status, text: 'This is a hot take', account: alice)
bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
end
end
end

View File

@@ -0,0 +1,89 @@
require 'rails_helper'
RSpec.describe Glitch::KeywordMute, type: :model do
let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
describe '.matcher_for' do
let(:matcher) { Glitch::KeywordMute.matcher_for(alice) }
describe 'with no mutes' do
before do
Glitch::KeywordMute.delete_all
end
it 'does not match' do
expect(matcher =~ 'This is a hot take').to be_falsy
end
end
describe 'with mutes' do
it 'does not match keywords set by a different account' do
Glitch::KeywordMute.create!(account: bob, keyword: 'take')
expect(matcher =~ 'This is a hot take').to be_falsy
end
it 'does not match if no keywords match the status text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
expect(matcher =~ 'This is a hot take').to be_falsy
end
it 'considers word boundaries when matching' do
Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
expect(matcher =~ 'bobcats').to be_falsy
end
it 'matches substrings if whole_word is false' do
Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false)
expect(matcher =~ 'This is a shiitake mushroom').to be_truthy
end
it 'matches keywords at the beginning of the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'take')
expect(matcher =~ 'Take this').to be_truthy
end
it 'matches keywords at the end of the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'take')
expect(matcher =~ 'This is a hot take').to be_truthy
end
it 'matches if at least one keyword case-insensitively matches the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
expect(matcher =~ 'This is a HOT take').to be_truthy
end
it 'matches keywords surrounded by non-alphanumeric ornamentation' do
Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
expect(matcher =~ '(hot take)').to be_truthy
end
it 'escapes metacharacters in keywords' do
Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
expect(matcher =~ '(hot take)').to be_truthy
end
it 'uses case-folding rules appropriate for more than just English' do
Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern')
expect(matcher =~ 'besuch der grosseltern').to be_truthy
end
it 'matches keywords that are composed of multiple words' do
Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
expect(matcher =~ 'This is a shiitake').to be_truthy
expect(matcher =~ 'This is shiitake').to_not be_truthy
end
end
end
end

View File

@@ -20,20 +20,29 @@ RSpec.describe MediaAttachment, type: :model do
end
describe 'non-animated gif non-conversion' do
let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture('attachment.gif')) }
fixtures = [
{ filename: 'attachment.gif', width: 600, height: 400, aspect: 1.5 },
{ filename: 'mini-static.gif', width: 32, height: 32, aspect: 1.0 },
]
it 'sets type to image' do
expect(media.type).to eq 'image'
end
fixtures.each do |fixture|
context fixture[:filename] do
let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture(fixture[:filename])) }
it 'leaves original file as-is' do
expect(media.file_content_type).to eq 'image/gif'
end
it 'sets type to image' do
expect(media.type).to eq 'image'
end
it 'sets meta' do
expect(media.file.meta["original"]["width"]).to eq 600
expect(media.file.meta["original"]["height"]).to eq 400
expect(media.file.meta["original"]["aspect"]).to eq 1.5
it 'leaves original file as-is' do
expect(media.file_content_type).to eq 'image/gif'
end
it 'sets meta' do
expect(media.file.meta["original"]["width"]).to eq fixture[:width]
expect(media.file.meta["original"]["height"]).to eq fixture[:height]
expect(media.file.meta["original"]["aspect"]).to eq fixture[:aspect]
end
end
end
end

View File

@@ -232,6 +232,55 @@ RSpec.describe Status, type: :model do
end
end
describe '.as_direct_timeline' do
let(:account) { Fabricate(:account) }
let(:followed) { Fabricate(:account) }
let(:not_followed) { Fabricate(:account) }
before do
Fabricate(:follow, account: account, target_account: followed)
@self_public_status = Fabricate(:status, account: account, visibility: :public)
@self_direct_status = Fabricate(:status, account: account, visibility: :direct)
@followed_public_status = Fabricate(:status, account: followed, visibility: :public)
@followed_direct_status = Fabricate(:status, account: followed, visibility: :direct)
@not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct)
@results = Status.as_direct_timeline(account)
end
it 'does not include public statuses from self' do
expect(@results).to_not include(@self_public_status)
end
it 'includes direct statuses from self' do
expect(@results).to include(@self_direct_status)
end
it 'does not include public statuses from followed' do
expect(@results).to_not include(@followed_public_status)
end
it 'includes direct statuses mentioning recipient from followed' do
Fabricate(:mention, account: account, status: @followed_direct_status)
expect(@results).to include(@followed_direct_status)
end
it 'does not include direct statuses not mentioning recipient from followed' do
expect(@results).to_not include(@followed_direct_status)
end
it 'includes direct statuses mentioning recipient from non-followed' do
Fabricate(:mention, account: account, status: @not_followed_direct_status)
expect(@results).to include(@not_followed_direct_status)
end
it 'does not include direct statuses not mentioning recipient from non-followed' do
expect(@results).to_not include(@not_followed_direct_status)
end
end
describe '.as_public_timeline' do
it 'only includes statuses with public visibility' do
public_status = Fabricate(:status, visibility: :public)

View File

@@ -402,6 +402,10 @@ const startWorker = (workerId) => {
streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true);
});
app.get('/api/v1/streaming/direct', (req, res) => {
streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
app.get('/api/v1/streaming/hashtag', (req, res) => {
streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
@@ -437,6 +441,9 @@ const startWorker = (workerId) => {
case 'public:local':
streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
case 'direct':
streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
case 'hashtag':
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;