Merge commit '04492e7f934d07f8e89fa9c3d4fe3381f251e8a2' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2025-03-07 18:34:27 +01:00
commit 4bea9a0a66
83 changed files with 1520 additions and 846 deletions

View file

@ -20,3 +20,9 @@ postgres14
redis redis
elasticsearch elasticsearch
chart 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 - .github/workflows/test-image-build.yml
- Dockerfile - Dockerfile
- streaming/Dockerfile - streaming/Dockerfile
- .dockerignore
permissions: permissions:
contents: read contents: read

View file

@ -77,6 +77,18 @@ jobs:
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby 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 - name: Test "one step migration" flow
run: | run: |
bin/rails db:drop bin/rails db:drop

View file

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

View file

@ -96,6 +96,9 @@ RUN \
# Set /opt/mastodon as working directory # Set /opt/mastodon as working directory
WORKDIR /opt/mastodon 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 # hadolint ignore=DL3008,DL3005
RUN \ RUN \
# Mount Apt cache and lib directories from Docker buildx caches # Mount Apt cache and lib directories from Docker buildx caches
@ -165,7 +168,7 @@ RUN \
libexif-dev \ libexif-dev \
libexpat1-dev \ libexpat1-dev \
libgirepository1.0-dev \ libgirepository1.0-dev \
libheif-dev \ libheif-dev/bookworm-backports \
libimagequant-dev \ libimagequant-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
liblcms2-dev \ liblcms2-dev \
@ -348,7 +351,7 @@ RUN \
# libvips components # libvips components
libcgif0 \ libcgif0 \
libexif12 \ libexif12 \
libheif1 \ libheif1/bookworm-backports \
libimagequant0 \ libimagequant0 \
libjpeg62-turbo \ libjpeg62-turbo \
liblcms2-2 \ liblcms2-2 \

View file

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

View file

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

View file

@ -70,7 +70,7 @@ export const MediaItem: React.FC<{
attachment.get('description')) as string | undefined; attachment.get('description')) as string | undefined;
const previewUrl = attachment.get('preview_url') as string; const previewUrl = attachment.get('preview_url') as string;
const fullUrl = attachment.get('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 lang = status.get('language') as string;
const blurhash = attachment.get('blurhash') as string; const blurhash = attachment.get('blurhash') as string;
const statusId = status.get('id') 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 MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import emojiCompressed from 'mastodon/features/emoji/emoji_compressed';
import { assetHost } from 'mastodon/utils/config'; import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, 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 listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const notFoundFn = () => ( const notFoundFn = () => (
<div className='emoji-mart-no-results'> <div className='emoji-mart-no-results'>
<Emoji <Emoji
data={nimblePickerData}
emoji='sleuth_or_spy' emoji='sleuth_or_spy'
set='twitter' set='twitter'
size={32} size={32}
sheetSize={32} sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn} backgroundImageFn={backgroundImageFn}
/> />
@ -104,12 +110,12 @@ class ModifierPickerMenu extends PureComponent {
return ( return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> <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={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 emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} 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 emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} 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 emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} 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 emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} 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 emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} 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> </div>
); );
} }
@ -144,7 +150,7 @@ class ModifierPicker extends PureComponent {
return ( return (
<div className='emoji-picker-dropdown__modifiers'> <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} /> <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div> </div>
); );
@ -280,6 +286,9 @@ class EmojiPickerMenuImpl extends PureComponent {
return ( return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker <EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8} perLine={8}
emojiSize={22} emojiSize={22}
sheetSize={32} sheetSize={32}

View file

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

View file

@ -9,18 +9,91 @@
// This version comment should be bumped each time the emoji data is changed // This version comment should be bumped each time the emoji data is changed
// to ensure that the prevaled file is regenerated by Babel // to ensure that the prevaled file is regenerated by Babel
// version: 2 // version: 3
const { emojiIndex } = require('emoji-mart'); // This json file contains the names of the categories.
let data = require('emoji-mart/data/all.json'); 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 { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
const _ = require('lodash');
const emojiMap = require('./emoji_map.json'); 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 { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
// 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) { if (data.compressed) {
data = emojiMartUncompress(data); emojiMartUncompress(data);
} }
const emojiMartData = data; const emojiMartData = data;
@ -32,15 +105,10 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {}; const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = []; const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiMart5Data.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key]; let emoji = emojiMart5Data.emojis[key];
// Emojis with skin tone modifiers are stored like this shortcodeMap[emoji.skins[0].native] = emoji.id;
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
}); });
const stripModifiers = unicode => { const stripModifiers = unicode => {
@ -84,13 +152,9 @@ Object.keys(emojiMap).forEach(key => {
} }
}); });
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiMartData.emojis).forEach(key => {
let emoji = emojiIndex.emojis[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; const { native } = emoji;
let { short_names, search, unified } = emojiMartData.emojis[key]; let { short_names, search, unified } = emojiMartData.emojis[key];
@ -135,4 +199,5 @@ module.exports = JSON.parse(JSON.stringify([
emojiMartData.categories, emojiMartData.categories,
emojiMartData.aliases, emojiMartData.aliases,
emojisWithoutShortCodes, emojisWithoutShortCodes,
emojiMartData
])); ]));

