Merge remote-tracking branch 'parent/main' into upstream-20240531

This commit is contained in:
KMY 2024-05-31 08:27:09 +09:00
commit 13ad383039
101 changed files with 1486 additions and 1232 deletions

View file

@ -111,11 +111,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_ids
Array(accounts_params[:ids]).uniq.map(&:to_i)
Array(accounts_params[:id]).uniq.map(&:to_i)
end
def accounts_params
params.permit(ids: [])
params.permit(id: [])
end
def account_params

View file

@ -38,15 +38,15 @@ class Api::V1::ConversationsController < Api::BaseController
def paginated_conversations
AccountConversation.where(account: current_account)
.includes(
account: :account_stat,
account: [:account_stat, user: :role],
last_status: [
:media_attachments,
:status_stat,
:tags,
{
preview_cards_status: :preview_card,
active_mentions: [account: :account_stat],
account: :account_stat,
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
active_mentions: :account,
account: [:account_stat, user: :role],
},
]
)

View file

@ -155,11 +155,11 @@ class Api::V1::StatusesController < Api::BaseController
end
def status_ids
Array(statuses_params[:ids]).uniq.map(&:to_i)
Array(statuses_params[:id]).uniq.map(&:to_i)
end
def statuses_params
params.permit(ids: [])
params.permit(id: [])
end
def status_params

View file

@ -44,7 +44,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def build_resource(hash = nil)
super(hash)
super
resource.locale = I18n.locale
resource.invite_code = @invite&.code if resource.invite_code.blank?

View file

