Merge pull request #1187 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
pull/1189/head
ThibG 2019-08-05 17:58:39 +02:00 committed by GitHub
commit 0aee74d78a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 296 additions and 203 deletions

View File

@ -113,7 +113,7 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.27' gem 'capybara', '~> 3.28'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9' gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1' gem 'microformats', '~> 4.1'
@ -133,7 +133,7 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.73', require: false gem 'rubocop', '~> 0.74', require: false
gem 'rubocop-rails', '~> 2.2', require: false gem 'rubocop-rails', '~> 2.2', require: false
gem 'brakeman', '~> 4.6', require: false gem 'brakeman', '~> 4.6', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false

View File

@ -150,7 +150,7 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.27.0) capybara (3.28.0)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -209,9 +209,9 @@ GEM
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.1.0) doorkeeper (5.1.0)
railties (>= 5) railties (>= 5)
dotenv (2.7.4) dotenv (2.7.5)
dotenv-rails (2.7.4) dotenv-rails (2.7.5)
dotenv (= 2.7.4) dotenv (= 2.7.5)
railties (>= 3.2, < 6.1) railties (>= 3.2, < 6.1)
elasticsearch (6.0.2) elasticsearch (6.0.2)
elasticsearch-api (= 6.0.2) elasticsearch-api (= 6.0.2)
@ -274,7 +274,7 @@ GEM
railties (>= 4.0.1) railties (>= 4.0.1)
hamster (3.0.0) hamster (3.0.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
hashdiff (0.4.0) hashdiff (1.0.0)
hashie (3.6.0) hashie (3.6.0)
heapy (0.1.4) heapy (0.1.4)
highline (2.0.1) highline (2.0.1)
@ -478,7 +478,7 @@ GEM
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4) rails-html-sanitizer (1.1.0)
loofah (~> 2.2, >= 2.2.2) loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.3) rails-i18n (5.1.3)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
@ -492,7 +492,7 @@ GEM
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (12.3.2) rake (12.3.3)
rdf (3.0.12) rdf (3.0.12)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
@ -548,7 +548,7 @@ GEM
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.8.0) rspec-support (3.8.0)
rubocop (0.73.0) rubocop (0.74.0)
jaro_winkler (~> 1.5.1) jaro_winkler (~> 1.5.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.6) parser (>= 2.6)
@ -645,7 +645,7 @@ GEM
uniform_notifier (1.12.1) uniform_notifier (1.12.1)
warden (1.2.8) warden (1.2.8)
rack (>= 2.0.6) rack (>= 2.0.6)
webmock (3.6.0) webmock (3.6.2)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -684,7 +684,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4) capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.27) capybara (~> 3.28)
charlock_holmes (~> 0.7.6) charlock_holmes (~> 0.7.6)
chewy (~> 5.0) chewy (~> 5.0)
cld3 (~> 3.2.4) cld3 (~> 3.2.4)
@ -766,7 +766,7 @@ DEPENDENCIES
rqrcode (~> 0.10) rqrcode (~> 0.10)
rspec-rails (~> 3.8) rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0) rspec-sidekiq (~> 3.0)
rubocop (~> 0.73) rubocop (~> 0.74)
rubocop-rails (~> 2.2) rubocop-rails (~> 2.2)
sanitize (~> 5.0) sanitize (~> 5.0)
sidekiq (~> 5.2) sidekiq (~> 5.2)

View File

@ -9,17 +9,8 @@ module WellKnown
def show def show
@account = Account.find_local!(username_from_resource) @account = Account.find_local!(username_from_resource)
respond_to do |format|
format.any(:json, :html) do
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
end
format.xml do
render content_type: 'application/xrd+xml'
end
end
expires_in 3.days, public: true expires_in 3.days, public: true
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
head 404 head 404
end end

View File