View file

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

View file

@ -1,5 +1,5 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji'; import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker'; import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export { export {
Picker, 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 emojiCompressed from './emoji_compressed';
import { unicodeToFilename } from './unicode_to_filename'; import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = { type UnicodeMapping = Record<
[key in FilenameData[number][0]]: { FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey; shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number]; filename: FilenameData[number][number];
}; }
}; >;
const [ const [
shortCodesToEmojiData, shortCodesToEmojiData,

View file

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

View file

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

View file

@ -242,12 +242,26 @@ const expiresInFromExpiresAt = expires_at => {
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase(); prefix = prefix.toLowerCase();
if (suggestions.length < 4) { if (suggestions.length < 4) {
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); 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 }))); suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
} else {
return suggestions;
} }
// 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 }) => { const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,8 @@
= l(announcement.created_at) = l(announcement.created_at)
%div %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 can?(:update, announcement)
- if announcement.published? - 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') } = 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 .fields-group
= form.input :domain, wrapper: :with_label = form.input :domain, wrapper: :with_label
.fields-group
= form.input :min_age, wrapper: :with_label
.fields-group .fields-group
= form.input :jurisdiction, wrapper: :with_label = 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 # before other initializers as Rails may otherwise memoize a list of migrations
# excluding the post deployment migrations. # excluding the post deployment migrations.
unless ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS'] Mastodon::Database.add_post_migrate_path_to_rails
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

View file

@ -309,6 +309,7 @@ en:
title: Audit log title: Audit log
unavailable_instance: "(domain name unavailable)" unavailable_instance: "(domain name unavailable)"
announcements: announcements:
back: Back to announcements
destroyed_msg: Announcement successfully deleted! destroyed_msg: Announcement successfully deleted!
edit: edit:
title: Edit announcement title: Edit announcement
@ -317,6 +318,9 @@ en:
new: new:
create: Create announcement create: Create announcement
title: New 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 publish: Publish
published_msg: Announcement successfully published! published_msg: Announcement successfully published!
scheduled_for: Scheduled for %{time} 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. 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 webauthn: Security keys
user_mailer: user_mailer:
announcement_published:
description: 'The administrators of %{domain} are making an announcement:'
subject: Service announcement
title: "%{domain} service announcement"
appeal_approved: appeal_approved:
action: Account Settings 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. 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. text: Can be structured with Markdown syntax.
terms_of_service_generator: terms_of_service_generator:
admin_email: Legal notices include counternotices, court orders, takedown requests, and law enforcement requests. 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_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_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. 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_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. 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. 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: user:
chosen_languages: When checked, only posts in selected languages will be displayed in public timelines chosen_languages: When checked, only posts in selected languages will be displayed in public timelines
role: The role controls which permissions the user has. role: The role controls which permissions the user has.
@ -346,6 +347,7 @@ en:
dmca_email: Email address for DMCA/copyright notices dmca_email: Email address for DMCA/copyright notices
domain: Domain domain: Domain
jurisdiction: Legal jurisdiction jurisdiction: Legal jurisdiction
min_age: Minimum age
user: user:
role: Role role: Role
time_zone: Time zone time_zone: Time zone

View file

