Compare commits

...

20 Commits

Author SHA1 Message Date
nachtjasmin cea84abb03
Add Mastodon v3 click behaviour back
This reverts commit 2bf1233a7e.
2023-12-27 01:33:57 +01:00
nachtjasmin bf6d451ff7
Add missing inner container for right sidebar 2023-12-27 01:14:46 +01:00
nachtjasmin 9abc807591
Smaller formatting fixes 2023-12-27 01:14:46 +01:00
nachtjasmin 94794cd4cf
Fix ruby templates 2023-12-27 01:14:46 +01:00
nachtjasmin a99a5fecd2
Rename is_exclusive -> exclusive
Already tested it against the production data of my instance, it's
working!
2023-12-27 01:14:46 +01:00
nachtjasmin b200e4ad1e
Add missing transcoder options back 2023-12-27 01:14:46 +01:00
nachtjasmin 6947207a30
Add missing numeric value for "dislike" category
This is diverging behaviour from upstream and was fixed by me in [1]
already. It is working fine on queer.group and therefore now going to be
merged into this fork.

[1]: https://github.com/hometown-fork/hometown/pull/1321
2023-12-27 01:14:46 +01:00
nachtjasmin fb6547ea7c
Use upstream code for exclusive lists 2023-12-27 01:14:46 +01:00
Claire 7efc85c2f7
Fix incoming status creation date not being restricted to standard ISO8601 (#27655) 2023-12-27 01:14:46 +01:00
nachtjasmin 35742cdf3d
Move norss settings to privacy subpage 2023-12-27 01:14:46 +01:00
nachtjasmin ecb892afbc
Add site title to all mails 2023-12-27 01:14:46 +01:00
nachtjasmin 66ff566453
Fix several merge errors (whitespace, duplicate lines)
- Align webfinger_controller with upstream
- Remove validation from webhook (It's not in v4.2.1, I don't know where it came from)
- Remove show_application from other view (merge error)
- Remove duplicate display name from account header
- Fix misspelled className for navigation bar
2023-12-27 01:14:46 +01:00
nachtjasmin a23ca40a44
Respect user settings for RSS feeds 2023-12-27 01:14:46 +01:00
nachtjasmin 104981bbba
Switch to the JS-based start page for now
I like the plain homepage more, but for now I just want to get it
running.
2023-12-27 01:14:46 +01:00
nachtjasmin 76d94f3850
Update setting migration to include Hometown settings
Fixes: a9b5598c97
2023-12-27 01:14:46 +01:00
nachtjasmin 13bd1cca81
Add JSDoc for initial state 2023-12-27 01:14:46 +01:00
nachtjasmin 967aa653d3
Only show share entry for federated posts 2023-12-27 01:14:46 +01:00
nachtjasmin 2bf1233a7e
Delete unused permalink component 2023-12-27 01:14:46 +01:00
nachtjasmin 38d112cc6f
Use upstream version for media gallery
Mastodon also has a alt badge, now we have a combination of both
behaviours. We keep the class if the alt text is missing and add the alt
badge if it's there.
2023-12-27 01:14:46 +01:00
nachtjasmin f5747f4b88
Refactor the federation dropdown
The privacy dropdown also changed a lot, this commit aligns both
codebases.
2023-12-27 01:14:45 +01:00
47 changed files with 494 additions and 547 deletions

View File

@ -1,58 +1,19 @@
# frozen_string_literal: true
class AboutController < ApplicationController
include RegistrationSpamConcern
include WebAppControllerConcern
layout 'public'
skip_before_action :require_functional!
before_action :set_body_classes, only: :show
before_action :set_instance_presenter
before_action :set_expires_in, only: [:more, :terms]
before_action :set_registration_form_time, only: :show
skip_before_action :require_functional!, only: [:more, :terms]
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
def terms; end
helper_method :display_blocks?
helper_method :display_blocks_rationale?
helper_method :public_fetch_mode?
helper_method :new_user
private
def markdown
@markdown ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true)
end
def display_blocks?
Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
end
def display_blocks_rationale?
Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)
end
def new_user
User.new.tap do |user|
user.build_account
user.build_invite_request
end
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_body_classes
@hide_navbar = true
end
def set_expires_in
expires_in 0, public: true
end
end

View File