@ -65,7 +65,7 @@ window.addEventListener('message', (e) => {
{
type: 'setHeight',
id: data.id,
height: document.getElementsByTagName('html')[0].scrollHeight,
height: document.getElementsByTagName('html')[0]?.scrollHeight,
},
'*',
);
@ -135,7 +135,7 @@ function loaded() {
);
};
const todayFormat = new IntlMessageFormat(
localeData['relative_format.today'] || 'Today at {time}',
localeData['relative_format.today'] ?? 'Today at {time}',
locale,
);
@ -288,13 +288,13 @@ function loaded() {
if (statusEl.dataset.spoiler === 'expanded') {
statusEl.dataset.spoiler = 'folded';
this.textContent = new IntlMessageFormat(
localeData['status.show_more'] || 'Show more',
localeData['status.show_more'] ?? 'Show more',
locale,
).format() as string;
} else {
statusEl.dataset.spoiler = 'expanded';
this.textContent = new IntlMessageFormat(
localeData['status.show_less'] || 'Show less',
localeData['status.show_less'] ?? 'Show less',
locale,
).format() as string;
}
@ -316,8 +316,8 @@ function loaded() {
const message =
statusEl.dataset.spoiler === 'expanded'
? localeData['status.show_less'] || 'Show less'
: localeData['status.show_more'] || 'Show more';
? localeData['status.show_less'] ?? 'Show less'
: localeData['status.show_more'] ?? 'Show more';
spoilerLink.textContent = new IntlMessageFormat(
message,
locale,

View file

@ -67,7 +67,9 @@ const fetchInteractionURLFailure = () => {
);
};
const isValidDomain = (value: string) => {
const isValidDomain = (value: unknown) => {
if (typeof value !== 'string') return false;
const url = new URL('https:///path');
url.hostname = value;
return url.hostname === value;
@ -124,6 +126,11 @@ const fromAcct = (acct: string) => {
const domain = segments[1];
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
if (!domain) {
fetchInteractionURLFailure();
return;
}
axios
.get(`https://${domain}/.well-known/webfinger`, {
params: { resource: `acct:${acct}` },

View file

@ -48,8 +48,9 @@ export const AnimatedNumber: React.FC<Props> = ({ value }) => {
<span
key={key}
style={{
position: direction * style.y > 0 ? 'absolute' : 'static',
transform: `translateY(${style.y * 100}%)`,
position:
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
transform: `translateY(${(style.y ?? 0) * 100}%)`,
}}
>
<ShortNumber value={data as number} />

View file

@ -52,7 +52,10 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
);
return Object.values(groups).map((tags) => {
if (tags.length === 1) return tags[0];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the array has at least one element
const firstTag = tags[0]!;
if (tags.length === 1) return firstTag;
// The best match is the one where we have the less difference between upper and lower case letter count
const best = minBy(tags, (tag) => {
@ -66,7 +69,7 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
return Math.abs(lowerCase - upperCase);
});
return best ?? tags[0];
return best ?? firstTag;
});
}

View file

@ -54,7 +54,7 @@ const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => {
const count = (
<FormattedNumber
value={rawNumber}
value={rawNumber ?? 0}
maximumFractionDigits={maxFractionDigits}
/>
);

View file

@ -29,7 +29,10 @@ const emojis: Emojis = {};
// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
const [_filenameData, searchData] = shortCodesToEmojiData[shortCode];
const emojiData = shortCodesToEmojiData[shortCode];
if (!emojiData) return;
const [_filenameData, searchData] = emojiData;
const [native, short_names, search, unified] = searchData;
emojis[shortCode] = {

View file

@ -46,7 +46,10 @@ function processEmojiMapData(
Object.keys(shortCodesToEmojiData).forEach(
(shortCode: ShortCodesToEmojiDataKey) => {
if (shortCode === undefined) return;
const [filenameData, _searchData] = shortCodesToEmojiData[shortCode];
const emojiData = shortCodesToEmojiData[shortCode];
if (!emojiData) return;
const [filenameData, _searchData] = emojiData;
filenameData.forEach((emojiMapData) => {
processEmojiMapData(emojiMapData, shortCode);
});

View file

@ -15,7 +15,7 @@ const mapStateToProps = (state, { columnId }) => {
return {
settings: columns.get(index).get('params'),
onLoad (value) {
return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` };
});

View file

@ -6,6 +6,8 @@ import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -13,6 +15,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import { Avatar } from 'mastodon/components/avatar';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@ -56,6 +59,20 @@ const addAutoPlay = html => {
return html;
};
const MoreFromAuthor = ({ author }) => (
<div className='more-from-author'>
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <Link to={`/@${author.get('acct')}`}><Avatar account={author} size={16} /> {author.get('display_name')}</Link> }} />
</div>
);
MoreFromAuthor.propTypes = {
author: ImmutablePropTypes.map,
};
export default class Card extends PureComponent {
static propTypes = {
@ -136,6 +153,7 @@ export default class Card extends PureComponent {
const interactive = card.get('type') === 'video';
const language = card.get('language') || '';
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
const showAuthor = !!card.get('author_account');
const description = (
<div className='status-card__content'>
@ -146,7 +164,7 @@ export default class Card extends PureComponent {
<strong className='status-card__title' title={card.get('title')} lang={language}>{card.get('title')}</strong>
{card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>}
{!showAuthor && (card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>)}
</div>
);
@ -235,10 +253,14 @@ export default class Card extends PureComponent {
}
return (
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage })} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
{embed}
{description}
</a>
<>
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
{embed}
{description}
</a>
{showAuthor && <MoreFromAuthor author={card.get('author_account')} />}
</>
);
}

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Mostra el perfil de totes maneres",
"limited_account_hint.title": "Aquest perfil l'han amagat els moderadors de {domain}.",
"link_preview.author": "Per {name}",
"link_preview.more_from_author": "Més de {name}",
"lists.account.add": "Afegeix a la llista",
"lists.account.remove": "Elimina de la llista",
"lists.delete": "Elimina la llista",

View file

@ -520,6 +520,7 @@
"limited_account_hint.action": "Show profile anyway",
"limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
"link_preview.author": "By {name}",
"link_preview.more_from_author": "More from {name}",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.antennas": "Related antennas",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Mostrar perfil de todos modos",
"limited_account_hint.title": "Este perfil fue ocultado por los moderadores de {domain}.",
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Más de {name}",
"lists.account.add": "Agregar a lista",
"lists.account.remove": "Quitar de lista",
"lists.delete": "Eliminar lista",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Näytä profiili joka tapauksessa",
"limited_account_hint.title": "Palvelimen {domain} valvojat ovat piilottaneet tämän käyttäjätilin.",
"link_preview.author": "Julkaissut {name}",
"link_preview.more_from_author": "Lisää käyttäjältä {name}",
"lists.account.add": "Lisää listalle",
"lists.account.remove": "Poista listalta",
"lists.delete": "Poista lista",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Mostrar perfil igualmente",
"limited_account_hint.title": "Este perfil foi agochado pola moderación de {domain}.",
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Máis de {name}",
"lists.account.add": "Engadir á listaxe",
"lists.account.remove": "Eliminar da listaxe",
"lists.delete": "Eliminar listaxe",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Mostra comunque il profilo",
"limited_account_hint.title": "Questo profilo è stato nascosto dai moderatori di {domain}.",
"link_preview.author": "Di {name}",
"link_preview.more_from_author": "Altro da {name}",
"lists.account.add": "Aggiungi all'elenco",
"lists.account.remove": "Rimuovi dall'elenco",
"lists.delete": "Elimina elenco",

View file

@ -234,7 +234,7 @@
"embed.preview": "이렇게 표시됩니다:",
"emoji_button.activity": "활동",
"emoji_button.clear": "지우기",
"emoji_button.custom": "사용자 지정",
"emoji_button.custom": "커스텀",
"emoji_button.flags": "깃발",
"emoji_button.food": "음식과 마실것",
"emoji_button.label": "에모지 추가",
@ -414,6 +414,7 @@
"limited_account_hint.action": "그래도 프로필 보기",
"limited_account_hint.title": "이 프로필은 {domain}의 중재자에 의해 숨겨진 상태입니다.",
"link_preview.author": "{name}",
"link_preview.more_from_author": "{name} 더 둘러보기",
"lists.account.add": "리스트에 추가",
"lists.account.remove": "리스트에서 제거",
"lists.delete": "리스트 삭제",

View file

@ -466,6 +466,7 @@
"notification.follow_request": "{name} paprašė tave sekti",
"notification.mention": "{name} paminėjo tave",
"notification.moderation-warning.learn_more": "Sužinoti daugiau",
"notification.moderation_warning": "Gavai prižiūrėjimo įspėjimą",
"notification.moderation_warning.action_delete_statuses": "Kai kurie tavo įrašai buvo pašalintos.",
"notification.moderation_warning.action_disable": "Tavo paskyra buvo išjungta.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Kai kurie tavo įrašai buvo pažymėtos kaip jautrios.",

View file

@ -297,6 +297,7 @@
"filter_modal.select_filter.subtitle": "Bruk ein eksisterande kategori eller opprett ein ny",
"filter_modal.select_filter.title": "Filtrer dette innlegget",
"filter_modal.title.status": "Filtrer eit innlegg",
"filtered_notifications_banner.mentions": "{count, plural, one {omtale} other {omtaler}}",
"filtered_notifications_banner.pending_requests": "Varsel frå {count, plural, =0 {ingen} one {ein person} other {# folk}} du kanskje kjenner",
"filtered_notifications_banner.title": "Filtrerte varslingar",
"firehose.all": "Alle",
@ -307,6 +308,8 @@
"follow_requests.unlocked_explanation": "Sjølv om kontoen din ikkje er låst tenkte dei som driv {domain} at du kanskje ville gå gjennom førespurnadar frå desse kontoane manuelt.",
"follow_suggestions.curated_suggestion": "Utvalt av staben",
"follow_suggestions.dismiss": "Ikkje vis igjen",
"follow_suggestions.featured_longer": "Hanplukka av gjengen på {domain}",
"follow_suggestions.friends_of_friends_longer": "Populært hjå dei du fylgjer",
"follow_suggestions.hints.featured": "Denne profilen er handplukka av folka på {domain}.",
"follow_suggestions.hints.friends_of_friends": "Denne profilen er populær hjå dei du fylgjer.",
"follow_suggestions.hints.most_followed": "Mange på {domain} fylgjer denne profilen.",
@ -314,6 +317,8 @@
"follow_suggestions.hints.similar_to_recently_followed": "Denne profilen liknar på dei andre profilane du har fylgt i det siste.",
"follow_suggestions.personalized_suggestion": "Personleg forslag",
"follow_suggestions.popular_suggestion": "Populært forslag",
"follow_suggestions.popular_suggestion_longer": "Populært på {domain}",
"follow_suggestions.similar_to_recently_followed_longer": "Liknar på profilar du har fylgt i det siste",
"follow_suggestions.view_all": "Vis alle",
"follow_suggestions.who_to_follow": "Kven du kan fylgja",
"followed_tags": "Fylgde emneknaggar",
@ -668,7 +673,7 @@
"search.quick_action.account_search": "Profiler som samsvarer med {x}",
"search.quick_action.go_to_account": "Gå til profil {x}",
"search.quick_action.go_to_hashtag": "Gå til emneknagg {x}",
"search.quick_action.open_url": "Åpne URL i Mastodon",
"search.quick_action.open_url": "Opne adressa i Mastodon",
"search.quick_action.status_search": "Innlegg som samsvarer med {x}",
"search.search_or_paste": "Søk eller lim inn URL",
"search_popout.full_text_search_disabled_message": "Ikkje tilgjengeleg på {domain}.",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Pokaż profil mimo to",
"limited_account_hint.title": "Ten profil został ukryty przez moderatorów {domain}.",
"link_preview.author": "{name}",
"link_preview.more_from_author": "Więcej od {name}",
"lists.account.add": "Dodaj do listy",
"lists.account.remove": "Usunąć z listy",
"lists.delete": "Usuń listę",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Exibir perfil mesmo assim",
"limited_account_hint.title": "Este perfil foi ocultado pelos moderadores do {domain}.",
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Mais de {name}",
"lists.account.add": "Adicionar à lista",
"lists.account.remove": "Remover da lista",
"lists.delete": "Excluir lista",
@ -474,6 +475,7 @@
"notification.follow_request": "{name} quer te seguir",
"notification.mention": "{name} te mencionou",
"notification.moderation-warning.learn_more": "Aprender mais",
"notification.moderation_warning": "Você recebeu um aviso de moderação",
"notification.moderation_warning.action_delete_statuses": "Algumas das suas publicações foram removidas.",
"notification.moderation_warning.action_disable": "Sua conta foi desativada.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Algumas de suas publicações foram marcadas por ter conteúdo sensível.",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Exibir perfil mesmo assim",
"limited_account_hint.title": "Este perfil foi ocultado pelos moderadores de {domain}.",
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Mais de {name}",
"lists.account.add": "Adicionar à lista",
"lists.account.remove": "Remover da lista",
"lists.delete": "Eliminar lista",

View file

@ -18,6 +18,7 @@
"account.edit_profile": "පැතිකඩ සංස්කරණය",
"account.enable_notifications": "@{name} පළ කරන විට මට දැනුම් දෙන්න",
"account.endorse": "පැතිකඩෙහි විශේෂාංගය",
"account.featured_tags.last_status_at": "අවසාන ලිපිය: {date}",
"account.featured_tags.last_status_never": "ලිපි නැත",
"account.follow": "අනුගමනය",
"account.followers": "අනුගාමිකයින්",
@ -104,6 +105,7 @@
"compose_form.poll.duration": "මත විමසීමේ කාලය",
"compose_form.poll.switch_to_multiple": "තේරීම් කිහිපයකට මත විමසුම වෙනස් කරන්න",
"compose_form.poll.switch_to_single": "තනි තේරීමකට මත විමසුම වෙනස් කරන්න",
"compose_form.publish": "ප්‍රකාශනය",
"compose_form.publish_form": "නව ලිපිය",
"compose_form.spoiler.marked": "අන්තර්ගත අවවාදය ඉවත් කරන්න",
"compose_form.spoiler.unmarked": "අන්තර්ගත අවවාදයක් එක් කරන්න",
@ -154,6 +156,7 @@
"empty_column.bookmarked_statuses": "ඔබ සතුව පොත්යොමු තබන ලද ලිපි කිසිවක් නැත. ඔබ පොත්යොමුවක් තබන විට, එය මෙහි දිස්වනු ඇත.",
"empty_column.domain_blocks": "අවහිර කරන ලද වසම් නැත.",
"empty_column.explore_statuses": "දැන් කිසිවක් නැඹුරු නොවේ. පසුව නැවත පරීක්ෂා කරන්න!",
"empty_column.favourited_statuses": "ඔබ සතුව ප්‍රියතම ලිපි කිසිවක් නැත. ඔබ යමකට ප්‍රිය කළ විට එය මෙහි පෙන්වනු ඇත.",
"empty_column.follow_requests": "ඔබට තවමත් අනුගමන ඉල්ලීම් ලැබී නැත. ඉල්ලීමක් ලැබුණු විට, එය මෙහි පෙන්වනු ඇත.",
"empty_column.home": "මුල් පිටුව හිස් ය! මෙය පිරවීමට බොහෝ පුද්ගලයින් අනුගමනය කරන්න.",
"empty_column.lists": "ඔබට තවමත් ලැයිස්තු කිසිවක් නැත. ඔබ එකක් සාදන විට, එය මෙහි පෙන්වනු ඇත.",
@ -205,6 +208,7 @@
"interaction_modal.on_this_server": "මෙම සේවාදායකයෙහි",
"interaction_modal.title.favourite": "{name}ගේ ලිපිය ප්‍රිය කරන්න",
"interaction_modal.title.follow": "{name} අනුගමනය",
"interaction_modal.title.reply": "{name}ගේ ලිපියට පිළිතුරු",
"intervals.full.days": "{number, plural, one {දවස් #} other {දවස් #}}",
"intervals.full.hours": "{number, plural, one {පැය #} other {පැය #}}",
"intervals.full.minutes": "{number, plural, one {විනාඩි #} other {විනාඩි #}}",
@ -239,6 +243,7 @@
"lists.delete": "ලැයිස්තුව මකන්න",
"lists.edit": "ලැයිස්තුව සංස්කරණය",
"lists.edit.submit": "සිරැසිය සංශෝධනය",
"lists.new.create": "එකතු",
"lists.new.title_placeholder": "නව ලැයිස්තුවේ සිරැසිය",
"lists.replies_policy.list": "ලැයිස්තුවේ සාමාජිකයින්",
"lists.replies_policy.none": "කිසිවෙක් නැත",
@ -266,6 +271,7 @@
"navigation_bar.search": "සොයන්න",
"navigation_bar.security": "ආරක්ෂාව",
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
"notification.favourite": "{name} ඔබගේ ලිපියට ප්‍රිය කළා",
"notification.follow": "{name} ඔබව අනුගමනය කළා",
"notification.mention": "{name} ඔබව සඳහන් කර ඇත",
"notification.own_poll": "ඔබගේ මත විමසුම නිමයි",
@ -395,6 +401,7 @@
"status.admin_status": "මෙම ලිපිය මැදිහත්කරණ අතුරුමුහුණතෙහි අරින්න",
"status.block": "@{name} අවහිර",
"status.bookmark": "පොත්යොමුවක්",
"status.copy": "ලිපියට සබැඳියේ පිටපතක්",
"status.delete": "මකන්න",
"status.detailed_status": "විස්තරාත්මක සංවාද දැක්ම",
"status.edit": "සංස්කරණය",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Vseeno pokaži profil",
"limited_account_hint.title": "Profil so moderatorji strežnika {domain} skrili.",
"link_preview.author": "Avtor_ica {name}",
"link_preview.more_from_author": "Več od {name}",
"lists.account.add": "Dodaj na seznam",
"lists.account.remove": "Odstrani s seznama",
"lists.delete": "Izbriši seznam",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "Vẫn cứ xem",
"limited_account_hint.title": "Người này đã bị ẩn bởi quản trị viên của {domain}.",
"link_preview.author": "Bởi {name}",
"link_preview.more_from_author": "Thêm từ {name}",
"lists.account.add": "Thêm vào danh sách",
"lists.account.remove": "Xóa khỏi danh sách",
"lists.delete": "Xóa danh sách",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "仍要显示个人资料",
"limited_account_hint.title": "此账号资料已被 {domain} 管理员隐藏。",
"link_preview.author": "由 {name}",
"link_preview.more_from_author": "查看 {name} 的更多内容",
"lists.account.add": "添加到列表",
"lists.account.remove": "从列表中移除",
"lists.delete": "删除列表",

View file

@ -414,6 +414,7 @@
"limited_account_hint.action": "一律顯示個人檔案",
"limited_account_hint.title": "此個人檔案已被 {domain} 的管理員隱藏。",
"link_preview.author": "來自 {name}",
"link_preview.more_from_author": "來自 {name} 之更多內容",
"lists.account.add": "新增至列表",
"lists.account.remove": "自列表中移除",
"lists.delete": "刪除列表",

View file

@ -74,8 +74,9 @@ export const soundsMiddleware = (): Middleware<
if (isActionWithMetaSound(action)) {
const sound = action.meta.sound;
if (sound && Object.hasOwn(soundCache, sound)) {
play(soundCache[sound]);
if (sound) {
const s = soundCache[sound];
if (s) play(s);
}
}

View file

@ -89,21 +89,17 @@ type OnData<LoadDataResult, ReturnedData> = (
},
) => ReturnedData | DiscardLoadData | Promise<ReturnedData | DiscardLoadData>;
type ArgsType = Record<string, unknown> | undefined;
// Overload when there is no `onData` method, the payload is the `onData` result
export function createDataLoadingThunk<
LoadDataResult,
Args extends Record<string, unknown>,
>(
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: (args: Args) => Promise<LoadDataResult>,
thunkOptions?: AppThunkOptions,
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
export function createDataLoadingThunk<
LoadDataResult,
Args extends Record<string, unknown>,
>(
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: (args: Args) => Promise<LoadDataResult>,
onDataOrThunkOptions?:
@ -113,10 +109,7 @@ export function createDataLoadingThunk<
): ReturnType<typeof createThunk<Args, void>>;
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
export function createDataLoadingThunk<
LoadDataResult,
Args extends Record<string, unknown>,
>(
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: (args: Args) => Promise<LoadDataResult>,
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, void>,
@ -126,7 +119,7 @@ export function createDataLoadingThunk<
// Overload when there is an `onData` method returning something
export function createDataLoadingThunk<
LoadDataResult,
Args extends Record<string, unknown>,
Args extends ArgsType,
Returned,
>(
name: string,
@ -162,7 +155,7 @@ export function createDataLoadingThunk<
*/
export function createDataLoadingThunk<
LoadDataResult,
Args extends Record<string, unknown>,
Args extends ArgsType,
Returned,
>(
name: string,

View file

@ -4040,6 +4040,10 @@ button.icon-button.active i.fa-retweet {
border: 1px solid var(--background-border-color);
border-radius: 8px;
&.bottomless {
border-radius: 8px 8px 0 0;
}
&__actions {
bottom: 0;
inset-inline-start: 0;
@ -10654,3 +10658,42 @@ noscript {
}
}
}
.more-from-author {
font-size: 14px;
color: $darker-text-color;
background: var(--surface-background-color);
border: 1px solid var(--background-border-color);
border-top: 0;
border-radius: 0 0 8px 8px;
padding: 15px;
display: flex;
align-items: center;
gap: 8px;
.logo {
height: 16px;
color: $darker-text-color;
}
& > span {
display: flex;
align-items: center;
gap: 8px;
}
a {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
color: $primary-text-color;
text-decoration: none;
&:hover,
&:focus,
&:active {
color: $highlight-text-color;
}
}
}

View file

@ -124,7 +124,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status_params
@status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object, account: @account, friend_domain: friend_domain?)
attachment_ids = process_attachments.take(MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX).map(&:id)
attachment_ids = process_attachments.take(Status::MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE).map(&:id)
@params = {
uri: @status_parser.uri,
@ -341,7 +341,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
as_array(@object['attachment']).each do |attachment|
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
next if media_attachment_parser.remote_url.blank? || media_attachments.size >= MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX
next if media_attachment_parser.remote_url.blank? || media_attachments.size >= Status::MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE
begin
media_attachment = MediaAttachment.create(

View file

@ -33,6 +33,6 @@ class ActivityPub::Serializer < ActiveModel::Serializer
adapter_options[:named_contexts].merge!(_named_contexts)
adapter_options[:context_extensions].merge!(_context_extensions)
end
super(adapter_options, options, adapter_instance)
super
end
end

View file

@ -5,7 +5,7 @@ require_relative 'shared_timed_stack'
class ConnectionPool::SharedConnectionPool < ConnectionPool
def initialize(options = {}, &block)
super(options, &block)
super
@available = ConnectionPool::SharedTimedStack.new(@size, &block)
end

View file

@ -195,6 +195,10 @@ class LinkDetailsExtractor
structured_data&.author_url
end
def author_account
opengraph_tag('fediverse:creator')
end
def embed_url
valid_url_or_nil(opengraph_tag('twitter:player:stream'))
end

View file

@ -2,7 +2,7 @@
class RSS::Channel < RSS::Element
def initialize
super()
super
@root = create_element('channel')
end

View file

@ -2,7 +2,7 @@
class RSS::Item < RSS::Element
def initialize
super()
super
@root = create_element('item')
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
class Vacuum::ApplicationsVacuum
def perform
Doorkeeper::Application.where(owner_id: nil)
.where.missing(:created_users, :access_tokens, :access_grants)
.where(created_at: ...1.day.ago)
.in_batches.delete_all
end
end

View file

@ -150,6 +150,8 @@ class Account < ApplicationRecord
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) }
scope :with_username, ->(value) { where arel_table[:username].lower.eq(value.to_s.downcase) }
scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) }
after_update_commit :trigger_update_webhooks

View file

@ -3,7 +3,7 @@
class AccountSuggestions::SettingSource < AccountSuggestions::Source
def get(account, limit: DEFAULT_LIMIT)
if setting_enabled?
base_account_scope(account).where(setting_to_where_condition).limit(limit).pluck(:id).zip([key].cycle)
base_account_scope(account).merge(setting_to_where_condition).limit(limit).pluck(:id).zip([key].cycle)
else
[]
end
@ -25,11 +25,9 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
def setting_to_where_condition
usernames_and_domains.map do |(username, domain)|
Arel::Nodes::Grouping.new(
Account.arel_table[:username].lower.eq(username.downcase).and(
Account.arel_table[:domain].lower.eq(domain&.downcase)
)
)
Account
.with_username(username)
.with_domain(domain)
end.reduce(:or)
end

View file

@ -25,42 +25,11 @@ module Account::FinderConcern
end
def find_remote(username, domain)
AccountFinder.new(username, domain).account
end
end
class AccountFinder
attr_reader :username, :domain
def initialize(username, domain)
@username = username
@domain = domain
end
def account
scoped_accounts.order(id: :asc).take
end
private
def scoped_accounts
Account.unscoped.tap do |scope|
scope.merge! with_usernames
scope.merge! matching_username
scope.merge! matching_domain
end
end
def with_usernames
Account.where.not(Account.arel_table[:username].lower.eq '')
end
def matching_username
Account.where(Account.arel_table[:username].lower.eq username.to_s.downcase)
end
def matching_domain
Account.where(Account.arel_table[:domain].lower.eq(domain.nil? ? nil : domain.to_s.downcase))
Account
.with_username(username)
.with_domain(domain)
.order(id: :asc)
.take
end
end
end

View file

@ -23,7 +23,7 @@ module Attachmentable
included do
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
super(name, options)
super
send(:"before_#{name}_validate", prepend: true) do
attachment = send(name)

View file

@ -22,7 +22,7 @@ module User::LdapAuthenticable
safe_username = safe_username.gsub(keys, replacement)
end
resource = joins(:account).merge(Account.where(Account.arel_table[:username].lower.eq safe_username.downcase)).take
resource = joins(:account).merge(Account.with_username(safe_username)).take
if resource.blank?
resource = new(

View file

@ -35,10 +35,6 @@ class MediaAttachment < ApplicationRecord
include Attachmentable
include RoutingHelper
LOCAL_STATUS_ATTACHMENT_MAX = 4
LOCAL_STATUS_ATTACHMENT_MAX_WITH_POLL = 4
ACTIVITYPUB_STATUS_ATTACHMENT_MAX = 16
enum :type, { image: 0, gifv: 1, video: 2, unknown: 3, audio: 4 }
enum :processing, { queued: 0, in_progress: 1, complete: 2, failed: 3 }, prefix: true

View file

@ -32,6 +32,7 @@
# published_at :datetime
# image_description :string default(""), not null
# image_file_size :integer
# author_account_id :bigint(8)
#
class PreviewCard < ApplicationRecord
@ -54,6 +55,7 @@ class PreviewCard < ApplicationRecord
has_many :statuses, through: :preview_cards_statuses
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
belongs_to :author_account, class_name: 'Account', optional: true
has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, validate_media_type: false

View file

@ -47,6 +47,10 @@ class Status < ApplicationRecord
include Status::ThreadingConcern
include DtlHelper
MEDIA_ATTACHMENTS_LIMIT = 4
MEDIA_ATTACHMENTS_LIMIT_WITH_POLL = 4
MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE = 16
rate_limit by: :account, family: :statuses
self.discard_column = :deleted_at
@ -193,9 +197,9 @@ class Status < ApplicationRecord
:reference_objects,
:references,
:scheduled_expiration_status,
preview_cards_status: [:preview_card],
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: { account: :account_stat },
active_mentions: :account,
reblog: [
:application,
:tags,
@ -205,7 +209,7 @@ class Status < ApplicationRecord
:preloadable_poll,
:reference_objects,
:scheduled_expiration_status,
preview_cards_status: [:preview_card],
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: { account: :account_stat },
],
@ -218,11 +222,11 @@ class Status < ApplicationRecord
:preloadable_poll,
:reference_objects,
:scheduled_expiration_status,
preview_cards_status: [:preview_card],
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: { account: :account_stat },
active_mentions: :account,
],
thread: { account: :account_stat }
thread: :account
delegate :domain, to: :account, prefix: true
@ -395,10 +399,6 @@ class Status < ApplicationRecord
update_status_stat!(key => [public_send(key) - 1, 0].max)
end
def add_status_referred_by_count!(diff)
update_status_stat!(status_referred_by_count: [public_send(:status_referred_by_count) + diff, 0].max)
end
def emoji_reactions_grouped_by_name(account = nil, **options)
return [] if account.present? && !self.account.show_emoji_reaction?(account)
return [] if account.nil? && !options[:force] && self.account.emoji_reaction_policy != :allow

View file

@ -61,9 +61,9 @@ class REST::InstanceSerializer < ActiveModel::Serializer
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX,
max_media_attachments_with_poll: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX_WITH_POLL,
max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX,
max_media_attachments: Status::MEDIA_ATTACHMENTS_LIMIT,
max_media_attachments_with_poll: Status::MEDIA_ATTACHMENTS_LIMIT_WITH_POLL,
max_media_attachments_from_activitypub: Status::MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
},

View file

@ -8,6 +8,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
:provider_url, :html, :width, :height,
:image, :image_description, :embed_url, :blurhash, :published_at
has_one :author_account, serializer: REST::AccountSerializer, if: -> { object.author_account.present? }
def url
object.original_url.presence || object.url
end

View file

@ -66,9 +66,9 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX,
max_media_attachments_with_poll: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX_WITH_POLL,
max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX,
max_media_attachments: Status::MEDIA_ATTACHMENTS_LIMIT,
max_media_attachments_with_poll: Status::MEDIA_ATTACHMENTS_LIMIT_WITH_POLL,
max_media_attachments_from_activitypub: Status::MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
},

View file

@ -174,13 +174,23 @@ class AccountSearchService < BaseService
end
def call(query, account = nil, options = {})
@query = query&.strip&.gsub(/\A@/, '')
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@options = options
@account = account
MastodonOTELTracer.in_span('AccountSearchService#call') do |span|
@query = query&.strip&.gsub(/\A@/, '')
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@options = options
@account = account
search_service_results.compact.uniq
span.add_attributes(
'search.offset' => @offset,
'search.limit' => @limit,
'search.backend' => Chewy.enabled? ? 'elasticsearch' : 'database'
)
search_service_results.compact.uniq.tap do |results|
span.set_attribute('search.results.count', results.size)
end
end
end
private

View file

@ -84,7 +84,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
as_array(@json['attachment']).each do |attachment|
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX
next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > Status::MEDIA_ATTACHMENTS_LIMIT_FROM_REMOTE
begin
media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }

View file

@ -169,9 +169,12 @@ class FetchLinkCardService < BaseService
return if html.nil?
link_details_extractor = LinkDetailsExtractor.new(@url, @html, @html_charset)
provider = PreviewCardProvider.matching_domain(Addressable::URI.parse(link_details_extractor.canonical_url).normalized_host)
linked_account = ResolveAccountService.new.call(link_details_extractor.author_account, suppress_errors: true) if link_details_extractor.author_account.present? && provider&.trendable?
@card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url
@card.assign_attributes(link_details_extractor.to_preview_card_attributes)
@card.author_account = linked_account
@card.save_with_optional_image! unless @card.title.blank? && @card.html.blank?
end
end

View file

@ -150,6 +150,9 @@ class NotifyService < BaseService
end
def statuses_that_mention_sender
# This queries private mentions from the recipient to the sender up in the thread.
# This allows up to 100 messages that do not match in the thread, allowing conversations
# involving multiple people.
Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100])
WITH RECURSIVE ancestors(id, in_reply_to_id, mention_id, path, depth) AS (
SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0
@ -157,16 +160,17 @@ class NotifyService < BaseService
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
WHERE s.id = :id
UNION ALL
SELECT s.id, s.in_reply_to_id, m.id, st.path || s.id, st.depth + 1
FROM ancestors st
JOIN statuses s ON s.id = st.in_reply_to_id
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
WHERE st.mention_id IS NULL AND NOT s.id = ANY(path) AND st.depth < :depth_limit
SELECT s.id, s.in_reply_to_id, m.id, ancestors.path || s.id, ancestors.depth + 1
FROM ancestors
JOIN statuses s ON s.id = ancestors.in_reply_to_id
/* early exit if we already have a mention matching our requirements */
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id AND s.account_id = :recipient_id
WHERE ancestors.mention_id IS NULL AND NOT s.id = ANY(path) AND ancestors.depth < :depth_limit
)
SELECT COUNT(*)
FROM ancestors st
JOIN statuses s ON s.id = st.id
WHERE st.mention_id IS NOT NULL AND s.visibility = 3
FROM ancestors
JOIN statuses s ON s.id = ancestors.id
WHERE ancestors.mention_id IS NOT NULL AND s.account_id = :recipient_id AND s.visibility = 3
SQL
end
end

View file

@ -284,7 +284,7 @@ class PostStatusService < BaseService
return
end
media_max = @options[:poll] ? MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX_WITH_POLL : MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX
media_max = @options[:poll] ? Status::MEDIA_ATTACHMENTS_LIMIT_WITH_POLL : Status::MEDIA_ATTACHMENTS_LIMIT
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > media_max

View file

@ -81,7 +81,7 @@ class ReportService < BaseService
# If the account making reports is remote, it is likely anonymized so we have to relax the requirements for attaching statuses.
domain = @source_account.domain.to_s.downcase
has_followers = @target_account.followers.where(Account.arel_table[:domain].lower.eq(domain)).exists?
has_followers = @target_account.followers.with_domain(domain).exists?
visibility = has_followers ? %i(public unlisted public_unlisted login private) : %i(public unlisted public_unlisted)
scope = @target_account.statuses.with_discarded
scope.merge!(scope.where(visibility: visibility).or(scope.where('EXISTS (SELECT 1 FROM mentions m JOIN accounts a ON m.account_id = a.id WHERE lower(a.domain) = ?)', domain)))

View file

@ -2,15 +2,25 @@
class StatusesSearchService < BaseService
def call(query, account = nil, options = {})
@query = query&.strip
@account = account
@options = options
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@searchability = options[:searchability]&.to_sym
MastodonOTELTracer.in_span('StatusesSearchService#call') do |span|
@query = query&.strip
@account = account
@options = options
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@searchability = options[:searchability]&.to_sym
convert_deprecated_options!
convert_deprecated_options!
status_search_results
span.add_attributes(
'search.offset' => @offset,
'search.limit' => @limit,
'search.backend' => Chewy.enabled? ? 'elasticsearch' : 'database'
)
status_search_results.tap do |results|
span.set_attribute('search.results.count', results.size)
end
end
end
private

View file

@ -2,15 +2,25 @@
class TagSearchService < BaseService
def call(query, options = {})
@query = query.strip.delete_prefix('#')
@offset = options.delete(:offset).to_i
@limit = options.delete(:limit).to_i
@options = options
MastodonOTELTracer.in_span('TagSearchService#call') do |span|
@query = query.strip.delete_prefix('#')
@offset = options.delete(:offset).to_i
@limit = options.delete(:limit).to_i
@options = options
results = from_elasticsearch if Chewy.enabled?
results ||= from_database
span.add_attributes(
'search.offset' => @offset,
'search.limit' => @limit,
'search.backend' => Chewy.enabled? ? 'elasticsearch' : 'database'
)
results
results = from_elasticsearch if Chewy.enabled?
results ||= from_database
span.set_attribute('search.results.count', results.size)
results
end
end
private

View file

@ -152,7 +152,7 @@ class UpdateStatusService < BaseService
def validate_media!
return [] if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
media_max = @options[:poll] ? MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX_WITH_POLL : MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX
media_max = @options[:poll] ? Status::MEDIA_ATTACHMENTS_LIMIT_WITH_POLL : Status::MEDIA_ATTACHMENTS_LIMIT
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > media_max

View file

@ -6,10 +6,7 @@ class UniqueUsernameValidator < ActiveModel::Validator
def validate(account)
return if account.username.blank?
normalized_username = account.username.downcase
normalized_domain = account.domain&.downcase
scope = Account.where(Account.arel_table[:username].lower.eq normalized_username).where(Account.arel_table[:domain].lower.eq normalized_domain)
scope = Account.with_username(account.username).with_domain(account.domain)
scope = scope.where.not(id: account.id) if account.persisted?
account.errors.add(:username, :taken) if scope.exists?

View file

@ -22,7 +22,6 @@ class Scheduler::VacuumScheduler
preview_cards_vacuum,
backups_vacuum,
access_tokens_vacuum,
applications_vacuum,
feeds_vacuum,
imports_vacuum,
list_statuses_vacuum,
@ -62,10 +61,6 @@ class Scheduler::VacuumScheduler
Vacuum::ImportsVacuum.new
end
def applications_vacuum
Vacuum::ApplicationsVacuum.new
end
def ng_histories_vacuum
Vacuum::NgHistoriesVacuum.new
end