@ -182,6 +182,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
className='value' className='value'
disabled={disabled} disabled={disabled}
icon={icon} icon={icon}
inverted
onClick={handleToggle} onClick={handleToggle}
size={18} size={18}
style={{ style={{

View File

@ -7,7 +7,7 @@ import spring from 'react-motion/lib/spring';
// Components. // Components.
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from 'flavours/glitch/components/text_icon_button'; import TextIconButton from './text_icon_button';
import Dropdown from './dropdown'; import Dropdown from './dropdown';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const iconStyle = {
height: null,
lineHeight: '27px',
width: `${18 * 1.28571429}px`,
};
export default class TextIconButton extends React.PureComponent { export default class TextIconButton extends React.PureComponent {
static propTypes = { static propTypes = {
@ -20,7 +26,15 @@ export default class TextIconButton extends React.PureComponent {
const { label, title, active, ariaControls } = this.props; const { label, title, active, ariaControls } = this.props;
return ( return (
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> <button
title={title}
aria-label={title}
className={`text-icon-button ${active ? 'active' : ''}`}
aria-expanded={active}
onClick={this.handleClick}
aria-controls={ariaControls}
style={iconStyle}
>
{label} {label}
</button> </button>
); );

View File

@ -11,7 +11,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']), value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']), disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -66,7 +66,7 @@ export default class NewListForm extends React.PureComponent {
</label> </label>
<IconButton <IconButton
disabled={disabled} disabled={disabled || !value}
icon='plus' icon='plus'
title={title} title={title}
onClick={this.handleClick} onClick={this.handleClick}

View File

@ -118,20 +118,29 @@
display: inline-block; display: inline-block;
padding: 0; padding: 0;
color: $action-button-color; color: $action-button-color;
border: none; border: 0;
border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: color 100ms ease-in; transition: all 100ms ease-in;
transition-property: background-color, color;
&:hover, &:hover,
&:active, &:active,
&:focus { &:focus {
color: lighten($action-button-color, 7%); color: lighten($action-button-color, 7%);
transition: color 200ms ease-out; background-color: rgba($action-button-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}
&:focus {
background-color: rgba($action-button-color, 0.3);
} }
&.disabled { &.disabled {
color: darken($action-button-color, 13%); color: darken($action-button-color, 13%);
background-color: transparent;
cursor: default; cursor: default;
} }
@ -156,10 +165,16 @@
&:active, &:active,
&:focus { &:focus {
color: darken($lighter-text-color, 7%); color: darken($lighter-text-color, 7%);
background-color: rgba($lighter-text-color, 0.15);
}
&:focus {
background-color: rgba($lighter-text-color, 0.3);
} }
&.disabled { &.disabled {
color: lighten($lighter-text-color, 7%); color: lighten($lighter-text-color, 7%);
background-color: transparent;
} }
&.active { &.active {
@ -186,7 +201,8 @@
.text-icon-button { .text-icon-button {
color: $lighter-text-color; color: $lighter-text-color;
border: none; border: 0;
border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
@ -194,17 +210,25 @@
padding: 0 3px; padding: 0 3px;
line-height: 27px; line-height: 27px;
outline: 0; outline: 0;
transition: color 100ms ease-in; transition: all 100ms ease-in;
transition-property: background-color, color;
&:hover, &:hover,
&:active, &:active,
&:focus { &:focus {
color: darken($lighter-text-color, 7%); color: darken($lighter-text-color, 7%);
transition: color 200ms ease-out; background-color: rgba($lighter-text-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}
&:focus {
background-color: rgba($lighter-text-color, 0.3);
} }
&.disabled { &.disabled {
color: lighten($lighter-text-color, 20%); color: lighten($lighter-text-color, 20%);
background-color: transparent;
cursor: default; cursor: default;
} }

View File

@ -8,10 +8,11 @@ export default class Column extends React.PureComponent {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
label: PropTypes.string, label: PropTypes.string,
bindToDocument: PropTypes.bool,
}; };
scrollTop () { scrollTop () {
const scrollable = this.node.querySelector('.scrollable'); const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
if (!scrollable) { if (!scrollable) {
return; return;
@ -33,12 +34,20 @@ export default class Column extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
} else {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
} }
}
componentWillUnmount () { componentWillUnmount () {
if (this.props.bindToDocument) {
document.removeEventListener('wheel', this.handleWheel);
} else {
this.node.removeEventListener('wheel', this.handleWheel); this.node.removeEventListener('wheel', this.handleWheel);
} }
}
render () { render () {
const { label, children } = this.props; const { label, children } = this.props;

View File

@ -2,6 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { createPortal } from 'react-dom';
export default class ColumnBackButton extends React.PureComponent { export default class ColumnBackButton extends React.PureComponent {
@ -9,6 +10,10 @@ export default class ColumnBackButton extends React.PureComponent {
router: PropTypes.object, router: PropTypes.object,
}; };
static propTypes = {
multiColumn: PropTypes.bool,
};
handleClick = () => { handleClick = () => {
if (window.history && window.history.length === 1) { if (window.history && window.history.length === 1) {
this.context.router.history.push('/'); this.context.router.history.push('/');
@ -18,12 +23,20 @@ export default class ColumnBackButton extends React.PureComponent {
} }
render () { render () {
return ( const { multiColumn } = this.props;
const component = (
<button onClick={this.handleClick} className='column-back-button'> <button onClick={this.handleClick} className='column-back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button> </button>
); );
if (multiColumn) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
}
} }
} }

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -28,6 +29,7 @@ class ColumnHeader extends React.PureComponent {
showBackButton: PropTypes.bool, showBackButton: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
pinned: PropTypes.bool, pinned: PropTypes.bool,
placeholder: PropTypes.bool,
onPin: PropTypes.func, onPin: PropTypes.func,
onMove: PropTypes.func, onMove: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
@ -79,7 +81,7 @@ class ColumnHeader extends React.PureComponent {
} }
render () { render () {
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -146,7 +148,7 @@ class ColumnHeader extends React.PureComponent {
const hasTitle = icon && title; const hasTitle = icon && title;
return ( const component = (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
{hasTitle && ( {hasTitle && (
@ -172,6 +174,12 @@ class ColumnHeader extends React.PureComponent {
</div> </div>
</div> </div>
); );
if (multiColumn || placeholder) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
}
} }
} }

View File

@ -56,6 +56,7 @@ class AccountGallery extends ImmutablePureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
}; };
state = { state = {
@ -116,7 +117,7 @@ class AccountGallery extends ImmutablePureComponent {
} }
render () { render () {
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props;
const { width } = this.state; const { width } = this.state;
if (!isAccount) { if (!isAccount) {
@ -143,7 +144,7 @@ class AccountGallery extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}> <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>

View File

@ -100,7 +100,7 @@ class AccountTimeline extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />} prepend={<HeaderContainer accountId={this.props.params.accountId} />}

View File

@ -57,7 +57,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />; const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
return ( return (
<Column icon='ban' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='blocks' scrollKey='blocks'

View File

@ -105,7 +105,7 @@ class CommunityTimeline extends React.PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='users' icon='users'
active={hasUnread} active={hasUnread}

View File

@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const iconStyle = {
height: null,
lineHeight: '27px',
width: `${18 * 1.28571429}px`,
};
export default class TextIconButton extends React.PureComponent { export default class TextIconButton extends React.PureComponent {
static propTypes = { static propTypes = {
@ -20,7 +26,14 @@ export default class TextIconButton extends React.PureComponent {
const { label, title, active, ariaControls } = this.props; const { label, title, active, ariaControls } = this.props;
return ( return (
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> <button
title={title}
aria-label={title}
className={`text-icon-button ${active ? 'active' : ''}`}
aria-expanded={active}
onClick={this.handleClick}
aria-controls={ariaControls} style={iconStyle}
>
{label} {label}
</button> </button>
); );

View File

@ -75,7 +75,7 @@ class DirectTimeline extends React.PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='envelope' icon='envelope'
active={hasUnread} active={hasUnread}

View File

@ -58,7 +58,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />; const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />;
return ( return (
<Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='domain_blocks' scrollKey='domain_blocks'

View File

@ -74,7 +74,7 @@ class Favourites extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader <ColumnHeader
icon='star' icon='star'
title={intl.formatMessage(messages.heading)} title={intl.formatMessage(messages.heading)}

View File

@ -51,7 +51,7 @@ class Favourites extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollableList <ScrollableList
scrollKey='favourites' scrollKey='favourites'

View File

@ -57,7 +57,7 @@ class FollowRequests extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
return ( return (
<Column icon='user-plus' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='follow_requests' scrollKey='follow_requests'

View File

@ -78,7 +78,7 @@ class Followers extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollableList <ScrollableList
scrollKey='followers' scrollKey='followers'

View File

@ -78,7 +78,7 @@ class Following extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollableList <ScrollableList
scrollKey='following' scrollKey='following'

View File

@ -148,7 +148,7 @@ class GettingStarted extends ImmutablePureComponent {
} }
return ( return (
<Column label={intl.formatMessage(messages.menu)}> <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.menu)}>
{multiColumn && <div className='column-header__wrapper'> {multiColumn && <div className='column-header__wrapper'>
<h1 className='column-header'> <h1 className='column-header'>
<button> <button>

View File

@ -135,7 +135,7 @@ class HashtagTimeline extends React.PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
<Column ref={this.setRef} label={`#${id}`}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
<ColumnHeader <ColumnHeader
icon='hashtag' icon='hashtag'
active={hasUnread} active={hasUnread}

View File

@ -98,7 +98,7 @@ class HomeTimeline extends React.PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='home' icon='home'
active={hasUnread} active={hasUnread}

View File

@ -18,10 +18,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
}; };
render () { render () {
const { intl } = this.props; const { intl, multiColumn } = this.props;
return ( return (
<Column icon='question' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='question' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<div className='keyboard-shortcuts scrollable optionally-scrollable'> <div className='keyboard-shortcuts scrollable optionally-scrollable'>
<table> <table>

View File

@ -11,7 +11,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']), value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']), disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -148,14 +148,14 @@ class ListTimeline extends React.PureComponent {
} else if (list === false) { } else if (list === false) {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator /> <MissingIndicator />
</Column> </Column>
); );
} }
return ( return (
<Column ref={this.setRef} label={title}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader <ColumnHeader
icon='list-ul' icon='list-ul'
active={hasUnread} active={hasUnread}

View File

@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
</label> </label>
<IconButton <IconButton
disabled={disabled} disabled={disabled || !value}
icon='plus' icon='plus'
title={title} title={title}
onClick={this.handleClick} onClick={this.handleClick}

View File

@ -61,7 +61,7 @@ class Lists extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
return ( return (
<Column icon='list-ul' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='list-ul' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<NewListForm /> <NewListForm />

View File

@ -57,7 +57,7 @@ class Mutes extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
return ( return (
<Column icon='volume-off' heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<ScrollableList <ScrollableList
scrollKey='mutes' scrollKey='mutes'

View File

@ -198,7 +198,7 @@ class Notifications extends React.PureComponent {
); );
return ( return (
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='bell' icon='bell'
active={isUnread} active={isUnread}

View File

@ -47,7 +47,7 @@ class PinnedStatuses extends ImmutablePureComponent {
const { intl, shouldUpdateScroll, statusIds, hasMore, multiColumn } = this.props; const { intl, shouldUpdateScroll, statusIds, hasMore, multiColumn } = this.props;
return ( return (
<Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
<ColumnBackButtonSlim /> <ColumnBackButtonSlim />
<StatusList <StatusList
statusIds={statusIds} statusIds={statusIds}

View File

@ -105,7 +105,7 @@ class PublicTimeline extends React.PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='globe' icon='globe'
active={hasUnread} active={hasUnread}

View File

@ -51,7 +51,7 @@ class Reblogs extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollableList <ScrollableList
scrollKey='reblogs' scrollKey='reblogs'

View File

@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent {
descendantsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
askReplyConfirmation: PropTypes.bool, askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
}; };
@ -437,13 +438,13 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; let ancestors, descendants;
const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props; const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
if (status === null) { if (status === null) {
return ( return (
<Column> <Column>
<ColumnBackButton /> <ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator /> <MissingIndicator />
</Column> </Column>
); );
@ -470,9 +471,10 @@ class Status extends ImmutablePureComponent {
}; };
return ( return (
<Column label={intl.formatMessage(messages.detailedStatus)}> <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.detailedStatus)}>
<ColumnHeader <ColumnHeader
showBackButton showBackButton
multiColumn={multiColumn}
extraButton={( extraButton={(
<button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button> <button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
)} )}

View File

@ -21,7 +21,7 @@ export default class ColumnLoading extends ImmutablePureComponent {
let { title, icon } = this.props; let { title, icon } = this.props;
return ( return (
<Column> <Column>
<ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder />
<div className='scrollable' /> <div className='scrollable' />
</Column> </Column>
); );

View File

@ -73,9 +73,13 @@ class TabsBar extends React.PureComponent {
const { intl: { formatMessage } } = this.props; const { intl: { formatMessage } } = this.props;
return ( return (
<div className='tabs-bar__wrapper'>
<nav className='tabs-bar' ref={this.setRef}> <nav className='tabs-bar' ref={this.setRef}>
{links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
</nav> </nav>
<div id='tabs-bar__portal' />
</div>
); );
} }

View File

@ -153,9 +153,9 @@ const sortHashtagsByUse = (state, tags) => {
if (usedA === usedB) { if (usedA === usedB) {
return 0; return 0;
} else if (usedA && !usedB) { } else if (usedA && !usedB) {
return 1;
} else {
return -1; return -1;
} else {
return 1;
} }
}); });
}; };

View File

@ -39,7 +39,7 @@ body {
&.layout-single-column { &.layout-single-column {
height: auto; height: auto;
min-height: 100%; min-height: 100vh;
overflow-y: scroll; overflow-y: scroll;
} }

View File

@ -129,19 +129,28 @@
padding: 0; padding: 0;
color: $action-button-color; color: $action-button-color;
border: 0; border: 0;
border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: color 100ms ease-in; transition: all 100ms ease-in;
transition-property: background-color, color;
&:hover, &:hover,
&:active, &:active,
&:focus { &:focus {
color: lighten($action-button-color, 7%); color: lighten($action-button-color, 7%);
transition: color 200ms ease-out; background-color: rgba($action-button-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}
&:focus {
background-color: rgba($action-button-color, 0.3);
} }
&.disabled { &.disabled {
color: darken($action-button-color, 13%); color: darken($action-button-color, 13%);
background-color: transparent;
cursor: default; cursor: default;
} }
@ -166,10 +175,16 @@
&:active, &:active,
&:focus { &:focus {
color: darken($lighter-text-color, 7%); color: darken($lighter-text-color, 7%);
background-color: rgba($lighter-text-color, 0.15);
}
&:focus {
background-color: rgba($lighter-text-color, 0.3);
} }
&.disabled { &.disabled {
color: lighten($lighter-text-color, 7%); color: lighten($lighter-text-color, 7%);
background-color: transparent;
} }
&.active { &.active {
@ -197,6 +212,7 @@
.text-icon-button { .text-icon-button {
color: $lighter-text-color; color: $lighter-text-color;
border: 0; border: 0;
border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
@ -204,17 +220,25 @@
padding: 0 3px; padding: 0 3px;
line-height: 27px; line-height: 27px;
outline: 0; outline: 0;
transition: color 100ms ease-in; transition: all 100ms ease-in;
transition-property: background-color, color;
&:hover, &:hover,
&:active, &:active,
&:focus { &:focus {
color: darken($lighter-text-color, 7%); color: darken($lighter-text-color, 7%);
transition: color 200ms ease-out; background-color: rgba($lighter-text-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}
&:focus {
background-color: rgba($lighter-text-color, 0.3);
} }
&.disabled { &.disabled {
color: lighten($lighter-text-color, 20%); color: lighten($lighter-text-color, 20%);
background-color: transparent;
cursor: default; cursor: default;
} }
@ -604,7 +628,8 @@
} }
} }
.icon-button { .icon-button,
.text-icon-button {
box-sizing: content-box; box-sizing: content-box;
padding: 0 3px; padding: 0 3px;
} }
@ -731,7 +756,7 @@
white-space: pre-wrap; white-space: pre-wrap;
&:last-child { &:last-child {
margin-bottom: 2px; margin-bottom: 0;
} }
} }
@ -1852,6 +1877,26 @@ a.account__display-name {
} }
} }
.tabs-bar__wrapper {
background: darken($ui-base-color, 8%);
position: sticky;
top: 0;
z-index: 2;
padding-top: 0;
@media screen and (min-width: $no-gap-breakpoint) {
padding-top: 10px;
}
.tabs-bar {
margin-bottom: 0;
@media screen and (min-width: $no-gap-breakpoint) {
margin-bottom: 10px;
}
}
}
.react-swipeable-view-container { .react-swipeable-view-container {
&, &,
.columns-area, .columns-area,
@ -1949,9 +1994,6 @@ a.account__display-name {
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
flex: 0 0 auto; flex: 0 0 auto;
overflow-y: auto; overflow-y: auto;
position: sticky;
top: 0;
z-index: 3;
} }
.tabs-bar__link { .tabs-bar__link {
@ -2014,6 +2056,14 @@ a.account__display-name {
padding: 0; padding: 0;
} }
//.column {
// margin-top: 0;
// @media screen and (min-width: $no-gap-breakpoint) {
// margin-top: 10px;
// }
//}
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {
font-size: 16px; font-size: 16px;
} }
@ -2039,6 +2089,7 @@ a.account__display-name {
@media screen and (min-width: $no-gap-breakpoint) { @media screen and (min-width: $no-gap-breakpoint) {
padding: 10px 0; padding: 10px 0;
padding-top: 0;
} }
@media screen and (min-width: 630px) { @media screen and (min-width: 630px) {
@ -2153,13 +2204,11 @@ a.account__display-name {
@media screen and (min-width: $no-gap-breakpoint) { @media screen and (min-width: $no-gap-breakpoint) {
.tabs-bar { .tabs-bar {
margin: 10px auto;
margin-bottom: 0;
width: 100%; width: 100%;
} }
.react-swipeable-view-container .columns-area--mobile { .react-swipeable-view-container .columns-area--mobile {
height: calc(100% - 20px) !important; height: calc(100% - 10px) !important;
} }
.getting-started__wrapper, .getting-started__wrapper,
@ -2387,6 +2436,8 @@ a.account__display-name {
} }
.column-back-button { .column-back-button {
box-sizing: border-box;
width: 100%;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
color: $highlight-text-color; color: $highlight-text-color;
cursor: pointer; cursor: pointer;

View File

@ -37,7 +37,7 @@ class FeedManager
end end
def unpush_from_home(account, status) def unpush_from_home(account, status)
return false unless remove_from_feed(:home, account.id, status) return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -56,7 +56,7 @@ class FeedManager
end end
def unpush_from_list(list, status) def unpush_from_list(list, status)
return false unless remove_from_feed(:list, list.id, status) return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -120,7 +120,7 @@ class FeedManager
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
remove_from_feed(:home, into_account.id, status) remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
end end
end end
@ -316,10 +316,11 @@ class FeedManager
# with reblogs, and returning true if a status was removed. As with # with reblogs, and returning true if a status was removed. As with
# `add_to_feed`, this does not trigger push updates, so callers must # `add_to_feed`, this does not trigger push updates, so callers must
# do so if appropriate. # do so if appropriate.
def remove_from_feed(timeline_type, account_id, status) def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
timeline_key = key(timeline_type, account_id) timeline_key = key(timeline_type, account_id)
reblog_key = key(timeline_type, account_id, 'reblogs')
if status.reblog? if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
# 1. If the reblogging status is not in the feed, stop. # 1. If the reblogging status is not in the feed, stop.
status_rank = redis.zrevrank(timeline_key, status.id) status_rank = redis.zrevrank(timeline_key, status.id)
return false if status_rank.nil? return false if status_rank.nil?
@ -328,6 +329,7 @@ class FeedManager
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}") reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
redis.srem(reblog_set_key, status.id) redis.srem(reblog_set_key, status.id)
redis.zrem(reblog_key, status.reblog_of_id)
# 3. Re-insert another reblog or original into the feed if one # 3. Re-insert another reblog or original into the feed if one
# remains in the set. We could pick a random element, but this # remains in the set. We could pick a random element, but this
# set should generally be small, and it seems ideal to show the # set should generally be small, and it seems ideal to show the
@ -335,12 +337,14 @@ class FeedManager
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
redis.zadd(reblog_key, other_reblog, status.reblog_of_id) if other_reblog
# 4. Remove the reblogging status from the feed (as normal) # 4. Remove the reblogging status from the feed (as normal)
# (outside conditional) # (outside conditional)
else else
# If the original is getting deleted, no use for reblog references # If the original is getting deleted, no use for reblog references
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}")) redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
redis.zrem(reblog_key, status.id)
end end
redis.zrem(timeline_key, status.id) redis.zrem(timeline_key, status.id)

View File

@ -7,6 +7,7 @@
# name :string default(""), not null # name :string default(""), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# score :integer
# #
class Tag < ApplicationRecord class Tag < ApplicationRecord
@ -75,10 +76,12 @@ class Tag < ApplicationRecord
end end
def search_for(term, limit = 5, offset = 0) def search_for(term, limit = 5, offset = 0)
pattern = sanitize_sql_like(normalize(term.strip)) + '%' normalized_term = normalize(term.strip).mb_chars.downcase.to_s
pattern = sanitize_sql_like(normalized_term) + '%'
Tag.where(arel_table[:name].lower.matches(pattern.mb_chars.downcase.to_s)) Tag.where(arel_table[:name].lower.matches(pattern))
.order(:name) .where(arel_table[:score].gt(0).or(arel_table[:name].lower.eq(normalized_term)))
.order(Arel.sql('length(name) ASC, score DESC, name ASC'))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
end end

View File

@ -48,12 +48,17 @@ class TrendingTags
redis.zrem(key, tag_id.to_s) redis.zrem(key, tag_id.to_s)
else else
score = ((observed - expected)**2) / expected score = ((observed - expected)**2) / expected
redis.zadd(key, score, tag_id.to_s) added = redis.zadd(key, score, tag_id.to_s)
bump_tag_score!(tag_id) if added
end end
redis.expire(key, EXPIRE_TRENDS_AFTER) redis.expire(key, EXPIRE_TRENDS_AFTER)
end end
def bump_tag_score!(tag_id)
Tag.where(id: tag_id).update_all('score = COALESCE(score, 0) + 1')
end
def disallowed_hashtags def disallowed_hashtags
return @disallowed_hashtags if defined?(@disallowed_hashtags) return @disallowed_hashtags if defined?(@disallowed_hashtags)

View File

@ -26,7 +26,6 @@ class WebfingerSerializer < ActiveModel::Serializer
else else
[ [
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) },
{ rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') },
{ rel: 'self', type: 'application/activity+json', href: account_url(object) }, { rel: 'self', type: 'application/activity+json', href: account_url(object) },
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
] ]

View File

@ -7,7 +7,6 @@
- if @account.user&.setting_noindex - if @account.user&.setting_noindex
%meta{ name: 'robots', content: 'noindex, noarchive' }/ %meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
@ -74,7 +73,7 @@
- if featured_tag.last_status_at.nil? - if featured_tag.last_status_at.nil?
= t('accounts.nothing_here') = t('accounts.nothing_here')
- else - else
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
= render 'application/sidebar' = render 'application/sidebar'

View File

@ -1,51 +0,0 @@
doc = Ox::Document.new(version: '1.0')
doc << Ox::Element.new('XRD').tap do |xrd|
xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'
xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s)
if @account.instance_actor?
xrd << (Ox::Element.new('Alias') << instance_actor_url)
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://webfinger.net/rel/profile-page'
link['type'] = 'text/html'
link['href'] = about_more_url(instance_actor: true)
end
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'self'
link['type'] = 'application/activity+json'
link['href'] = instance_actor_url
end
else
xrd << (Ox::Element.new('Alias') << short_account_url(@account))
xrd << (Ox::Element.new('Alias') << account_url(@account))
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://webfinger.net/rel/profile-page'
link['type'] = 'text/html'
link['href'] = short_account_url(@account)
end
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://schemas.google.com/g/2010#updates-from'
link['type'] = 'application/atom+xml'
link['href'] = account_url(@account, format: 'atom')
end
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'self'
link['type'] = 'application/activity+json'
link['href'] = account_url(@account)
end
xrd << Ox::Element.new('Link').tap do |link|
link['rel'] = 'http://ostatus.org/schema/1.0/subscribe'
link['template'] = "#{authorize_interaction_url}?acct={uri}"
end
end
end
('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(doc, effort: :tolerant)).force_encoding('UTF-8')

View File

@ -0,0 +1,5 @@
class AddScoreToTags < ActiveRecord::Migration[5.2]
def change
add_column :tags, :score, :int
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_07_28_084117) do ActiveRecord::Schema.define(version: 2019_07_29_185330) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -672,6 +672,7 @@ ActiveRecord::Schema.define(version: 2019_07_28_084117) do
t.string "name", default: "", null: false t.string "name", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "score"
t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
end end

View File

@ -58,6 +58,7 @@ module Mastodon
option :concurrency, type: :numeric, default: 50, aliases: [:c] option :concurrency, type: :numeric, default: 50, aliases: [:c]
option :silent, type: :boolean, default: false, aliases: [:s] option :silent, type: :boolean, default: false, aliases: [:s]
option :format, type: :string, default: 'summary', aliases: [:f] option :format, type: :string, default: 'summary', aliases: [:f]
option :exclude_suspended, type: :boolean, default: false, aliases: [:x]
desc 'crawl [START]', 'Crawl all known peers, optionally beginning at START' desc 'crawl [START]', 'Crawl all known peers, optionally beginning at START'
long_desc <<-LONG_DESC long_desc <<-LONG_DESC
Crawl the fediverse by using the Mastodon REST API endpoints that expose Crawl the fediverse by using the Mastodon REST API endpoints that expose
@ -74,6 +75,10 @@ module Mastodon
default (`summary`), a summary of the statistics is returned. The other options default (`summary`), a summary of the statistics is returned. The other options
are `domains`, which returns a newline-delimited list of all discovered peers, are `domains`, which returns a newline-delimited list of all discovered peers,
and `json`, which dumps all the aggregated data raw. and `json`, which dumps all the aggregated data raw.
The --exclude-suspended (-x) option means that domains that are suspended
instance-wide do not appear in the output and are not included in summaries.
This also excludes subdomains of any of those domains.
LONG_DESC LONG_DESC
def crawl(start = nil) def crawl(start = nil)
stats = Concurrent::Hash.new stats = Concurrent::Hash.new
@ -81,11 +86,14 @@ module Mastodon
failed = Concurrent::AtomicFixnum.new(0) failed = Concurrent::AtomicFixnum.new(0)
start_at = Time.now.to_f start_at = Time.now.to_f
seed = start ? [start] : Account.remote.domains seed = start ? [start] : Account.remote.domains
blocked_domains = Regexp.new('\\.?' + DomainBlock.where(severity: 1).pluck(:domain).join('|') + '$')
pool = Concurrent::ThreadPoolExecutor.new(min_threads: 0, max_threads: options[:concurrency], idletime: 10, auto_terminate: true, max_queue: 0) pool = Concurrent::ThreadPoolExecutor.new(min_threads: 0, max_threads: options[:concurrency], idletime: 10, auto_terminate: true, max_queue: 0)
work_unit = ->(domain) do work_unit = ->(domain) do
next if stats.key?(domain) next if stats.key?(domain)
next if options[:exclude_suspended] && domain.match(blocked_domains)
stats[domain] = nil stats[domain] = nil
processed.increment processed.increment

View File

@ -67,7 +67,7 @@
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-react-inline-elements": "^7.2.0", "@babel/plugin-transform-react-inline-elements": "^7.2.0",
"@babel/plugin-transform-react-jsx-self": "^7.2.0", "@babel/plugin-transform-react-jsx-self": "^7.2.0",
"@babel/plugin-transform-react-jsx-source": "^7.2.0", "@babel/plugin-transform-react-jsx-source": "^7.5.0",
"@babel/plugin-transform-runtime": "^7.4.4", "@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.5.5", "@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
@ -164,7 +164,7 @@
"webpack": "^4.35.3", "webpack": "^4.35.3",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.3.2", "webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.5", "webpack-cli": "^3.3.6",
"webpack-merge": "^4.2.1", "webpack-merge": "^4.2.1",
"websocket.js": "^0.1.12" "websocket.js": "^0.1.12"
}, },
@ -177,7 +177,7 @@
"eslint-plugin-import": "~2.18.0", "eslint-plugin-import": "~2.18.0",
"eslint-plugin-jsx-a11y": "~6.2.3", "eslint-plugin-jsx-a11y": "~6.2.3",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-react": "~7.14.2", "eslint-plugin-react": "~7.14.3",
"jest": "^24.8.0", "jest": "^24.8.0",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",

View File

@ -56,17 +56,6 @@ PEM
expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice') expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
end end
it 'returns JSON when account can be found' do
get :show, params: { resource: alice.to_webfinger_s }, format: :xml
xml = Nokogiri::XML(response.body)
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/xrd+xml'
expect(xml.at_xpath('//xmlns:Subject').content).to eq 'acct:alice@cb6e6126.ngrok.io'
expect(xml.xpath('//xmlns:Alias').map(&:content)).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
end
it 'returns http not found when account cannot be found' do it 'returns http not found when account cannot be found' do
get :show, params: { resource: 'acct:not@existing.com' }, format: :json get :show, params: { resource: 'acct:not@existing.com' }, format: :json

View File

@ -254,6 +254,23 @@ RSpec.describe FeedManager do
expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
end end
it 'saves a new reblog of a recently-reblogged status when previous reblog has been deleted' do
account = Fabricate(:account)
reblogged = Fabricate(:status)
old_reblog = Fabricate(:status, reblog: reblogged)
# The first reblog should be accepted
expect(FeedManager.instance.push_to_home(account, old_reblog)).to be true
# The first reblog should be successfully removed
expect(FeedManager.instance.unpush_from_home(account, old_reblog)).to be true
reblog = Fabricate(:status, reblog: reblogged)
# The second reblog should be accepted
expect(FeedManager.instance.push_to_home(account, reblog)).to be true
end
it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
account = Fabricate(:account) account = Fabricate(:account)
reblogged = Fabricate(:status) reblogged = Fabricate(:status)

View File

@ -136,8 +136,8 @@ RSpec.describe Tag, type: :model do
end end
it 'finds the exact matching tag as the first item' do it 'finds the exact matching tag as the first item' do
similar_tag = Fabricate(:tag, name: "matchlater") similar_tag = Fabricate(:tag, name: "matchlater", score: 1)
tag = Fabricate(:tag, name: "match") tag = Fabricate(:tag, name: "match", score: 1)
results = Tag.search_for("match") results = Tag.search_for("match")

View File

@ -12,23 +12,6 @@ describe 'The webfinger route' do
end end
end end
describe 'asking for xml format' do
it 'returns an xml response for xml format' do
get webfinger_url(resource: alice.to_webfinger_s, format: :xml)
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/xrd+xml'
end
it 'returns an xml response for xml accept header' do
headers = { 'HTTP_ACCEPT' => 'application/xrd+xml' }
get webfinger_url(resource: alice.to_webfinger_s), headers: headers
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/xrd+xml'
end
end
describe 'asking for json format' do describe 'asking for json format' do
it 'returns a json response for json format' do it 'returns a json response for json format' do
get webfinger_url(resource: alice.to_webfinger_s, format: :json) get webfinger_url(resource: alice.to_webfinger_s, format: :json)

View File

@ -643,10 +643,10 @@
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-jsx" "^7.2.0" "@babel/plugin-syntax-jsx" "^7.2.0"
"@babel/plugin-transform-react-jsx-source@^7.0.0", "@babel/plugin-transform-react-jsx-source@^7.2.0": "@babel/plugin-transform-react-jsx-source@^7.0.0", "@babel/plugin-transform-react-jsx-source@^7.5.0":
version "7.2.0" version "7.5.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.2.0.tgz#20c8c60f0140f5dd3cd63418d452801cf3f7180f" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.5.0.tgz#583b10c49cf057e237085bcbd8cc960bd83bd96b"
integrity sha512-A32OkKTp4i5U6aE88GwwcuV4HAprUgHcTq0sSafLxjr6AW0QahrCRCjxogkbbcdtpbXkuTOlgpjophCxb6sh5g== integrity sha512-58Q+Jsy4IDCZx7kqEZuSDdam/1oW8OdDX8f+Loo6xyxdfg1yF0GE2XNJQSTZCaMol93+FBzpWiPEwtbMloAcPg==
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-jsx" "^7.2.0" "@babel/plugin-syntax-jsx" "^7.2.0"
@ -3734,10 +3734,10 @@ eslint-plugin-promise@~4.2.1:
resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
eslint-plugin-react@~7.14.2: eslint-plugin-react@~7.14.3:
version "7.14.2" version "7.14.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.2.tgz#94c193cc77a899ac0ecbb2766fbef88685b7ecc1" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz#911030dd7e98ba49e1b2208599571846a66bdf13"
integrity sha512-jZdnKe3ip7FQOdjxks9XPN0pjUKZYq48OggNMd16Sk+8VXx6JOvXmlElxROCgp7tiUsTsze3jd78s/9AFJP2mA== integrity sha512-EzdyyBWC4Uz2hPYBiEJrKCUi2Fn+BJ9B/pJQcjw5X+x/H2Nm59S4MJIvL4O5NEE0+WbnQwEBxWY03oUk+Bc3FA==
dependencies: dependencies:
array-includes "^3.0.3" array-includes "^3.0.3"
doctrine "^2.1.0" doctrine "^2.1.0"
@ -10337,10 +10337,10 @@ webpack-bundle-analyzer@^3.3.2:
opener "^1.5.1" opener "^1.5.1"
ws "^6.0.0" ws "^6.0.0"
webpack-cli@^3.3.5: webpack-cli@^3.3.6:
version "3.3.5" version "3.3.6"
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.5.tgz#f4d1238a66a2843d9cebf189835ea22142e72767" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.6.tgz#2c8c399a2642133f8d736a359007a052e060032c"
integrity sha512-w0j/s42c5UhchwTmV/45MLQnTVwRoaUTu9fM5LuyOd/8lFoCNCELDogFoecx5NzRUndO0yD/gF2b02XKMnmAWQ== integrity sha512-0vEa83M7kJtxK/jUhlpZ27WHIOndz5mghWL2O53kiDoA9DIxSKnfqB92LoqEn77cT4f3H2cZm1BMEat/6AZz3A==
dependencies: dependencies:
chalk "2.4.2" chalk "2.4.2"
cross-spawn "6.0.5" cross-spawn "6.0.5"