From 02ea16150647ac3baf0bb8a89203ccc7200b4a2f Mon Sep 17 00:00:00 2001 From: Renaud Chaput <renchap@gmail.com> Date: Tue, 26 Mar 2024 10:25:49 +0100 Subject: [PATCH] Support "system" theme setting (light/dark theme depending on user system preference) (#29748) Co-authored-by: Nishiki Liu <hello@nshki.com> --- app/helpers/application_helper.rb | 9 +++++ .../features/emoji/__tests__/emoji-test.js | 38 +++++++++--------- .../mastodon/features/emoji/emoji.js | 39 +++++++++++++++---- app/lib/themes.rb | 2 +- app/views/layouts/application.html.haml | 2 +- app/views/layouts/embedded.html.haml | 2 +- app/views/layouts/error.html.haml | 2 +- config/locales/en.yml | 1 + config/settings.yml | 2 +- 9 files changed, 65 insertions(+), 32 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 668afe7fd..d46d0674a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -160,6 +160,15 @@ module ApplicationHelper output.compact_blank.join(' ') end + def theme_style_tags(theme) + if theme == 'system' + concat stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + concat stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + else + stylesheet_pack_tag theme, media: 'all', crossorigin: 'anonymous' + end + end + def cdn_host Rails.configuration.action_controller.asset_host end diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 7b917ac43..9d6ff5226 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -22,23 +22,23 @@ describe('emoji', () => { it('does unicode', () => { expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual( - '<img draggable="false" class="emojione" alt="๐ฉโ๐ฉโ๐ฆโ๐ฆ" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">'); + '<picture><img draggable="false" class="emojione" alt="๐ฉโ๐ฉโ๐ฆโ๐ฆ" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg"></picture>'); expect(emojify('๐จโ๐ฉโ๐งโ๐ง')).toEqual( - '<img draggable="false" class="emojione" alt="๐จโ๐ฉโ๐งโ๐ง" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">'); - expect(emojify('๐ฉโ๐ฉโ๐ฆ')).toEqual('<img draggable="false" class="emojione" alt="๐ฉโ๐ฉโ๐ฆ" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">'); + '<picture><img draggable="false" class="emojione" alt="๐จโ๐ฉโ๐งโ๐ง" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg"></picture>'); + expect(emojify('๐ฉโ๐ฉโ๐ฆ')).toEqual('<picture><img draggable="false" class="emojione" alt="๐ฉโ๐ฉโ๐ฆ" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg"></picture>'); expect(emojify('\u2757')).toEqual( - '<img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg">'); + '<picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture>'); }); it('does multiple unicode', () => { expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual( - '<img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg">'); + '<picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"></picture>'); expect(emojify('\u2757#\uFE0F\u20E3')).toEqual( - '<img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg">'); + '<picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture><picture><img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"></picture>'); expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual( - '<img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg">'); + '<picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"></picture> <picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture>'); expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual( - 'foo <img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"> bar'); + 'foo <picture><img draggable="false" class="emojione" alt="โ" title=":exclamation:" src="/emoji/2757.svg"></picture> <picture><img draggable="false" class="emojione" alt="#๏ธโฃ" title=":hash:" src="/emoji/23-20e3.svg"></picture> bar'); }); it('ignores unicode inside of tags', () => { @@ -46,16 +46,16 @@ describe('emoji', () => { }); it('does multiple emoji properly (issue 5188)', () => { - expect(emojify('๐๐๐')).toEqual('<img draggable="false" class="emojione" alt="๐" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="๐" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg">'); - expect(emojify('๐ ๐ ๐')).toEqual('<img draggable="false" class="emojione" alt="๐" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="๐" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg">'); + expect(emojify('๐๐๐')).toEqual('<picture><img draggable="false" class="emojione" alt="๐" title=":ok_hand:" src="/emoji/1f44c.svg"></picture><picture><img draggable="false" class="emojione" alt="๐" title=":rainbow:" src="/emoji/1f308.svg"></picture><picture><img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg"></picture>'); + expect(emojify('๐ ๐ ๐')).toEqual('<picture><img draggable="false" class="emojione" alt="๐" title=":ok_hand:" src="/emoji/1f44c.svg"></picture> <picture><img draggable="false" class="emojione" alt="๐" title=":rainbow:" src="/emoji/1f308.svg"></picture> <picture><img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg"></picture>'); }); it('does an emoji that has no shortcode', () => { - expect(emojify('๐โ๐จ')).toEqual('<img draggable="false" class="emojione" alt="๐โ๐จ" title="" src="/emoji/1f441-200d-1f5e8.svg">'); + expect(emojify('๐โ๐จ')).toEqual('<picture><img draggable="false" class="emojione" alt="๐โ๐จ" title="" src="/emoji/1f441-200d-1f5e8.svg"></picture>'); }); it('does an emoji whose filename is irregular', () => { - expect(emojify('โ๏ธ')).toEqual('<img draggable="false" class="emojione" alt="โ๏ธ" title=":arrow_lower_left:" src="/emoji/2199.svg">'); + expect(emojify('โ๏ธ')).toEqual('<picture><img draggable="false" class="emojione" alt="โ๏ธ" title=":arrow_lower_left:" src="/emoji/2199.svg"></picture>'); }); it('avoid emojifying on invisible text', () => { @@ -67,11 +67,11 @@ describe('emoji', () => { it('avoid emojifying on invisible text with nested tags', () => { expect(emojify('<span class="invisible">๐<span class="foo">bar</span>๐ด</span>๐')) - .toEqual('<span class="invisible">๐<span class="foo">bar</span>๐ด</span><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg">'); + .toEqual('<span class="invisible">๐<span class="foo">bar</span>๐ด</span><picture><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg"></picture>'); expect(emojify('<span class="invisible">๐<span class="invisible">๐</span>๐ด</span>๐')) - .toEqual('<span class="invisible">๐<span class="invisible">๐</span>๐ด</span><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg">'); + .toEqual('<span class="invisible">๐<span class="invisible">๐</span>๐ด</span><picture><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg"></picture>'); expect(emojify('<span class="invisible">๐<br>๐ด</span>๐')) - .toEqual('<span class="invisible">๐<br>๐ด</span><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg">'); + .toEqual('<span class="invisible">๐<br>๐ด</span><picture><img draggable="false" class="emojione" alt="๐" title=":innocent:" src="/emoji/1f607.svg"></picture>'); }); it('does not emojify emojis with textual presentation VS15 character', () => { @@ -79,19 +79,19 @@ describe('emoji', () => { .toEqual('โด๏ธ'); }); - it('does an simple emoji properly', () => { + it('does a simple emoji properly', () => { expect(emojify('โโ')) - .toEqual('<img draggable="false" class="emojione" alt="โ" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="โ" title=":male_sign:" src="/emoji/2642.svg">'); + .toEqual('<picture><img draggable="false" class="emojione" alt="โ" title=":female_sign:" src="/emoji/2640.svg"></picture><picture><img draggable="false" class="emojione" alt="โ" title=":male_sign:" src="/emoji/2642.svg"></picture>'); }); it('does an emoji containing ZWJ properly', () => { expect(emojify('๐โโ๏ธ๐โโ๏ธ')) - .toEqual('<img draggable="false" class="emojione" alt="๐\u200Dโ๏ธ" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="๐\u200Dโ๏ธ" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">'); + .toEqual('<picture><img draggable="false" class="emojione" alt="๐\u200Dโ๏ธ" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"></picture><picture><img draggable="false" class="emojione" alt="๐\u200Dโ๏ธ" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg"></picture>'); }); it('keeps ordering as expected (issue fixed by PR 20677)', () => { expect(emojify('<p>๐ <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>')) - .toEqual('<p><img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'); + .toEqual('<p><picture><img draggable="false" class="emojione" alt="๐" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'); }); }); }); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 5918a65ed..e4aad302f 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -17,8 +17,13 @@ const emojiFilenames = (emojis) => { const darkEmoji = emojiFilenames(['๐ฑ', '๐', 'โซ', '๐ค', 'โฌ', 'โผ๏ธ', 'โพ', 'โผ๏ธ', 'โ๏ธ', 'โช๏ธ', '๐ฃ', '๐ณ', '๐ท', '๐ธ', 'โฃ๏ธ', '๐ถ๏ธ', 'โด๏ธ', '๐', '๐โโ๏ธ', '๐ฝ๏ธ', '๐ณ', '๐ฆ', '๐', '๐ช', '๐ณ๏ธ', '๐น๏ธ', '๐', '๐๏ธ', '๐๏ธ', '๐โโ๏ธ', '๐ค', '๐', '๐ฅ', '๐ผ', 'โ ๏ธ', '๐ฉ', '๐ฆ', '๐ผ', '๐น', '๐ฎ', '๐', '๐ด', '๐', '๐บ', '๐ฑ', '๐ฒ', '๐ฒ', '๐ชฎ', '๐ฆโโฌ']); const lightEmoji = emojiFilenames(['๐ฝ', 'โพ', '๐', 'โ๏ธ', '๐จ', '๐๏ธ', '๐', '๐ฅ', '๐ป', '๐', 'โ', 'โ', 'โธ๏ธ', '๐ฉ๏ธ', '๐', '๐', '๐', '๐ง๏ธ', '๐', '๐', '๐', '๐', '๐', '๐', 'โ ๏ธ', '๐จ๏ธ', '๐', '๐', '๐ฌ', '๐ญ', '๐', '๐ณ๏ธ', 'โช', 'โฌ', 'โฝ', 'โป๏ธ', 'โซ๏ธ', '๐ชฝ', '๐ชฟ']); -const emojiFilename = (filename) => { - const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji; +/** + * @param {string} filename + * @param {"light" | "dark" } colorScheme + * @returns {string} + */ +const emojiFilename = (filename, colorScheme) => { + const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji; return borderedEmoji.includes(filename) ? (filename + '_border') : filename; }; @@ -92,12 +97,30 @@ const emojifyTextNode = (node, customEmojis) => { const { filename, shortCode } = unicodeMapping[unicode_emoji]; const title = shortCode ? `:${shortCode}:` : ''; - replacement = document.createElement('img'); - replacement.setAttribute('draggable', 'false'); - replacement.setAttribute('class', 'emojione'); - replacement.setAttribute('alt', unicode_emoji); - replacement.setAttribute('title', title); - replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`); + replacement = document.createElement('picture'); + + const isSystemTheme = !!document.body?.classList.contains('theme-system'); + + if(isSystemTheme) { + let source = document.createElement('source'); + source.setAttribute('media', '(prefers-color-scheme: dark)'); + source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, "dark")}.svg`); + replacement.appendChild(source); + } + + let img = document.createElement('img'); + img.setAttribute('draggable', 'false'); + img.setAttribute('class', 'emojione'); + img.setAttribute('alt', unicode_emoji); + img.setAttribute('title', title); + + let theme = "light"; + + if(!isSystemTheme && !document.body?.classList.contains('theme-mastodon-light')) + theme = "dark"; + + img.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename, theme)}.svg`); + replacement.appendChild(img); } // Add the processed-up-to-now string and the emoji replacement diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 243ffb9ab..4010d8443 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -11,6 +11,6 @@ class Themes end def names - @conf.keys + ['system'] + @conf.keys end end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 449657f8c..0cd7fc9f4 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -27,7 +27,7 @@ %title= html_title = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' - = stylesheet_pack_tag current_theme, media: 'all', crossorigin: 'anonymous' + = theme_style_tags current_theme -# Needed for the wicg-inert polyfill. It needs to be on it's own <style> tag, with this `id` = stylesheet_pack_tag 'inert', media: 'all', id: 'inert-style' diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 54d4ba715..c633fa9e0 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -12,7 +12,7 @@ %link{ rel: 'dns-prefetch', href: storage_host }/ = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous' + = theme_style_tags Setting.theme # Use the admin-configured theme here, even if logged in = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' = preload_pack_asset "locale/#{I18n.locale}-json.js" = render_initial_state diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index b7aafe987..485a69c90 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -6,7 +6,7 @@ %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ') %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ = stylesheet_pack_tag 'common', media: 'all', crossorigin: 'anonymous' - = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all', crossorigin: 'anonymous' + = theme_style_tags Setting.default_settings['theme'] = javascript_pack_tag 'common', crossorigin: 'anonymous' = javascript_pack_tag 'error', crossorigin: 'anonymous' %body.error diff --git a/config/locales/en.yml b/config/locales/en.yml index 823e720ea..6cd996594 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1769,6 +1769,7 @@ en: contrast: Mastodon (High contrast) default: Mastodon (Dark) mastodon-light: Mastodon (Light) + system: Automatic (use system theme) time: formats: default: "%b %d, %Y, %H:%M" diff --git a/config/settings.yml b/config/settings.yml index 208c8e376..297bf0281 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -16,7 +16,7 @@ defaults: &defaults show_staff_badge: true preview_sensitive_media: false noindex: false - theme: 'default' + theme: 'system' trends: true trends_as_landing_page: true trendable_by_default: false