@ -50,6 +50,10 @@ namespace :admin do
post :publish post :publish
post :unpublish post :unpublish
end end
resource :preview, only: [:show], module: :announcements
resource :test, only: [:create], module: :announcements
resource :distribution, only: [:create], module: :announcements
end end
with_options to: redirect('/admin/settings/branding') do 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 ## 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 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 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 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 logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
}), }),
new InjectManifest({ 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 path = resolve(root, 'public', 'emoji', filename);
const body = readFileSync(path); const body = readFileSync(path);
const md5 = createHash('md5'); 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 "updated_at", precision: nil, null: false
t.datetime "published_at", precision: nil t.datetime "published_at", precision: nil
t.bigint "status_ids", array: true t.bigint "status_ids", array: true
t.datetime "notification_sent_at"
end end
create_table "annual_report_statuses_per_account_counts", force: :cascade do |t| 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.date "effective_date" t.date "effective_date"
t.index ["effective_date"], name: "index_terms_of_services_on_effective_dat t.index ["effective_date"], name: "index_terms_of_services_on_effective_date", unique: true, where: "(effective_date IS NOT NULL)"
e", unique: true, where: "(effective_date IS NOT NULL)"
end end
create_table "tombstones", force: :cascade do |t| create_table "tombstones", force: :cascade do |t|

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative '../mastodon/database'
require_relative '../mastodon/snowflake' require_relative '../mastodon/snowflake'
module ActiveRecord module ActiveRecord
@ -8,6 +9,8 @@ module ActiveRecord
original_load_schema = instance_method(:load_schema) original_load_schema = instance_method(:load_schema)
define_method(:load_schema) do |db_config, *args| define_method(:load_schema) do |db_config, *args|
Mastodon::Database.add_post_migrate_path_to_rails(force: true)
ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Base.establish_connection(db_config)
Mastodon::Snowflake.define_timestamp_id 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' gen_border map[emoji], 'white'
end end
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 end

View file

@ -1,7 +1,7 @@
{ {
"name": "@mastodon/mastodon", "name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.6.0", "packageManager": "yarn@4.7.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -46,6 +46,7 @@
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "1.2.1",
"@formatjs/intl-pluralrules": "^5.2.2", "@formatjs/intl-pluralrules": "^5.2.2",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1", "@github/webauthn-json": "^2.1.1",
@ -183,10 +184,10 @@
"eslint-define-config": "^2.0.0", "eslint-define-config": "^2.0.0",
"eslint-import-resolver-typescript": "^3.5.5", "eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-formatjs": "^5.0.0", "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-jsdoc": "^50.0.0",
"eslint-plugin-jsx-a11y": "~6.10.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": "^7.33.2",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"husky": "^9.0.11", "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'))) .and(have_body_text(I18n.t('user_mailer.terms_of_service_changed.changelog')))
end end
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 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 # frozen_string_literal: true
module SystemHelpers module SystemHelpers
def admin_user
Fabricate(:admin_user)
end
def submit_button def submit_button
I18n.t('generic.save_changes') I18n.t('generic.save_changes')
end 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 end
def text_label def text_label
I18n.t('simple_form.labels.announcement.text') form_label('announcement.text')
end end
def admin_user def admin_user

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,14 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Admin::Settings::ContentRetention' do 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 it 'Saves changes to content retention settings' do
sign_in admin_user
visit admin_settings_content_retention_path 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, fill_in media_cache_retention_period_field,
with: '2' with: '2'

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Admin TermsOfService Generates' do RSpec.describe 'Admin TermsOfService Generates' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) } before { sign_in(admin_user) }
describe 'Generating a TOS policy' do 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_domain', with: 'host.example'
fill_in 'terms_of_service_generator_jurisdiction', with: 'Europe' 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_choice_of_law', with: 'New York'
fill_in 'terms_of_service_generator_min_age', with: '16'
expect { submit_form } expect { submit_form }
.to change(TermsOfService, :count).by(1) .to change(TermsOfService, :count).by(1)
expect(page) expect(page)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,10 +28,10 @@ RSpec.describe 'Settings profile page' do
end end
def display_name_field def display_name_field
I18n.t('simple_form.labels.defaults.display_name') form_label('defaults.display_name')
end end
def avatar_field def avatar_field
I18n.t('simple_form.labels.defaults.avatar') form_label('defaults.avatar')
end end
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 end
def attribution_field def attribution_field
I18n.t('simple_form.labels.account.attribution_domains') form_label('account.attribution_domains')
end end
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", "name": "@mastodon/streaming",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.6.0", "packageManager": "yarn@4.7.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },

1464
yarn.lock

File diff suppressed because it is too large Load diff