Compare commits
37 Commits
cea84abb03
...
efc3ec1e4a
Author | SHA1 | Date |
---|---|---|
nachtjasmin | efc3ec1e4a | |
nachtjasmin | 458a9a6f24 | |
nachtjasmin | aa986fb619 | |
nachtjasmin | 17a4311617 | |
nachtjasmin | 8925306af0 | |
nachtjasmin | d0014788f6 | |
nachtjasmin | 7fa01d2eb8 | |
nachtjasmin | 7a71a11082 | |
nachtjasmin | a392a76660 | |
nachtjasmin | 27a1a044f7 | |
nachtjasmin | 8e906d0ea8 | |
nachtjasmin | e14ae645d6 | |
nachtjasmin | 47d203d8a2 | |
Claire | 4b8fe9df73 | |
Claire | 7b9496322f | |
Claire | 09115731d6 | |
Claire | e11100d782 | |
Jonathan de Jong | 252ea2fc67 | |
Claire | 8d02e58ff4 | |
Claire | 1076a6cd62 | |
Claire | 54a07731d1 | |
Claire | 81d7cfd544 | |
Claire | e6f4c91c5c | |
Claire | de86e822f4 | |
Claire | 4c38706474 | |
Renaud Chaput | 4fc2523546 | |
Renaud Chaput | d5bc10b711 | |
Claire | c66ade7de8 | |
Claire | bece853e3c | |
Claire | 700ae1f918 | |
Claire | 13205b54fd | |
KMY(雪あすか) | 8be33d4316 | |
Claire | cdedae6d63 | |
Claire | aa69ca74ed | |
gunchleoc | 156d32689b | |
Claire | ef149674f0 | |
Claire | eea2654236 |
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -2,6 +2,33 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.2.2] - 2023-12-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055))
|
||||
- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927))
|
||||
- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586))
|
||||
- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476))
|
||||
- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889))
|
||||
- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890))
|
||||
- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081))
|
||||
- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653))
|
||||
- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620))
|
||||
- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569))
|
||||
- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554))
|
||||
- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474))
|
||||
- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459))
|
||||
- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442))
|
||||
- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423))
|
||||
- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391))
|
||||
- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584))
|
||||
- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634))
|
||||
|
||||
## [4.2.1] - 2023-10-10
|
||||
|
||||
### Added
|
||||
|
|
|
@ -377,7 +377,7 @@ GEM
|
|||
ipaddress (0.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
json-canonicalization (0.3.2)
|
||||
json-canonicalization (0.3.3)
|
||||
json-jwt (1.15.3)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
|
|
|
@ -17,6 +17,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
| ------- | ---------------- |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Yes |
|
||||
| 4.0.x | Until 2023-10-31 |
|
||||
| 4.0.x | No |
|
||||
| 3.5.x | Until 2023-12-31 |
|
||||
| < 3.5 | No |
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AccountsIndex < Chewy::Index
|
||||
include DatetimeClampingConcern
|
||||
|
||||
settings index: index_preset(refresh_interval: '30s'), analysis: {
|
||||
filter: {
|
||||
english_stop: {
|
||||
|
@ -60,7 +62,7 @@ class AccountsIndex < Chewy::Index
|
|||
field(:following_count, type: 'long')
|
||||
field(:followers_count, type: 'long')
|
||||
field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties })
|
||||
field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at })
|
||||
field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) })
|
||||
field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
|
||||
field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
|
||||
field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DatetimeClampingConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze
|
||||
MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze
|
||||
|
||||
class_methods do
|
||||
def clamp_date(datetime)
|
||||
datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PublicStatusesIndex < Chewy::Index
|
||||
include DatetimeClampingConcern
|
||||
|
||||
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
|
||||
filter: {
|
||||
english_stop: {
|
||||
|
@ -62,6 +64,6 @@ class PublicStatusesIndex < Chewy::Index
|
|||
field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) })
|
||||
field(:language, type: 'keyword')
|
||||
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
|
||||
field(:created_at, type: 'date')
|
||||
field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class StatusesIndex < Chewy::Index
|
||||
include DatetimeClampingConcern
|
||||
|
||||
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
|
||||
filter: {
|
||||
english_stop: {
|
||||
|
@ -60,6 +62,6 @@ class StatusesIndex < Chewy::Index
|
|||
field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
|
||||
field(:language, type: 'keyword')
|
||||
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
|
||||
field(:created_at, type: 'date')
|
||||
field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TagsIndex < Chewy::Index
|
||||
include DatetimeClampingConcern
|
||||
|
||||
settings index: index_preset(refresh_interval: '30s'), analysis: {
|
||||
analyzer: {
|
||||
content: {
|
||||
|
@ -42,6 +44,6 @@ class TagsIndex < Chewy::Index
|
|||
field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') }
|
||||
field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? })
|
||||
field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts })
|
||||
field(:last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at })
|
||||
field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,7 +21,7 @@ module Admin
|
|||
account_action.save!
|
||||
|
||||
if account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
end
|
||||
|
|
|
@ -254,6 +254,7 @@ module LanguagesHelper
|
|||
|
||||
def valid_locale_or_nil(str)
|
||||
return if str.blank?
|
||||
return str if valid_locale?(str)
|
||||
|
||||
code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP
|
||||
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call,
|
||||
@typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-assignment,
|
||||
@typescript-eslint/no-unsafe-member-access
|
||||
-- the settings store is not yet typed */
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { bannerSettings } from 'mastodon/settings';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
|
@ -19,13 +26,25 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
|||
id,
|
||||
children,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(!bannerSettings.get(id));
|
||||
const dismissed = useAppSelector((state) =>
|
||||
state.settings.getIn(['dismissed_banners', id], false),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
|
||||
const intl = useIntl();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
bannerSettings.set(id, true);
|
||||
}, [id]);
|
||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible && !dismissed) {
|
||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||
}
|
||||
}, [id, dispatch, visible, dismissed]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
|
|
|
@ -419,7 +419,6 @@ class Header extends ImmutablePureComponent {
|
|||
|
||||
<div className='account__header__tabs__name'>
|
||||
<h1>
|
||||
<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}
|
||||
|
|
|
@ -53,24 +53,30 @@ class NavigationPanel extends Component {
|
|||
const { intl } = this.props;
|
||||
const { signedIn, disabledAccountId } = this.context.identity;
|
||||
|
||||
let banner = undefined;
|
||||
|
||||
if(transientSingleColumn)
|
||||
banner = (<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>);
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
|
||||
|
||||
{transientSingleColumn ? (
|
||||
<div class='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}
|
||||
{" "}
|
||||
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<hr />
|
||||
)}
|
||||
{!banner && <hr />}
|
||||
</div>
|
||||
|
||||
{banner &&
|
||||
<div class='navigation-panel__banner'>
|
||||
{banner}
|
||||
</div>
|
||||
}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} />
|
||||
|
|
|
@ -100,6 +100,15 @@ const initialState = ImmutableMap({
|
|||
body: '',
|
||||
}),
|
||||
}),
|
||||
|
||||
dismissed_banners: ImmutableMap({
|
||||
'public_timeline': false,
|
||||
'community_timeline': false,
|
||||
'home.explore_prompt': false,
|
||||
'explore/links': false,
|
||||
'explore/statuses': false,
|
||||
'explore/tags': false,
|
||||
}),
|
||||
});
|
||||
|
||||
const defaultColumns = fromJS([
|
||||
|
|
|
@ -25,3 +25,5 @@
|
|||
@import 'mastodon/rtl';
|
||||
@import 'mastodon/accessibility';
|
||||
@import 'mastodon/rich_text';
|
||||
|
||||
@import 'hometown';
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
// app/javascript/mastodon/features/account_timeline/components/header.jsx
|
||||
// Because we provide a link to the profile instead of a plain <span>, we override
|
||||
// the color to ensure it doesn't use the accent color.
|
||||
.account__header__tabs__name a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// app/javascript/mastodon/features/ui/components/navigation_panel.jsx
|
||||
// We hide the logo from the sidebar. It's kept in the JSX, to keep the modifications of the React
|
||||
// codebase as small as possible.
|
||||
.navigation-panel__logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// app/views/layouts/admin.html.haml
|
||||
// The logo is set to a size of 100x100px. We don't have a funny/beautiful icon for hometown in the
|
||||
// admin sidebar, therefore we unset the dimensions [1], center the link [2] and unset the color for it [3].
|
||||
.admin-wrapper .sidebar .logo {
|
||||
// 1
|
||||
width: unset;
|
||||
height: unset;
|
||||
|
||||
// 2
|
||||
h2 {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
// 3
|
||||
.brand {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// app/views/layouts/auth.html.haml
|
||||
// Increase the default font size for the page title on the login screen.
|
||||
.logo-container h1 a {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
// app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx
|
||||
// Hide the "this is your home base" banner.
|
||||
.dismissable-banner:has([href='/explore']) {
|
||||
display: none;
|
||||
}
|
|
@ -150,12 +150,6 @@ body {
|
|||
height: auto;
|
||||
margin-top: -120px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
a.brand {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
|
|
@ -2239,8 +2239,7 @@ $ui-header-height: 55px;
|
|||
|
||||
> .scrollable {
|
||||
background: $ui-base-color;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2466,6 +2465,7 @@ $ui-header-height: 55px;
|
|||
|
||||
.navigation-panel__sign-in-banner,
|
||||
.navigation-panel__logo,
|
||||
.navigation-panel__banner,
|
||||
.getting-started__trends {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -104,780 +104,3 @@
|
|||
margin-inline-start: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-auto-columns: 25%;
|
||||
grid-auto-rows: max-content;
|
||||
|
||||
.column-0 {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-2 {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-3 {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
grid-gap: 0;
|
||||
grid-template-columns: minmax(0, 100%);
|
||||
|
||||
.column-0 {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.column-2 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-3 {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-columns: 25%;
|
||||
grid-auto-rows: max-content;
|
||||
|
||||
.column-0 {
|
||||
grid-column: 1 / 5;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-2 {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-3 {
|
||||
grid-column: 2 / 5;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.column-4 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.landing-page__call-to-action {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 738px) {
|
||||
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
|
||||
|
||||
.landing-page__call-to-action {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.row__information-board {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row__mascot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
grid-gap: 0;
|
||||
grid-template-columns: minmax(0, 100%);
|
||||
|
||||
.column-0 {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.column-2 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.column-3 {
|
||||
grid-column: 1;
|
||||
grid-row: 5;
|
||||
}
|
||||
|
||||
.column-4 {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-layout {
|
||||
.originalheader {
|
||||
padding: 50px 30px 30px;
|
||||
|
||||
.logo--icon {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
|
||||
a {
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
#register {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closed {
|
||||
margin-top: 10px;
|
||||
border-radius: 4px;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
border-radius: 4px;
|
||||
height: 48px;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
& > div {
|
||||
flex: 1 1 33.3%;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: block;
|
||||
padding: 15px;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
height: 18px;
|
||||
width: auto;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
fill: $primary-text-color;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: lighten($ui-base-color, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: $darker-text-color;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
@media screen and (width <= 550px) {
|
||||
&.optional {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: lighten($ui-base-color, 16%);
|
||||
margin: 8px;
|
||||
margin-left: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
background: lighten($ui-base-color, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$no-columns-breakpoint: 600px;
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);
|
||||
grid-auto-columns: 25%;
|
||||
grid-auto-rows: max-content;
|
||||
|
||||
.column-0 {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.column-1 {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-columns-breakpoint) {
|
||||
grid-template-columns: 100%;
|
||||
grid-gap: 0;
|
||||
|
||||
.column-1 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.public-account-header {
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
height: auto;
|
||||
|
||||
&.inactive {
|
||||
opacity: 0.5;
|
||||
|
||||
.public-account-header__image,
|
||||
.avatar {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
background-color: $secondary-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
padding: 3px 15px;
|
||||
}
|
||||
|
||||
&--no-bar {
|
||||
margin-bottom: 0;
|
||||
|
||||
.public-account-header__image,
|
||||
.public-account-header__image img {
|
||||
border-radius: 4px;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&__image::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__image,
|
||||
&__image img {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: relative;
|
||||
margin-top: -80px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
padding-left: 20px - 4px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
border: 4px solid lighten($ui-base-color, 4%);
|
||||
background: darken($ui-base-color, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
margin-top: 0;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 5px;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 7px 0;
|
||||
padding-left: 10px;
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 360px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-columns-breakpoint) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
flex: 1 1 auto;
|
||||
margin-left: 20px;
|
||||
|
||||
&__name {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
line-height: 18px * 1.5;
|
||||
color: $primary-text-color;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 1px 1px 1px $base-shadow-color;
|
||||
|
||||
small {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: $primary-text-color;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
margin-left: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&__name {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-shadow: none;
|
||||
|
||||
small {
|
||||
color: $darker-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
height: 58px;
|
||||
|
||||
.details-counters {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-columns-breakpoint) {
|
||||
.details-counters {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
min-width: 33.3%;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
color: $darker-text-color;
|
||||
padding: 10px;
|
||||
border-right: 1px solid lighten($ui-base-color, 4%);
|
||||
cursor: default;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-bottom: 4px solid $ui-primary-color;
|
||||
opacity: 0.5;
|
||||
transition: all 400ms ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
&::after {
|
||||
border-bottom: 4px solid $highlight-text-color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.inactive::after {
|
||||
border-bottom-color: $secondary-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.counter-label {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.counter-number {
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
color: $primary-text-color;
|
||||
font-family: $font-display, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__extra {
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
|
||||
.public-account-bio {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
margin: 0 -5px;
|
||||
|
||||
.account__header__fields {
|
||||
border-top: 1px solid lighten($ui-base-color, 12%);
|
||||
}
|
||||
|
||||
.roles {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__links {
|
||||
margin-top: -15px;
|
||||
font-size: 14px;
|
||||
color: $darker-text-color;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
padding: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-columns-breakpoint) {
|
||||
display: block;
|
||||
flex: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account__section-headline {
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__meta {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.public-account-bio {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
box-shadow: none;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.account__header__fields {
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
|
||||
a {
|
||||
color: $highlight-text-color;
|
||||
}
|
||||
|
||||
dl:first-child .verified {
|
||||
border-radius: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.verified a {
|
||||
color: $valid-value-color;
|
||||
}
|
||||
}
|
||||
|
||||
.account__header__content {
|
||||
padding: 20px;
|
||||
padding-bottom: 0;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
&__extra,
|
||||
.roles {
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
.roles {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.directory__list {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
|
||||
|
||||
.account-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
display: block;
|
||||
|
||||
.account-card {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 100%;
|
||||
margin: 0 -5px;
|
||||
|
||||
& > div {
|
||||
box-sizing: border-box;
|
||||
flex: 1 0 auto;
|
||||
width: 300px;
|
||||
padding: 0 5px;
|
||||
margin-bottom: 10px;
|
||||
max-width: 33.333%;
|
||||
|
||||
@media screen and (width <= 900px) {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
margin: 0;
|
||||
border-top: 1px solid lighten($ui-base-color, 8%);
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.card__bar {
|
||||
background: $ui-base-color;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,9 +89,6 @@ $media-modal-media-max-height: 80%;
|
|||
|
||||
$no-gap-breakpoint: 1175px;
|
||||
|
||||
// Hometown: different breakpoint for static pages
|
||||
$no-gap-breakpoint-static: 700px;
|
||||
|
||||
$font-sans-serif: 'mastodon-font-sans-serif' !default;
|
||||
$font-display: 'mastodon-font-display' !default;
|
||||
$font-monospace: 'mastodon-font-monospace' !default;
|
||||
|
|
|
@ -114,13 +114,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.box-widget {
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
background: $ui-base-color;
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
}
|
||||
|
||||
.placeholder-widget {
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
|
@ -130,51 +123,6 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contact-widget {
|
||||
min-height: 100%;
|
||||
font-size: 15px;
|
||||
color: $darker-text-color;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 400;
|
||||
padding: 0;
|
||||
|
||||
h4 {
|
||||
padding: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
.account {
|
||||
border-bottom: 0;
|
||||
padding: 10px 0;
|
||||
padding-top: 5px;
|
||||
|
||||
.account__wrapper {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
& > a {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moved-account-widget {
|
||||
padding: 15px;
|
||||
padding-bottom: 20px;
|
||||
|
@ -255,37 +203,6 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 60px 15px;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
|
||||
h1 {
|
||||
color: $primary-text-color;
|
||||
font-size: 36px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 15px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
margin-top: 0;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.directory {
|
||||
background: $ui-base-color;
|
||||
border-radius: 4px;
|
||||
|
@ -372,34 +289,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.avatar-stack {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.account__avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-left: -10px;
|
||||
background: darken($ui-base-color, 8%);
|
||||
border: 2px solid $ui-base-color;
|
||||
|
||||
&:nth-child(1) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-table {
|
||||
width: 100%;
|
||||
|
||||
|
@ -502,100 +391,14 @@
|
|||
|
||||
.moved-account-widget,
|
||||
.memoriam-widget,
|
||||
.box-widget,
|
||||
.contact-widget,
|
||||
.landing-page__information.contact-widget,
|
||||
.directory,
|
||||
.page-header {
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
.directory {
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
margin-bottom: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
$maximum-width: 1235px;
|
||||
$fluid-breakpoint: $maximum-width + 20px;
|
||||
|
||||
.statuses-grid {
|
||||
min-height: 600px;
|
||||
|
||||
@media screen and (width <= 640px) {
|
||||
width: 100% !important; // Masonry layout is unnecessary at this width
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: math.div(960px - 20px, 3);
|
||||
|
||||
@media screen and (max-width: $fluid-breakpoint) {
|
||||
width: math.div(940px - 20px, 3);
|
||||
}
|
||||
|
||||
@media screen and (width <= 640px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
border-radius: 4px;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint-static) {
|
||||
border-top: 1px solid lighten($ui-base-color, 16%);
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.detailed-status__meta {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.status__content {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
|
||||
.emojione {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -3px 0 0;
|
||||
}
|
||||
|
||||
.status__content__spoiler-link {
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery,
|
||||
.status-card,
|
||||
.video-player {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-widget {
|
||||
margin-bottom: 10px;
|
||||
color: $darker-text-color;
|
||||
|
||||
p {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-widget,
|
||||
.placeholder-widget {
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
@ -609,37 +412,3 @@ $fluid-breakpoint: $maximum-width + 20px;
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-of-contents {
|
||||
background: darken($ui-base-color, 4%);
|
||||
min-height: 100%;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
|
||||
li a {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
padding: 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
color: $primary-text-color;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 4%);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child a {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
li ul {
|
||||
padding-left: 20px;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 4%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,15 +129,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
conversation: conversation_from_uri(@object['conversation']),
|
||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||
poll: process_poll,
|
||||
activity_pub_type: @object['type']
|
||||
activity_pub_type: @object['type'],
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
class Handler < ::Ox::Sax
|
||||
attr_reader :srcs
|
||||
attr_reader :alts
|
||||
def initialize(block)
|
||||
attr_reader :srcs, :alts
|
||||
|
||||
def initialize(_block)
|
||||
@stack = []
|
||||
@srcs = []
|
||||
@alts = {}
|
||||
|
@ -147,7 +147,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
@stack << [element_name, {}]
|
||||
end
|
||||
|
||||
def end_element(element_name)
|
||||
def end_element(_element_name)
|
||||
self_name, self_attributes = @stack[-1]
|
||||
if self_name == :img && !self_attributes[:src].nil?
|
||||
@srcs << self_attributes[:src]
|
||||
|
@ -163,7 +163,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
end
|
||||
|
||||
def process_inline_images
|
||||
proc = Proc.new { |name| puts name }
|
||||
proc = proc { |name| puts name }
|
||||
handler = Handler.new(proc)
|
||||
Ox.sax_parse(handler, @object['content'])
|
||||
handler.srcs.each do |src|
|
||||
|
@ -436,7 +436,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
|
||||
return article_format(@object['content']) if @object['content'].present? && @object['type'] == 'Article'
|
||||
|
||||
return @status_parser.text || ''
|
||||
@status_parser.text || ''
|
||||
end
|
||||
|
||||
def text_from_name
|
||||
|
|
|
@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
|
|||
when String
|
||||
camel_lower_cache[value] ||= if value.start_with?('_:')
|
||||
"_:#{value.delete_prefix('_:').underscore.camelize(:lower)}"
|
||||
elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym)
|
||||
value
|
||||
else
|
||||
value.underscore.camelize(:lower)
|
||||
end
|
||||
|
|
|
@ -18,8 +18,8 @@ class ActivityPub::LinkedDataSignature
|
|||
|
||||
return unless type == 'RsaSignature2017'
|
||||
|
||||
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
|
||||
creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false)
|
||||
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
|
||||
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
|
||||
|
||||
return if creator.nil?
|
||||
|
||||
|
@ -28,6 +28,8 @@ class ActivityPub::LinkedDataSignature
|
|||
to_be_verified = options_hash + document_hash
|
||||
|
||||
creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified)
|
||||
rescue OpenSSL::PKey::RSAError
|
||||
false
|
||||
end
|
||||
|
||||
def sign!(creator, sign_with: nil)
|
||||
|
|
|
@ -75,7 +75,12 @@ class AttachmentBatch
|
|||
end
|
||||
when :fog
|
||||
logger.debug { "Deleting #{attachment.path(style)}" }
|
||||
attachment.directory.files.new(key: attachment.path(style)).destroy
|
||||
|
||||
begin
|
||||
attachment.send(:directory).files.new(key: attachment.path(style)).destroy
|
||||
rescue Fog::Storage::OpenStack::NotFound
|
||||
# Ignore failure to delete a file that has already been deleted
|
||||
end
|
||||
when :azure
|
||||
logger.debug { "Deleting #{attachment.path(style)}" }
|
||||
attachment.destroy
|
||||
|
|
|
@ -192,6 +192,7 @@ class FeedManager
|
|||
# also tagged with another followed hashtag or from a followed user
|
||||
scope = from_tag.statuses
|
||||
.where(id: timeline_status_ids)
|
||||
.where.not(account: into_account)
|
||||
.where.not(account: into_account.following)
|
||||
.tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id))
|
||||
|
||||
|
|
|
@ -36,7 +36,8 @@ class LinkDetailsExtractor
|
|||
end
|
||||
|
||||
def language
|
||||
json['inLanguage']
|
||||
lang = json['inLanguage']
|
||||
lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
|
||||
end
|
||||
|
||||
def type
|
||||
|
|
|
@ -52,9 +52,13 @@ module Attachmentable
|
|||
return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
|
||||
|
||||
width, height = FastImage.size(attachment.queued_for_write[:original].path)
|
||||
matrix_limit = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT
|
||||
return unless width.present? && height.present?
|
||||
|
||||
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit)
|
||||
if attachment.content_type == 'image/gif' && width * height > GIF_MATRIX_LIMIT
|
||||
raise Mastodon::DimensionsValidationError, "#{width}x#{height} GIF files are not supported"
|
||||
elsif width * height > MAX_MATRIX_LIMIT
|
||||
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported"
|
||||
end
|
||||
end
|
||||
|
||||
def appropriate_extension(attachment)
|
||||
|
|
|
@ -35,7 +35,7 @@ class Tag < ApplicationRecord
|
|||
HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}"
|
||||
|
||||
HASHTAG_RE = %r{(?:^|[^/)\w])#(#{HASHTAG_NAME_PAT})}i
|
||||
HASHTAG_RE = %r{(?<![=/)\w])#(#{HASHTAG_NAME_PAT})}i
|
||||
HASHTAG_NAME_RE = /\A(#{HASHTAG_NAME_PAT})\z/i
|
||||
HASHTAG_INVALID_CHARS_RE = /[^[:alnum:]#{HASHTAG_SEPARATORS}]/
|
||||
|
||||
|
|
|
@ -106,7 +106,7 @@ class Trends::Statuses < Trends::Base
|
|||
private
|
||||
|
||||
def eligible?(status)
|
||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && !status.account.sensitized? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||
end
|
||||
|
||||
def calculate_scores(statuses, at_time)
|
||||
|
|
|
@ -10,7 +10,9 @@ class REST::FeaturedTagSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def url
|
||||
short_account_tag_url(object.account, object.tag)
|
||||
# The path is hardcoded because we have to deal with both local and
|
||||
# remote users, which are different routes
|
||||
account_with_domain_url(object.account, "tagged/#{object.tag.to_param}")
|
||||
end
|
||||
|
||||
def name
|
||||
|
|
|
@ -8,6 +8,7 @@ class FanOutOnWriteService < BaseService
|
|||
# @param [Hash] options
|
||||
# @option options [Boolean] update
|
||||
# @option options [Array<Integer>] silenced_account_ids
|
||||
# @option options [Boolean] skip_notifications
|
||||
def call(status, options = {})
|
||||
@status = status
|
||||
@account = status.account
|
||||
|
@ -37,8 +38,11 @@ class FanOutOnWriteService < BaseService
|
|||
|
||||
def fan_out_to_local_recipients!
|
||||
deliver_to_self!
|
||||
notify_mentioned_accounts!
|
||||
notify_about_update! if update?
|
||||
|
||||
unless @options[:skip_notifications]
|
||||
notify_mentioned_accounts!
|
||||
notify_about_update! if update?
|
||||
end
|
||||
|
||||
case @status.visibility.to_sym
|
||||
when :public, :unlisted, :private
|
||||
|
|
|
@ -71,7 +71,7 @@ class FollowService < BaseService
|
|||
if @target_account.local?
|
||||
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
|
||||
elsif @target_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, { 'bypass_availability' => true })
|
||||
end
|
||||
|
||||
follow_request
|
||||
|
|
|
@ -122,13 +122,11 @@ class PostStatusService < BaseService
|
|||
spoiler_text&.include?(':local_only')
|
||||
return true
|
||||
end
|
||||
|
||||
if local_only.nil?
|
||||
if in_reply_to && in_reply_to.local_only
|
||||
return true
|
||||
end
|
||||
if in_reply_to && !in_reply_to.local_only
|
||||
return false
|
||||
end
|
||||
return true if in_reply_to&.local_only
|
||||
return false if in_reply_to && !in_reply_to.local_only
|
||||
|
||||
return !federation_setting
|
||||
end
|
||||
local_only
|
||||
|
@ -139,9 +137,7 @@ class PostStatusService < BaseService
|
|||
Trends.tags.register(@status)
|
||||
LinkCrawlWorker.perform_async(@status.id)
|
||||
DistributionWorker.perform_async(@status.id)
|
||||
unless @status.local_only?
|
||||
ActivityPub::DistributionWorker.perform_async(@status.id)
|
||||
end
|
||||
ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.local_only?
|
||||
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
|
||||
end
|
||||
|
||||
|
|
|
@ -30,9 +30,7 @@ class ReblogService < BaseService
|
|||
|
||||
Trends.register!(reblog)
|
||||
DistributionWorker.perform_async(reblog.id)
|
||||
unless reblogged_status.local_only?
|
||||
ActivityPub::DistributionWorker.perform_async(reblog.id)
|
||||
end
|
||||
ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
|
||||
|
||||
create_notification(reblog)
|
||||
bump_potential_friendship(account, reblog)
|
||||
|
|
|
@ -23,9 +23,10 @@ class ActivityPub::DeliveryWorker
|
|||
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
|
||||
|
||||
def perform(json, source_account_id, inbox_url, options = {})
|
||||
return unless DeliveryFailureTracker.available?(inbox_url)
|
||||
|
||||
@options = options.with_indifferent_access
|
||||
|
||||
return unless @options[:bypass_availability] || DeliveryFailureTracker.available?(inbox_url)
|
||||
|
||||
@json = json
|
||||
@source_account = Account.find(source_account_id)
|
||||
@inbox_url = inbox_url
|
||||
|
|
|
@ -7,13 +7,18 @@ class ThreadResolveWorker
|
|||
sidekiq_options queue: 'pull', retry: 3
|
||||
|
||||
def perform(child_status_id, parent_url, options = {})
|
||||
child_status = Status.find(child_status_id)
|
||||
parent_status = FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
|
||||
child_status = Status.find(child_status_id)
|
||||
return if child_status.in_reply_to_id.present?
|
||||
|
||||
parent_status = ActivityPub::TagManager.instance.uri_to_resource(parent_url, Status)
|
||||
parent_status ||= FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
|
||||
|
||||
return if parent_status.nil?
|
||||
|
||||
child_status.thread = parent_status
|
||||
child_status.save!
|
||||
|
||||
DistributionWorker.perform_async(child_status_id, { 'skip_notifications' => true }) if child_status.within_realtime_window?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
|
|
@ -5,7 +5,11 @@
|
|||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
||||
|
||||
def host_to_url(str)
|
||||
"http#{Rails.configuration.x.use_https ? 's' : ''}://#{str.split('/').first}" if str.present?
|
||||
return if str.blank?
|
||||
|
||||
uri = Addressable::URI.parse("http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}")
|
||||
uri.path += '/' unless uri.path.blank? || uri.path.end_with?('/')
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
base_host = Rails.configuration.x.web_domain
|
||||
|
|
|
@ -4,7 +4,7 @@ require_relative '../../lib/mastodon/sidekiq_middleware'
|
|||
|
||||
Sidekiq.configure_server do |config|
|
||||
if Rails.configuration.database_configuration.dig('production', 'adapter') == 'postgresql_makara'
|
||||
STDERR.puts 'ERROR: Database replication is not currently supported in Sidekiq workers. Check your configuration.'
|
||||
warn 'ERROR: Database replication is not currently supported in Sidekiq workers. Check your configuration.'
|
||||
exit 1
|
||||
end
|
||||
|
||||
|
|
|
@ -3,6 +3,18 @@
|
|||
require 'sidekiq_unique_jobs/web'
|
||||
require 'sidekiq-scheduler/web'
|
||||
|
||||
class RedirectWithVary < ActionDispatch::Routing::PathRedirect
|
||||
def serve(...)
|
||||
super.tap do |_, headers, _|
|
||||
headers['Vary'] = 'Origin, Accept'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_with_vary(path)
|
||||
RedirectWithVary.new(301, path)
|
||||
end
|
||||
|
||||
Rails.application.routes.draw do
|
||||
# Paths of routes on the web app that to not require to be indexed or
|
||||
# have alternative format representations requiring separate controllers
|
||||
|
@ -90,10 +102,13 @@ Rails.application.routes.draw do
|
|||
confirmations: 'auth/confirmations',
|
||||
}
|
||||
|
||||
get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/following', to: redirect('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/followers', to: redirect('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/statuses/:id', to: redirect('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
# rubocop:disable Style/FormatStringToken - those do not go through the usual formatting functions and are not safe to correct
|
||||
get '/users/:username', to: redirect_with_vary('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/following', to: redirect_with_vary('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/followers', to: redirect_with_vary('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
get '/users/:username/statuses/:id', to: redirect_with_vary('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
|
||||
# rubocop:enable Style/FormatStringToken
|
||||
|
||||
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
|
||||
|
||||
resources :accounts, path: 'users', only: [:show], param: :username do
|
||||
|
@ -134,7 +149,7 @@ Rails.application.routes.draw do
|
|||
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
|
||||
end
|
||||
|
||||
get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, format: false
|
||||
get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, as: :account_with_domain, format: false
|
||||
get '/settings', to: redirect('/settings/profile')
|
||||
|
||||
draw(:settings)
|
||||
|
|
|
@ -50,6 +50,7 @@ namespace :admin do
|
|||
resource :about, only: [:show, :update], controller: 'about'
|
||||
resource :appearance, only: [:show, :update], controller: 'appearance'
|
||||
resource :discovery, only: [:show, :update], controller: 'discovery'
|
||||
resource :hometown, only: [:show, :update], controller: 'hometown'
|
||||
end
|
||||
|
||||
resources :site_uploads, only: [:destroy]
|
||||
|
|
|
@ -56,7 +56,7 @@ services:
|
|||
|
||||
web:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.1
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.2
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||
|
@ -77,7 +77,7 @@ services:
|
|||
|
||||
streaming:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.1
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.2
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming
|
||||
|
@ -95,7 +95,7 @@ services:
|
|||
|
||||
sidekiq:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.1
|
||||
image: ghcr.io/mastodon/mastodon:v4.2.2
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
def patch
|
||||
1
|
||||
2
|
||||
end
|
||||
|
||||
def default_prerelease
|
||||
|
|
|
@ -68,13 +68,13 @@ class Sanitize
|
|||
elements: %w(p br span a abbr del pre blockquote code b strong i em h1 h2 h3 h4 h5 ul ol li img u),
|
||||
|
||||
attributes: {
|
||||
'abbr' => %w(title),
|
||||
'abbr' => %w(title),
|
||||
'blockquote' => %w(cite),
|
||||
'img' => %w(src alt),
|
||||
'a' => %w(href rel class translate title),
|
||||
'span' => %w(class translate),
|
||||
'ol' => %w(start reversed),
|
||||
'li' => %w(value),
|
||||
'img' => %w(src alt),
|
||||
'a' => %w(href rel class translate title),
|
||||
'span' => %w(class translate),
|
||||
'ol' => %w(start reversed),
|
||||
'li' => %w(value),
|
||||
},
|
||||
|
||||
add_attributes: {
|
||||
|
@ -84,10 +84,7 @@ class Sanitize
|
|||
},
|
||||
},
|
||||
|
||||
protocols: {
|
||||
'a' => { 'href' => HTTP_PROTOCOLS },
|
||||
'blockquote' => { 'cite' => HTTP_PROTOCOLS },
|
||||
},
|
||||
protocols: {},
|
||||
|
||||
transformers: [
|
||||
CLASS_WHITELIST_TRANSFORMER,
|
||||
|
@ -101,16 +98,15 @@ class Sanitize
|
|||
elements: %w(audio embed iframe source video),
|
||||
|
||||
attributes: {
|
||||
'audio' => %w(controls),
|
||||
'embed' => %w(height src type width),
|
||||
'audio' => %w(controls),
|
||||
'embed' => %w(height src type width),
|
||||
'iframe' => %w(allowfullscreen frameborder height scrolling src width),
|
||||
'source' => %w(src type),
|
||||
'video' => %w(controls height loop width),
|
||||
'div' => [:data]
|
||||
'video' => %w(controls height loop width),
|
||||
},
|
||||
|
||||
protocols: {
|
||||
'embed' => { 'src' => HTTP_PROTOCOLS },
|
||||
'embed' => { 'src' => HTTP_PROTOCOLS },
|
||||
'iframe' => { 'src' => HTTP_PROTOCOLS },
|
||||
'source' => { 'src' => HTTP_PROTOCOLS },
|
||||
},
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Api::V1::Accounts::FeaturedTagsController do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
|
||||
let(:account) { Fabricate(:account) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns http success' do
|
||||
get :index, params: { account_id: account.id, limit: 2 }
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -23,6 +23,109 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' })
|
||||
end
|
||||
|
||||
describe 'processing posts received out of order' do
|
||||
let(:follower) { Fabricate(:account, username: 'bob') }
|
||||
|
||||
let(:object_json) do
|
||||
{
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
|
||||
type: 'Note',
|
||||
to: [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
ActivityPub::TagManager.instance.uri_for(follower),
|
||||
],
|
||||
content: '@bob lorem ipsum',
|
||||
published: 1.hour.ago.utc.iso8601,
|
||||
updated: 1.hour.ago.utc.iso8601,
|
||||
tag: {
|
||||
type: 'Mention',
|
||||
href: ActivityPub::TagManager.instance.uri_for(follower),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
let(:reply_json) do
|
||||
{
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), 'reply'].join('/'),
|
||||
type: 'Note',
|
||||
inReplyTo: object_json[:id],
|
||||
to: [
|
||||
'https://www.w3.org/ns/activitystreams#Public',
|
||||
ActivityPub::TagManager.instance.uri_for(follower),
|
||||
],
|
||||
content: '@bob lorem ipsum',
|
||||
published: Time.now.utc.iso8601,
|
||||
updated: Time.now.utc.iso8601,
|
||||
tag: {
|
||||
type: 'Mention',
|
||||
href: ActivityPub::TagManager.instance.uri_for(follower),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def activity_for_object(json)
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: [json[:id], 'activity'].join('/'),
|
||||
type: 'Create',
|
||||
actor: ActivityPub::TagManager.instance.uri_for(sender),
|
||||
object: json,
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
before do
|
||||
follower.follow!(sender)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
Sidekiq::Testing.fake! do
|
||||
example.run
|
||||
Sidekiq::Worker.clear_all
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly processes posts and inserts them in timelines', :aggregate_failures do
|
||||
# Simulate a temporary failure preventing from fetching the parent post
|
||||
stub_request(:get, object_json[:id]).to_return(status: 500)
|
||||
|
||||
# When receiving the reply…
|
||||
described_class.new(activity_for_object(reply_json), sender, delivery: true).perform
|
||||
|
||||
# NOTE: Refering explicitly to the workers is a bit awkward
|
||||
DistributionWorker.drain
|
||||
FeedInsertWorker.drain
|
||||
|
||||
# …it creates a status with an unknown parent
|
||||
reply = Status.find_by(uri: reply_json[:id])
|
||||
expect(reply.reply?).to be true
|
||||
expect(reply.in_reply_to_id).to be_nil
|
||||
|
||||
# …and creates a notification
|
||||
expect(LocalNotificationWorker.jobs.size).to eq 1
|
||||
|
||||
# …but does not insert it into timelines
|
||||
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_nil
|
||||
|
||||
# When receiving the parent…
|
||||
described_class.new(activity_for_object(object_json), sender, delivery: true).perform
|
||||
|
||||
Sidekiq::Worker.drain_all
|
||||
|
||||
# …it creates a status and insert it into timelines
|
||||
parent = Status.find_by(uri: object_json[:id])
|
||||
expect(parent.reply?).to be false
|
||||
expect(parent.in_reply_to_id).to be_nil
|
||||
expect(reply.reload.in_reply_to_id).to eq parent.id
|
||||
|
||||
# Check that the both statuses have been inserted into the home feed
|
||||
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), parent.id)).to be_within(0.1).of(parent.id.to_f)
|
||||
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_within(0.1).of(reply.id.to_f)
|
||||
|
||||
# Creates two notifications
|
||||
expect(Notification.count).to eq 2
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when fetching' do
|
||||
subject { described_class.new(json, sender) }
|
||||
|
|
|
@ -38,6 +38,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when local account record is missing a public key' do
|
||||
let(:raw_signature) do
|
||||
{
|
||||
'creator' => 'http://example.com/alice',
|
||||
'created' => '2017-09-23T20:21:34Z',
|
||||
}
|
||||
end
|
||||
|
||||
let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) }
|
||||
|
||||
let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) }
|
||||
|
||||
before do
|
||||
# Ensure signature is computed with the old key
|
||||
signature
|
||||
|
||||
# Unset key
|
||||
old_key = sender.public_key
|
||||
sender.update!(private_key: '', public_key: '')
|
||||
|
||||
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
|
||||
|
||||
allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
|
||||
sender.update!(public_key: old_key)
|
||||
sender
|
||||
end
|
||||
end
|
||||
|
||||
it 'fetches key and returns creator' do
|
||||
expect(subject.verify_actor!).to eq sender
|
||||
expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signature is missing' do
|
||||
let(:signature) { nil }
|
||||
|
||||
|
|
|
@ -525,6 +525,44 @@ RSpec.describe FeedManager do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#unmerge_tag_from_home' do
|
||||
let(:receiver) { Fabricate(:account) }
|
||||
let(:tag) { Fabricate(:tag) }
|
||||
|
||||
it 'leaves a tagged status' do
|
||||
status = Fabricate(:status)
|
||||
status.tags << tag
|
||||
described_class.instance.push_to_home(receiver, status)
|
||||
|
||||
described_class.instance.unmerge_tag_from_home(tag, receiver)
|
||||
|
||||
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to_not include(status.id.to_s)
|
||||
end
|
||||
|
||||
it 'remains a tagged status written by receiver\'s followee' do
|
||||
followee = Fabricate(:account)
|
||||
receiver.follow!(followee)
|
||||
|
||||
status = Fabricate(:status, account: followee)
|
||||
status.tags << tag
|
||||
described_class.instance.push_to_home(receiver, status)
|
||||
|
||||
described_class.instance.unmerge_tag_from_home(tag, receiver)
|
||||
|
||||
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s)
|
||||
end
|
||||
|
||||
it 'remains a tagged status written by receiver' do
|
||||
status = Fabricate(:status, account: receiver)
|
||||
status.tags << tag
|
||||
described_class.instance.push_to_home(receiver, status)
|
||||
|
||||
described_class.instance.unmerge_tag_from_home(tag, receiver)
|
||||
|
||||
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#clear_from_home' do
|
||||
let(:account) { Fabricate(:account) }
|
||||
let(:followed_account) { Fabricate(:account) }
|
||||
|
|
|
@ -82,6 +82,10 @@ RSpec.describe LinkDetailsExtractor do
|
|||
'name' => 'Pet News',
|
||||
'url' => 'https://example.com',
|
||||
},
|
||||
'inLanguage' => {
|
||||
name: 'English',
|
||||
alternateName: 'en',
|
||||
},
|
||||
}.to_json
|
||||
end
|
||||
|
||||
|
@ -115,6 +119,12 @@ RSpec.describe LinkDetailsExtractor do
|
|||
expect(subject.provider_name).to eq 'Pet News'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#language' do
|
||||
it 'returns the language from structured data' do
|
||||
expect(subject.language).to eq 'en'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when is wrapped in CDATA tags' do
|
||||
|
|
|
@ -6,10 +6,6 @@ describe Sanitize::Config do
|
|||
describe '::MASTODON_STRICT' do
|
||||
subject { Sanitize::Config::MASTODON_STRICT }
|
||||
|
||||
it 'converts h1 to p strong' do
|
||||
expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p><strong>Foo</strong></p>'
|
||||
end
|
||||
|
||||
it 'keeps ul' do
|
||||
expect(Sanitize.fragment('<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>'
|
||||
end
|
||||
|
|
|
@ -60,9 +60,9 @@ describe TagFeed, type: :service do
|
|||
end
|
||||
|
||||
it 'excludes local-only posts when specified' do
|
||||
status1.update(local_only: true)
|
||||
results = described_class.new(tag1, nil, any: [tag2.name], without_local_only: true).get(20)
|
||||
expect(results).to_not include status1
|
||||
status_tagged_with_cats.update(local_only: true)
|
||||
results = described_class.new(tag_cats, nil, any: [tag_dogs.name], without_local_only: true).get(20)
|
||||
expect(results).to_not include status_tagged_with_cats
|
||||
end
|
||||
|
||||
it 'allows replies to be included' do
|
||||
|
|
|
@ -32,44 +32,52 @@ RSpec.describe Tag do
|
|||
expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil
|
||||
end
|
||||
|
||||
it 'does not match URLs with hashtag-like anchors after a numeral' do
|
||||
expect(subject.match('https://gcc.gnu.org/bugzilla/show_bug.cgi?id=111895#c4')).to be_nil
|
||||
end
|
||||
|
||||
it 'does not match URLs with hashtag-like anchors after an empty query parameter' do
|
||||
expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)?foo=#Lawsuit')).to be_nil
|
||||
end
|
||||
|
||||
it 'matches #aesthetic' do
|
||||
expect(subject.match('this is #aesthetic').to_s).to eq ' #aesthetic'
|
||||
expect(subject.match('this is #aesthetic').to_s).to eq '#aesthetic'
|
||||
end
|
||||
|
||||
it 'matches digits at the start' do
|
||||
expect(subject.match('hello #3d').to_s).to eq ' #3d'
|
||||
expect(subject.match('hello #3d').to_s).to eq '#3d'
|
||||
end
|
||||
|
||||
it 'matches digits in the middle' do
|
||||
expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k'
|
||||
expect(subject.match('hello #l33ts35k').to_s).to eq '#l33ts35k'
|
||||
end
|
||||
|
||||
it 'matches digits at the end' do
|
||||
expect(subject.match('hello #world2016').to_s).to eq ' #world2016'
|
||||
expect(subject.match('hello #world2016').to_s).to eq '#world2016'
|
||||
end
|
||||
|
||||
it 'matches underscores at the beginning' do
|
||||
expect(subject.match('hello #_test').to_s).to eq ' #_test'
|
||||
expect(subject.match('hello #_test').to_s).to eq '#_test'
|
||||
end
|
||||
|
||||
it 'matches underscores at the end' do
|
||||
expect(subject.match('hello #test_').to_s).to eq ' #test_'
|
||||
expect(subject.match('hello #test_').to_s).to eq '#test_'
|
||||
end
|
||||
|
||||
it 'matches underscores in the middle' do
|
||||
expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three'
|
||||
expect(subject.match('hello #one_two_three').to_s).to eq '#one_two_three'
|
||||
end
|
||||
|
||||
it 'matches middle dots' do
|
||||
expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three'
|
||||
expect(subject.match('hello #one·two·three').to_s).to eq '#one·two·three'
|
||||
end
|
||||
|
||||
it 'matches ・unicode in ぼっち・ざ・ろっく correctly' do
|
||||
expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq ' #ぼっち・ざ・ろっく'
|
||||
expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq '#ぼっち・ざ・ろっく'
|
||||
end
|
||||
|
||||
it 'matches ZWNJ' do
|
||||
expect(subject.match('just add #نرمافزار and').to_s).to eq ' #نرمافزار'
|
||||
expect(subject.match('just add #نرمافزار and').to_s).to eq '#نرمافزار'
|
||||
end
|
||||
|
||||
it 'does not match middle dots at the start' do
|
||||
|
@ -77,7 +85,7 @@ RSpec.describe Tag do
|
|||
end
|
||||
|
||||
it 'does not match middle dots at the end' do
|
||||
expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three'
|
||||
expect(subject.match('hello #one·two·three·').to_s).to eq '#one·two·three'
|
||||
end
|
||||
|
||||
it 'does not match purely-numeric hashtags' do
|
||||
|
|
|
@ -84,18 +84,18 @@ RSpec.describe StatusPolicy, type: :model do
|
|||
|
||||
expect(subject).to_not permit(viewer, status)
|
||||
end
|
||||
end
|
||||
|
||||
it 'denies access when local-only and the viewer is not logged in' do
|
||||
allow(status).to receive(:local_only?) { true }
|
||||
it 'denies access when local-only and the viewer is not logged in' do
|
||||
allow(status).to receive(:local_only?).and_return(true)
|
||||
|
||||
expect(subject).to_not permit(nil, status)
|
||||
end
|
||||
expect(subject).to_not permit(nil, status)
|
||||
end
|
||||
|
||||
it 'denies access when local-only and the viewer is from another domain' do
|
||||
viewer = Fabricate(:account, domain: 'remote-domain')
|
||||
allow(status).to receive(:local_only?) { true }
|
||||
expect(subject).to_not permit(viewer, status)
|
||||
it 'denies access when local-only and the viewer is from another domain' do
|
||||
viewer = Fabricate(:account, domain: 'remote-domain')
|
||||
allow(status).to receive(:local_only?).and_return(true)
|
||||
expect(subject).to_not permit(viewer, status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'account featured tags API' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||
let(:scopes) { 'read:accounts' }
|
||||
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
|
||||
let(:account) { Fabricate(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/:id/featured_tags' do
|
||||
subject do
|
||||
get "/api/v1/accounts/#{account.id}/featured_tags", headers: headers
|
||||
end
|
||||
|
||||
before do
|
||||
account.featured_tags.create!(name: 'foo')
|
||||
account.featured_tags.create!(name: 'bar')
|
||||
end
|
||||
|
||||
it 'returns the expected tags', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to contain_exactly(a_hash_including({
|
||||
name: 'bar',
|
||||
url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/bar",
|
||||
}), a_hash_including({
|
||||
name: 'foo',
|
||||
url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/foo",
|
||||
}))
|
||||
end
|
||||
|
||||
context 'when the account is remote' do
|
||||
it 'returns the expected tags', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to contain_exactly(a_hash_including({
|
||||
name: 'bar',
|
||||
url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/bar",
|
||||
}), a_hash_including({
|
||||
name: 'foo',
|
||||
url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/foo",
|
||||
}))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -124,7 +124,7 @@ describe 'Caching behavior' do
|
|||
expect(response.cookies).to be_empty
|
||||
end
|
||||
|
||||
it 'sets public cache control' do
|
||||
it 'sets public cache control', :aggregate_failures do
|
||||
# expect(response.cache_control[:max_age]&.to_i).to be_positive
|
||||
expect(response.cache_control[:public]).to be_truthy
|
||||
expect(response.cache_control[:private]).to be_falsy
|
||||
|
@ -141,11 +141,8 @@ describe 'Caching behavior' do
|
|||
end
|
||||
|
||||
shared_examples 'non-cacheable error' do
|
||||
it 'does not return HTTP success' do
|
||||
it 'does not return HTTP success and does not have cache headers', :aggregate_failures do
|
||||
expect(response).to_not have_http_status(200)
|
||||
end
|
||||
|
||||
it 'does not have cache headers' do
|
||||
expect(response.cache_control[:public]).to be_falsy
|
||||
end
|
||||
end
|
||||
|
@ -182,6 +179,15 @@ describe 'Caching behavior' do
|
|||
end
|
||||
|
||||
context 'when anonymously accessed' do
|
||||
describe '/users/alice' do
|
||||
it 'redirects with proper cache header', :aggregate_failures do
|
||||
get '/users/alice'
|
||||
|
||||
expect(response).to redirect_to('/@alice')
|
||||
expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept')
|
||||
end
|
||||
end
|
||||
|
||||
TestEndpoints::ALWAYS_CACHED.each do |endpoint|
|
||||
describe endpoint do
|
||||
before { get endpoint }
|
||||
|
|
|
@ -7,7 +7,7 @@ describe ActivityPub::NoteSerializer do
|
|||
|
||||
let!(:account) { Fabricate(:account) }
|
||||
let!(:other) { Fabricate(:account) }
|
||||
let!(:parent) { Fabricate(:status, account: account, visibility: :public) }
|
||||
let!(:parent) { Fabricate(:status, account: account, visibility: :public, language: 'zh-TW') }
|
||||
let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
|
||||
let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
|
||||
let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) }
|
||||
|
@ -18,8 +18,15 @@ describe ActivityPub::NoteSerializer do
|
|||
@serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
|
||||
it 'has a Note type' do
|
||||
expect(subject['type']).to eql('Note')
|
||||
it 'has the expected shape' do
|
||||
expect(subject).to include({
|
||||
'@context' => include('https://www.w3.org/ns/activitystreams'),
|
||||
'type' => 'Note',
|
||||
'attributedTo' => ActivityPub::TagManager.instance.uri_for(account),
|
||||
'contentMap' => include({
|
||||
'zh-TW' => a_kind_of(String),
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
it 'has a replies collection' do
|
||||
|
|
Loading…
Reference in New Issue