Merge pull request #315 from kmycode/upstream-20231129

Upstream 20231129
This commit is contained in:
KMY(雪あすか) 2023-11-29 13:38:12 +09:00 committed by GitHub
commit a52a8ce214
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 851 additions and 428 deletions

View file

@ -18,8 +18,6 @@ class AccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in?
@rss_url = rss_url
end
format.rss do
@ -86,29 +84,21 @@ class AccountsController < ApplicationController
short_account_url(@account, format: 'rss')
end
end
helper_method :rss_url
def media_requested?
request.path.split('.').first.end_with?('/media') && !tag_requested?
path_without_format.end_with?('/media') && !tag_requested?
end
def replies_requested?
request.path.split('.').first.end_with?('/with_replies') && !tag_requested?
path_without_format.end_with?('/with_replies') && !tag_requested?
end
def tag_requested?
request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
path_without_format.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def cached_filtered_status_page
cache_collection_paginated_by_id(
filtered_statuses,
Status,
PAGE_SIZE,
params_slice(:max_id, :min_id, :since_id)
)
end
def params_slice(*keys)
params.slice(*keys).permit(*keys)
def path_without_format
request.path.split('.').first
end
end

View file

@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import Hashtag from 'mastodon/components/hashtag';
import { Hashtag } from 'mastodon/components/hashtag';
export default class Trends extends PureComponent {

View file

@ -1,120 +0,0 @@
// @ts-check
import PropTypes from 'prop-types';
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 { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
class SilentErrorBoundary extends Component {
static propTypes = {
children: PropTypes.node,
};
state = {
error: false,
};
componentDidCatch() {
this.setState({ error: true });
}
render() {
if (this.state.error) {
return null;
}
return this.props.children;
}
}
/**
* Used to render counter of how much people are talking about hashtag
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
*/
export const accountsCountRenderer = (displayNumber, pluralReady) => (
<FormattedMessage
id='trends.counter_by_accounts'
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
days: 2,
}}
/>
);
// @ts-expect-error
export const ImmutableHashtag = ({ hashtag }) => (
<Hashtag
name={hashtag.get('name')}
to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
// @ts-expect-error
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
);
ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
// @ts-expect-error
const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Link to={to}>
{name ? <>#<span>{name}</span></> : <Skeleton width={50} />}
</Link>
{description ? (
<span>{description}</span>
) : (
typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />
)}
</div>
{typeof uses !== 'undefined' && (
<div className='trends__item__current'>
<ShortNumber value={uses} />
</div>
)}
{withGraph && (
<div className='trends__item__sparkline'>
<SilentErrorBoundary>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</SilentErrorBoundary>
</div>
)}
</div>
);
Hashtag.propTypes = {
name: PropTypes.string,
to: PropTypes.string,
people: PropTypes.number,
description: PropTypes.node,
uses: PropTypes.number,
history: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string,
withGraph: PropTypes.bool,
};
Hashtag.defaultProps = {
withGraph: true,
};
export default Hashtag;

View file

