Merge pull request #2987 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 04492e7f93
pull/2988/head
Claire 2025-03-07 23:59:34 +01:00 committed by GitHub
commit d94aeb2ce2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 1668 additions and 899 deletions

View File

@ -20,3 +20,9 @@ postgres14
redis
elasticsearch
chart
.yarn/
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -8,6 +8,7 @@ on:
- .github/workflows/test-image-build.yml
- Dockerfile
- streaming/Dockerfile
- .dockerignore
permissions:
contents: read

View File

@ -77,6 +77,18 @@ jobs:
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
- name: Ensure no errors with `db:prepare`
run: |
bin/rails db:drop
bin/rails db:prepare
bin/rails db:migrate
- name: Ensure no errors with `db:prepare` and SKIP_POST_DEPLOYMENT_MIGRATIONS
run: |
bin/rails db:drop
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:prepare
bin/rails db:migrate
- name: Test "one step migration" flow
run: |
bin/rails db:drop

View File

@ -63,6 +63,7 @@ docker-compose.override.yml
# Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json
/app/javascript/mastodon/features/emoji/emoji_sheet.json
# Ignore locale files
/app/javascript/mastodon/locales/*.json
@ -86,6 +87,7 @@ AUTHORS.md
# Ignore glitch-soc emoji map file
/app/javascript/flavours/glitch/features/emoji/emoji_map.json
/app/javascript/flavours/glitch/features/emoji/emoji_sheet.json
# Ignore glitch-soc locale files
/app/javascript/flavours/glitch/locales

View File

@ -96,6 +96,9 @@ RUN \
# Set /opt/mastodon as working directory
WORKDIR /opt/mastodon
# Add backport repository for some specific packages where we need the latest version
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
# hadolint ignore=DL3008,DL3005
RUN \
# Mount Apt cache and lib directories from Docker buildx caches
@ -165,7 +168,7 @@ RUN \
libexif-dev \
libexpat1-dev \
libgirepository1.0-dev \
libheif-dev \
libheif-dev/bookworm-backports \
libimagequant-dev \
libjpeg62-turbo-dev \
liblcms2-dev \
@ -348,7 +351,7 @@ RUN \
# libvips components
libcgif0 \
libexif12 \
libheif1 \
libheif1/bookworm-backports \
libimagequant0 \
libjpeg62-turbo \
liblcms2-2 \

View File

@ -39,7 +39,7 @@ gem 'net-ldap', '~> 0.18'
gem 'omniauth', '~> 2.0'
gem 'omniauth-cas', '~> 3.0.0.beta.1'
gem 'omniauth_openid_connect', '~> 0.6.1'
gem 'omniauth_openid_connect', '~> 0.8.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-saml', '~> 2.0'
@ -102,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.4.0'
gem 'opentelemetry-api', '~> 1.5.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false

View File

@ -217,6 +217,8 @@ GEM
htmlentities (~> 4.3.3)
launchy (>= 2.1, < 4.0)
mail (~> 2.7)
email_validator (2.2.4)
activemodel
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
@ -228,6 +230,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (2.0.1)
httpclient (>= 2.2)
faraday-net_http (3.4.0)
@ -261,8 +265,8 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (3.25.5)
googleapis-common-protos-types (1.15.0)
google-protobuf (3.25.6)
googleapis-common-protos-types (1.18.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
@ -330,11 +334,13 @@ GEM
jmespath (1.6.2)
json (2.10.1)
json-canonicalization (1.0.0)
json-jwt (1.15.3.1)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
httpclient
faraday (~> 2.0)
faraday-follow_redirects
json-ld (3.3.2)
htmlentities (~> 4.3)
json-canonicalization (~> 1.0)
@ -435,41 +441,43 @@ GEM
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
omniauth (2.1.3)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-cas (3.0.0)
omniauth-cas (3.0.1)
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.1)
omniauth-saml (2.2.2)
omniauth (~> 2.1)
ruby-saml (~> 1.17)
omniauth_openid_connect (0.6.1)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 1.1)
openid_connect (1.4.2)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
json-jwt (>= 1.15.0)
net-smtp
rack-oauth2 (~> 1.21)
swd (~> 1.3)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_email
validate_url
webfinger (~> 1.2)
webfinger (~> 2.0)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.4.0)
opentelemetry-common (0.21.0)
opentelemetry-api (1.5.0)
opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.29.1)
opentelemetry-exporter-otlp (0.30.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
@ -500,8 +508,8 @@ GEM
opentelemetry-instrumentation-active_record (0.9.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_storage (0.1.0)
opentelemetry-api (~> 1.4.0)
opentelemetry-instrumentation-active_storage (0.1.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_support (0.8.0)
@ -553,14 +561,14 @@ GEM
opentelemetry-instrumentation-sidekiq (0.26.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.3.1)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.7.0)
opentelemetry-sdk (1.8.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.10.1)
opentelemetry-semantic_conventions (1.11.0)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
ostruct (0.6.1)
@ -609,10 +617,11 @@ GEM
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-oauth2 (1.21.3)
rack-oauth2 (2.2.1)
activesupport
attr_required
httpclient
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (3.2.0)
@ -816,10 +825,11 @@ GEM
stringio (3.1.4)
strong_migrations (2.2.0)
activerecord (>= 7)
swd (1.3.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
temple (0.10.3)
terminal-table (4.0.0)
@ -859,9 +869,6 @@ GEM
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
@ -875,9 +882,10 @@ GEM
openssl (>= 2.2)
safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.14.0)
webfinger (1.2.0)
webfinger (2.1.3)
activesupport
httpclient (>= 2.4)
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.25.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@ -976,9 +984,9 @@ DEPENDENCIES
omniauth-cas (~> 3.0.0.beta.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1)
opentelemetry-api (~> 1.4.0)
opentelemetry-exporter-otlp (~> 0.29.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.5.0)
opentelemetry-exporter-otlp (~> 0.30.0)
opentelemetry-instrumentation-active_job (~> 0.8.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Admin::Announcements::DistributionsController < Admin::BaseController
before_action :set_announcement
def create
authorize @announcement, :distribute?
@announcement.touch(:notification_sent_at)
Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id)
redirect_to admin_announcements_path
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Admin::Announcements::PreviewsController < Admin::BaseController
before_action :set_announcement
def show
authorize @announcement, :distribute?
@user_count = @announcement.scope_for_notification.count
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Admin::Announcements::TestsController < Admin::BaseController
before_action :set_announcement
def create
authorize @announcement, :distribute?
UserMailer.announcement_published(current_user, @announcement).deliver_later!
redirect_to admin_announcements_path
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -151,7 +151,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
<Sparklines
width={50}
height={28}
data={history ? history : Array.from(Array(7)).map(() => 0)}
data={history ?? Array.from(Array(7)).map(() => 0)}
>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>

View File

@ -153,6 +153,7 @@ export class IconButton extends PureComponent<Props, States> {
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}

View File

@ -12,12 +12,15 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import emojiCompressed from 'flavours/glitch/features/emoji/emoji_compressed';
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
import { assetHost } from 'flavours/glitch/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
@ -38,15 +41,18 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>
<Emoji
data={nimblePickerData}
emoji='sleuth_or_spy'
set='twitter'
size={32}
sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn}
/>
@ -68,7 +74,7 @@ class ModifierPickerMenu extends PureComponent {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
};
UNSAFE_componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.active) {
this.attachListeners();
} else {
@ -76,7 +82,7 @@ class ModifierPickerMenu extends PureComponent {
}
}
componentWillUnmount () {
componentWillUnmount() {
this.removeListeners();
}
@ -86,12 +92,12 @@ class ModifierPickerMenu extends PureComponent {
}
};
attachListeners () {
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners () {
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -100,17 +106,17 @@ class ModifierPickerMenu extends PureComponent {
this.node = c;
};
render () {
render() {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
</div>
);
}
@ -145,7 +151,7 @@ class ModifierPicker extends PureComponent {
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
<Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
@ -185,7 +191,7 @@ class EmojiPickerMenuImpl extends PureComponent {
}
};
componentDidMount () {
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
@ -200,7 +206,7 @@ class EmojiPickerMenuImpl extends PureComponent {
});
}
componentWillUnmount () {
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -253,7 +259,7 @@ class EmojiPickerMenuImpl extends PureComponent {
this.props.onSkinTone(modifier);
};
render () {
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
@ -281,6 +287,9 @@ class EmojiPickerMenuImpl extends PureComponent {
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8}
emojiSize={22}
sheetSize={32}
@ -347,7 +356,7 @@ class EmojiPickerDropdown extends PureComponent {
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
@ -388,7 +397,7 @@ class EmojiPickerDropdown extends PureComponent {
this.setState({ placement: state.placement });
};
render () {
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
@ -405,7 +414,7 @@ class EmojiPickerDropdown extends PureComponent {
/>
<Overlay show={active} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement })=> (
{({ props, placement }) => (
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu

View File

@ -45,6 +45,7 @@ type EmojiCompressed = [
Category[],
Data['aliases'],
EmojisWithoutShortCodes,
Data,
];
/*

View File

@ -9,18 +9,91 @@
// This version comment should be bumped each time the emoji data is changed
// to ensure that the prevaled file is regenerated by Babel
// version: 2
// version: 3
const { emojiIndex } = require('emoji-mart');
let data = require('emoji-mart/data/all.json');
// This json file contains the names of the categories.
const emojiMart5LocalesData = require('@emoji-mart/data/i18n/en.json');
const emojiMart5Data = require('@emoji-mart/data/sets/15/all.json');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
const _ = require('lodash');
const emojiMap = require('./emoji_map.json');
// This json file is downloaded from https://github.com/iamcal/emoji-data/
// and is used to correct the sheet coordinates since we're using that repo's sheet
const emojiSheetData = require('./emoji_sheet.json');
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
if(data.compressed) {
data = emojiMartUncompress(data);
// Grabbed from `emoji_utils` to avoid circular dependency
function unifiedToNative(unified) {
let unicodes = unified.split('-'),
codePoints = unicodes.map((u) => `0x${u}`);
return String.fromCodePoint(...codePoints);
}
let data = {
compressed: true,
categories: emojiMart5Data.categories.map(cat => {
return {
...cat,
name: emojiMart5LocalesData.categories[cat.id]
};
}),
aliases: emojiMart5Data.aliases,
emojis: _(emojiMart5Data.emojis).values().map(emoji => {
let skin_variations = {};
const unified = emoji.skins[0].unified.toUpperCase();
const emojiFromRawData = emojiSheetData.find(e => e.unified === unified);
if (!emojiFromRawData) {
return undefined;
}
if (emoji.skins.length > 1) {
const [, ...nonDefaultSkins] = emoji.skins;
nonDefaultSkins.forEach(skin => {
const [matchingRawCodePoints,matchingRawEmoji] = Object.entries(emojiFromRawData.skin_variations).find((pair) => {
const [, value] = pair;
return value.unified.toLowerCase() === skin.unified;
});
if (matchingRawEmoji && matchingRawCodePoints) {
// At the time of writing, the json from `@emoji-mart/data` doesn't have data
// for emoji like `woman-heart-woman` with two different skin tones.
const skinToneCode = matchingRawCodePoints.split('-')[0];
skin_variations[skinToneCode] = {
unified: matchingRawEmoji.unified.toUpperCase(),
non_qualified: null,
sheet_x: matchingRawEmoji.sheet_x,
sheet_y: matchingRawEmoji.sheet_y,
has_img_twitter: true,
native: unifiedToNative(matchingRawEmoji.unified.toUpperCase())
};
}
});
}
return {
a: emoji.name,
b: unified,
c: undefined,
f: true,
j: [emoji.id, ...emoji.keywords],
k: [emojiFromRawData.sheet_x, emojiFromRawData.sheet_y],
m: emoji.emoticons?.[0],
l: emoji.emoticons,
o: emoji.version,
id: emoji.id,
skin_variations,
native: unifiedToNative(unified.toUpperCase())
};
}).compact().keyBy(e => e.id).mapValues(e => _.omit(e, 'id')).value()
};
if (data.compressed) {
emojiMartUncompress(data);
}
const emojiMartData = data;
@ -32,15 +105,10 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMart5Data.emojis).forEach(key => {
let emoji = emojiMart5Data.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
shortcodeMap[emoji.skins[0].native] = emoji.id;
});
const stripModifiers = unicode => {
@ -84,13 +152,9 @@ Object.keys(emojiMap).forEach(key => {
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMartData.emojis).forEach(key => {
let emoji = emojiMartData.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
const { native } = emoji;
let { short_names, search, unified } = emojiMartData.emojis[key];
@ -135,4 +199,5 @@ module.exports = JSON.parse(JSON.stringify([
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
emojiMartData
]));

View File

@ -8,14 +8,15 @@ import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = {
[key in NonNullable<keyof ShortCodesToEmojiData>]: {
type Emojis = Record<
NonNullable<keyof ShortCodesToEmojiData>,
{
native: BaseEmoji['native'];
search: Search;
short_names: Emoji['short_names'];
unified: Emoji['unified'];
};
};
}
>;
const [
shortCodesToEmojiData,

View File

@ -1,5 +1,5 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export {
Picker,

File diff suppressed because one or more lines are too long

View File

@ -9,12 +9,13 @@ import type {
import emojiCompressed from './emoji_compressed';
import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = {
[key in FilenameData[number][0]]: {
type UnicodeMapping = Record<
FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number];
};
};
}
>;
const [
shortCodesToEmojiData,

View File

@ -28,7 +28,7 @@ export const ColumnSettings: React.FC = () => {
const dispatch = useAppDispatch();
const onChange = useCallback(
(key: string, checked: boolean) => {
(key: string[], checked: boolean) => {
dispatch(changeSetting(['home', ...key], checked));
},
[dispatch],

View File

@ -101,6 +101,7 @@ const EmbedModal: React.FC<{
/>
<iframe
// eslint-disable-next-line @typescript-eslint/no-deprecated
frameBorder='0'
ref={iframeRef}
sandbox='allow-scripts allow-same-origin'

View File

@ -318,12 +318,26 @@ const expiresInFromExpiresAt = expires_at => {
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase();
if (suggestions.length < 4) {
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
} else {
return suggestions;
suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
}
// Prefer capitalization from personal history, unless personal history is all lower-case
const fixSuggestionCapitalization = (suggestion) => {
if (suggestion.type !== 'hashtag')
return suggestion;
const tagFromHistory = tagHistory.find((tag) => tag.localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) === 0);
if (!tagFromHistory || tagFromHistory.toLowerCase() === tagFromHistory)
return suggestion;
return { ...suggestion, name: tagFromHistory };
};
return suggestions.map(fixSuggestionCapitalization);
};
const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {

View File

@ -151,7 +151,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
<Sparklines
width={50}
height={28}
data={history ? history : Array.from(Array(7)).map(() => 0)}
data={history ?? Array.from(Array(7)).map(() => 0)}
>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>

View File

@ -149,6 +149,7 @@ export class IconButton extends PureComponent<Props, States> {
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}

View File

@ -70,7 +70,7 @@ export const MediaItem: React.FC<{
attachment.get('description')) as string | undefined;
const previewUrl = attachment.get('preview_url') as string;
const fullUrl = attachment.get('url') as string;
const avatarUrl = status.getIn(['account', 'avatar_static']) as string;
const avatarUrl = account?.avatar_static;
const lang = status.get('language') as string;
const blurhash = attachment.get('blurhash') as string;
const statusId = status.get('id') as string;

View File

@ -12,11 +12,14 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import emojiCompressed from 'mastodon/features/emoji/emoji_compressed';
import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
@ -37,15 +40,18 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>
<Emoji
data={nimblePickerData}
emoji='sleuth_or_spy'
set='twitter'
size={32}
sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn}
/>
@ -67,7 +73,7 @@ class ModifierPickerMenu extends PureComponent {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
};
UNSAFE_componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.active) {
this.attachListeners();
} else {
@ -75,7 +81,7 @@ class ModifierPickerMenu extends PureComponent {
}
}
componentWillUnmount () {
componentWillUnmount() {
this.removeListeners();
}
@ -85,12 +91,12 @@ class ModifierPickerMenu extends PureComponent {
}
};
attachListeners () {
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners () {
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -99,17 +105,17 @@ class ModifierPickerMenu extends PureComponent {
this.node = c;
};
render () {
render() {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
@ -139,12 +145,12 @@ class ModifierPicker extends PureComponent {
this.props.onClose();
};
render () {
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
@ -184,7 +190,7 @@ class EmojiPickerMenuImpl extends PureComponent {
}
};
componentDidMount () {
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
@ -199,7 +205,7 @@ class EmojiPickerMenuImpl extends PureComponent {
});
}
componentWillUnmount () {
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -252,7 +258,7 @@ class EmojiPickerMenuImpl extends PureComponent {
this.props.onSkinTone(modifier);
};
render () {
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
@ -280,6 +286,9 @@ class EmojiPickerMenuImpl extends PureComponent {
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8}
emojiSize={22}
sheetSize={32}
@ -345,7 +354,7 @@ class EmojiPickerDropdown extends PureComponent {
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
@ -386,7 +395,7 @@ class EmojiPickerDropdown extends PureComponent {
this.setState({ placement: state.placement });
};
render () {
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
@ -403,7 +412,7 @@ class EmojiPickerDropdown extends PureComponent {
/>
<Overlay show={active} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement })=> (
{({ props, placement }) => (
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu

View File

@ -45,6 +45,7 @@ type EmojiCompressed = [
Category[],
Data['aliases'],
EmojisWithoutShortCodes,
Data,
];
/*

View File

@ -9,18 +9,91 @@
// This version comment should be bumped each time the emoji data is changed
// to ensure that the prevaled file is regenerated by Babel
// version: 2
// version: 3
const { emojiIndex } = require('emoji-mart');
let data = require('emoji-mart/data/all.json');
// This json file contains the names of the categories.
const emojiMart5LocalesData = require('@emoji-mart/data/i18n/en.json');
const emojiMart5Data = require('@emoji-mart/data/sets/15/all.json');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
const _ = require('lodash');
const emojiMap = require('./emoji_map.json');
// This json file is downloaded from https://github.com/iamcal/emoji-data/
// and is used to correct the sheet coordinates since we're using that repo's sheet
const emojiSheetData = require('./emoji_sheet.json');
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
if(data.compressed) {
data = emojiMartUncompress(data);
// Grabbed from `emoji_utils` to avoid circular dependency
function unifiedToNative(unified) {
let unicodes = unified.split('-'),
codePoints = unicodes.map((u) => `0x${u}`);
return String.fromCodePoint(...codePoints);
}
let data = {
compressed: true,
categories: emojiMart5Data.categories.map(cat => {
return {
...cat,
name: emojiMart5LocalesData.categories[cat.id]
};
}),
aliases: emojiMart5Data.aliases,
emojis: _(emojiMart5Data.emojis).values().map(emoji => {
let skin_variations = {};
const unified = emoji.skins[0].unified.toUpperCase();
const emojiFromRawData = emojiSheetData.find(e => e.unified === unified);
if (!emojiFromRawData) {
return undefined;
}
if (emoji.skins.length > 1) {
const [, ...nonDefaultSkins] = emoji.skins;
nonDefaultSkins.forEach(skin => {
const [matchingRawCodePoints,matchingRawEmoji] = Object.entries(emojiFromRawData.skin_variations).find((pair) => {
const [, value] = pair;
return value.unified.toLowerCase() === skin.unified;
});
if (matchingRawEmoji && matchingRawCodePoints) {
// At the time of writing, the json from `@emoji-mart/data` doesn't have data
// for emoji like `woman-heart-woman` with two different skin tones.
const skinToneCode = matchingRawCodePoints.split('-')[0];
skin_variations[skinToneCode] = {
unified: matchingRawEmoji.unified.toUpperCase(),
non_qualified: null,
sheet_x: matchingRawEmoji.sheet_x,
sheet_y: matchingRawEmoji.sheet_y,
has_img_twitter: true,
native: unifiedToNative(matchingRawEmoji.unified.toUpperCase())
};
}
});
}
return {
a: emoji.name,
b: unified,
c: undefined,
f: true,
j: [emoji.id, ...emoji.keywords],
k: [emojiFromRawData.sheet_x, emojiFromRawData.sheet_y],
m: emoji.emoticons?.[0],
l: emoji.emoticons,
o: emoji.version,
id: emoji.id,
skin_variations,
native: unifiedToNative(unified.toUpperCase())
};
}).compact().keyBy(e => e.id).mapValues(e => _.omit(e, 'id')).value()
};
if (data.compressed) {
emojiMartUncompress(data);
}
const emojiMartData = data;
@ -32,15 +105,10 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMart5Data.emojis).forEach(key => {
let emoji = emojiMart5Data.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
shortcodeMap[emoji.skins[0].native] = emoji.id;
});
const stripModifiers = unicode => {
@ -84,13 +152,9 @@ Object.keys(emojiMap).forEach(key => {
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMartData.emojis).forEach(key => {
let emoji = emojiMartData.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
const { native } = emoji;
let { short_names, search, unified } = emojiMartData.emojis[key];
@ -135,4 +199,5 @@ module.exports = JSON.parse(JSON.stringify([
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
emojiMartData
]));

View File

@ -8,14 +8,15 @@ import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = {
[key in NonNullable<keyof ShortCodesToEmojiData>]: {
type Emojis = Record<
NonNullable<keyof ShortCodesToEmojiData>,
{
native: BaseEmoji['native'];
search: Search;
short_names: Emoji['short_names'];
unified: Emoji['unified'];
};
};
}
>;
const [
shortCodesToEmojiData,

View File

@ -1,5 +1,5 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export {
Picker,

File diff suppressed because one or more lines are too long

View File

@ -9,12 +9,13 @@ import type {
import emojiCompressed from './emoji_compressed';
import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = {
[key in FilenameData[number][0]]: {
type UnicodeMapping = Record<
FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number];
};
};
}
>;
const [
shortCodesToEmojiData,

View File

@ -17,7 +17,7 @@ export const ColumnSettings: React.FC = () => {
const dispatch = useAppDispatch();
const onChange = useCallback(
(key: string, checked: boolean) => {
(key: string[], checked: boolean) => {
dispatch(changeSetting(['home', ...key], checked));
},
[dispatch],

View File

@ -101,6 +101,7 @@ const EmbedModal: React.FC<{
/>
<iframe
// eslint-disable-next-line @typescript-eslint/no-deprecated
frameBorder='0'
ref={iframeRef}
sandbox='allow-scripts allow-same-origin'

View File

@ -242,12 +242,26 @@ const expiresInFromExpiresAt = expires_at => {
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase();
if (suggestions.length < 4) {
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
} else {
return suggestions;
suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
}
// Prefer capitalization from personal history, unless personal history is all lower-case
const fixSuggestionCapitalization = (suggestion) => {
if (suggestion.type !== 'hashtag')
return suggestion;
const tagFromHistory = tagHistory.find((tag) => tag.localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) === 0);
if (!tagFromHistory || tagFromHistory.toLowerCase() === tagFromHistory)
return suggestion;
return { ...suggestion, name: tagFromHistory };
};
return suggestions.map(fixSuggestionCapitalization);
};
const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {

View File

@ -219,6 +219,15 @@ class UserMailer < Devise::Mailer
end
end
def announcement_published(user, announcement)
@resource = user
@announcement = announcement
I18n.with_locale(locale) do
mail subject: default_i18n_subject
end
end
private
def default_devise_subject

View File

@ -4,17 +4,18 @@
#
# Table name: announcements
#
# id :bigint(8) not null, primary key
# text :text default(""), not null
# published :boolean default(FALSE), not null
# all_day :boolean default(FALSE), not null
# scheduled_at :datetime
# starts_at :datetime
# ends_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# published_at :datetime
# status_ids :bigint(8) is an Array
# id :bigint(8) not null, primary key
# all_day :boolean default(FALSE), not null
# ends_at :datetime
# notification_sent_at :datetime
# published :boolean default(FALSE), not null
# published_at :datetime
# scheduled_at :datetime
# starts_at :datetime
# status_ids :bigint(8) is an Array
# text :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Announcement < ApplicationRecord
@ -54,6 +55,10 @@ class Announcement < ApplicationRecord
update!(published: false, scheduled_at: nil)
end
def notification_sent?
notification_sent_at.present?
end
def mentions
@mentions ||= Account.from_text(text)
end
@ -86,6 +91,10 @@ class Announcement < ApplicationRecord
end
end
def scope_for_notification
User.confirmed.joins(:account).merge(Account.without_suspended)
end
private
def grouped_ordered_announcement_reactions

View File

@ -9,11 +9,12 @@ class TermsOfService::Generator
admin_email
arbitration_address
arbitration_website
choice_of_law
dmca_address
dmca_email
domain
jurisdiction
choice_of_law
min_age
).freeze
attr_accessor(*VARIABLES)

View File

@ -16,4 +16,8 @@ class AnnouncementPolicy < ApplicationPolicy
def destroy?
role.can?(:manage_announcements)
end
def distribute?
record.published? && !record.notification_sent? && role.can?(:manage_settings)
end
end

View File

@ -10,6 +10,8 @@
= l(announcement.created_at)
%div
- if can?(:distribute, announcement)
= table_link_to 'mail', t('admin.terms_of_service.notify_users'), admin_announcement_preview_path(announcement)
- if can?(:update, announcement)
- if announcement.published?
= table_link_to 'toggle_off', t('admin.announcements.unpublish'), unpublish_admin_announcement_path(announcement), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }

View File

@ -0,0 +1,20 @@
- content_for :page_title do
= t('admin.announcements.preview.title')
- content_for :heading_actions do
.back-link
= link_to admin_announcements_path do
= material_symbol 'chevron_left'
= t('admin.announcements.back')
%p.lead
= t('admin.announcements.preview.explanation_html', count: @user_count, display_count: number_with_delimiter(@user_count))
.prose
= linkify(@announcement.text)
%hr.spacer/
.content__heading__actions
= link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_announcement_test_path(@announcement), method: :post, class: 'button button-secondary'
= link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_announcement_distribution_path(@announcement), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') }

View File

@ -19,6 +19,9 @@
.fields-group
= form.input :domain, wrapper: :with_label
.fields-group
= form.input :min_age, wrapper: :with_label
.fields-group
= form.input :jurisdiction, wrapper: :with_label

View File

@ -0,0 +1,12 @@
= content_for :heading do
= render 'application/mailer/heading',
image_url: frontend_asset_url('images/mailer-new/heading/user.png'),
title: t('user_mailer.announcement_published.title', domain: site_hostname)
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-body-padding-td
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-inner-card-td.email-prose
%p= t('user_mailer.announcement_published.description', domain: site_hostname)
= linkify(@announcement.text)

View File

@ -0,0 +1,7 @@
<%= t('user_mailer.announcement_published.title') %>
===
<%= t('user_mailer.announcement_published.description', domain: site_hostname) %>
<%= @announcement.text %>

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Admin::DistributeAnnouncementNotificationWorker
include Sidekiq::Worker
def perform(announcement_id)
announcement = Announcement.find(announcement_id)
announcement.scope_for_notification.find_each do |user|
UserMailer.announcement_published(user, announcement).deliver_later!
end
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -4,14 +4,4 @@
# before other initializers as Rails may otherwise memoize a list of migrations
# excluding the post deployment migrations.
unless ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS']
Rails.application.config.paths['db'].each do |db_path|
path = Rails.root.join(db_path, 'post_migrate').to_s
Rails.application.config.paths['db/migrate'] << path
# Rails memoizes migrations at certain points where it won't read the above
# path just yet. As such we must also update the following list of paths.
ActiveRecord::Migrator.migrations_paths << path
end
end
Mastodon::Database.add_post_migrate_path_to_rails

View File

@ -309,6 +309,7 @@ en:
title: Audit log
unavailable_instance: "(domain name unavailable)"
announcements:
back: Back to announcements
destroyed_msg: Announcement successfully deleted!
edit:
title: Edit announcement
@ -317,6 +318,9 @@ en:
new:
create: Create announcement
title: New announcement
preview:
explanation_html: 'The email will be sent to <strong>%{display_count} users</strong>. The following text will be included in the e-mail:'
title: Preview announcement notification
publish: Publish
published_msg: Announcement successfully published!
scheduled_for: Scheduled for %{time}
@ -1906,6 +1910,10 @@ en:
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
webauthn: Security keys
user_mailer:
announcement_published:
description: 'The administrators of %{domain} are making an announcement:'
subject: Service announcement
title: "%{domain} service announcement"
appeal_approved:
action: Account Settings
explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been approved. Your account is once again in good standing.

View File

@ -136,13 +136,14 @@ en:
text: Can be structured with Markdown syntax.
terms_of_service_generator:
admin_email: Legal notices include counternotices, court orders, takedown requests, and law enforcement requests.
arbitration_address: Can be the same as Physical address above, or “N/A” if using email
arbitration_website: Can be a web form, or “N/A” if using email
arbitration_address: Can be the same as Physical address above, or “N/A” if using email.
arbitration_website: Can be a web form, or “N/A” if using email.
choice_of_law: City, region, territory or state the internal substantive laws of which shall govern any and all claims.
dmca_address: For US operators, use the address registered in the DMCA Designated Agent Directory. A P.O. Box listing is available upon direct request, use the DMCA Designated Agent Post Office Box Waiver Request to email the Copyright Office and describe that you are a home-based content moderator who fears revenge or retribution for your actions and need to use a P.O. Box to remove your home address from public view.
dmca_email: Can be the same email used for “Email address for legal notices” above
dmca_email: Can be the same email used for “Email address for legal notices” above.
domain: Unique identification of the online service you are providing.
jurisdiction: List the country where whoever pays the bills lives. If its a company or other entity, list the country where its incorporated, and the city, region, territory or state as appropriate.
min_age: Should not be below the minimum age required by the laws of your jurisdiction.
user:
chosen_languages: When checked, only posts in selected languages will be displayed in public timelines
role: The role controls which permissions the user has.
@ -346,6 +347,7 @@ en:
dmca_email: Email address for DMCA/copyright notices
domain: Domain
jurisdiction: Legal jurisdiction
min_age: Minimum age
user:
role: Role
time_zone: Time zone

View File

@ -50,6 +50,10 @@ namespace :admin do
post :publish
post :unpublish
end
resource :preview, only: [:show], module: :announcements
resource :test, only: [:create], module: :announcements
resource :distribution, only: [:create], module: :announcements
end
with_options to: redirect('/admin/settings/branding') do

View File

@ -17,7 +17,7 @@ into these Terms. You should also read these policies before using the Instance.
## Age Requirements and Responsibility of Parents and Legal Guardians
By accessing the Instance, you signify that you are at least thirteen years old
By accessing the Instance, you signify that you are at least %{min_age} years old
and that you meet the minimum age required by the laws in your country. If you
are old enough to access the Instance in your country, but are not old enough to
have the legal authority to consent to our Terms, please ask your parent or

View File

@ -48,7 +48,7 @@ module.exports = merge(sharedConfig, {
logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
}),
new InjectManifest({
additionalManifestEntries: ['1f602.svg', 'sheet_13.png'].map((filename) => {
additionalManifestEntries: ['1f602.svg', 'sheet_15.png'].map((filename) => {
const path = resolve(root, 'public', 'emoji', filename);
const body = readFileSync(path);
const md5 = createHash('md5');

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddNotificationSentAtToAnnouncements < ActiveRecord::Migration[8.0]
def change
add_column :announcements, :notification_sent_at, :datetime
end
end

View File

@ -258,6 +258,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_074104) do
t.datetime "updated_at", precision: nil, null: false
t.datetime "published_at", precision: nil
t.bigint "status_ids", array: true
t.datetime "notification_sent_at"
end
create_table "annual_report_statuses_per_account_counts", force: :cascade do |t|
@ -1114,8 +1115,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_074104) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.date "effective_date"
t.index ["effective_date"], name: "index_terms_of_services_on_effective_dat
e", unique: true, where: "(effective_date IS NOT NULL)"
t.index ["effective_date"], name: "index_terms_of_services_on_effective_date", unique: true, where: "(effective_date IS NOT NULL)"
end
create_table "tombstones", force: :cascade do |t|

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
require_relative '../mastodon/database'
require_relative '../mastodon/snowflake'
module ActiveRecord
@ -8,6 +9,8 @@ module ActiveRecord
original_load_schema = instance_method(:load_schema)
define_method(:load_schema) do |db_config, *args|
Mastodon::Database.add_post_migrate_path_to_rails(force: true)
ActiveRecord::Base.establish_connection(db_config)
Mastodon::Snowflake.define_timestamp_id

46
lib/mastodon/database.rb Normal file
View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
# This file is entirely lifted from GitLab.
# The original file:
# https://gitlab.com/gitlab-org/gitlab/-/blob/69127d59467185cf4ff1109d88ceec1f499f354f/lib/gitlab/database.rb#L244-258
# Copyright (c) 2011-present GitLab B.V.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
module Mastodon
module Database
def self.add_post_migrate_path_to_rails(force: false)
return if ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS'] && !force
Rails.application.config.paths['db'].each do |db_path|
path = Rails.root.join(db_path, 'post_migrate').to_s
next if Rails.application.config.paths['db/migrate'].include?(path)
Rails.application.config.paths['db/migrate'] << path
# Rails memoizes migrations at certain points where it won't read the above
# path just yet. As such we must also update the following list of paths.
ActiveRecord::Migrator.migrations_paths << path
end
end
end
end

View File

@ -103,4 +103,36 @@ namespace :emojis do
gen_border map[emoji], 'white'
end
end
desc 'Download the JSON sheet data of emojis'
task :download_sheet_json do
source = 'https://raw.githubusercontent.com/iamcal/emoji-data/refs/tags/v15.1.2/emoji.json'
dest = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_sheet.json')
puts "Downloading emoji data from source... (#{source})"
res = HTTP.get(source).to_s
data = JSON.parse(res)
filtered_data = data.map do |emoji|
filtered_item = {
'unified' => emoji['unified'],
'sheet_x' => emoji['sheet_x'],
'sheet_y' => emoji['sheet_y'],
'skin_variations' => {},
}
emoji['skin_variations']&.each do |key, variation|
filtered_item['skin_variations'][key] = {
'unified' => variation['unified'],
'sheet_x' => variation['sheet_x'],
'sheet_y' => variation['sheet_y'],
}
end
filtered_item
end
File.write(dest, JSON.generate(filtered_data))
end
end

View File

@ -1,7 +1,7 @@
{
"name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.7.0",
"engines": {
"node": ">=18"
},
@ -46,6 +46,7 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "1.2.1",
"@formatjs/intl-pluralrules": "^5.2.2",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
@ -183,10 +184,10 @@
"eslint-define-config": "^2.0.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-formatjs": "^5.0.0",
"eslint-plugin-import": "~2.30.0",
"eslint-plugin-import": "~2.31.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-jsx-a11y": "~6.10.0",
"eslint-plugin-promise": "~7.1.0",
"eslint-plugin-promise": "~7.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^5.0.0",
"husky": "^9.0.11",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/emoji/sheet_15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,33 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Settings::SessionsController do
render_views
let(:user) { Fabricate(:user) }
let(:session_activation) { Fabricate(:session_activation, user: user) }
before { sign_in user, scope: :user }
describe 'DELETE #destroy' do
subject { delete :destroy, params: { id: id } }
context 'when session activation exists' do
let(:id) { session_activation.id }
it 'destroys session activation' do
expect(subject).to redirect_to edit_user_registration_path
expect(SessionActivation.find_by(id: id)).to be_nil
end
end
context 'when session activation does not exist' do
let(:id) { session_activation.id + 1000 }
it 'destroys session activation' do
expect(subject).to have_http_status 404
end
end
end
end

View File

@ -317,4 +317,16 @@ RSpec.describe UserMailer do
.and(have_body_text(I18n.t('user_mailer.terms_of_service_changed.changelog')))
end
end
describe '#announcement_published' do
let(:announcement) { Fabricate :announcement }
let(:mail) { described_class.announcement_published(receiver, announcement) }
it 'renders announcement_published mail' do
expect(mail)
.to be_present
.and(have_subject(I18n.t('user_mailer.announcement_published.subject')))
.and(have_body_text(I18n.t('user_mailer.announcement_published.description', domain: Rails.configuration.x.local_domain)))
end
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Settings Sessions' do
let(:user) { Fabricate(:user) }
before { sign_in(user) }
describe 'DELETE /settings/sessions/:id' do
context 'when session activation does not exist' do
it 'returns not found' do
delete settings_session_path(123_456_789)
expect(response)
.to have_http_status(404)
end
end
end
end

View File

@ -1,10 +1,6 @@
# frozen_string_literal: true
module SystemHelpers
def admin_user
Fabricate(:admin_user)
end
def submit_button
I18n.t('generic.save_changes')
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Announcement Mail Distributions' do
let(:user) { Fabricate(:admin_user) }
let(:announcement) { Fabricate(:announcement, notification_sent_at: nil) }
before { sign_in(user) }
describe 'Sending an announcement notification', :inline_jobs do
it 'marks the announcement as notified and sends the email' do
visit admin_announcement_preview_path(announcement)
expect(page)
.to have_title(I18n.t('admin.announcements.preview.title'))
emails = capture_emails do
expect { click_on I18n.t('admin.terms_of_service.preview.send_to_all', count: 1, display_count: 1) }
.to(change { announcement.reload.notification_sent_at })
end
expect(emails.first)
.to be_present
.and(deliver_to(user.email))
expect(page)
.to have_title(I18n.t('admin.announcements.title'))
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Announcements Mail Previews' do
let(:admin_user) { Fabricate(:admin_user) }
let(:announcement) { Fabricate(:announcement, notification_sent_at: nil) }
before { sign_in(admin_user) }
describe 'Viewing Announcements Mail previews' do
it 'shows the Announcement Mail preview page' do
visit admin_announcement_preview_path(announcement)
expect(page)
.to have_title(I18n.t('admin.announcements.preview.title'))
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin TermsOfService Tests' do
let(:user) { Fabricate(:admin_user) }
let(:announcement) { Fabricate(:announcement, notification_sent_at: nil) }
before { sign_in(user) }
describe 'Sending test Announcement email', :inline_jobs do
it 'generates the test email' do
visit admin_announcement_preview_path(announcement)
expect(page)
.to have_title(I18n.t('admin.announcements.preview.title'))
emails = capture_emails { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) }
expect(emails.first)
.to be_present
.and(deliver_to(user.email))
expect(page)
.to have_title(I18n.t('admin.announcements.title'))
end
end
end

View File

@ -117,7 +117,7 @@ RSpec.describe 'Admin::Announcements' do
end
def text_label
I18n.t('simple_form.labels.announcement.text')
form_label('announcement.text')
end
def admin_user

View File

@ -57,7 +57,7 @@ RSpec.describe 'Admin Invites' do
end
def max_use_field
I18n.t('simple_form.labels.defaults.max_uses')
form_label('defaults.max_uses')
end
end
end

View File

@ -3,6 +3,8 @@
require 'rails_helper'
RSpec.describe 'Admin Relationships' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
describe 'Viewing account relationships page' do

View File

@ -65,7 +65,7 @@ RSpec.describe 'Admin Rules' do
end
def submit_form
click_on I18n.t('generic.save_changes')
click_on(submit_button)
end
end

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::About' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to about settings' do
sign_in admin_user
visit admin_settings_about_path
expect(page)
.to have_title(I18n.t('admin.settings.about.title'))
fill_in extended_description_field,
with: 'new site description'

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::Appearance' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to appearance settings' do
sign_in admin_user
visit admin_settings_appearance_path
expect(page)
.to have_title(I18n.t('admin.settings.appearance.title'))
fill_in custom_css_field,
with: 'html { display: inline; }'

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::Branding' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to branding settings' do
sign_in admin_user
visit admin_settings_branding_path
expect(page)
.to have_title(I18n.t('admin.settings.branding.title'))
fill_in short_description_field,
with: 'new key value'

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::ContentRetention' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to content retention settings' do
sign_in admin_user
visit admin_settings_content_retention_path
expect(page)
.to have_title(I18n.t('admin.settings.content_retention.title'))
fill_in media_cache_retention_period_field,
with: '2'

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::Discovery' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to discovery settings' do
sign_in admin_user
visit admin_settings_discovery_path
expect(page)
.to have_title(I18n.t('admin.settings.discovery.title'))
check trends_box

View File

@ -3,9 +3,14 @@
require 'rails_helper'
RSpec.describe 'Admin::Settings::Registrations' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to registrations settings' do
sign_in admin_user
visit admin_settings_registrations_path
expect(page)
.to have_title(I18n.t('admin.settings.registrations.title'))
select open_mode_option,
from: registrations_mode_field

View File

@ -28,7 +28,7 @@ RSpec.describe 'Admin Tags' do
end
def display_name_field
I18n.t('simple_form.labels.defaults.display_name')
form_label('defaults.display_name')
end
def match_error_text

View File

@ -3,6 +3,8 @@
require 'rails_helper'
RSpec.describe 'Admin TermsOfService Drafts' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
describe 'Managing TOS drafts' do

View File

@ -3,6 +3,8 @@
require 'rails_helper'
RSpec.describe 'Admin TermsOfService Generates' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
describe 'Generating a TOS policy' do
@ -27,6 +29,8 @@ RSpec.describe 'Admin TermsOfService Generates' do
fill_in 'terms_of_service_generator_domain', with: 'host.example'
fill_in 'terms_of_service_generator_jurisdiction', with: 'Europe'
fill_in 'terms_of_service_generator_choice_of_law', with: 'New York'
fill_in 'terms_of_service_generator_min_age', with: '16'
expect { submit_form }
.to change(TermsOfService, :count).by(1)
expect(page)

View File

@ -4,6 +4,7 @@ require 'rails_helper'
RSpec.describe 'Admin TermsOfService Previews' do
let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) }
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }

View File

@ -61,7 +61,7 @@ RSpec.describe 'Admin Warning Presets' do
end
def submit_form
click_on I18n.t('generic.save_changes')
click_on(submit_button)
end
end

View File

@ -81,7 +81,7 @@ RSpec.describe 'Admin Webhooks' do
end
def submit_form
click_on I18n.t('generic.save_changes')
click_on(submit_button)
end
end

View File

@ -108,6 +108,6 @@ RSpec.describe 'Filters' do
end
def filter_title_field
I18n.t('simple_form.labels.defaults.title')
form_label('defaults.title')
end
end

View File

@ -71,9 +71,9 @@ RSpec.describe 'Invites' do
def fill_invite_form
select I18n.t('invites.max_uses', count: 100),
from: I18n.t('simple_form.labels.defaults.max_uses')
from: form_label('defaults.max_uses')
select I18n.t("invites.expires_in.#{30.minutes.to_i}"),
from: I18n.t('simple_form.labels.defaults.expires_in')
check I18n.t('simple_form.labels.defaults.autofollow')
from: form_label('defaults.expires_in')
check form_label('defaults.autofollow')
end
end

View File

@ -96,7 +96,7 @@ RSpec.describe 'Settings applications page' do
end
def submit_form
click_on I18n.t('generic.save_changes')
click_on(submit_button)
end
end

View File

@ -33,18 +33,18 @@ RSpec.describe 'Settings preferences appearance page' do
end
def confirm_delete_field
I18n.t('simple_form.labels.defaults.setting_delete_modal')
form_label('defaults.setting_delete_modal')
end
def confirm_reblog_field
I18n.t('simple_form.labels.defaults.setting_boost_modal')
form_label('defaults.setting_boost_modal')
end
def theme_selection_field
I18n.t('simple_form.labels.defaults.setting_theme')
form_label('defaults.setting_theme')
end
def advanced_layout_field
I18n.t('simple_form.labels.defaults.setting_advanced_layout')
form_label('defaults.setting_advanced_layout')
end
end

View File

@ -22,6 +22,6 @@ RSpec.describe 'Settings preferences notifications page' do
end
def notifications_follow_field
I18n.t('simple_form.labels.notification_emails.follow')
form_label('notification_emails.follow')
end
end

View File

@ -29,7 +29,7 @@ RSpec.describe 'Settings preferences other page' do
end
def mark_sensitive_field
I18n.t('simple_form.labels.defaults.setting_default_sensitive')
form_label('defaults.setting_default_sensitive')
end
def language_field(key)

View File

@ -27,7 +27,7 @@ RSpec.describe 'Settings Privacy' do
.to change { user.account.reload.discoverable }.to(true)
expect(page)
.to have_content(I18n.t('privacy.title'))
.and have_content(I18n.t('generic.changes_saved_msg'))
.and have_content(success_message)
expect(ActivityPub::UpdateDistributionWorker)
.to have_received(:perform_async).with(user.account.id)
end

View File

@ -28,10 +28,10 @@ RSpec.describe 'Settings profile page' do
end
def display_name_field
I18n.t('simple_form.labels.defaults.display_name')
form_label('defaults.display_name')
end
def avatar_field
I18n.t('simple_form.labels.defaults.avatar')
form_label('defaults.avatar')
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Settings Sessions' do
let(:user) { Fabricate(:user) }
let!(:session_activation) { Fabricate(:session_activation, user: user) }
before { sign_in(user) }
describe 'deleting a session' do
it 'deletes listed session activation from the auth page' do
visit edit_user_registration_path
expect(page)
.to have_title(I18n.t('settings.account_settings'))
expect { click_on(I18n.t('sessions.revoke')) }
.to change(SessionActivation, :count).by(-1)
expect { session_activation.reload }
.to raise_error(ActiveRecord::RecordNotFound)
expect(page)
.to have_content(I18n.t('sessions.revoke_success'))
end
end
end

View File

@ -44,6 +44,6 @@ RSpec.describe 'Settings verification page' do
end
def attribution_field
I18n.t('simple_form.labels.account.attribution_domains')
form_label('account.attribution_domains')
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Admin::DistributeAnnouncementNotificationWorker do
let(:worker) { described_class.new }
describe '#perform' do
context 'with missing record' do
it 'runs without error' do
expect { worker.perform(nil) }.to_not raise_error
end
end
context 'with valid announcement' do
let(:announcement) { Fabricate(:announcement) }
let!(:user) { Fabricate :user, confirmed_at: 3.days.ago }
it 'sends the announcement via email', :inline_jobs do
emails = capture_emails { worker.perform(announcement.id) }
expect(emails.size)
.to eq(1)
expect(emails.first)
.to have_attributes(
to: [user.email],
subject: I18n.t('user_mailer.announcement_published.subject')
)
end
end
end
end

View File

@ -1,7 +1,7 @@
{
"name": "@mastodon/streaming",
"license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.7.0",
"engines": {
"node": ">=18"
},

1464
yarn.lock

File diff suppressed because it is too large Load Diff