@ -25,6 +25,12 @@ class AccountsController < ApplicationController
format.rss do
expires_in 1.minute, public: true
# Hometown: Do not display any entries in the RSS feed if it's disabled
if @account&.user&.setting_norss == true
@statuses = []
next
end
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status)
@ -48,7 +54,12 @@ class AccountsController < ApplicationController
end
def default_statuses
@account.statuses.where(visibility: [:public, :unlisted])
# Hometown: local-only posts are not meant for RSS feeds, even if they are public.
if current_user.nil?
@account.statuses.without_local_only.where(visibility: [:public, :unlisted])
else
@account.statuses.where(visibility: [:public, :unlisted])
end
end
def only_media_scope

View File

@ -11,8 +11,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }
before_action :set_last_used_at_by_app, only: :index, unless: -> { request.format == :json }
skip_before_action :require_functional!
include Localized

View File

@ -19,6 +19,7 @@ module WellKnown
def set_account
username = username_from_resource
@account = begin
if username == Rails.configuration.x.local_domain
Account.representative

View File

@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { Permalink } from 'mastodon/components/permalink';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
@ -151,7 +151,7 @@ class Account extends ImmutablePureComponent {
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={size} />
</div>
@ -164,7 +164,7 @@ class Account extends ImmutablePureComponent {
</div>
)}
</div>
</Link>
</Permalink>
{!minimal && (
<div className='account__relationship'>

View File

@ -5,12 +5,12 @@ import { Component } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { Permalink } from 'mastodon/components/permalink';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
@ -74,9 +74,9 @@ ImmutableHashtag.propTypes = {
const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Link to={to} href={href}>
<Permalink to={to} href={href}>
{name ? <>#<span>{name}</span></> : <Skeleton width={50} />}
</Link>
</Permalink>
{description ? (
<span>{description}</span>

View File

@ -18,7 +18,6 @@ import { IconButton } from './icon_button';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
no_descriptive_text: { id: 'media.no_descriptive_text', defaultMessage: 'No descriptive text was provided for this media.' },
});
class Item extends PureComponent {
@ -33,7 +32,6 @@ class Item extends PureComponent {
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
autoplay: PropTypes.bool,
noDescriptionTitle: PropTypes.string,
};
static defaultProps = {
@ -63,7 +61,7 @@ class Item extends PureComponent {
return this.props.autoplay || autoPlayGif;
}
hoverToPlay () {
hoverToPlay() {
const { attachment } = this.props;
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
}
@ -87,12 +85,12 @@ class Item extends PureComponent {
this.setState({ loaded: true });
};
render () {
render() {
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
let badges = [], thumbnail;
let width = 50;
let width = 50;
let height = 100;
if (size === 1) {
@ -103,7 +101,8 @@ class Item extends PureComponent {
height = 50;
}
if (attachment.get('description')?.length > 0) {
const hasMediaDescription = !attachment.get('description')?.length > 0;
if (hasMediaDescription) {
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
}
@ -111,8 +110,8 @@ class Item extends PureComponent {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<a className={`media-gallery__item-thumbnail ${!attachment.get('description') && 'media-missing-description'}`} href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100, 'media-missing-description': !hasMediaDescription })} key={attachment.get('id')}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
<Blurhash
hash={attachment.get('blurhash')}
className='media-gallery__preview'
@ -122,31 +121,30 @@ class Item extends PureComponent {
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<a
className={`media-gallery__item-thumbnail ${!attachment.get('description') && 'media-missing-description'}`}
className={classNames("media-gallery__item-thumbnail", { "media-missing-description": !hasMediaDescription })}
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
rel='noopener noreferrer'
>
{ !attachment.get('description') && <IconButton className='media-gallery__item-no-description media__no-description-icon' title={this.props.noDescriptionTitle} icon='exclamation-triangle' style={{ right: `calc(4px + ${(left === 'auto' ? '0px' : left)})`, bottom: `calc(4px + ${(top === 'auto' ? '0px' : top)})` }} overlay /> }
<img
src={previewUrl}
srcSet={srcSet}
@ -167,7 +165,7 @@ class Item extends PureComponent {
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className={`media-gallery__item-gifv-thumbnail ${!attachment.get('description') && 'media-missing-description'}`}
className={classNames("media-gallery__item-gifv-thumbnail", { "media-missing-description": !hasMediaDescription })}
aria-label={description}
title={description}
lang={lang}
@ -181,10 +179,6 @@ class Item extends PureComponent {
loop
muted
/>
<div className='media-gallery__gifv__label__container'>
<span className='media-gallery__gifv__label'>GIF</span>
{ !attachment.get('description') && <span className='media-gallery__gifv__label__no-description'><IconButton title={this.props.noDescriptionTitle} icon='exclamation-triangle' overlay /></span> }
</div>
</div>
);
}
@ -234,15 +228,15 @@ class MediaGallery extends PureComponent {
width: this.props.defaultWidth,
};
componentDidMount () {
componentDidMount() {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
UNSAFE_componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
@ -278,7 +272,7 @@ class MediaGallery extends PureComponent {
}
};
_setDimensions () {
_setDimensions() {
const width = this.node.offsetWidth;
// offsetWidth triggers a layout, so only calculate when we need to
@ -296,7 +290,7 @@ class MediaGallery extends PureComponent {
return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
}
render () {
render() {
const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@ -311,14 +305,13 @@ class MediaGallery extends PureComponent {
style.aspectRatio = '3 / 2';
}
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
const noDescriptionTitle = intl.formatMessage(messages.no_descriptive_text);
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
if (standalone && this.isFullSizeEligible()) {
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} noDescriptionTitle={noDescriptionTitle} />;
if (this.isFullSizeEligible()) {
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} noDescriptionTitle={noDescriptionTitle} />);
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
}
if (uncached) {

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
export default class Permalink extends React.PureComponent {
export class Permalink extends PureComponent {
static contextTypes = {
router: PropTypes.object,

View File

@ -4,12 +4,12 @@ import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Icon } from 'mastodon/components/icon';
import { Permalink } from 'mastodon/components/permalink';
import PollContainer from 'mastodon/containers/poll_container';
import { autoPlayGif, languages as preloadedLanguages, expandUsernames } from 'mastodon/initial_state';
@ -284,9 +284,9 @@ class StatusContent extends PureComponent {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Link to={`/@${item.get('acct')}`} href={item.get('url')} key={item.get('id')} className='status-link mention'>
<Permalink to={`/@${item.get('acct')}`} href={item.get('url')} key={item.get('id')} className='status-link mention'>
@<span>{expandUsernames ? item.get('acct') : item.get('username')}</span>
</Link>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;

View File

@ -419,8 +419,8 @@ class Header extends ImmutablePureComponent {
<div className='account__header__tabs__name'>
<h1>
<a href={account.get('url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={displayNameHtml} /></a>{isRemote ? <span> <IconButton icon='external-link' size={14} onClick={this.handleDisplayNameClick} /></span> : null}
<span dangerouslySetInnerHTML={displayNameHtml} />
<a href={account.get('url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={displayNameHtml} /></a>{isRemote ? <span> <IconButton icon='external-link' size={14} onClick={this.handleDisplayNameClick} /></span> : null}
<small>
<span>@{acct}</span> {lockedIcon}
</small>

View File

@ -1,10 +1,10 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Permalink } from 'mastodon/components/permalink';
import { AvatarOverlay } from '../../../components/avatar_overlay';
import { DisplayName } from '../../../components/display_name';
@ -25,12 +25,12 @@ export default class MovedNote extends ImmutablePureComponent {
</div>
<div className='moved-account-banner__action'>
<Link href={to.get('url')} to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<Permalink href={to.get('url')} to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Link>
</Permalink>
<Link href={to.get('url')} to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Link>
<Permalink href={to.get('url')} to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Permalink>
</div>
</div>
);

View File

@ -1,252 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({
federate_short: { id: 'federation.federated.short', defaultMessage: 'Federated' },
federate_long: { id: 'federation.federated.long', defaultMessage: 'Allow post to reach other instances' },
local_only_short: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
local_only_long: { id: 'federation.local_only.long', defaultMessage: 'Restrict this post only to my instance' },
change_federation: { id: 'federation.change', defaultMessage: 'Adjust status federation' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const getValue = element => element.dataset.index === 'true';
class FederationDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
state = {
mounted: false,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleKeyDown = e => {
const { items } = this.props;
const value = getValue(e.currentTarget);
const index = items.findIndex(item => {
return (item.value === value);
});
let element;
switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(getValue(element));
}
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(getValue(element));
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(getValue(element));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(getValue(element));
}
break;
}
}
handleClick = e => {
e.preventDefault();
this.props.onClose();
this.props.onChange(getValue(e.currentTarget));
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
this.setState({ mounted: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
setFocusRef = c => {
this.focusedItem = c;
}
render () {
const { mounted } = this.state;
const { style, items, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
}
}
@injectIntl
export default class FederationDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
placement: null,
};
handleToggle = ({ target }) => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
}
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
}
handleKeyDown = e => {
switch(e.key) {
case 'Escape':
this.handleClose();
break;
}
}
handleClose = () => {
this.setState({ open: false });
}
handleChange = value => {
this.props.onChange(value);
}
componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'link', value: true, text: formatMessage(messages.federate_short), meta: formatMessage(messages.federate_long) },
{ icon: 'chain-broken', value: false, text: formatMessage(messages.local_only_short), meta: formatMessage(messages.local_only_long) },
];
}
render () {
const { value, intl, disabled } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_federation)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled}
/>
</div>
<Overlay show={open} placement={placement} target={this}>
<FederationDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div>
);
}
}

View File

@ -0,0 +1,286 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
federate_short: { id: 'federation.federated.short', defaultMessage: 'Federated' },
federate_long: { id: 'federation.federated.long', defaultMessage: 'Allow post to reach other instances' },
local_only_short: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
local_only_long: { id: 'federation.local_only.long', defaultMessage: 'Restrict this post only to my instance' },
change_federation: { id: 'federation.change', defaultMessage: 'Adjust status federation' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
class FederationDropdownMenu extends PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
}
};
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null;
switch (e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
break;
case 'Home':
element = this.node.firstChild;
break;
case 'End':
element = this.node.lastChild;
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
};
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
render() {
const { style, items, value } = this.props;
return (
<div style={{ ...style }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} fixedWidth />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
);
}
}
class FederationDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
container: PropTypes.func,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
placement: 'bottom',
};
handleToggle = () => {
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
}
};
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
};
handleKeyDown = e => {
switch (e.key) {
case 'Escape':
this.handleClose();
break;
}
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleChange = value => {
// handleChange receives the values as string, therefore we need to convert them
// to proper JS booleans.
value = value === "true";
this.props.onChange(value);
};
UNSAFE_componentWillMount() {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'link', value: true, text: formatMessage(messages.federate_short), meta: formatMessage(messages.federate_long) },
{ icon: 'chain-broken', value: false, text: formatMessage(messages.local_only_short), meta: formatMessage(messages.local_only_long) },
];
}
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
render() {
const { value, container, disabled, intl } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })} ref={this.setTargetRef}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_federation)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled}
/>
</div>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<FederationDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}
export default injectIntl(FederationDropdown);

View File

@ -2,11 +2,11 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Permalink } from 'mastodon/components/permalink';
import { Avatar } from '../../../components/avatar';
import ActionBar from './action_bar';
@ -23,17 +23,17 @@ export default class NavigationBar extends ImmutablePureComponent {
const username = this.props.account.get('acct')
const url = this.props.account.get('url')
return (
<div classNamesName='navigation-bar'>
<Link to={`/@${username}`} href={url}>
<div className='navigation-bar'>
<Permalink to={`/@${username}`} href={url}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Link>
</Permalink>
<div className='navigation-bar__profile'>
<span>
<Link to={`/@${username}`} href={url}>
<Permalink to={`/@${username}`} href={url}>
<strong className='navigation-bar__profile-account'>@{username}</strong>
</Link>
</Permalink>
</span>
<span>

View File

@ -6,7 +6,6 @@ import { isUserTouching } from '../../../is_mobile';
import FederationDropdown from '../components/federation_dropdown';
const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'federation']),
});
@ -17,9 +16,14 @@ const mapDispatchToProps = dispatch => ({
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
onModalOpen: props => dispatch(openModal({
modalType: 'ACTIONS',
modalProps: props,
})),
onModalClose: () => dispatch(closeModal({
modalType: undefined,
ignoreFocus: false,
})),
});
export default connect(mapStateToProps, mapDispatchToProps)(FederationDropdown);

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -13,6 +12,7 @@ import { HotKeys } from 'react-hotkeys';
import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
import { Permalink } from 'mastodon/components/permalink';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
@ -136,7 +136,7 @@ class Conversation extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]);
const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = {
reply: this.handleReply,

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -19,6 +18,7 @@ import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import Button from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { Permalink } from 'mastodon/components/permalink';
import { ShortNumber } from 'mastodon/components/short_number';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
@ -174,7 +174,7 @@ class AccountCard extends ImmutablePureComponent {
return (
<div className='account-card'>
<Link href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
<div className='account-card__header'>
<img
src={
@ -188,7 +188,7 @@ class AccountCard extends ImmutablePureComponent {
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
<DisplayName account={account} />
</div>
</Link>
</Permalink>
{account.get('note').length > 0 && (
<div

View File

@ -2,11 +2,11 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Permalink } from 'mastodon/components/permalink';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
@ -32,10 +32,10 @@ class AccountAuthorize extends ImmutablePureComponent {
return (
<div className='account-authorize__wrapper'>
<div className='account-authorize'>
<Link href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
<DisplayName account={account} />
</Link>
</Permalink>
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
</div>

View File

@ -30,7 +30,6 @@ class ListEditor extends ImmutablePureComponent {
static propTypes = {
listId: PropTypes.string.isRequired,
isExclusive: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
@ -56,7 +55,7 @@ class ListEditor extends ImmutablePureComponent {
return (
<div className='modal-root__modal list-editor'>
<EditListForm isExclusive={this.props.isExclusive} />
<EditListForm />
<Search />

View File

@ -2,14 +2,13 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import { Permalink } from 'mastodon/components/permalink';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
@ -44,10 +43,10 @@ class FollowRequest extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Link>
</Permalink>
<div className='account__relationship'>
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -11,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import { Icon } from 'mastodon/components/icon';
import { Permalink } from 'mastodon/components/permalink';
import AccountContainer from 'mastodon/containers/account_container';
import StatusContainer from 'mastodon/containers/status_container';
import { me } from 'mastodon/initial_state';
@ -398,7 +398,7 @@ class Notification extends ImmutablePureComponent {
const targetAccount = report.get('target_account');
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} href={targetAccount.get('url')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
const targetLink = <bdi><Permalink className='notification__display-name' title={targetAccount.get('acct')} href={targetAccount.get('url')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
return (
<HotKeys handlers={this.getHandlers()}>
@ -423,7 +423,7 @@ class Notification extends ImmutablePureComponent {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <bdi><Link className='notification__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
const link = <bdi><Permalink className='notification__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
switch(notification.get('type')) {
case 'follow':

View File

@ -15,6 +15,7 @@ const messages = defineMessages({
spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
legal: { id: 'report_notification.categories.legal', defaultMessage: 'Legal' },
violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
dislike: { id: 'report_notification.categories.dislike', defaultMessage: 'Dislike' },
});
class Report extends ImmutablePureComponent {

View File

@ -45,6 +45,7 @@ class Category extends PureComponent {
const { onNextStep, category } = this.props;
switch(category) {
// Hometown: also send reports for "disliked" toots, it's quite unfair to do nothing in this case.
case 'dislike':
onNextStep('statuses');
break;

View File

@ -190,7 +190,7 @@ class ActionBar extends PureComponent {
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
const federated = !status.get('local_only');
const federated = !status.get('local_only');
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
@ -203,7 +203,7 @@ class ActionBar extends PureComponent {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
if (publicStatus && 'share' in navigator) {
if (publicStatus && 'share' in navigator && federated) {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
}

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { NavLink } from 'react-router-dom';
import { Icon } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
const ColumnLink = ({ icon, text, to, href, method, badge, transparent, button, onClick, ...other }) => {
const className = classNames('column-link', { 'column-link--transparent': transparent, 'column-link--button': button });
@ -46,7 +46,7 @@ ColumnLink.propTypes = {
badge: PropTypes.node,
transparent: PropTypes.bool,
button: PropTypes.bool,
onClick: PropTypes.function,
onClick: PropTypes.func,
};
export default ColumnLink;

View File

@ -155,8 +155,10 @@ export default class ColumnsArea extends ImmutablePureComponent {
<div className='columns-area columns-area--mobile'>{children}</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational columns-area__panels__pane__inner'>
<NavigationPanel />
<div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
<div className='columns-area__panels__pane__inner'>
<NavigationPanel />
</div>
</div>
</div>
);

View File

@ -18,7 +18,7 @@ const messages = defineMessages({
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
onLogout() {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
@ -52,7 +52,7 @@ class LinkFooter extends PureComponent {
return false;
};
render () {
render() {
const { signedIn, permissions } = this.context.identity;
const { multiColumn } = this.props;
@ -110,7 +110,7 @@ class LinkFooter extends PureComponent {
{DividingCircle}
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
{DividingCircle}
<span class='version'>v{version}</span>
<span className='version'>v{version}</span>
</p>
</div>
);

View File

@ -57,6 +57,7 @@
* @property {string} display_media
* @property {string} domain
* @property {boolean=} expand_spoilers
* @property {boolean=} expand_usernames
* @property {boolean} limited_federation_mode
* @property {string} locale
* @property {string | null} mascot
@ -89,6 +90,7 @@
* @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta
* @property {number} max_toot_chars
*/
const element = document.getElementById('initial-state');

View File

@ -10,7 +10,6 @@ import {
LIST_EDITOR_RESET,
LIST_EDITOR_SETUP,
LIST_EDITOR_TITLE_CHANGE,
LIST_EDITOR_IS_EXCLUSIVE_CHANGE,
LIST_ACCOUNTS_FETCH_REQUEST,
LIST_ACCOUNTS_FETCH_SUCCESS,
LIST_ACCOUNTS_FETCH_FAIL,
@ -56,11 +55,6 @@ export default function listEditorReducer(state = initialState, action) {
map.set('title', action.value);
map.set('isChanged', true);
});
case LIST_EDITOR_IS_EXCLUSIVE_CHANGE:
return state.withMutations(map => {
map.set('isExclusive', action.value);
map.set('isChanged', true);
});
case LIST_CREATE_REQUEST:
case LIST_UPDATE_REQUEST:
return state.withMutations(map => {

View File

@ -40,8 +40,4 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
def report_comment
(@json['content'] || '')[0...5000]
end
def report_comment
(@json['content'] || '')[0...5000]
end
end

View File

@ -53,7 +53,8 @@ class ActivityPub::Parser::StatusParser
end
def created_at
@object['published']&.to_datetime
datetime = @object['published']&.to_datetime
datetime if datetime.present? && (0..9999).cover?(datetime.year)
rescue ArgumentError
nil
end

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true
class PlainTextFormatter
include ActionView::Helpers::TextHelper
NEWLINE_TAGS_RE = %r{(<br />|<br>|</p>)+}
attr_reader :text, :local

View File

@ -20,10 +20,4 @@ class ApplicationMailer < ActionMailer::Base
headers['X-Auto-Response-Suppress'] = 'All'
headers['Auto-Submitted'] = 'auto-generated'
end
def set_autoreply_headers!
headers['Precedence'] = 'list'
headers['X-Auto-Response-Suppress'] = 'All'
headers['Auto-Submitted'] = 'auto-generated'
end
end

View File

@ -66,7 +66,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject', title: Setting.site_title)
end
end
@ -77,7 +77,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject', title: Setting.site_title)
end
end
@ -88,7 +88,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject', title: Setting.site_title)
end
end
@ -99,7 +99,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject', title: Setting.site_title)
end
end
@ -110,7 +110,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject', title: Setting.site_title)
end
end
@ -122,7 +122,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject', title: Setting.site_title)
end
end
@ -134,7 +134,7 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject', title: Setting.site_title)
end
end
@ -145,7 +145,6 @@ class UserMailer < Devise::Mailer
return unless @resource.active_for_authentication?
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject', title: Setting.site_title)
end
end
@ -169,7 +168,7 @@ class UserMailer < Devise::Mailer
@statuses = @warning.statuses.includes(:account, :preloadable_poll, :media_attachments, active_mentions: [:account])
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t("user_mailer.warning.subject.#{@warning.action}", acct: "@#{user.account.local_username_and_domain}")
mail to: @resource.email, subject: I18n.t("user_mailer.warning.subject.#{@warning.action}", acct: "@#{user.account.local_username_and_domain}", title: Setting.site_title)
end
end
@ -179,7 +178,7 @@ class UserMailer < Devise::Mailer
@appeal = appeal
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at))
mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at), title: Setting.site_title)
end
end
@ -189,7 +188,7 @@ class UserMailer < Devise::Mailer
@appeal = appeal
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at))
mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at), title: Setting.site_title)
end
end
@ -202,7 +201,7 @@ class UserMailer < Devise::Mailer
@timestamp = timestamp.to_time.utc
I18n.with_locale(locale) do
mail to: @resource.email, subject: I18n.t('user_mailer.suspicious_sign_in.subject')
mail to: @resource.email, subject: I18n.t('user_mailer.suspicious_sign_in.subject', title: Setting.site_title)
end
end

View File

@ -134,4 +134,17 @@ module HasUserSettings
def hide_all_media?
settings['web.display_media'] == 'hide_all'
end
## --- Hometown-specific settings from here on ---
def setting_expand_usernames
settings['web.expand_usernames']
end
def setting_default_federation
settings['default_federation']
end
def setting_norss
settings['norss']
end
end

View File

@ -57,6 +57,7 @@ class Report < ApplicationRecord
spam: 1_000,
legal: 1_500,
violation: 2_000,
dislike: 3_000,
}
before_validation :set_uri, only: :create

View File

@ -16,6 +16,11 @@ class UserSettings
setting :default_sensitive, default: false
setting :default_privacy, default: nil, in: %w(public unlisted private)
# Hometown-specific: Opt-out of RSS feeds for public posts
setting :norss, default: false
# Hometown: New posts should federate by default
setting :default_federation, default: true
setting_inverse_alias :indexable, :noindex
namespace :web do
@ -32,6 +37,9 @@ class UserSettings
setting :expand_content_warnings, default: false
setting :display_media, default: 'default', in: %w(default show_all hide_all)
setting :auto_play, default: false
# Hometown: Show full username (including domain) for remote users
setting :expand_usernames, default: true
end
namespace :notification_emails do

View File

@ -88,10 +88,6 @@ class Webhook < ApplicationRecord
end
end
def validate_permissions
errors.add(:events, :invalid_permissions) if defined?(@current_account) && required_permissions.any? { |permission| !@current_account.user_role.can?(permission) }
end
def strip_events
self.events = events.filter_map { |str| str.strip.presence } if events.present?
end

View File

@ -1,119 +1,7 @@
- content_for :page_title do
= site_hostname
= t('about.title')
- content_for :header_tags do
= javascript_pack_tag 'public', crossorigin: 'anonymous'
= render partial: 'shared/og'
.grid-4
.column-0
.public-account-header.public-account-header--no-bar
.public-account-header__image
%div{:class => ("originalheader")}
%h1
= link_to root_url, class: 'brand' do
= site_title
.box-widget
%p <a href="/auth/sign_in" id="login" class="btn button button-primary">Sign in</a>
- if !closed_registrations?
%p <a href="/auth/sign_up" id="register" class="btn button button-primary">Create account</a>
- if closed_registrations? && @instance_presenter.closed_registrations_message.present?
.closed
.rich-formatting
%h3= 'Registrations closed.'
%p= @instance_presenter.closed_registrations_message.html_safe
- if closed_registrations? && !@instance_presenter.closed_registrations_message.present?
%hr
.closed
.rich-formatting
%h3= 'Registrations closed.'
%p= 'This server is closed to registrations.'
.column-1
.landing-page__call-to-action{ dir: 'ltr' }
.row
.row__information-board
.information-board__section
%span= t 'about.user_count_before'
%strong= friendly_number_to_human @instance_presenter.user_count
%span= t 'about.user_count_after', count: @instance_presenter.user_count
.information-board__section
%span= t 'about.status_count_before'
%strong= friendly_number_to_human @instance_presenter.status_count
%span= t 'about.status_count_after', count: @instance_presenter.status_count
.row__mascot
- if @instance_presenter.mascot&.file&.url
.landing-page__mascot
= image_tag @instance_presenter.mascot&.file&.url
- else
.landing-page__mascot{:class => ("originalmascot")}
%div{:class => ("originalmascotimg")}
= logo_as_symbol
.column-2
.contact-widget
%h4= t 'about.administered_by'
= account_link_to(@instance_presenter.contact.account)
- if @instance_presenter.contact.email.present?
%h4
= succeed ':' do
= t 'about.contact'
= mail_to @instance_presenter.contact.email, nil, title: @instance_presenter.contact.email
.column-3
= render 'application/flashes'
- if @contents.blank? && @rules.empty? && (!display_blocks? || @blocks&.empty?)
= nothing_here
- else
.box-widget
.rich-formatting
- unless @rules.empty?
%h2#rules= t('about.rules')
%p= t('about.rules_html')
%ol.rules-list
- @rules.each do |rule|
%li
.rules-list__text= rule.text
= @contents.html_safe
- if display_blocks? && !@blocks.empty?
%h2#unavailable-content= t('about.unavailable_content')
%p= t('about.unavailable_content_html')
- if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.rejecting_media_title')
%p= t('about.unavailable_content_description.rejecting_media')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }
- if (blocks = @blocks.select(&:silence?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.silenced_title')
%p= t('about.unavailable_content_description.silenced')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }
- if (blocks = @blocks.select(&:suspend?)) && !blocks.empty?
%h3= t('about.unavailable_content_description.suspended_title')
%p= t('about.unavailable_content_description.suspended')
= render partial: 'domain_blocks', locals: { domain_blocks: blocks }
.column-4
%ul.table-of-contents
- unless @rules.empty?
%li= link_to t('about.rules'), '#rules'
- @table_of_contents.each do |item|
%li
= link_to item.title, "##{item.anchor}"
- unless item.children.empty?
%ul
- item.children.each do |sub_item|
%li= link_to sub_item.title, "##{sub_item.anchor}"
- if display_blocks? && !@blocks.empty?
%li= link_to t('about.unavailable_content'), '#unavailable-content'
= render partial: 'shared/web_app'

View File

@ -5,7 +5,7 @@
- if controller_name != 'sessions'
%li= link_to_login t('auth.login')
- if controller_name != 'registrations' && !whitelist_mode?
- if controller_name != 'registrations'
%li= link_to t('auth.register'), available_sign_up_path
- if controller_name != 'passwords' && controller_name != 'registrations'

View File

@ -11,12 +11,6 @@
.fields-group
= ff.input :noindex, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_noindex'), hint: I18n.t('simple_form.hints.defaults.setting_noindex')
.fields-group
= ff.input :norss, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_norss'), hint: I18n.t('simple_form.hints.defaults.setting_norss')
.fields-group
= ff.input :noindex, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_noindex'), hint: I18n.t('simple_form.hints.defaults.setting_noindex')
.fields-group
= ff.input :aggregate_reblogs, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_aggregate_reblogs'), hint: I18n.t('simple_form.hints.defaults.setting_aggregate_reblogs')
@ -35,9 +29,6 @@
.fields-group
= ff.input :default_federation, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_federation'), hint: I18n.t('simple_form.hints.defaults.setting_default_federation')
.fields-group
= ff.input :show_application, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_show_application'), hint: I18n.t('simple_form.hints.defaults.setting_show_application')
%h4= t 'preferences.public_timelines'
.fields-group

View File

@ -20,6 +20,10 @@
.fields-group
= f.input :unlocked, as: :boolean, wrapper: :with_label
= f.simple_fields_for :settings, current_user.settings do |ff|
.fields-group
= ff.input :norss, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_norss')
%h4= t('privacy.search')
%p.lead= t('privacy.search_hint_html')

View File

@ -44,6 +44,11 @@ class MoveUserSettings < ActiveRecord::Migration[6.1]
must_be_following: 'interactions.must_be_following',
must_be_following_dm: 'interactions.must_be_following_dm',
}.freeze,
# Hometown-specific fields
norss: 'norss',
default_federation: 'default_federation',
expand_usernames: 'web.expand_usernames',
}.freeze
class LegacySetting < ApplicationRecord

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class MigrateHometownExclusiveListsToMastodon < ActiveRecord::Migration[7.0]
def up
List.where(is_exclusive: true).in_batches.update_all(exclusive: :is_exclusive) # rubocop:disable Rails/SkipsModelValidations
safety_assured { remove_column :lists, :is_exclusive }
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
ActiveRecord::Schema[7.0].define(version: 2023_11_23_195926) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -116,6 +116,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
t.integer "min_reblogs"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "keep_local", default: false
t.index ["account_id"], name: "index_account_statuses_cleanup_policies_on_account_id"
end
@ -977,7 +978,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
t.bigint "account_id", null: false
t.bigint "application_id"
t.bigint "in_reply_to_account_id"
t.boolean "local_only"
t.bigint "poll_id"
t.string "activity_pub_type"
t.datetime "deleted_at", precision: nil
t.datetime "edited_at", precision: nil
t.boolean "trendable"

View File

@ -47,6 +47,10 @@ module Paperclip
maximum_bitrate = (size_limit_in_bits / duration).floor - 192_000 # Leave some space for the audio stream
bitrate = [desired_bitrate, maximum_bitrate].min
@output_options['b:v'] = bitrate
@output_options['maxrate'] = bitrate + 192_000
@output_options['bufsize'] = bitrate * 5
if high_vfr?(metadata)
@output_options['vsync'] = 'vfr'
@output_options['r'] = @vfr_threshold

View File

@ -31,6 +31,46 @@ RSpec.describe ActivityPub::Activity::Create do
subject.perform
end
context 'when object publication date is below ISO8601 range' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
published: '-0977-11-03T08:31:22Z',
}
end
it 'creates status with a valid creation date', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
expect(status.created_at).to be_within(30).of(Time.now.utc)
end
end
context 'when object publication date is above ISO8601 range' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
published: '10000-11-03T08:31:22Z',
}
end
it 'creates status with a valid creation date', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
expect(status.created_at).to be_within(30).of(Time.now.utc)
end
end
context 'when object has been edited' do
let(:object_json) do
{
@ -42,18 +82,16 @@ RSpec.describe ActivityPub::Activity::Create do
}
end
it 'creates status' do
it 'creates status with appropriate creation and edition dates', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
it 'marks status as edited' do
status = sender.statuses.first
expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime
expect(status).to_not be_nil
expect(status.edited?).to be true
expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime
end
end