@ -0,0 +1,145 @@
import type { JSX } from 'react';
import { Component } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type Immutable from 'immutable';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
interface SilentErrorBoundaryProps {
children: React.ReactNode;
}
class SilentErrorBoundary extends Component<SilentErrorBoundaryProps> {
state = {
error: false,
};
componentDidCatch() {
this.setState({ error: true });
}
render() {
if (this.state.error) {
return null;
}
return this.props.children;
}
}
/**
* Used to render counter of how much people are talking about hashtag
* @param displayNumber Counter number to display
* @param pluralReady Whether the count is plural
* @returns Formatted counter of how much people are talking about hashtag
*/
export const accountsCountRenderer = (
displayNumber: JSX.Element,
pluralReady: number,
) => (
<FormattedMessage
id='trends.counter_by_accounts'
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
days: 2,
}}
/>
);
interface ImmutableHashtagProps {
hashtag: Immutable.Map<string, unknown>;
}
export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
<Hashtag
name={hashtag.get('name') as string}
to={`/tags/${hashtag.get('name') as string}`}
people={
(hashtag.getIn(['history', 0, 'accounts']) as number) * 1 +
(hashtag.getIn(['history', 1, 'accounts']) as number) * 1
}
history={(
hashtag.get('history') as Immutable.Collection.Indexed<
Immutable.Map<string, number>
>
)
.reverse()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((day) => day.get('uses')!)
.toArray()}
/>
);
export interface HashtagProps {
className?: string;
description?: React.ReactNode;
history?: number[];
name: string;
people: number;
to: string;
uses?: number;
withGraph?: boolean;
}
export const Hashtag: React.FC<HashtagProps> = ({
name,
to,
people,
uses,
history,
className,
description,
withGraph = true,
}) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Link to={to}>
{name ? (
<>
#<span>{name}</span>
</>
) : (
<Skeleton width={50} />
)}
</Link>
{description ? (
<span>{description}</span>
) : typeof people !== 'undefined' ? (
<ShortNumber value={people} renderer={accountsCountRenderer} />
) : (
<Skeleton width={100} />
)}
</div>
{typeof uses !== 'undefined' && (
<div className='trends__item__current'>
<ShortNumber value={uses} />
</div>
)}
{withGraph && (
<div className='trends__item__sparkline'>
<SilentErrorBoundary>
<Sparklines
width={50}
height={28}
data={history ? history : Array.from(Array(7)).map(() => 0)}
>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</SilentErrorBoundary>
</div>
)}
</div>
);

View file

@ -5,7 +5,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from 'mastodon/components/hashtag';
import { Hashtag } from 'mastodon/components/hashtag';
const messages = defineMessages({
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },

View file

@ -13,7 +13,7 @@ import { debounce } from 'lodash';
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
import ColumnHeader from 'mastodon/components/column_header';
import Hashtag from 'mastodon/components/hashtag';
import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list';
import Column from 'mastodon/features/ui/components/column';

View file

@ -687,7 +687,7 @@
"status.translated_from_with": "Traducido do {lang} usando {provider}",
"status.uncached_media_warning": "A vista previa non está dispoñíble",
"status.unmute_conversation": "Deixar de silenciar conversa",
"status.unpin": "Desafixar do perfil",
"status.unpin": "Non fixar no perfil",
"subscribed_languages.lead": "Ao facer cambios só as publicacións nos idiomas seleccionados aparecerán nas túas cronoloxías. Non elixas ningún para poder ver publicacións en tódolos idiomas.",
"subscribed_languages.save": "Gardar cambios",
"subscribed_languages.target": "Cambiar a subscrición a idiomas para {target}",

View file

@ -21,6 +21,7 @@
"account.blocked": "Заблокировано",
"account.browse_more_on_origin_server": "Посмотреть в оригинальном профиле",
"account.cancel_follow_request": "Отозвать запрос на подписку",
"account.copy": "Скопировать ссылку на профиль",
"account.direct": "Лично упоминать @{name}",
"account.disable_notifications": "Не уведомлять о постах от @{name}",
"account.domain_blocked": "Домен заблокирован",
@ -191,6 +192,7 @@
"conversation.mark_as_read": "Отметить как прочитанное",
"conversation.open": "Просмотр беседы",
"conversation.with": "С {names}",
"copy_icon_button.copied": "Скопировано в буфер обмена",
"copypaste.copied": "Скопировано",
"copypaste.copy_to_clipboard": "Копировать в буфер обмена",
"directory.federated": "Со всей федерации",
@ -222,6 +224,7 @@
"emoji_button.search_results": "Результаты поиска",
"emoji_button.symbols": "Символы",
"emoji_button.travel": "Путешествия и места",
"empty_column.account_hides_collections": "Данный пользователь решил не предоставлять эту информацию",
"empty_column.account_suspended": "Учетная запись заблокирована",
"empty_column.account_timeline": "Здесь нет постов!",
"empty_column.account_unavailable": "Профиль недоступен",
@ -389,6 +392,7 @@
"lists.search": "Искать среди подписок",
"lists.subheading": "Ваши списки",
"load_pending": "{count, plural, one {# новый элемент} few {# новых элемента} other {# новых элементов}}",
"loading_indicator.label": "Загрузка…",
"media_gallery.toggle_visible": "Показать/скрыть {number, plural, =1 {изображение} other {изображения}}",
"moved_to_account_banner.text": "Ваша учетная запись {disabledAccount} в настоящее время заморожена, потому что вы переехали на {movedToAccount}.",
"mute_modal.duration": "Продолжительность",
@ -477,6 +481,17 @@
"onboarding.follows.empty": "К сожалению, сейчас нет результатов. Вы можете попробовать использовать поиск или просмотреть страницу \"Исследования\", чтобы найти людей, за которыми можно следить, или повторить попытку позже.",
"onboarding.follows.lead": "Вы сами формируете свою домашнюю ленту. Чем больше людей, за которыми вы следите, тем активнее и интереснее она будет. Эти профили могут быть хорошей отправной точкой - вы всегда можете от них отказаться!",
"onboarding.follows.title": "Популярно на Mastodon",
"onboarding.profile.discoverable": "Сделать мой профиль открытым",
"onboarding.profile.discoverable_hint": "Если вы соглашаетесь на открытость на Mastodon, ваши сообщения могут появляться в результатах поиска и трендах, а ваш профиль может быть предложен людям со схожими с вами интересами.",
"onboarding.profile.display_name": "Отображаемое имя",
"onboarding.profile.display_name_hint": "Ваше полное имя или псевдоним…",
"onboarding.profile.lead": "Вы всегда можете завершить это позже в настройках, где доступны еще более широкие возможности настройки.",
"onboarding.profile.note": "О себе",
"onboarding.profile.note_hint": "Вы можете @упоминать других людей или использовать #хэштеги…",
"onboarding.profile.save_and_continue": "Сохранить и продолжить",
"onboarding.profile.title": "Настройка профиля",
"onboarding.profile.upload_avatar": "Загрузить фотографию профиля",
"onboarding.profile.upload_header": "Загрузить заголовок профиля",
"onboarding.share.lead": "Расскажите людям, как они могут найти вас на Mastodon!",
"onboarding.share.message": "Я {username} на #Mastodon! Следуйте за мной по адресу {url}",
"onboarding.share.next_steps": "Возможные дальнейшие шаги:",
@ -520,6 +535,7 @@
"privacy.unlisted.short": "Скрытый",
"privacy_policy.last_updated": "Последнее обновление {date}",
"privacy_policy.title": "Политика конфиденциальности",
"recommended": "Рекомендуется",
"refresh": "Обновить",
"regeneration_indicator.label": "Загрузка…",
"regeneration_indicator.sublabel": "Один момент, мы подготавливаем вашу ленту!",
@ -590,6 +606,7 @@
"search.quick_action.status_search": "Посты, соответствующие {x}",
"search.search_or_paste": "Поиск (или вставьте URL)",
"search_popout.full_text_search_disabled_message": "Недоступно на {domain}.",
"search_popout.full_text_search_logged_out_message": "Доступно только при авторизации.",
"search_popout.language_code": "Код языка по стандарту ISO",
"search_popout.options": "Параметры поиска",
"search_popout.quick_actions": "Быстрые действия",

View file

@ -606,6 +606,7 @@
"search.quick_action.status_search": "โพสต์ที่ตรงกับ {x}",
"search.search_or_paste": "ค้นหาหรือวาง URL",
"search_popout.full_text_search_disabled_message": "ไม่พร้อมใช้งานใน {domain}",
"search_popout.full_text_search_logged_out_message": "พร้อมใช้งานเฉพาะเมื่อเข้าสู่ระบบแล้วเท่านั้น",
"search_popout.language_code": "รหัสภาษา ISO",
"search_popout.options": "ตัวเลือกการค้นหา",
"search_popout.quick_actions": "การกระทำด่วน",

View file

@ -1,10 +0,0 @@
import ready from '../ready';
export let assetHost = '';
ready(() => {
const cdnHost = document.querySelector('meta[name=cdn-host]');
if (cdnHost) {
assetHost = cdnHost.content || '';
}
});

View file

@ -0,0 +1,13 @@
import ready from '../ready';
export let assetHost = '';
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ready(() => {
const cdnHost = document.querySelector<HTMLMetaElement>(
'meta[name=cdn-host]',
);
if (cdnHost) {
assetHost = cdnHost.content || '';
}
});

View file

@ -1,6 +0,0 @@
// NB: This function can still return unsafe HTML
export const unescapeHTML = (html) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, '');
return wrapper.textContent;
};

View file

@ -0,0 +1,9 @@
// NB: This function can still return unsafe HTML
export const unescapeHTML = (html: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html
.replace(/<br\s*\/?>/g, '\n')
.replace(/<\/p><p>/g, '\n\n')
.replace(/<[^>]*>/g, '');
return wrapper.textContent;
};

View file

@ -1,13 +1,23 @@
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
export const loupeIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'
width='13'
height='13'
>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
);
export const deleteIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'
width='13'
height='13'
>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
);

View file

@ -1,30 +0,0 @@
// Handles browser quirks, based on
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
const checkNotificationPromise = () => {
try {
// eslint-disable-next-line promise/valid-params, promise/catch-or-return
Notification.requestPermission().then();
} catch(e) {
return false;
}
return true;
};
const handlePermission = (permission, callback) => {
// Whatever the user answers, we make sure Chrome stores the information
if(!('permission' in Notification)) {
Notification.permission = permission;
}
callback(Notification.permission);
};
export const requestNotificationPermission = (callback) => {
if (checkNotificationPromise()) {
Notification.requestPermission().then((permission) => handlePermission(permission, callback)).catch(console.warn);
} else {
Notification.requestPermission((permission) => handlePermission(permission, callback));
}
};

View file

@ -0,0 +1,13 @@
/**
* Tries Notification.requestPermission, console warning instead of rejecting on error.
* @param callback Runs with the permission result on completion.
*/
export const requestNotificationPermission = async (
callback: NotificationPermissionCallback,
) => {
try {
callback(await Notification.requestPermission());
} catch (error) {
console.warn(error);
}
};

View file

@ -1,8 +1,8 @@
import PropTypes from "prop-types";
import PropTypes from 'prop-types';
import { __RouterContext } from "react-router";
import { __RouterContext } from 'react-router';
import hoistStatics from "hoist-non-react-statics";
import hoistStatics from 'hoist-non-react-statics';
export const WithRouterPropTypes = {
match: PropTypes.object.isRequired,
@ -16,31 +16,37 @@ export const WithOptionalRouterPropTypes = {
history: PropTypes.object,
};
export interface OptionalRouterProps {
ref: unknown;
wrappedComponentRef: unknown;
}
// This is copied from https://github.com/remix-run/react-router/blob/v5.3.4/packages/react-router/modules/withRouter.js
// but does not fail if called outside of a React Router context
export function withOptionalRouter(Component) {
const displayName = `withRouter(${Component.displayName || Component.name})`;
const C = props => {
export function withOptionalRouter<
ComponentType extends React.ComponentType<OptionalRouterProps>,
>(Component: ComponentType) {
const displayName = `withRouter(${Component.displayName ?? Component.name})`;
const C = (props: React.ComponentProps<ComponentType>) => {
const { wrappedComponentRef, ...remainingProps } = props;
return (
<__RouterContext.Consumer>
{context => {
if(context)
{(context) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (context) {
return (
// @ts-expect-error - Dynamic covariant generic components are tough to type.
<Component
{...remainingProps}
{...context}
ref={wrappedComponentRef}
/>
);
else
return (
<Component
{...remainingProps}
ref={wrappedComponentRef}
/>
);
} else {
// @ts-expect-error - Dynamic covariant generic components are tough to type.
return <Component {...remainingProps} ref={wrappedComponentRef} />;
}
}}
</__RouterContext.Consumer>
);
@ -53,8 +59,8 @@ export function withOptionalRouter(Component) {
wrappedComponentRef: PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
PropTypes.object
])
PropTypes.object,
]),
};
return hoistStatics(C, Component);

View file

@ -1,11 +1,7 @@
import { isMobile } from '../is_mobile';
/** @type {number | null} */
let cachedScrollbarWidth = null;
let cachedScrollbarWidth: number | null = null;
/**
* @returns {number}
*/
const getActualScrollbarWidth = () => {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
@ -16,20 +12,19 @@ const getActualScrollbarWidth = () => {
outer.appendChild(inner);
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentNode.removeChild(outer);
outer.remove();
return scrollbarWidth;
};
/**
* @returns {number}
*/
export const getScrollbarWidth = () => {
if (cachedScrollbarWidth !== null) {
return cachedScrollbarWidth;
}
const scrollbarWidth = isMobile(window.innerWidth) ? 0 : getActualScrollbarWidth();
const scrollbarWidth = isMobile(window.innerWidth)
? 0
: getActualScrollbarWidth();
cachedScrollbarWidth = scrollbarWidth;
return scrollbarWidth;

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class AdminMailer < ApplicationMailer
layout 'admin_mailer'
helper :accounts
helper :languages

View file

@ -40,6 +40,8 @@ class NodeInfo::Serializer < ActiveModel::Serializer
def metadata
{
nodeName: Setting.site_title,
nodeDescription: Setting.site_short_description,
features: capabilities_for_nodeinfo,
}
end

View file

@ -100,7 +100,9 @@ class ResolveAccountService < BaseService
end
def split_acct(acct)
acct.delete_prefix('acct:').split('@')
acct.delete_prefix('acct:').split('@').tap do |parts|
raise Webfinger::Error, 'Webfinger response is missing user or host value' unless parts.size == 2
end
end
def fetch_account!

View file

@ -9,7 +9,7 @@
%meta{ name: 'robots', content: 'noai, noimageai' }/
%meta{ name: 'CCBot', content: 'nofollow' }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: @rss_url }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: rss_url }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
- @account.fields.select(&:verifiable?).each do |field|

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('admin_mailer.new_appeal.body', target: @appeal.account.username, action_taken_by: @appeal.strike.account.username, date: l(@appeal.strike.created_at, format: :with_time_zone), type: t(@appeal.strike.action, scope: 'admin_mailer.new_appeal.actions')) %>
> <%= raw word_wrap(@appeal.text, break_sequence: "\n> ") %>

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('admin_mailer.new_critical_software_updates.body') %>
<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %>

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('admin_mailer.new_pending_account.body') %>
<%= @account.user_email %> (@<%= @account.username %>)

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw(@report.account.local? ? t('admin_mailer.new_report.body', target: @report.target_account.pretty_acct, reporter: @report.account.pretty_acct) : t('admin_mailer.new_report.body_remote', target: @report.target_account.acct, domain: @report.account.domain)) %>
<%= raw t('application_mailer.view')%> <%= admin_report_url(@report) %>

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('admin_mailer.new_software_updates.body') %>
<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %>

View file

@ -1,5 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('admin_mailer.new_trends.body') %>
<%= render partial: 'new_trending_links', object: @links unless @links.empty? %>

View file

@ -0,0 +1,3 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= yield %>