Compare commits

...

62 Commits

Author SHA1 Message Date
Claire
88c0f52e99 Merge pull request #3313 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d730f6b0c5
2025-12-11 19:23:18 +01:00
Claire
a56b739c68 [Glitch] Fix wrapstodon modal closing on any click
Port dfbf908870 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 19:07:26 +01:00
diondiondion
183a42a5ee [Glitch] Add Wrapstodon footer links
Port c06eb371e6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 19:06:54 +01:00
Claire
303a5478af Merge commit 'dfbf908870fcde76396ebccfb3d71ee1a06ffe82' into glitch-soc/merge-upstream
Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict because of glitch-soc's theming system.
  Applied upstream's changes.
2025-12-11 19:05:32 +01:00
Claire
dfbf908870 Fix wrapstodon modal closing on any click (#37209) 2025-12-11 17:49:26 +00:00
diondiondion
aa067370d8 [Glitch] Fix Wrapstodon modal scrolling not working on iOS
Port 4323963053 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 18:07:59 +01:00
diondiondion
5e0db46b2a [Glitch] Wrapstodon design QA tweaks
Port 5651900b89 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-11 18:07:34 +01:00
diondiondion
c06eb371e6 Add Wrapstodon footer links (#37207) 2025-12-11 17:06:26 +00:00
Claire
53617cef5a Merge commit 'd730f6b0c5cfb18894d1a9e34d0aa2556dda3c62' into glitch-soc/merge-upstream 2025-12-11 18:05:28 +01:00
Emelia Smith
d730f6b0c5 Add spec for client_credentials being used with /api/v1/apps/verify_credentials (#37195) 2025-12-11 16:40:22 +00:00
Claire
addeb28292 Change wrapstodon 2025 to allow unlisted posts in top statuses (#37206) 2025-12-11 16:35:35 +00:00
Claire
5e3387539e Add image to Wrapstodon OpenGraph banner (#37205) 2025-12-11 16:22:48 +00:00
diondiondion
4323963053 Fix Wrapstodon modal scrolling not working on iOS (#37203) 2025-12-11 14:25:28 +00:00
diondiondion
5651900b89 Wrapstodon design QA tweaks (#37201) 2025-12-11 11:40:53 +00:00
renovate[bot]
d1b996b7e3 Update dependency omniauth-rails_csrf_protection to v2.0.1 (#37199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 11:12:07 +00:00
renovate[bot]
fed26a41fa Update dependency jsdom to v27.3.0 (#37165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:33:47 +00:00
Claire
37d309bcaf Fix Wrapstodon font loading by disabling inlining of fonts in Vite (#37198) 2025-12-11 10:33:15 +00:00
renovate[bot]
d25f672c50 Update dependency active_model_serializers to v0.10.16 (#37167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:52 +00:00
renovate[bot]
15c9088761 Update dependency vite to v7.2.7 (#37156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:38 +00:00
renovate[bot]
da1505a495 Update dependency @vitejs/plugin-react to v5.1.2 (#37155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:30 +00:00
renovate[bot]
d1f690f50c Update dependency stoplight to v5.7.0 (#37151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:07:25 +00:00
Claire
8b418b84d0 Merge pull request #3312 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d6f2a3ac8d
2025-12-10 22:09:22 +01:00
diondiondion
f817300d8d [Glitch] Implement custom font for Wrapstodon heading
Port c42b9f6996 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:13:17 +01:00
Echo
35a89a0173 [Glitch] Fix issue where Wrapstodon was pushed to the bottom of the feed
Port 76184c998c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:12:23 +01:00
diondiondion
b5721dbd4a [Glitch] Fix Wrapstodon Storybook & other Wrapstodon issues
Port 8137ce87ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:11:08 +01:00
diondiondion
38f623eee7 [Glitch] Minor Wrapstodon tweaks, add stub Storybook page
Port 91500a7f53 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:09:09 +01:00
Claire
17ba99e5de Merge commit 'd6f2a3ac8d61e0828a17f68a6e9094d0f4662f4c' into glitch-soc/merge-upstream
Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict because of glitch-soc's theming change.
  Adapted upstream's changes.
- `docker-compose.yml`:
  Conflict because of container repo name change.
  Adapted upstream's changes.
- `yarn.lock`:
  Conflict because of an additional glitch-soc dependency.
  Updated the dependencies upstream did.
2025-12-10 18:05:44 +01:00
Claire
da2b75bdcd Change build-releases workflow to tag images latest based on latest stable-x.y branch (#37179)
Co-authored-by: emilweth <7402764+emilweth@users.noreply.github.com>
2025-12-10 17:01:25 +00:00
David Roetzel
adf8a3601d Add service to add item to a collection (#37192) 2025-12-10 16:59:21 +00:00
Claire
d6f2a3ac8d Bump version to v4.5.3 (#37166) 2025-12-10 16:42:19 +00:00
diondiondion
c42b9f6996 Implement custom font for Wrapstodon heading (#37193) 2025-12-10 16:26:46 +00:00
Echo
76184c998c Fix issue where Wrapstodon was pushed to the bottom of the feed (#37190) 2025-12-10 15:55:12 +00:00
diondiondion
8137ce87ce Fix Wrapstodon Storybook & other Wrapstodon issues (#37189) 2025-12-10 14:07:25 +00:00
renovate[bot]
37426288d9 Update dependency postcss-preset-env to v10.5.0 (#37132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:39 +00:00
renovate[bot]
801fee7593 Update dependency test-prof to v1.5.0 (#37127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:29 +00:00
Claire
6838497fe8 Add title and description to Opengraph data for Wrapstodon share page (#37188) 2025-12-10 11:27:10 +00:00
Claire
7b8a5d42f1 Remove unused time series details from 2025 annual report (#37187) 2025-12-10 11:02:24 +00:00
Claire
cd71fdcdff Merge pull request #3311 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 9d81561bb2
2025-12-10 11:53:00 +01:00
diondiondion
91500a7f53 Minor Wrapstodon tweaks, add stub Storybook page (#37186) 2025-12-10 09:05:14 +00:00
Claire
5422e43e31 Fix wrapstodon standalone page not loading JS module 2025-12-10 09:49:09 +01:00
diondiondion
5a66331003 [Glitch] Update Wrapstodon design
Port 9d81561bb2 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-09 18:27:14 +01:00
Matt Jankowski
09e3955145 [Glitch] Fix misc comment typos
Port ac71771d98 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-09 18:10:04 +01:00
Echo
e554e5723d [Glitch] Fix emoji on Wrapstodon
Port 9702cbb41c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-09 18:09:22 +01:00
Claire
315f5e5a31 Merge commit '9d81561bb2440c8fb9a75bd05277120aff346b1e' into glitch-soc/merge-upstream 2025-12-09 18:06:56 +01:00
diondiondion
9d81561bb2 Update Wrapstodon design (#37169) 2025-12-09 16:51:05 +00:00
Matt Jankowski
ac71771d98 Fix misc comment typos (#37183) 2025-12-09 16:09:01 +00:00
Claire
697569e5f9 Add account_id attribute to AnnualReport entity (#37182) 2025-12-09 15:59:40 +00:00
Claire
4cdcdaa7d9 Fix streaming image build after removal of .yarn (#37181) 2025-12-09 15:36:46 +00:00
Echo
9702cbb41c Fix emoji on Wrapstodon (#37177) 2025-12-09 10:36:08 +00:00
David Roetzel
ea768c17db Add counter cache to collections (#37176) 2025-12-09 10:31:35 +00:00
renovate[bot]
5347cabf3e Update dependency oj to v3.16.13 (#37135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 10:07:35 +00:00
renovate[bot]
eef40ba96b Update dependency hiredis-client to v0.26.2 (#37137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 09:47:12 +00:00
Matt Jankowski
9063c3b660 Remove yarn patch for babel-plugin-lodash, removed during Vite upgrade (#37161) 2025-12-09 09:30:57 +00:00
Matt Jankowski
e147947eb8 Add wrapstodon page spec (#37168) 2025-12-09 09:27:29 +00:00
Claire
8c52889c86 Merge pull request #3307 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 607449336d
2025-12-08 17:14:29 +01:00
Claire
05e45beb34 Merge commit '607449336da198ea9fe9c014220a5374a0ca1ae4' into glitch-soc/merge-upstream 2025-12-08 16:33:27 +01:00
Claire
607449336d Merge commit from fork 2025-12-08 15:44:08 +01:00
github-actions[bot]
85bf5be604 New Crowdin Translations (automated) (#37146)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-12-08 11:42:24 +00:00
David Roetzel
cf23f0414f Add id to collection serializers (#37157) 2025-12-08 11:40:17 +00:00
David Roetzel
55becaa1b5 Preload tag to prevent n+1 (#37154) 2025-12-08 10:30:10 +00:00
David Roetzel
8625721805 Draft API to get all collections by an account (#37139) 2025-12-08 08:56:13 +00:00
Matt Jankowski
7fe3e80758 Rely on locale for options order in DOB input (#36895) 2025-12-05 15:21:22 +00:00
158 changed files with 3639 additions and 1852 deletions

View File

@@ -9,7 +9,44 @@ permissions:
packages: write
jobs:
check-latest-stable:
runs-on: ubuntu-latest
outputs:
latest: ${{ steps.check.outputs.is_latest_stable }}
steps:
# Repository needs to be cloned to list branches
- name: Clone repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check latest stable
shell: bash
id: check
run: |
ref="${GITHUB_REF#refs/tags/}"
if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then
current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
else
echo "tag $ref is not semver"
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
exit 0
fi
latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \
| sed -E 's#^origin/stable-##' \
| sort -Vr \
| head -n1)
if [[ "$current" == "$latest" ]]; then
echo "is_latest_stable=true" >> "$GITHUB_OUTPUT"
else
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
fi
build-image:
needs: check-latest-stable
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: Dockerfile
@@ -20,13 +57,14 @@ jobs:
# Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release
flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
latest=${{ needs.check-latest-stable.outputs.latest }}
tags: |
type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}
secrets: inherit
build-image-streaming:
needs: check-latest-stable
uses: ./.github/workflows/build-container-image.yml
with:
file_to_build: streaming/Dockerfile
@@ -37,7 +75,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release
flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
latest=${{ needs.check-latest-stable.outputs.latest }}
tags: |
type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}}

View File

@@ -1,13 +0,0 @@
diff --git a/lib/index.js b/lib/index.js
index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -99,7 +99,7 @@ function lodash(_ref) {
var node = _ref3;
- if ((0, _types.isModuleDeclaration)(node)) {
+ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) {
isModule = true;
break;
}

View File

@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## [4.5.3] - 2025-12-08
### Security
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
### Fixed
- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire)
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
- Fix creation of duplicate conversations (#37108 by @oneiros)
- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima)
- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire)
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion)
- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire)
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire)
## [4.5.2] - 2025-11-20
### Changed

View File

@@ -53,7 +53,7 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.15)
active_model_serializers (0.10.16)
actionpack (>= 4.1)
activemodel (>= 4.1)
case_transform (>= 0.2)
@@ -304,8 +304,8 @@ GEM
highline (3.1.2)
reline
hiredis (0.6.3)
hiredis-client (0.26.1)
redis-client (= 0.26.1)
hiredis-client (0.26.2)
redis-client (= 0.26.2)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.3.1)
@@ -469,7 +469,7 @@ GEM
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.12)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -481,7 +481,7 @@ GEM
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (2.0.0)
omniauth-rails_csrf_protection (2.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.4)
@@ -703,7 +703,7 @@ GEM
reline
redcarpet (3.6.1)
redis (4.8.1)
redis-client (0.26.1)
redis-client (0.26.2)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@@ -839,7 +839,8 @@ GEM
stackprof (0.2.27)
starry (0.2.0)
base64
stoplight (5.6.0)
stoplight (5.7.0)
concurrent-ruby
zeitwerk
stringio (3.1.8)
strong_migrations (2.5.1)
@@ -855,7 +856,7 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
climate_control
test-prof (1.4.4)
test-prof (1.5.0)
thor (1.4.0)
tilt (2.6.1)
timeout (0.4.3)

View File

@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -24,7 +24,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
return not_found unless @quote.status.present? && @quote.quoted_status.present?
authorize @quote.quoted_status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
def set_poll
@poll = Poll.find(params[:poll_id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
def set_poll
@poll = Poll.find(params[:id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
bookmark&.destroy!
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
def set_reblog
@reblog = Status.find(params[:status_id])
authorize @reblog, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -148,7 +148,7 @@ class Api::V1::StatusesController < Api::BaseController
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -3,20 +3,34 @@
class Api::V1Alpha::CollectionsController < Api::BaseController
include Authorization
DEFAULT_COLLECTIONS_LIMIT = 40
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422
end
before_action :check_feature_enabled
before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy]
before_action :require_user!, only: [:create, :update, :destroy]
before_action :set_account, only: [:index]
before_action :set_collections, only: [:index]
before_action :set_collection, only: [:show, :update, :destroy]
after_action :insert_pagination_headers, only: [:index]
after_action :verify_authorized
def index
cache_if_unauthenticated!
authorize Collection, :index?
render json: @collections, each_serializer: REST::BaseCollectionSerializer
end
def show
cache_if_unauthenticated!
authorize @collection, :show?
@@ -50,6 +64,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
private
def set_account
@account = Account.find(params[:account_id])
end
def set_collections
@collections = @account.collections
.with_tag
.order(created_at: :desc)
.offset(offset_param)
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
end
def set_collection
@collection = Collection.find(params[:id])
end
@@ -65,4 +91,24 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
def next_path
return unless records_continue?
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
end
def prev_path
return if offset_param.zero?
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
end
def records_continue?
((offset_param * limit_param(DEFAULT_COLLECTIONS_LIMIT)) + @collections.size) < @account.collections.size
end
def offset_param
params[:offset].to_i
end
end

View File

@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
end

View File

@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
def set_resource
@resource = located_resource
authorize(@resource, :show?) if @resource.is_a?(Status)
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -34,7 +34,7 @@ class MediaController < ApplicationController
def verify_permitted_status!
authorize @media_attachment.status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -62,7 +62,7 @@ class StatusesController < ApplicationController
def set_status
@status = @account.statuses.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end

View File

@@ -155,6 +155,7 @@ module ApplicationHelper
def html_classes
output = []
output << content_for(:html_classes)
output << 'system-font' if current_account&.user&.setting_system_font_ui
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')

View File

@@ -1,31 +1,49 @@
# frozen_string_literal: true
class DateOfBirthInput < SimpleForm::Inputs::Base
OPTIONS = [
{ autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
{ autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
{ autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
].freeze
OPTIONS = {
day: { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' },
month: { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' },
year: { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' },
}.freeze
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
merged_input_options[:inputmode] = 'numeric'
values = (object.public_send(attribute_name) || '').to_s.split('-')
safe_join(2.downto(0).map do |index|
options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
@builder.text_field("#{attribute_name}(#{index + 1}i)", options)
end)
safe_join(
ordered_options.map do |option|
options = merged_input_options
.merge(OPTIONS[option])
.merge(
id: generate_id(option),
'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{param_for(option)}"),
value: values[option]
)
@builder.text_field("#{attribute_name}(#{param_for(option)})", options)
end
)
end
def label_target
"#{attribute_name}_3i"
"#{attribute_name}_#{param_for(ordered_options.first)}"
end
private
def generate_id(index)
"#{object_name}_#{attribute_name}_#{index + 1}i"
def ordered_options
I18n.t('date.order').map(&:to_sym)
end
def generate_id(option)
"#{object_name}_#{attribute_name}_#{param_for(option)}"
end
def param_for(option)
"#{ActionView::Helpers::DateTimeSelector::POSITION[option]}i"
end
def values
Date._parse((object.public_send(attribute_name) || '').to_s).transform_keys(mon: :month, mday: :day)
end
end

View File

@@ -2,13 +2,11 @@ import { createRoot } from 'react-dom/client';
import { Provider as ReduxProvider } from 'react-redux';
import {
importFetchedAccounts,
importFetchedStatuses,
} from '@/mastodon/actions/importer';
import { importFetchedStatuses } from '@/mastodon/actions/importer';
import { hydrateStore } from '@/mastodon/actions/store';
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
import { Router } from '@/mastodon/components/router';
import { WrapstodonShare } from '@/mastodon/features/annual_report/share';
import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page';
import { IntlProvider, loadLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
import ready from '@/mastodon/ready';
@@ -33,7 +31,14 @@ function loaded() {
if (!report) {
throw new Error('Initial state report not found');
}
store.dispatch(importFetchedAccounts(initialState.accounts));
// Set up store
store.dispatch(
hydrateStore({
meta: { locale: document.documentElement.lang },
accounts: initialState.accounts,
}),
);
store.dispatch(importFetchedStatuses(initialState.statuses));
store.dispatch(setReport(report));
@@ -43,7 +48,7 @@ function loaded() {
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonShare />
<WrapstodonSharedPage />
</Router>
</ReduxProvider>
</IntlProvider>,

View File

@@ -37,7 +37,9 @@ export function hydrateStore(rawState) {
dispatch(hydrateCompose());
dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
if (rawState.accounts) {
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
}
dispatch(saveSettings());
};
}

View File

@@ -1,5 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
import api, { getLinks } from 'flavours/glitch/api';
import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
@@ -7,7 +8,7 @@ import { toServerSideType } from 'flavours/glitch/utils/filters';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed';
import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed';
@@ -25,9 +26,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
TIMELINE_WRAPSTODON,
];
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
timeline,
@@ -135,6 +143,7 @@ export function expandTimeline(timelineId, path, params = {}) {
if (timelineId === 'home') {
dispatch(submitMarkers());
dispatch(reinsertAnnualReport())
}
} catch(error) {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
export function isNonStatusId(value: unknown) {
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
}
export const disconnectTimeline = createAction(
'timeline/disconnect',
({ timeline }: { timeline: string }) => ({

View File

@@ -115,6 +115,7 @@ class Status extends ImmutablePureComponent {
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
showActions: PropTypes.bool,
prepend: PropTypes.string,
withDismiss: PropTypes.bool,
isQuotedPost: PropTypes.bool,
@@ -465,7 +466,8 @@ class Status extends ImmutablePureComponent {
onOpenMedia,
notification,
history,
isQuotedPost,
showActions = true,
isQuotedPost = false,
...other
} = this.props;
let attachments = null;
@@ -763,7 +765,7 @@ class Status extends ImmutablePureComponent {
{/* This is a glitch-soc addition to have a placeholder */}
{!expanded && <MentionsPlaceholder status={status} />}
{!isQuotedPost &&
{(showActions && !isQuotedPost) &&
<StatusActionBar
status={status}
account={status.get('account')}

View File

@@ -0,0 +1,45 @@
import { useCallback, useRef } from 'react';
export const InterceptStatusClicks: React.FC<{
onPreventedClick: (
clickedArea: 'account' | 'post',
event: React.MouseEvent,
) => void;
children: React.ReactNode;
}> = ({ onPreventedClick, children }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const clickTarget = e.target as Element;
const allowedElementsSelector =
'.video-player, .audio-player, .media-gallery, .content-warning';
const allowedElements = wrapperRef.current?.querySelectorAll(
allowedElementsSelector,
);
const isTargetClickAllowed =
allowedElements &&
Array.from(allowedElements).some((element) => {
return clickTarget === element || element.contains(clickTarget);
});
if (!isTargetClickAllowed) {
e.preventDefault();
e.stopPropagation();
const wasAccountAreaClicked = !!clickTarget.closest(
'a.status__display-name',
);
onPreventedClick(wasAccountAreaClicked ? 'account' : 'post', e);
}
},
[onPreventedClick],
);
return (
<div ref={wrapperRef} onClickCapture={handleClick}>
{children}
</div>
);
};

View File

@@ -2,13 +2,11 @@ import { createRoot } from 'react-dom/client';
import { Provider as ReduxProvider } from 'react-redux';
import {
importFetchedAccounts,
importFetchedStatuses,
} from '@/flavours/glitch/actions/importer';
import { importFetchedStatuses } from '@/flavours/glitch/actions/importer';
import { hydrateStore } from '@/flavours/glitch/actions/store';
import type { ApiAnnualReportResponse } from '@/flavours/glitch/api/annual_report';
import { Router } from '@/flavours/glitch/components/router';
import { WrapstodonShare } from '@/flavours/glitch/features/annual_report/share';
import { WrapstodonSharedPage } from '@/flavours/glitch/features/annual_report/shared_page';
import { IntlProvider, loadLocale } from '@/flavours/glitch/locales';
import { loadPolyfills } from '@/flavours/glitch/polyfills';
import ready from '@/flavours/glitch/ready';
@@ -33,7 +31,14 @@ function loaded() {
if (!report) {
throw new Error('Initial state report not found');
}
store.dispatch(importFetchedAccounts(initialState.accounts));
// Set up store
store.dispatch(
hydrateStore({
meta: { locale: document.documentElement.lang },
accounts: initialState.accounts,
}),
);
store.dispatch(importFetchedStatuses(initialState.statuses));
store.dispatch(setReport(report));
@@ -43,7 +48,7 @@ function loaded() {
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonShare />
<WrapstodonSharedPage />
</Router>
</ReduxProvider>
</IntlProvider>,

View File

@@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Button } from '@/flavours/glitch/components/button';
import styles from './styles.module.scss';
@@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{
onOpen: () => void;
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
return (
<div className={styles.wrapper}>
<div className={classNames('theme-dark', styles.wrapper)}>
<h2>
<FormattedMessage
id='annual_report.announcement.title'

View File

@@ -6,14 +6,14 @@
text-align: center;
font-size: 15px;
line-height: 1.5;
color: var(--color-text-on-media);
background: var(--color-bg-media-base);
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
var(--color-bg-media-base);
var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary);
h2 {

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standalone: Story = {
args: {
context: 'standalone',
},
};
export const InModal: Story = {
args: {
context: 'modal',
},
};
export const ArchetypeOracle: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'oracle',
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
};
export const NoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'booster',
}),
},
},
};
export const NoNewPosts: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'pollster',
top_hashtag: SAMPLE_HASHTAG,
without_posts: true,
}),
},
},
};
export const NoNewPostsNoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'replier',
without_posts: true,
}),
},
},
};

View File

@@ -1,65 +1,214 @@
import { defineMessages, useIntl } from 'react-intl';
import { useCallback, useRef, useState } from 'react';
import type { Archetype as ArchetypeData } from '@/flavours/glitch/models/annual_report';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button';
import type { Account } from '@/flavours/glitch/models/account';
import type {
AnnualReport,
Archetype as ArchetypeData,
} from '@/flavours/glitch/models/annual_report';
import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png';
import pollster from '@/images/archetypes/pollster.png';
import replier from '@/images/archetypes/replier.png';
import space_elements from '@/images/archetypes/space_elements.png';
import styles from './index.module.scss';
import { ShareButton } from './share_button';
export const archetypeNames = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster',
defaultMessage: 'The cool-hunter',
id: 'annual_report.summary.archetype.booster.name',
defaultMessage: 'The Archer',
},
replier: {
id: 'annual_report.summary.archetype.replier',
defaultMessage: 'The social butterfly',
id: 'annual_report.summary.archetype.replier.name',
defaultMessage: 'The Butterfly',
},
pollster: {
id: 'annual_report.summary.archetype.pollster',
defaultMessage: 'The pollster',
id: 'annual_report.summary.archetype.pollster.name',
defaultMessage: 'The Wonderer',
},
lurker: {
id: 'annual_report.summary.archetype.lurker',
defaultMessage: 'The lurker',
id: 'annual_report.summary.archetype.lurker.name',
defaultMessage: 'The Stoic',
},
oracle: {
id: 'annual_report.summary.archetype.oracle',
defaultMessage: 'The oracle',
id: 'annual_report.summary.archetype.oracle.name',
defaultMessage: 'The Oracle',
},
});
export const Archetype: React.FC<{
data: ArchetypeData;
}> = ({ data }) => {
const intl = useIntl();
let illustration;
export const archetypeSelfDescriptions = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster.desc_self',
defaultMessage:
'You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
},
replier: {
id: 'annual_report.summary.archetype.replier.desc_self',
defaultMessage:
'You frequently replied to other peoples posts, pollinating Mastodon with new discussions.',
},
pollster: {
id: 'annual_report.summary.archetype.pollster.desc_self',
defaultMessage:
'You created more polls than other post types, cultivating curiosity on Mastodon.',
},
lurker: {
id: 'annual_report.summary.archetype.lurker.desc_self',
defaultMessage:
'We know you were out there, somewhere, enjoying Mastodon in your own quiet way.',
},
oracle: {
id: 'annual_report.summary.archetype.oracle.desc_self',
defaultMessage:
'You created new posts more than replies, keeping Mastodon fresh and future-facing.',
},
});
switch (data) {
case 'booster':
illustration = booster;
break;
case 'replier':
illustration = replier;
break;
case 'pollster':
illustration = pollster;
break;
case 'lurker':
illustration = lurker;
break;
case 'oracle':
illustration = oracle;
break;
}
export const archetypePublicDescriptions = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster.desc_public',
defaultMessage:
'{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
},
replier: {
id: 'annual_report.summary.archetype.replier.desc_public',
defaultMessage:
'{name} frequently replied to other peoples posts, pollinating Mastodon with new discussions.',
},
pollster: {
id: 'annual_report.summary.archetype.pollster.desc_public',
defaultMessage:
'{name} created more polls than other post types, cultivating curiosity on Mastodon.',
},
lurker: {
id: 'annual_report.summary.archetype.lurker.desc_public',
defaultMessage:
'We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.',
},
oracle: {
id: 'annual_report.summary.archetype.oracle.desc_public',
defaultMessage:
'{name} created new posts more than replies, keeping Mastodon fresh and future-facing.',
},
});
const illustrations = {
booster,
replier,
pollster,
lurker,
oracle,
} as const;
export const Archetype: React.FC<{
report: AnnualReport;
account?: Account;
context: 'modal' | 'standalone';
}> = ({ report, account, context }) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView);
const reveal = useCallback(() => {
setIsRevealed(true);
wrapperRef.current?.focus();
}, []);
const archetype = report.data.archetype;
const descriptions = isSelfView
? archetypeSelfDescriptions
: archetypePublicDescriptions;
const name = account?.display_name;
return (
<div className='annual-report__bento__box annual-report__summary__archetype'>
<div className='annual-report__summary__archetype__label'>
{intl.formatMessage(archetypeNames[data])}
<div
className={classNames(styles.box, styles.archetype)}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={wrapperRef}
>
<div className={styles.archetypeArtboard}>
{account && (
<Avatar
account={account}
size={50}
className={styles.archetypeAvatar}
/>
)}
<div className={styles.archetypeIllustrationWrapper}>
<img
src={illustrations[archetype]}
alt=''
className={classNames(
styles.archetypeIllustration,
isRevealed ? '' : styles.blurredImage,
)}
/>
</div>
<img
src={space_elements}
alt=''
className={styles.archetypePlanetRing}
/>
</div>
<img src={illustration} alt='' />
<div className={classNames(styles.content, styles.comfortable)}>
<h2 className={styles.title}>
{isSelfView ? (
<FormattedMessage
id='annual_report.summary.archetype.title_self'
defaultMessage='Your archetype'
/>
) : (
<FormattedMessage
id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype"
values={{ name }}
/>
)}
</h2>
<p className={styles.statLarge}>
{isRevealed ? (
intl.formatMessage(archetypeNames[archetype])
) : (
<FormattedMessage
id='annual_report.summary.archetype.die_drei_fragezeichen'
defaultMessage='???'
/>
)}
</p>
<p>
{isRevealed ? (
intl.formatMessage(descriptions[archetype], {
name,
})
) : (
<FormattedMessage
id='annual_report.summary.archetype.reveal_description'
defaultMessage='Thanks for being part of Mastodon! Time to find out which archetype you embodied in {year}.'
values={{ year: report.year }}
/>
)}
</p>
</div>
{!isRevealed && (
<Button onClick={reveal}>
<FormattedMessage
id='annual_report.summary.archetype.reveal'
defaultMessage='Reveal my archetype'
/>
</Button>
)}
{isRevealed && isSelfView && <ShareButton report={report} />}
</div>
);
};

View File

@@ -1,68 +1,24 @@
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
import styles from './index.module.scss';
export const Followers: React.FC<{
data: TimeSeriesMonth[];
total?: number;
}> = ({ data, total }) => {
const change = data.reduce((sum, item) => sum + item.followers, 0);
const cumulativeGraph = data.reduce(
(newData, item) => [
...newData,
item.followers + (newData[newData.length - 1] ?? 0),
],
[0],
);
count: number;
}> = ({ count }) => {
return (
<div className='annual-report__bento__box annual-report__summary__followers'>
<Sparklines data={cumulativeGraph} margin={0}>
<svg>
<defs>
<linearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop
offset='0%'
stopColor='var(--sparkline-gradient-top)'
stopOpacity='1'
/>
<stop
offset='100%'
stopColor='var(--sparkline-gradient-bottom)'
stopOpacity='0'
/>
</linearGradient>
</defs>
</svg>
<div className={classNames(styles.box, styles.followers, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
<div className='annual-report__summary__followers__foreground'>
<div className='annual-report__summary__followers__number'>
{change > -1 ? '+' : '-'}
<FormattedNumber value={change} />
</div>
<div className='annual-report__summary__followers__label'>
<span>
<FormattedMessage
id='annual_report.summary.followers.followers'
defaultMessage='followers'
/>
</span>
<div className='annual-report__summary__followers__footnote'>
<FormattedMessage
id='annual_report.summary.followers.total'
defaultMessage='{count} total'
values={{ count: <ShortNumber value={total ?? 0} /> }}
/>
</div>
</div>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.followers.new_followers'
defaultMessage='{count, plural, one {new follower} other {new followers}}'
values={{ count }}
/>
</div>
</div>
);

View File

@@ -1,106 +1,102 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call */
import type { ComponentPropsWithoutRef } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
import { me } from 'flavours/glitch/initial_state';
import classNames from 'classnames';
import { InterceptStatusClicks } from 'flavours/glitch/components/status/intercept_status_clicks';
import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted';
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
import {
makeGetStatus,
makeGetPictureInPicture,
} from 'flavours/glitch/selectors';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { makeGetStatus } from 'flavours/glitch/selectors';
import { useAppSelector } from 'flavours/glitch/store';
import styles from './index.module.scss';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
let statusId, label;
context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data;
if (data.by_reblogs) {
statusId = data.by_reblogs;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_reblogs'
defaultMessage='most boosted post'
/>
);
} else if (data.by_favourites) {
statusId = data.by_favourites;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_favourites'
defaultMessage='most favourited post'
/>
);
} else {
statusId = data.by_replies;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_replies'
defaultMessage='post with the most replies'
/>
);
}
const statusId = by_reblogs || by_favourites || by_replies;
const dispatch = useAppDispatch();
const domain = useAppSelector((state) => state.meta.get('domain'));
const status = useAppSelector((state) =>
statusId ? getStatus(state, { id: statusId }) : undefined,
);
const pictureInPicture = useAppSelector((state) =>
statusId ? getPictureInPicture(state, { id: statusId }) : undefined,
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
const handleClick = useCallback<
ComponentPropsWithoutRef<typeof InterceptStatusClicks>['onPreventedClick']
>(
(clickedArea) => {
const link: string =
clickedArea === 'account'
? status.getIn(['account', 'url'])
: status.get('url');
if (context === 'standalone') {
window.location.href = link;
} else {
window.open(link, '_blank');
}
},
[status, context],
);
if (!status) {
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
}
let label;
if (by_reblogs) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.boost_count'
defaultMessage='This post was boosted {count, plural, one {once} other {# times}}.'
values={{ count: status.get('reblogs_count') }}
/>
);
} else if (by_favourites) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.favourite_count'
defaultMessage='This post was favorited {count, plural, one {once} other {# times}}.'
values={{ count: status.get('favourites_count') }}
/>
);
} else {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.reply_count'
defaultMessage='This post got {count, plural, one {one reply} other {# replies}}.'
values={{ count: status.get('replies_count') }}
/>
);
}
const displayName = (
<span className='display-name'>
<strong className='display-name__html'>
<FormattedMessage
id='annual_report.summary.highlighted_post.possessive'
defaultMessage="{name}'s"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</strong>
<span className='display-name__account'>{label}</span>
</span>
);
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
<DetailedStatus
status={status}
pictureInPicture={pictureInPicture}
domain={domain}
onToggleHidden={handleToggleHidden}
overrideDisplayName={displayName}
expanded={false}
/>
<div className={classNames(styles.box, styles.mostBoostedPost)}>
<div className={styles.content}>
<h2 className={styles.title}>
<FormattedMessage
id='annual_report.summary.highlighted_post.title'
defaultMessage='Most popular post'
/>
</h2>
{context === 'modal' && <p>{label}</p>}
</div>
<InterceptStatusClicks onPreventedClick={handleClick}>
<StatusQuoteManager showActions={false} id={statusId} />
</InterceptStatusClicks>
</div>
);
};

View File

@@ -0,0 +1,334 @@
$mobile-breakpoint: 540px;
@font-face {
font-family: silkscreen-wrapstodon;
src: url('@/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2')
format('woff2');
font-weight: normal;
font-display: swap;
font-style: normal;
}
.modalWrapper {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 40px;
overflow-y: auto;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding: 0;
}
.loading-indicator .circular-progress {
color: var(--lime);
}
}
.closeButton {
--default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-primary);
--corner-distance: 18px;
position: absolute;
top: var(--corner-distance);
right: var(--corner-distance);
padding: 8px;
border-radius: 100%;
@media (width < $mobile-breakpoint) {
--corner-distance: 16px;
padding: 4px;
}
}
.wrapper {
--gradient-strength: 0.4;
box-sizing: border-box;
position: relative;
max-width: 600px;
padding: 24px;
padding-top: 40px;
contain: layout;
flex: 0 0 auto;
pointer-events: all;
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(
at 10% 27%,
rgba(83, 12, 154, var(--gradient-strength)) 0,
transparent 50%
),
radial-gradient(
at 91% 10%,
rgba(30, 24, 223, var(--gradient-strength)) 0,
transparent 25%
),
radial-gradient(
at 10% 91%,
rgba(22, 218, 228, var(--gradient-strength)) 0,
transparent 40%
),
radial-gradient(
at 75% 87%,
rgba(37, 31, 217, var(--gradient-strength)) 0,
transparent 20%
),
radial-gradient(
at 84% 60%,
rgba(95, 30, 148, var(--gradient-strength)) 0,
transparent 40%
)
var(--color-bg-primary);
border-radius: 40px;
@media (width < $mobile-breakpoint) {
padding-inline: 12px;
padding-bottom: 12px;
border-radius: 0;
}
}
.header {
margin-bottom: 18px;
text-align: center;
h1 {
font-family: silkscreen-wrapstodon, monospace;
font-size: 28px;
line-height: 1;
margin-bottom: 4px;
padding-inline: 40px; // Prevent overlap with close button
@media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
}
p {
font-size: 14px;
line-height: 1.5;
}
}
.stack {
--grid-spacing: 12px;
display: grid;
gap: var(--grid-spacing);
}
.box {
position: relative;
padding: 24px;
border-radius: 16px;
background: rgb(from var(--color-bg-primary) r g b / 60%);
box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
&::before,
&::after {
content: '';
position: absolute;
inset-inline: 0;
display: block;
height: 1px;
background-image: linear-gradient(
to right,
transparent,
white,
transparent
);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
font-size: 14px;
text-align: center;
text-wrap: balance;
&.comfortable {
gap: 12px;
}
}
.title {
text-transform: uppercase;
color: #c2c8ff;
font-weight: 500;
&:last-child {
margin-bottom: -3px;
}
}
.statLarge {
font-size: 24px;
font-weight: 500;
overflow-wrap: break-word;
}
.statExtraLarge {
font-size: 32px;
font-weight: 500;
line-height: 1;
overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
}
.mostBoostedPost {
padding: 0;
padding-top: 24px;
overflow: hidden;
}
.statsGrid {
display: grid;
gap: var(--grid-spacing);
grid-template-columns: 1fr 2fr;
grid-template-areas:
'followers hashtag'
'new-posts hashtag';
@media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
'followers new-posts'
'hashtag hashtag';
}
&:empty {
display: none;
}
&.onlyHashtag {
grid-template-columns: 1fr;
grid-template-areas: 'hashtag';
}
&.noHashtag {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'followers new-posts';
}
&.singleNumber {
grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag';
@media (width < $mobile-breakpoint) {
grid-template-areas:
'number number'
'hashtag hashtag';
}
}
&.singleNumber.noHashtag {
grid-template-columns: 1fr;
grid-template-areas: 'number';
}
}
.followers {
grid-area: followers;
.singleNumber & {
grid-area: number;
}
}
.newPosts {
grid-area: new-posts;
.singleNumber & {
grid-area: number;
}
}
.mostUsedHashtag {
grid-area: hashtag;
padding-block: 24px;
}
.archetype {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
p {
max-width: 460px;
}
}
.archetypeArtboard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
align-self: center;
width: 180px;
padding-top: 40px;
}
.archetypeAvatar {
position: absolute;
top: 7px;
left: 4px;
border-radius: 100%;
overflow: hidden;
}
.archetypeIllustrationWrapper {
position: relative;
width: 92px;
aspect-ratio: 1;
overflow: hidden;
border-radius: 100%;
&::after {
content: '';
display: block;
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset -10px -4px 15px #00000080;
}
}
.archetypeIllustration {
width: 100%;
}
.blurredImage {
filter: blur(10px);
}
.archetypePlanetRing {
position: absolute;
top: 0;
left: 0;
mix-blend-mode: screen;
}

View File

@@ -1,95 +1,112 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import { useIntl } from 'react-intl';
import { useLocation } from 'react-router';
import classNames from 'classnames/bind';
import { focusCompose, resetCompose } from '@/flavours/glitch/actions/compose';
import { closeModal } from '@/flavours/glitch/actions/modal';
import { Button } from '@/flavours/glitch/components/button';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { me } from '@/flavours/glitch/initial_state';
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Archetype, archetypeNames } from './archetype';
import { Archetype } from './archetype';
import { Followers } from './followers';
import { HighlightedPost } from './highlighted_post';
import styles from './index.module.scss';
import { MostUsedHashtag } from './most_used_hashtag';
import { NewPosts } from './new_posts';
const shareMessage = defineMessage({
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
});
const moduleClassNames = classNames.bind(styles);
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => {
const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone',
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector((state) => {
if (me) {
return state.accounts.get(me);
}
if (report?.schema_version === 2) {
return state.accounts.get(report.account_id);
}
return undefined;
});
const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [dispatch]);
// Close modal when navigating away from within
const { pathname } = useLocation();
const [initialPathname] = useState(pathname);
useEffect(() => {
if (pathname !== initialPathname) {
close();
}
}, [pathname, initialPathname, close]);
if (!report) {
return <LoadingIndicator />;
}
const newPostCount = report.data.time_series.reduce(
(sum, item) => sum + item.statuses,
0,
);
const newFollowerCount =
context === 'modal' &&
report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
const topHashtag = report.data.top_hashtags[0];
return (
<div className='annual-report'>
<div className='annual-report__header'>
<h1>
<FormattedMessage
id='annual_report.summary.thanks'
defaultMessage='Thanks for being part of Mastodon!'
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}>
<h1>Wrapstodon {report.year}</h1>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton
title={intl.formatMessage({
id: 'annual_report.summary.close',
defaultMessage: 'Close',
})}
className={styles.closeButton}
icon='close'
iconComponent={CloseIcon}
onClick={close}
/>
</h1>
<p>
<FormattedMessage
id='annual_report.summary.here_it_is'
defaultMessage='Here is your {year} in review:'
values={{ year: report.year }}
/>
</p>
)}
</div>
<div className='annual-report__bento annual-report__summary'>
<Archetype data={report.data.archetype} />
<HighlightedPost data={report.data.top_statuses} />
<Followers
data={report.data.time_series}
total={currentAccount?.followers_count}
/>
<MostUsedHashtag data={report.data.top_hashtags} />
<NewPosts data={report.data.time_series} />
{share && <ShareButton report={report} />}
<div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} context={context} />
<div
className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag,
onlyHashtag: !(newFollowerCount && newPostCount),
singleNumber: !!newFollowerCount !== !!newPostCount,
})}
>
{!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div>
<Archetype report={report} account={account} context={context} />
</div>
</div>
);
};
const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => {
// Generate the share message.
const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype],
);
const shareLines = [
intl.formatMessage(shareMessage, {
archetype: archetypeName,
}),
];
// Share URL is only available for schema version 2.
if (report.schema_version === 2 && report.share_url) {
shareLines.push(report.share_url);
}
shareLines.push(`#Wrapstodon${report.year}`);
// Reset the composer and focus it with the share message, then close the modal.
dispatch(resetCompose());
dispatch(focusCompose(shareLines.join('\n\n')));
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]);
return <Button text='Share here' onClick={handleShareClick} />;
};

View File

@@ -0,0 +1,49 @@
import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { closeModal } from '@/flavours/glitch/actions/modal';
import { useAppDispatch } from '@/flavours/glitch/store';
import { AnnualReport } from '.';
import styles from './index.module.scss';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]);
const dispatch = useAppDispatch();
const handleCloseModal = useCallback<React.MouseEventHandler<HTMLDivElement>>(
(e) => {
if (e.target === e.currentTarget)
dispatch(
closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }),
);
},
[dispatch],
);
return (
// It's fine not to provide a keyboard handler here since there is a global
// [Esc] key listener that will close open modals.
// This onClick handler is needed since the modalWrapper styles overlap the
// default modal backdrop, preventing clicks to pass through.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={classNames(
'modal-root__modal',
styles.modalWrapper,
'theme-dark',
)}
onClick={handleCloseModal}
>
<AnnualReport context='modal' />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -1,30 +1,46 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
export const MostUsedHashtag: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const hashtag = data[0];
import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount;
name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
<div className='annual-report__summary__most-used-hashtag__hashtag'>
{hashtag ? (
<>#{hashtag.name}</>
) : (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.none'
defaultMessage='None'
/>
)}
</div>
<div className='annual-report__summary__most-used-hashtag__label'>
<div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
defaultMessage='most used hashtag'
defaultMessage='Most used hashtag'
/>
</div>
<div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p>
{context === 'modal' ? (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p>
</div>
);
};

View File

@@ -1,51 +1,23 @@
import { FormattedNumber, FormattedMessage } from 'react-intl';
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
import classNames from 'classnames';
import styles from './index.module.scss';
export const NewPosts: React.FC<{
data: TimeSeriesMonth[];
}> = ({ data }) => {
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
count: number;
}> = ({ count }) => {
return (
<div className='annual-report__bento__box annual-report__summary__new-posts'>
<svg width={500} height={500}>
<defs>
<pattern
id='posts'
x='0'
y='0'
width='32'
height='35'
patternUnits='userSpaceOnUse'
>
<circle cx='12' cy='12' r='12' fill='var(--lime)' />
<ChatBubbleIcon
fill='var(--indigo-1)'
x='4'
y='4'
width='16'
height='16'
/>
</pattern>
</defs>
<rect
width={500}
height={500}
fill='url(#posts)'
style={{ opacity: 0.2 }}
/>
</svg>
<div className='annual-report__summary__new-posts__number'>
<FormattedNumber value={posts} />
<div className={classNames(styles.box, styles.newPosts, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<div className='annual-report__summary__new-posts__label'>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.new_posts.new_posts'
defaultMessage='new posts'
defaultMessage='{count, plural, one {new post} other {new posts}}'
values={{ count }}
/>
</div>
</div>

View File

@@ -1,19 +0,0 @@
.wrapper {
max-width: 40rem;
margin: 0 auto;
}
.footer {
text-align: center;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}

View File

@@ -1,18 +0,0 @@
import type { FC } from 'react';
import { IconLogo } from '@/flavours/glitch/components/logo';
import { AnnualReport } from './index';
import classes from './share.module.css';
export const WrapstodonShare: FC = () => {
return (
<main className={classes.wrapper}>
<AnnualReport share={false} />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
Generated with by the Mastodon team
</footer>
</main>
);
};

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { resetCompose, focusCompose } from '@/flavours/glitch/actions/compose';
import { closeModal } from '@/flavours/glitch/actions/modal';
import { Button } from '@/flavours/glitch/components/button';
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
import { useAppDispatch } from '@/flavours/glitch/store';
import { archetypeNames } from './archetype';
const messages = defineMessages({
share_message: {
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
},
share_on_mastodon: {
id: 'annual_report.summary.share_on_mastodon',
defaultMessage: 'Share on Mastodon',
},
});
export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => {
// Generate the share message.
const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype],
);
const shareLines = [
intl.formatMessage(messages.share_message, {
archetype: archetypeName,
}),
];
// Share URL is only available for schema version 2.
if (report.schema_version === 2 && report.share_url) {
shareLines.push(report.share_url);
}
shareLines.push(`#Wrapstodon${report.year}`);
// Reset the composer and focus it with the share message, then close the modal.
dispatch(resetCompose());
dispatch(focusCompose(shareLines.join('\n\n')));
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]);
return (
<Button
text={intl.formatMessage(messages.share_on_mastodon)}
onClick={handleShareClick}
/>
);
};

View File

@@ -0,0 +1,45 @@
$mobile-breakpoint: 540px;
.wrapper {
box-sizing: border-box;
max-width: 600px;
margin-inline: auto;
padding: 40px 10px;
@media (width < $mobile-breakpoint) {
padding-top: 0;
padding-inline: 0;
}
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-top: 2rem;
font-size: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-text-primary);
}
}

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/flavours/glitch/components/logo';
import { me } from '@/flavours/glitch/initial_state';
import { AnnualReport } from './index';
import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => {
return (
<main className={classes.wrapper}>
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '♥' }}
/>
<nav className={classes.nav}>
<a href='/about'>
<FormattedMessage
id='footer.about_this_server'
defaultMessage='About'
/>
</a>
{!me && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</nav>
</footer>
</main>
);
};

View File

@@ -1,20 +0,0 @@
import { useEffect } from 'react';
import { AnnualReport } from 'flavours/glitch/features/annual_report';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--indigo-1)');
}, [onChangeBackgroundColor]);
return (
<div className='modal-root__modal annual-report-modal'>
<AnnualReport />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/flavours/glitch/actions/timelines';
import { scrollTopTimeline, loadPending } from '@/flavours/glitch/actions/timelines';
import { isNonStatusId } from '@/flavours/glitch/actions/timelines_typed';
import StatusList from '@/flavours/glitch/components/status_list';
import { me } from '@/flavours/glitch/initial_state';
import { TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
const getRegex = createSelector([
(state, { regex }) => regex,
@@ -29,7 +29,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
getRegex,
], (columnSettings, statusIds, statuses, regex) => {
return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true;
if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id);

View File

@@ -231,7 +231,7 @@ export function LinkTimeline () {
}
export function AnnualReportModal () {
return import('../components/annual_report_modal');
return import('../../annual_report/modal');
}
export function ListEdit () {

View File

@@ -16,9 +16,9 @@ export interface TimeSeriesMonth {
}
export interface TopStatuses {
by_reblogs: number;
by_favourites: number;
by_replies: number;
by_reblogs: string;
by_favourites: string;
by_replies: string;
}
export type Archetype =
@@ -55,5 +55,6 @@ export type AnnualReport = {
schema_version: 2;
data: AnnualReportV2;
share_url: string | null;
account_id: string;
}
);

View File

@@ -11,7 +11,7 @@ import type {
import type { ApiReportJSON } from 'flavours/glitch/api_types/reports';
// Maximum number of avatars displayed in a notification group
// This corresponds to the max lenght of `group.sampleAccountIds`
// This corresponds to the max length of `group.sampleAccountIds`
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
interface BaseNotificationGroup

View File

@@ -442,7 +442,9 @@ export const composeReducer = (state = initialState, action) => {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('compose'));
if (action.state.get('compose'))
return hydrate(state, action.state.get('compose'));
return state;
case COMPOSE_MOUNT:
return state
.set('mounted', state.get('mounted') + 1)

View File

@@ -6,6 +6,7 @@ import {
importFetchedStatuses,
} from '@/flavours/glitch/actions/importer';
import { insertIntoTimeline } from '@/flavours/glitch/actions/timelines';
import { timelineDelete } from '@/flavours/glitch/actions/timelines_typed';
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
import {
apiGetAnnualReport,
@@ -78,6 +79,25 @@ export const checkAnnualReport = createAppThunk(
},
);
export const reinsertAnnualReport = createAppThunk(
`${annualReportSlice.name}/reinsertAnnualReport`,
(_arg: unknown, { dispatch, getState }) => {
dispatch(
timelineDelete({
statusId: TIMELINE_WRAPSTODON,
accountId: '',
references: [],
reblogOf: null,
}),
);
const { state } = getState().annualReport;
if (!state || state === 'ineligible') {
return;
}
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
},
);
const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => {

View File

@@ -1,6 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
import { timelineDelete, isNonStatusId } from 'flavours/glitch/actions/timelines_typed';
import {
blockAccountSuccess,
@@ -19,7 +19,6 @@ import {
TIMELINE_MARK_AS_PARTIAL,
TIMELINE_INSERT,
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
disconnectTimeline,
} from '../actions/timelines';
import { compareId } from '../compare_id';
@@ -36,7 +35,6 @@ const initialTimeline = ImmutableMap({
items: ImmutableList(),
});
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because:
@@ -69,20 +67,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
// First, find the furthest (if properly sorted, oldest) item in the timeline that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item.
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1;
const lastIndex = oldIds.findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.last()) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
// is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newIds` (or that were deleted from the server, so should be removed
// anyway).
// Stop the gap *after* that item.
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1;
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.first()) > 0) + 1;
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
// It is possible, though unlikely, that the slice we are replacing contains items older
// than the elements we got from the API. Get them and add them back at the back of the
// slice.
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0);
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isNonStatusId(id) && compareId(id, newIds.last()) < 0);
insertedIds.union(olderIds);
// Make sure we aren't inserting duplicates

View File

@@ -16,7 +16,6 @@
@use 'mastodon/polls';
@use 'mastodon/modal';
@use 'mastodon/emoji_picker';
@use 'mastodon/annual_reports';
@use 'mastodon/about';
@use 'mastodon/tables';
@use 'mastodon/admin';

View File

@@ -1,342 +0,0 @@
@use 'variables' as *;
:root {
--indigo-1: #17063b;
--indigo-2: #2f0c7a;
--indigo-3: #562cfc;
--indigo-5: #858afa;
--indigo-6: #cccfff;
--lime: #baff3b;
--goldenrod-2: #ffc954;
}
.annual-report {
flex: 0 0 auto;
background: var(--indigo-1);
padding: 24px;
&__header {
margin-bottom: 16px;
h1 {
font-size: 25px;
font-weight: 600;
line-height: 30px;
color: var(--lime);
margin-bottom: 8px;
}
p {
font-size: 16px;
font-weight: 600;
line-height: 20px;
color: var(--indigo-6);
}
}
&__bento {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: minmax(0, auto) minmax(0, 1fr) minmax(0, auto) minmax(
0,
auto
);
&__box {
padding: 16px;
border-radius: 8px;
background: var(--indigo-2);
color: var(--indigo-5);
}
}
&__summary {
&__most-boosted-post {
grid-column: span 2;
grid-row: span 2;
padding: 0;
.status__content,
.content-warning {
color: var(--indigo-6);
}
.detailed-status {
border: 0;
}
.content-warning {
border: 0;
background: var(--indigo-1);
.link-button {
color: var(--indigo-5);
}
}
.detailed-status__meta__line {
border-bottom-color: var(--indigo-3);
}
.detailed-status__meta {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.detailed-status__meta,
.poll__footer,
.poll__link,
.detailed-status .logo,
.detailed-status__display-name {
color: var(--indigo-5);
}
.detailed-status__meta .animated-number,
.detailed-status__display-name strong {
color: var(--indigo-6);
}
.poll__chart {
background-color: var(--indigo-3);
&.leading {
background-color: var(--goldenrod-2);
}
}
.status-card,
.hashtag-bar {
display: none;
}
}
&__followers {
grid-column: span 1;
text-align: center;
position: relative;
overflow: hidden;
padding-block-start: 24px;
padding-block-end: 24px;
--sparkline-gradient-top: rgba(86, 44, 252, 50%);
--sparkline-gradient-bottom: rgba(86, 44, 252, 0%);
&__foreground {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
z-index: 1;
}
&__number {
font-size: 31px;
font-weight: 600;
line-height: 37px;
color: var(--lime);
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
color: var(--indigo-6);
}
&__footnote {
display: block;
font-weight: 400;
opacity: 0.5;
}
svg {
position: absolute;
bottom: 0;
inset-inline-end: 0;
pointer-events: none;
z-index: 0;
height: 70%;
width: auto;
path:first-child {
fill: url('#gradient') !important;
fill-opacity: 1 !important;
}
path:last-child {
stroke: var(--color-graph-primary-stroke) !important;
fill: none !important;
}
}
}
&__archetype {
grid-column: span 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
padding: 0;
img {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
&__label {
padding: 16px;
padding-bottom: 8px;
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--lime);
}
}
&__most-used-app {
grid-column: span 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
&__label {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--indigo-6);
}
&__icon {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--goldenrod-2);
}
}
&__percentile {
grid-row: span 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
text-align: center;
text-wrap: balance;
padding: 16px 8px;
&__label {
font-size: 14px;
line-height: 17px;
}
&__number {
font-size: 54px;
font-weight: 600;
line-height: 73px;
color: var(--goldenrod-2);
}
&__footnote {
font-size: 11px;
line-height: 14px;
opacity: 0.5;
}
}
&__new-posts {
grid-column: span 2;
text-align: center;
position: relative;
overflow: hidden;
&__label {
font-size: 20px;
font-weight: 600;
line-height: 24px;
color: var(--indigo-6);
z-index: 1;
position: relative;
}
&__number {
font-size: 76px;
font-weight: 600;
line-height: 91px;
color: var(--goldenrod-2);
z-index: 1;
position: relative;
}
svg {
position: absolute;
inset-inline-start: -7px;
top: -4px;
z-index: 0;
}
}
&__most-used-hashtag {
grid-column: span 2;
text-align: center;
overflow: hidden;
&__hashtag {
font-size: 42px;
font-weight: 600;
line-height: 58px;
color: var(--indigo-6);
margin-inline-start: -100%;
margin-inline-end: -100%;
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
}
}
}
}
.annual-report-modal {
max-width: 600px;
background: var(--indigo-1);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
.loading-indicator .circular-progress {
color: var(--lime);
}
@media screen and (max-width: $no-columns-breakpoint) {
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
}
.notification-group--annual-report {
.notification-group__icon {
color: var(--lime);
}
.notification-group__main .link-button {
font-weight: 500;
color: var(--lime);
}
}

View File

@@ -6,7 +6,7 @@
html {
@include base.palette;
&[data-user-theme='system'] {
&:where([data-user-theme='system']) {
color-scheme: dark light;
@media (prefers-color-scheme: dark) {

View File

@@ -0,0 +1,100 @@
Below you'll find the original License file for the Silkscreen font.
The file used on Mastodon is a custom file subset to only include the
characters "Wrapstodon 0123456789" using the Font Squirrel Font-face Generator
(https://www.fontsquirrel.com/tools/webfont-generator)
-----------------------------------------------------------
Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -22,6 +22,8 @@ export function hydrateStore(rawState) {
dispatch(hydrateCompose());
dispatch(hydrateSearch());
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
if (rawState.accounts) {
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
}
};
}

View File

@@ -1,12 +1,13 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
import api, { getLinks } from 'mastodon/api';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed';
import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed';
@@ -24,9 +25,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
TIMELINE_WRAPSTODON,
];
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
timeline,
@@ -124,6 +132,7 @@ export function expandTimeline(timelineId, path, params = {}) {
if (timelineId === 'home') {
dispatch(submitMarkers());
dispatch(reinsertAnnualReport())
}
} catch(error) {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
export function isNonStatusId(value: unknown) {
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
}
export const disconnectTimeline = createAction(
'timeline/disconnect',
({ timeline }: { timeline: string }) => ({

View File

@@ -117,6 +117,7 @@ class Status extends ImmutablePureComponent {
hidden: PropTypes.bool,
unread: PropTypes.bool,
showThread: PropTypes.bool,
showActions: PropTypes.bool,
isQuotedPost: PropTypes.bool,
shouldHighlightOnMount: PropTypes.bool,
getScrollPosition: PropTypes.func,
@@ -381,7 +382,7 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props;
const { intl, hidden, featured, unfocusable, unread, showThread, showActions = true, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props;
let { status, account, ...other } = this.props;
@@ -618,7 +619,7 @@ class Status extends ImmutablePureComponent {
</>
)}
{!isQuotedPost &&
{(showActions && !isQuotedPost) &&
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
}
</div>

View File

@@ -0,0 +1,45 @@
import { useCallback, useRef } from 'react';
export const InterceptStatusClicks: React.FC<{
onPreventedClick: (
clickedArea: 'account' | 'post',
event: React.MouseEvent,
) => void;
children: React.ReactNode;
}> = ({ onPreventedClick, children }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const clickTarget = e.target as Element;
const allowedElementsSelector =
'.video-player, .audio-player, .media-gallery, .content-warning';
const allowedElements = wrapperRef.current?.querySelectorAll(
allowedElementsSelector,
);
const isTargetClickAllowed =
allowedElements &&
Array.from(allowedElements).some((element) => {
return clickTarget === element || element.contains(clickTarget);
});
if (!isTargetClickAllowed) {
e.preventDefault();
e.stopPropagation();
const wasAccountAreaClicked = !!clickTarget.closest(
'a.status__display-name',
);
onPreventedClick(wasAccountAreaClicked ? 'account' : 'post', e);
}
},
[onPreventedClick],
);
return (
<div ref={wrapperRef} onClickCapture={handleClick}>
{children}
</div>
);
};

View File

@@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Button } from '@/mastodon/components/button';
import styles from './styles.module.scss';
@@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{
onOpen: () => void;
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
return (
<div className={styles.wrapper}>
<div className={classNames('theme-dark', styles.wrapper)}>
<h2>
<FormattedMessage
id='annual_report.announcement.title'

View File

@@ -6,14 +6,14 @@
text-align: center;
font-size: 15px;
line-height: 1.5;
color: var(--color-text-on-media);
background: var(--color-bg-media-base);
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
var(--color-bg-media-base);
var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary);
h2 {

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standalone: Story = {
args: {
context: 'standalone',
},
};
export const InModal: Story = {
args: {
context: 'modal',
},
};
export const ArchetypeOracle: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'oracle',
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
};
export const NoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'booster',
}),
},
},
};
export const NoNewPosts: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'pollster',
top_hashtag: SAMPLE_HASHTAG,
without_posts: true,
}),
},
},
};
export const NoNewPostsNoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'replier',
without_posts: true,
}),
},
},
};

View File

@@ -1,65 +1,214 @@
import { defineMessages, useIntl } from 'react-intl';
import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png';
import pollster from '@/images/archetypes/pollster.png';
import replier from '@/images/archetypes/replier.png';
import type { Archetype as ArchetypeData } from '@/mastodon/models/annual_report';
import space_elements from '@/images/archetypes/space_elements.png';
import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button';
import type { Account } from '@/mastodon/models/account';
import type {
AnnualReport,
Archetype as ArchetypeData,
} from '@/mastodon/models/annual_report';
import styles from './index.module.scss';
import { ShareButton } from './share_button';
export const archetypeNames = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster',
defaultMessage: 'The cool-hunter',
id: 'annual_report.summary.archetype.booster.name',
defaultMessage: 'The Archer',
},
replier: {
id: 'annual_report.summary.archetype.replier',
defaultMessage: 'The social butterfly',
id: 'annual_report.summary.archetype.replier.name',
defaultMessage: 'The Butterfly',
},
pollster: {
id: 'annual_report.summary.archetype.pollster',
defaultMessage: 'The pollster',
id: 'annual_report.summary.archetype.pollster.name',
defaultMessage: 'The Wonderer',
},
lurker: {
id: 'annual_report.summary.archetype.lurker',
defaultMessage: 'The lurker',
id: 'annual_report.summary.archetype.lurker.name',
defaultMessage: 'The Stoic',
},
oracle: {
id: 'annual_report.summary.archetype.oracle',
defaultMessage: 'The oracle',
id: 'annual_report.summary.archetype.oracle.name',
defaultMessage: 'The Oracle',
},
});
export const Archetype: React.FC<{
data: ArchetypeData;
}> = ({ data }) => {
const intl = useIntl();
let illustration;
export const archetypeSelfDescriptions = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster.desc_self',
defaultMessage:
'You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
},
replier: {
id: 'annual_report.summary.archetype.replier.desc_self',
defaultMessage:
'You frequently replied to other peoples posts, pollinating Mastodon with new discussions.',
},
pollster: {
id: 'annual_report.summary.archetype.pollster.desc_self',
defaultMessage:
'You created more polls than other post types, cultivating curiosity on Mastodon.',
},
lurker: {
id: 'annual_report.summary.archetype.lurker.desc_self',
defaultMessage:
'We know you were out there, somewhere, enjoying Mastodon in your own quiet way.',
},
oracle: {
id: 'annual_report.summary.archetype.oracle.desc_self',
defaultMessage:
'You created new posts more than replies, keeping Mastodon fresh and future-facing.',
},
});
switch (data) {
case 'booster':
illustration = booster;
break;
case 'replier':
illustration = replier;
break;
case 'pollster':
illustration = pollster;
break;
case 'lurker':
illustration = lurker;
break;
case 'oracle':
illustration = oracle;
break;
}
export const archetypePublicDescriptions = defineMessages<ArchetypeData>({
booster: {
id: 'annual_report.summary.archetype.booster.desc_public',
defaultMessage:
'{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
},
replier: {
id: 'annual_report.summary.archetype.replier.desc_public',
defaultMessage:
'{name} frequently replied to other peoples posts, pollinating Mastodon with new discussions.',
},
pollster: {
id: 'annual_report.summary.archetype.pollster.desc_public',
defaultMessage:
'{name} created more polls than other post types, cultivating curiosity on Mastodon.',
},
lurker: {
id: 'annual_report.summary.archetype.lurker.desc_public',
defaultMessage:
'We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.',
},
oracle: {
id: 'annual_report.summary.archetype.oracle.desc_public',
defaultMessage:
'{name} created new posts more than replies, keeping Mastodon fresh and future-facing.',
},
});
const illustrations = {
booster,
replier,
pollster,
lurker,
oracle,
} as const;
export const Archetype: React.FC<{
report: AnnualReport;
account?: Account;
context: 'modal' | 'standalone';
}> = ({ report, account, context }) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView);
const reveal = useCallback(() => {
setIsRevealed(true);
wrapperRef.current?.focus();
}, []);
const archetype = report.data.archetype;
const descriptions = isSelfView
? archetypeSelfDescriptions
: archetypePublicDescriptions;
const name = account?.display_name;
return (
<div className='annual-report__bento__box annual-report__summary__archetype'>
<div className='annual-report__summary__archetype__label'>
{intl.formatMessage(archetypeNames[data])}
<div
className={classNames(styles.box, styles.archetype)}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={wrapperRef}
>
<div className={styles.archetypeArtboard}>
{account && (
<Avatar
account={account}
size={50}
className={styles.archetypeAvatar}
/>
)}
<div className={styles.archetypeIllustrationWrapper}>
<img
src={illustrations[archetype]}
alt=''
className={classNames(
styles.archetypeIllustration,
isRevealed ? '' : styles.blurredImage,
)}
/>
</div>
<img
src={space_elements}
alt=''
className={styles.archetypePlanetRing}
/>
</div>
<img src={illustration} alt='' />
<div className={classNames(styles.content, styles.comfortable)}>
<h2 className={styles.title}>
{isSelfView ? (
<FormattedMessage
id='annual_report.summary.archetype.title_self'
defaultMessage='Your archetype'
/>
) : (
<FormattedMessage
id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype"
values={{ name }}
/>
)}
</h2>
<p className={styles.statLarge}>
{isRevealed ? (
intl.formatMessage(archetypeNames[archetype])
) : (
<FormattedMessage
id='annual_report.summary.archetype.die_drei_fragezeichen'
defaultMessage='???'
/>
)}
</p>
<p>
{isRevealed ? (
intl.formatMessage(descriptions[archetype], {
name,
})
) : (
<FormattedMessage
id='annual_report.summary.archetype.reveal_description'
defaultMessage='Thanks for being part of Mastodon! Time to find out which archetype you embodied in {year}.'
values={{ year: report.year }}
/>
)}
</p>
</div>
{!isRevealed && (
<Button onClick={reveal}>
<FormattedMessage
id='annual_report.summary.archetype.reveal'
defaultMessage='Reveal my archetype'
/>
</Button>
)}
{isRevealed && isSelfView && <ShareButton report={report} />}
</div>
);
};

View File

@@ -1,68 +1,24 @@
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import { ShortNumber } from 'mastodon/components/short_number';
import type { TimeSeriesMonth } from 'mastodon/models/annual_report';
import styles from './index.module.scss';
export const Followers: React.FC<{
data: TimeSeriesMonth[];
total?: number;
}> = ({ data, total }) => {
const change = data.reduce((sum, item) => sum + item.followers, 0);
const cumulativeGraph = data.reduce(
(newData, item) => [
...newData,
item.followers + (newData[newData.length - 1] ?? 0),
],
[0],
);
count: number;
}> = ({ count }) => {
return (
<div className='annual-report__bento__box annual-report__summary__followers'>
<Sparklines data={cumulativeGraph} margin={0}>
<svg>
<defs>
<linearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop
offset='0%'
stopColor='var(--sparkline-gradient-top)'
stopOpacity='1'
/>
<stop
offset='100%'
stopColor='var(--sparkline-gradient-bottom)'
stopOpacity='0'
/>
</linearGradient>
</defs>
</svg>
<div className={classNames(styles.box, styles.followers, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
<div className='annual-report__summary__followers__foreground'>
<div className='annual-report__summary__followers__number'>
{change > -1 ? '+' : '-'}
<FormattedNumber value={change} />
</div>
<div className='annual-report__summary__followers__label'>
<span>
<FormattedMessage
id='annual_report.summary.followers.followers'
defaultMessage='followers'
/>
</span>
<div className='annual-report__summary__followers__footnote'>
<FormattedMessage
id='annual_report.summary.followers.total'
defaultMessage='{count} total'
values={{ count: <ShortNumber value={total ?? 0} /> }}
/>
</div>
</div>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.followers.new_followers'
defaultMessage='{count, plural, one {new follower} other {new followers}}'
values={{ count }}
/>
</div>
</div>
);

View File

@@ -1,102 +1,102 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call */
import type { ComponentPropsWithoutRef } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/mastodon/components/display_name';
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { me } from 'mastodon/initial_state';
import classNames from 'classnames';
import { InterceptStatusClicks } from 'mastodon/components/status/intercept_status_clicks';
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import type { TopStatuses } from 'mastodon/models/annual_report';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { makeGetStatus } from 'mastodon/selectors';
import { useAppSelector } from 'mastodon/store';
import styles from './index.module.scss';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
let statusId, label;
context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data;
if (data.by_reblogs) {
statusId = data.by_reblogs;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_reblogs'
defaultMessage='most boosted post'
/>
);
} else if (data.by_favourites) {
statusId = data.by_favourites;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_favourites'
defaultMessage='most favourited post'
/>
);
} else {
statusId = data.by_replies;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_replies'
defaultMessage='post with the most replies'
/>
);
}
const statusId = by_reblogs || by_favourites || by_replies;
const dispatch = useAppDispatch();
const domain = useAppSelector((state) => state.meta.get('domain'));
const status = useAppSelector((state) =>
statusId ? getStatus(state, { id: statusId }) : undefined,
);
const pictureInPicture = useAppSelector((state) =>
statusId ? getPictureInPicture(state, { id: statusId }) : undefined,
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
const handleClick = useCallback<
ComponentPropsWithoutRef<typeof InterceptStatusClicks>['onPreventedClick']
>(
(clickedArea) => {
const link: string =
clickedArea === 'account'
? status.getIn(['account', 'url'])
: status.get('url');
if (context === 'standalone') {
window.location.href = link;
} else {
window.open(link, '_blank');
}
},
[status, context],
);
if (!status) {
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
}
let label;
if (by_reblogs) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.boost_count'
defaultMessage='This post was boosted {count, plural, one {once} other {# times}}.'
values={{ count: status.get('reblogs_count') }}
/>
);
} else if (by_favourites) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.favourite_count'
defaultMessage='This post was favorited {count, plural, one {once} other {# times}}.'
values={{ count: status.get('favourites_count') }}
/>
);
} else {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.reply_count'
defaultMessage='This post got {count, plural, one {one reply} other {# replies}}.'
values={{ count: status.get('replies_count') }}
/>
);
}
const displayName = (
<span className='display-name'>
<strong className='display-name__html'>
<FormattedMessage
id='annual_report.summary.highlighted_post.possessive'
defaultMessage="{name}'s"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</strong>
<span className='display-name__account'>{label}</span>
</span>
);
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
<DetailedStatus
status={status}
pictureInPicture={pictureInPicture}
domain={domain}
onToggleHidden={handleToggleHidden}
overrideDisplayName={displayName}
/>
<div className={classNames(styles.box, styles.mostBoostedPost)}>
<div className={styles.content}>
<h2 className={styles.title}>
<FormattedMessage
id='annual_report.summary.highlighted_post.title'
defaultMessage='Most popular post'
/>
</h2>
{context === 'modal' && <p>{label}</p>}
</div>
<InterceptStatusClicks onPreventedClick={handleClick}>
<StatusQuoteManager showActions={false} id={statusId} />
</InterceptStatusClicks>
</div>
);
};

View File

@@ -0,0 +1,334 @@
$mobile-breakpoint: 540px;
@font-face {
font-family: silkscreen-wrapstodon;
src: url('@/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2')
format('woff2');
font-weight: normal;
font-display: swap;
font-style: normal;
}
.modalWrapper {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 40px;
overflow-y: auto;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding: 0;
}
.loading-indicator .circular-progress {
color: var(--lime);
}
}
.closeButton {
--default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-primary);
--corner-distance: 18px;
position: absolute;
top: var(--corner-distance);
right: var(--corner-distance);
padding: 8px;
border-radius: 100%;
@media (width < $mobile-breakpoint) {
--corner-distance: 16px;
padding: 4px;
}
}
.wrapper {
--gradient-strength: 0.4;
box-sizing: border-box;
position: relative;
max-width: 600px;
padding: 24px;
padding-top: 40px;
contain: layout;
flex: 0 0 auto;
pointer-events: all;
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(
at 10% 27%,
rgba(83, 12, 154, var(--gradient-strength)) 0,
transparent 50%
),
radial-gradient(
at 91% 10%,
rgba(30, 24, 223, var(--gradient-strength)) 0,
transparent 25%
),
radial-gradient(
at 10% 91%,
rgba(22, 218, 228, var(--gradient-strength)) 0,
transparent 40%
),
radial-gradient(
at 75% 87%,
rgba(37, 31, 217, var(--gradient-strength)) 0,
transparent 20%
),
radial-gradient(
at 84% 60%,
rgba(95, 30, 148, var(--gradient-strength)) 0,
transparent 40%
)
var(--color-bg-primary);
border-radius: 40px;
@media (width < $mobile-breakpoint) {
padding-inline: 12px;
padding-bottom: 12px;
border-radius: 0;
}
}
.header {
margin-bottom: 18px;
text-align: center;
h1 {
font-family: silkscreen-wrapstodon, monospace;
font-size: 28px;
line-height: 1;
margin-bottom: 4px;
padding-inline: 40px; // Prevent overlap with close button
@media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
}
p {
font-size: 14px;
line-height: 1.5;
}
}
.stack {
--grid-spacing: 12px;
display: grid;
gap: var(--grid-spacing);
}
.box {
position: relative;
padding: 24px;
border-radius: 16px;
background: rgb(from var(--color-bg-primary) r g b / 60%);
box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%);
&::before,
&::after {
content: '';
position: absolute;
inset-inline: 0;
display: block;
height: 1px;
background-image: linear-gradient(
to right,
transparent,
white,
transparent
);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
font-size: 14px;
text-align: center;
text-wrap: balance;
&.comfortable {
gap: 12px;
}
}
.title {
text-transform: uppercase;
color: #c2c8ff;
font-weight: 500;
&:last-child {
margin-bottom: -3px;
}
}
.statLarge {
font-size: 24px;
font-weight: 500;
overflow-wrap: break-word;
}
.statExtraLarge {
font-size: 32px;
font-weight: 500;
line-height: 1;
overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
}
.mostBoostedPost {
padding: 0;
padding-top: 24px;
overflow: hidden;
}
.statsGrid {
display: grid;
gap: var(--grid-spacing);
grid-template-columns: 1fr 2fr;
grid-template-areas:
'followers hashtag'
'new-posts hashtag';
@media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
'followers new-posts'
'hashtag hashtag';
}
&:empty {
display: none;
}
&.onlyHashtag {
grid-template-columns: 1fr;
grid-template-areas: 'hashtag';
}
&.noHashtag {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'followers new-posts';
}
&.singleNumber {
grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag';
@media (width < $mobile-breakpoint) {
grid-template-areas:
'number number'
'hashtag hashtag';
}
}
&.singleNumber.noHashtag {
grid-template-columns: 1fr;
grid-template-areas: 'number';
}
}
.followers {
grid-area: followers;
.singleNumber & {
grid-area: number;
}
}
.newPosts {
grid-area: new-posts;
.singleNumber & {
grid-area: number;
}
}
.mostUsedHashtag {
grid-area: hashtag;
padding-block: 24px;
}
.archetype {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
p {
max-width: 460px;
}
}
.archetypeArtboard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
align-self: center;
width: 180px;
padding-top: 40px;
}
.archetypeAvatar {
position: absolute;
top: 7px;
left: 4px;
border-radius: 100%;
overflow: hidden;
}
.archetypeIllustrationWrapper {
position: relative;
width: 92px;
aspect-ratio: 1;
overflow: hidden;
border-radius: 100%;
&::after {
content: '';
display: block;
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset -10px -4px 15px #00000080;
}
}
.archetypeIllustration {
width: 100%;
}
.blurredImage {
filter: blur(10px);
}
.archetypePlanetRing {
position: absolute;
top: 0;
left: 0;
mix-blend-mode: screen;
}

View File

@@ -1,95 +1,112 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import { useIntl } from 'react-intl';
import { useLocation } from 'react-router';
import classNames from 'classnames/bind';
import { focusCompose, resetCompose } from '@/mastodon/actions/compose';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { me } from '@/mastodon/initial_state';
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Archetype, archetypeNames } from './archetype';
import { Archetype } from './archetype';
import { Followers } from './followers';
import { HighlightedPost } from './highlighted_post';
import styles from './index.module.scss';
import { MostUsedHashtag } from './most_used_hashtag';
import { NewPosts } from './new_posts';
const shareMessage = defineMessage({
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
});
const moduleClassNames = classNames.bind(styles);
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => {
const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone',
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector((state) => {
if (me) {
return state.accounts.get(me);
}
if (report?.schema_version === 2) {
return state.accounts.get(report.account_id);
}
return undefined;
});
const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [dispatch]);
// Close modal when navigating away from within
const { pathname } = useLocation();
const [initialPathname] = useState(pathname);
useEffect(() => {
if (pathname !== initialPathname) {
close();
}
}, [pathname, initialPathname, close]);
if (!report) {
return <LoadingIndicator />;
}
const newPostCount = report.data.time_series.reduce(
(sum, item) => sum + item.statuses,
0,
);
const newFollowerCount =
context === 'modal' &&
report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
const topHashtag = report.data.top_hashtags[0];
return (
<div className='annual-report'>
<div className='annual-report__header'>
<h1>
<FormattedMessage
id='annual_report.summary.thanks'
defaultMessage='Thanks for being part of Mastodon!'
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}>
<h1>Wrapstodon {report.year}</h1>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton
title={intl.formatMessage({
id: 'annual_report.summary.close',
defaultMessage: 'Close',
})}
className={styles.closeButton}
icon='close'
iconComponent={CloseIcon}
onClick={close}
/>
</h1>
<p>
<FormattedMessage
id='annual_report.summary.here_it_is'
defaultMessage='Here is your {year} in review:'
values={{ year: report.year }}
/>
</p>
)}
</div>
<div className='annual-report__bento annual-report__summary'>
<Archetype data={report.data.archetype} />
<HighlightedPost data={report.data.top_statuses} />
<Followers
data={report.data.time_series}
total={currentAccount?.followers_count}
/>
<MostUsedHashtag data={report.data.top_hashtags} />
<NewPosts data={report.data.time_series} />
{share && <ShareButton report={report} />}
<div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} context={context} />
<div
className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag,
onlyHashtag: !(newFollowerCount && newPostCount),
singleNumber: !!newFollowerCount !== !!newPostCount,
})}
>
{!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div>
<Archetype report={report} account={account} context={context} />
</div>
</div>
);
};
const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => {
// Generate the share message.
const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype],
);
const shareLines = [
intl.formatMessage(shareMessage, {
archetype: archetypeName,
}),
];
// Share URL is only available for schema version 2.
if (report.schema_version === 2 && report.share_url) {
shareLines.push(report.share_url);
}
shareLines.push(`#Wrapstodon${report.year}`);
// Reset the composer and focus it with the share message, then close the modal.
dispatch(resetCompose());
dispatch(focusCompose(shareLines.join('\n\n')));
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]);
return <Button text='Share here' onClick={handleShareClick} />;
};

View File

@@ -0,0 +1,49 @@
import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { closeModal } from '@/mastodon/actions/modal';
import { useAppDispatch } from '@/mastodon/store';
import { AnnualReport } from '.';
import styles from './index.module.scss';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]);
const dispatch = useAppDispatch();
const handleCloseModal = useCallback<React.MouseEventHandler<HTMLDivElement>>(
(e) => {
if (e.target === e.currentTarget)
dispatch(
closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }),
);
},
[dispatch],
);
return (
// It's fine not to provide a keyboard handler here since there is a global
// [Esc] key listener that will close open modals.
// This onClick handler is needed since the modalWrapper styles overlap the
// default modal backdrop, preventing clicks to pass through.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={classNames(
'modal-root__modal',
styles.modalWrapper,
'theme-dark',
)}
onClick={handleCloseModal}
>
<AnnualReport context='modal' />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -1,30 +1,46 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { NameAndCount } from 'mastodon/models/annual_report';
export const MostUsedHashtag: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const hashtag = data[0];
import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount;
name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
<div className='annual-report__summary__most-used-hashtag__hashtag'>
{hashtag ? (
<>#{hashtag.name}</>
) : (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.none'
defaultMessage='None'
/>
)}
</div>
<div className='annual-report__summary__most-used-hashtag__label'>
<div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
defaultMessage='most used hashtag'
defaultMessage='Most used hashtag'
/>
</div>
<div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p>
{context === 'modal' ? (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p>
</div>
);
};

View File

@@ -1,51 +1,23 @@
import { FormattedNumber, FormattedMessage } from 'react-intl';
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
import type { TimeSeriesMonth } from 'mastodon/models/annual_report';
import classNames from 'classnames';
import styles from './index.module.scss';
export const NewPosts: React.FC<{
data: TimeSeriesMonth[];
}> = ({ data }) => {
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
count: number;
}> = ({ count }) => {
return (
<div className='annual-report__bento__box annual-report__summary__new-posts'>
<svg width={500} height={500}>
<defs>
<pattern
id='posts'
x='0'
y='0'
width='32'
height='35'
patternUnits='userSpaceOnUse'
>
<circle cx='12' cy='12' r='12' fill='var(--lime)' />
<ChatBubbleIcon
fill='var(--indigo-1)'
x='4'
y='4'
width='16'
height='16'
/>
</pattern>
</defs>
<rect
width={500}
height={500}
fill='url(#posts)'
style={{ opacity: 0.2 }}
/>
</svg>
<div className='annual-report__summary__new-posts__number'>
<FormattedNumber value={posts} />
<div className={classNames(styles.box, styles.newPosts, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<div className='annual-report__summary__new-posts__label'>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.new_posts.new_posts'
defaultMessage='new posts'
defaultMessage='{count, plural, one {new post} other {new posts}}'
values={{ count }}
/>
</div>
</div>

View File

@@ -1,19 +0,0 @@
.wrapper {
max-width: 40rem;
margin: 0 auto;
}
.footer {
text-align: center;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}

View File

@@ -1,18 +0,0 @@
import type { FC } from 'react';
import { IconLogo } from '@/mastodon/components/logo';
import { AnnualReport } from './index';
import classes from './share.module.css';
export const WrapstodonShare: FC = () => {
return (
<main className={classes.wrapper}>
<AnnualReport share={false} />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
Generated with by the Mastodon team
</footer>
</main>
);
};

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { resetCompose, focusCompose } from '@/mastodon/actions/compose';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
import { useAppDispatch } from '@/mastodon/store';
import { archetypeNames } from './archetype';
const messages = defineMessages({
share_message: {
id: 'annual_report.summary.share_message',
defaultMessage: 'I got the {archetype} archetype!',
},
share_on_mastodon: {
id: 'annual_report.summary.share_on_mastodon',
defaultMessage: 'Share on Mastodon',
},
});
export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleShareClick = useCallback(() => {
// Generate the share message.
const archetypeName = intl.formatMessage(
archetypeNames[report.data.archetype],
);
const shareLines = [
intl.formatMessage(messages.share_message, {
archetype: archetypeName,
}),
];
// Share URL is only available for schema version 2.
if (report.schema_version === 2 && report.share_url) {
shareLines.push(report.share_url);
}
shareLines.push(`#Wrapstodon${report.year}`);
// Reset the composer and focus it with the share message, then close the modal.
dispatch(resetCompose());
dispatch(focusCompose(shareLines.join('\n\n')));
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
}, [report, intl, dispatch]);
return (
<Button
text={intl.formatMessage(messages.share_on_mastodon)}
onClick={handleShareClick}
/>
);
};

View File

@@ -0,0 +1,45 @@
$mobile-breakpoint: 540px;
.wrapper {
box-sizing: border-box;
max-width: 600px;
margin-inline: auto;
padding: 40px 10px;
@media (width < $mobile-breakpoint) {
padding-top: 0;
padding-inline: 0;
}
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-top: 2rem;
font-size: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-text-primary);
}
}

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/mastodon/components/logo';
import { me } from '@/mastodon/initial_state';
import { AnnualReport } from './index';
import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => {
return (
<main className={classes.wrapper}>
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '♥' }}
/>
<nav className={classes.nav}>
<a href='/about'>
<FormattedMessage
id='footer.about_this_server'
defaultMessage='About'
/>
</a>
{!me && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</nav>
</footer>
</main>
);
};

View File

@@ -1,20 +0,0 @@
import { useEffect } from 'react';
import { AnnualReport } from 'mastodon/features/annual_report';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--indigo-1)');
}, [onChangeBackgroundColor]);
return (
<div className='modal-root__modal annual-report-modal'>
<AnnualReport />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/mastodon/actions/timelines';
import { scrollTopTimeline, loadPending } from '@/mastodon/actions/timelines';
import { isNonStatusId } from '@/mastodon/actions/timelines_typed';
import StatusList from '@/mastodon/components/status_list';
import { me } from '@/mastodon/initial_state';
import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
@@ -15,7 +15,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
(state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true;
if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id);

View File

@@ -227,7 +227,7 @@ export function LinkTimeline () {
}
export function AnnualReportModal () {
return import('../components/annual_report_modal');
return import('../../annual_report/modal');
}
export function ListEdit () {

View File

@@ -113,6 +113,10 @@
"alt_text_modal.describe_for_people_with_visual_impairments": "Popište to pro osoby se zrakovým postižením…",
"alt_text_modal.done": "Hotovo",
"announcement.announcement": "Oznámení",
"annual_report.announcement.action_build": "Sestavit můj Wrapstodon",
"annual_report.announcement.action_view": "Zobrazit můj Wrapstodon",
"annual_report.announcement.description": "Zjistěte více o vaší aktivitě na Mastodonu za poslední rok.",
"annual_report.announcement.title": "Je zde Wrapstodon {year}",
"annual_report.summary.archetype.booster": "Lovec obsahu",
"annual_report.summary.archetype.lurker": "Špión",
"annual_report.summary.archetype.oracle": "Vědma",
@@ -131,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "nové příspěvky",
"annual_report.summary.percentile.text": "<topLabel>To vás umisťuje do horních</topLabel><percentage></percentage><bottomLabel> uživatelů domény {domain}.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "To, že jste zdejší smetánka, zůstane mezi námi ;).",
"annual_report.summary.share_message": "Mám archetyp {archetype}!",
"annual_report.summary.thanks": "Děkujeme, že jste součástí Mastodonu!",
"attachments_list.unprocessed": "(nezpracováno)",
"audio.hide": "Skrýt zvuk",
@@ -516,6 +521,7 @@
"keyboard_shortcuts.toggle_hidden": "Zobrazit/skrýt text za varováním o obsahu",
"keyboard_shortcuts.toggle_sensitivity": "Zobrazit/skrýt média",
"keyboard_shortcuts.toot": "Začít nový příspěvek",
"keyboard_shortcuts.top": "Přesunout na začátek seznamu",
"keyboard_shortcuts.translate": "k přeložení příspěvku",
"keyboard_shortcuts.unfocus": "Zrušit zaměření na nový příspěvek/hledání",
"keyboard_shortcuts.up": "Posunout v seznamu nahoru",

View File

@@ -74,7 +74,7 @@
"account.open_original_page": "Ursprüngliche Seite öffnen",
"account.posts": "Beiträge",
"account.posts_with_replies": "Beiträge & Antworten",
"account.remove_from_followers": "{name} als Follower entfernen",
"account.remove_from_followers": "@{name} als Follower entfernen",
"account.report": "@{name} melden",
"account.requested_follow": "{name} möchte dir folgen",
"account.requests_to_follow_you": "Möchte dir folgen",
@@ -320,7 +320,7 @@
"domain_pill.your_server": "Deine digitale Heimat. Hier „leben“ alle Beiträge von dir. Falls es dir hier nicht gefällt, kannst du jederzeit den Server wechseln und ebenso deine Follower übertragen.",
"domain_pill.your_username": "Deine eindeutige Identität auf diesem Server. Es ist möglich, Profile mit dem gleichen Profilnamen auf verschiedenen Servern zu finden.",
"dropdown.empty": "Option auswählen",
"embed.instructions": "Du kannst diesen Beitrag auf deiner Website einbetten, indem du den nachfolgenden Code kopierst.",
"embed.instructions": "Du kannst diesen Beitrag außerhalb des Fediverse (z. B. in deine Website) einbetten, indem du diesen Code kopierst und dort einfügst.",
"embed.preview": "Vorschau:",
"emoji_button.activity": "Aktivitäten",
"emoji_button.clear": "Leeren",
@@ -370,8 +370,8 @@
"errors.unexpected_crash.copy_stacktrace": "Fehlerdiagnose in die Zwischenablage kopieren",
"errors.unexpected_crash.report_issue": "Fehler melden",
"explore.suggested_follows": "Profile",
"explore.title": "Angesagt",
"explore.trending_links": "Neuigkeiten",
"explore.title": "Im Trend",
"explore.trending_links": "Artikel",
"explore.trending_statuses": "Beiträge",
"explore.trending_tags": "Hashtags",
"featured_carousel.current": "<sr>Beitrag</sr> {current, number}/{max, number}",
@@ -414,15 +414,15 @@
"follow_suggestions.personalized_suggestion": "Persönliche Empfehlung",
"follow_suggestions.popular_suggestion": "Beliebte Empfehlung",
"follow_suggestions.popular_suggestion_longer": "Beliebt auf {domain}",
"follow_suggestions.similar_to_recently_followed_longer": "Ähnlich zu Profilen, denen du seit kurzem folgst",
"follow_suggestions.similar_to_recently_followed_longer": "Ähnelt deinen kürzlich gefolgten Profilen",
"follow_suggestions.view_all": "Alle anzeigen",
"follow_suggestions.who_to_follow": "Empfohlene Profile",
"follow_suggestions.who_to_follow": "Wem folgen?",
"followed_tags": "Abonnierte Hashtags",
"footer.about": "Über",
"footer.about_this_server": "Über",
"footer.directory": "Profilverzeichnis",
"footer.get_app": "App herunterladen",
"footer.keyboard_shortcuts": "Tastenkombinationen",
"footer.keyboard_shortcuts": "Tastaturkürzel",
"footer.privacy_policy": "Datenschutzerklärung",
"footer.source_code": "Quellcode anzeigen",
"footer.status": "Status",
@@ -483,48 +483,48 @@
"interaction_modal.on_another_server": "Auf anderem Server",
"interaction_modal.on_this_server": "Auf diesem Server",
"interaction_modal.title": "Melde dich an, um fortzufahren",
"interaction_modal.username_prompt": "z. B. {example}",
"interaction_modal.username_prompt": "Z. B. {example}",
"intervals.full.days": "{number, plural, one {# Tag} other {# Tage}}",
"intervals.full.hours": "{number, plural, one {# Stunde} other {# Stunden}}",
"intervals.full.minutes": "{number, plural, one {# Minute} other {# Minuten}}",
"keyboard_shortcuts.back": "Zurücknavigieren",
"keyboard_shortcuts.blocked": "Liste blockierter Profile öffnen",
"keyboard_shortcuts.blocked": "Blockierte Profile öffnen",
"keyboard_shortcuts.boost": "Beitrag teilen",
"keyboard_shortcuts.column": "Auf die aktuelle Spalte fokussieren",
"keyboard_shortcuts.column": "Aktuelle Spalte fokussieren",
"keyboard_shortcuts.compose": "Eingabefeld fokussieren",
"keyboard_shortcuts.description": "Beschreibung",
"keyboard_shortcuts.direct": "Private Erwähnungen öffnen",
"keyboard_shortcuts.down": "Ansicht nach unten bewegen",
"keyboard_shortcuts.down": "Auswahl nach unten bewegen",
"keyboard_shortcuts.enter": "Beitrag öffnen",
"keyboard_shortcuts.favourite": "Beitrag favorisieren",
"keyboard_shortcuts.favourites": "Favoriten öffnen",
"keyboard_shortcuts.federated": "Föderierte Timeline öffnen",
"keyboard_shortcuts.heading": "Tastenkombinationen",
"keyboard_shortcuts.heading": "Tastenkürzel",
"keyboard_shortcuts.home": "Startseite öffnen",
"keyboard_shortcuts.hotkey": "Tastenkürzel",
"keyboard_shortcuts.legend": "Tastenkombinationen anzeigen",
"keyboard_shortcuts.legend": "Tastenkürzel anzeigen (diese Seite)",
"keyboard_shortcuts.load_more": "Schaltfläche „Mehr laden“ fokussieren",
"keyboard_shortcuts.local": "Lokale Timeline öffnen",
"keyboard_shortcuts.mention": "Profil erwähnen",
"keyboard_shortcuts.muted": "Liste stummgeschalteter Profile öffnen",
"keyboard_shortcuts.my_profile": "Eigenes Profil aufrufen",
"keyboard_shortcuts.notifications": "Benachrichtigungen aufrufen",
"keyboard_shortcuts.open_media": "Medieninhalt öffnen",
"keyboard_shortcuts.muted": "Stummgeschaltete Profile öffnen",
"keyboard_shortcuts.my_profile": "Eigenes Profil öffnen",
"keyboard_shortcuts.notifications": "Benachrichtigungen öffnen",
"keyboard_shortcuts.open_media": "Medien öffnen",
"keyboard_shortcuts.pinned": "Liste angehefteter Beiträge öffnen",
"keyboard_shortcuts.profile": "Profil aufrufen",
"keyboard_shortcuts.quote": "Beitrag zitieren",
"keyboard_shortcuts.reply": "Auf Beitrag antworten",
"keyboard_shortcuts.requests": "Liste der Follower-Anfragen aufrufen",
"keyboard_shortcuts.search": "Suchleiste fokussieren",
"keyboard_shortcuts.spoilers": "Feld für Inhaltswarnung anzeigen/ausblenden",
"keyboard_shortcuts.reply": "Beitrag beantworten",
"keyboard_shortcuts.requests": "Follower-Anfragen aufrufen",
"keyboard_shortcuts.search": "Eingabefeld / Suche fokussieren",
"keyboard_shortcuts.spoilers": "Feld für Inhaltswarnung anzeigen / ausblenden",
"keyboard_shortcuts.start": "„Auf gehts!“ öffnen",
"keyboard_shortcuts.toggle_hidden": "Beitragstext hinter der Inhaltswarnung anzeigen/ausblenden",
"keyboard_shortcuts.toggle_sensitivity": "Medien anzeigen/ausblenden",
"keyboard_shortcuts.toggle_hidden": "Beitrag hinter Inhaltswarnung anzeigen / ausblenden",
"keyboard_shortcuts.toggle_sensitivity": "Medien anzeigen / ausblenden",
"keyboard_shortcuts.toot": "Neuen Beitrag erstellen",
"keyboard_shortcuts.top": "Zum Listenanfang springen",
"keyboard_shortcuts.translate": "Beitrag übersetzen",
"keyboard_shortcuts.unfocus": "Eingabefeld/Suche nicht mehr fokussieren",
"keyboard_shortcuts.up": "Ansicht nach oben bewegen",
"keyboard_shortcuts.unfocus": "Eingabefeld / Suche nicht mehr fokussieren",
"keyboard_shortcuts.up": "Auswahl nach oben bewegen",
"learn_more_link.got_it": "Verstanden",
"learn_more_link.learn_more": "Mehr erfahren",
"lightbox.close": "Schließen",
@@ -547,7 +547,7 @@
"lists.done": "Fertig",
"lists.edit": "Liste bearbeiten",
"lists.exclusive": "Mitglieder auf der Startseite ausblenden",
"lists.exclusive_hint": "Profile, die sich auf dieser Liste befinden, werden nicht auf deiner Startseite angezeigt, damit deren Beiträge nicht doppelt erscheinen.",
"lists.exclusive_hint": "Profile, die sich auf dieser Liste befinden, werden nicht im Feed deiner Startseite angezeigt, damit deren Beiträge nicht doppelt erscheinen.",
"lists.find_users_to_add": "Suche nach Profilen, um sie hinzuzufügen",
"lists.list_members_count": "{count, plural, one {# Mitglied} other {# Mitglieder}}",
"lists.list_name": "Titel der Liste",
@@ -556,19 +556,19 @@
"lists.no_members_yet": "Keine Mitglieder vorhanden.",
"lists.no_results_found": "Keine Suchergebnisse.",
"lists.remove_member": "Entfernen",
"lists.replies_policy.followed": "Alle folgenden Profile",
"lists.replies_policy.followed": "alle folgenden Profile",
"lists.replies_policy.list": "Mitglieder der Liste",
"lists.replies_policy.none": "Niemanden",
"lists.replies_policy.none": "niemanden",
"lists.save": "Speichern",
"lists.search": "Suchen",
"lists.show_replies_to": "Antworten von Listenmitgliedern einbeziehen an …",
"load_pending": "{count, plural, one {# neuer Beitrag} other {# neue Beiträge}}",
"loading_indicator.label": "Wird geladen …",
"loading_indicator.label": "Lädt …",
"media_gallery.hide": "Ausblenden",
"moved_to_account_banner.text": "Dein Konto {disabledAccount} ist derzeit deaktiviert, weil du zu {movedToAccount} umgezogen bist.",
"mute_modal.hide_from_notifications": "Benachrichtigungen ausblenden",
"mute_modal.hide_options": "Einstellungen ausblenden",
"mute_modal.indefinite": "Bis ich die Stummschaltung aufhebe",
"mute_modal.hide_from_notifications": "Auch aus den Benachrichtigungen entfernen",
"mute_modal.hide_options": "Optionen ausblenden",
"mute_modal.indefinite": "Dauerhaft, bis ich die Stummschaltung aufhebe",
"mute_modal.show_options": "Optionen anzeigen",
"mute_modal.they_can_mention_and_follow": "Das Profil wird dich weiterhin erwähnen und dir folgen können, aber du wirst davon nichts sehen.",
"mute_modal.they_wont_know": "Das Profil wird nicht erkennen können, dass du es stummgeschaltet hast.",
@@ -578,7 +578,7 @@
"navigation_bar.about": "Über",
"navigation_bar.account_settings": "Passwort und Sicherheit",
"navigation_bar.administration": "Administration",
"navigation_bar.advanced_interface": "Im erweiterten Webinterface öffnen",
"navigation_bar.advanced_interface": "Erweitertes Webinterface öffnen",
"navigation_bar.automated_deletion": "Automatisiertes Löschen",
"navigation_bar.blocks": "Blockierte Profile",
"navigation_bar.bookmarks": "Lesezeichen",
@@ -588,8 +588,8 @@
"navigation_bar.filters": "Stummgeschaltete Wörter",
"navigation_bar.follow_requests": "Follower-Anfragen",
"navigation_bar.followed_tags": "Abonnierte Hashtags",
"navigation_bar.follows_and_followers": "Follower und Folge ich",
"navigation_bar.import_export": "Importieren und exportieren",
"navigation_bar.follows_and_followers": "Follower & Folge ich",
"navigation_bar.import_export": "Importieren & exportieren",
"navigation_bar.lists": "Listen",
"navigation_bar.live_feed_local": "Live-Feed (Dieser Server)",
"navigation_bar.live_feed_public": "Live-Feed (Alle Server)",
@@ -599,9 +599,9 @@
"navigation_bar.mutes": "Stummgeschaltete Profile",
"navigation_bar.opened_in_classic_interface": "Beiträge, Konten und andere bestimmte Seiten werden standardmäßig im klassischen Webinterface geöffnet.",
"navigation_bar.preferences": "Einstellungen",
"navigation_bar.privacy_and_reach": "Datenschutz und Reichweite",
"navigation_bar.privacy_and_reach": "Datenschutz & Reichweite",
"navigation_bar.search": "Suche",
"navigation_bar.search_trends": "Suche / Angesagt",
"navigation_bar.search_trends": "Suche / Trends",
"navigation_panel.collapse_followed_tags": "Menü für abonnierte Hashtags schließen",
"navigation_panel.collapse_lists": "Listen-Menü schließen",
"navigation_panel.expand_followed_tags": "Menü für abonnierte Hashtags öffnen",
@@ -635,7 +635,7 @@
"notification.moderation_warning": "Du wurdest von den Moderator*innen verwarnt",
"notification.moderation_warning.action_delete_statuses": "Einige deiner Beiträge sind entfernt worden.",
"notification.moderation_warning.action_disable": "Dein Konto wurde deaktiviert.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Einige deiner Beiträge wurden mit einer Inhaltswarnung versehen.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Einige deiner Beiträge haben eine Inhaltswarnung erhalten.",
"notification.moderation_warning.action_none": "Dein Konto ist von den Moderator*innen verwarnt worden.",
"notification.moderation_warning.action_sensitive": "Deine zukünftigen Beiträge werden mit einer Inhaltswarnung versehen.",
"notification.moderation_warning.action_silence": "Dein Konto wurde eingeschränkt.",
@@ -650,7 +650,7 @@
"notification.relationships_severance_event.domain_block": "Ein Admin von {from} hat {target} blockiert darunter {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst.",
"notification.relationships_severance_event.learn_more": "Mehr erfahren",
"notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.",
"notification.status": "{name} postete …",
"notification.status": "{name} veröffentlichte …",
"notification.update": "{name} bearbeitete einen Beitrag",
"notification_requests.accept": "Akzeptieren",
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage akzeptieren …} other {# Anfragen akzeptieren …}}",
@@ -686,9 +686,9 @@
"notifications.column_settings.mention": "Erwähnungen:",
"notifications.column_settings.poll": "Umfrageergebnisse:",
"notifications.column_settings.push": "Push-Benachrichtigungen",
"notifications.column_settings.quote": "Zitate:",
"notifications.column_settings.quote": "Zitierte Beiträge:",
"notifications.column_settings.reblog": "Geteilte Beiträge:",
"notifications.column_settings.show": "In dieser Spalte anzeigen",
"notifications.column_settings.show": "Im Feed „Benachrichtigungen“ anzeigen",
"notifications.column_settings.sound": "Ton abspielen",
"notifications.column_settings.status": "Neue Beiträge:",
"notifications.column_settings.unread_notifications.category": "Ungelesene Benachrichtigungen",
@@ -697,10 +697,10 @@
"notifications.filter.all": "Alles",
"notifications.filter.boosts": "Geteilte Beiträge",
"notifications.filter.favourites": "Favoriten",
"notifications.filter.follows": "Folgt",
"notifications.filter.follows": "Neue Follower",
"notifications.filter.mentions": "Erwähnungen",
"notifications.filter.polls": "Umfrageergebnisse",
"notifications.filter.statuses": "Neue Beiträge von Profilen, denen du folgst",
"notifications.filter.statuses": "Neue Beiträge von abonnierten Profilen",
"notifications.grant_permission": "Berechtigung erteilen.",
"notifications.group": "{count} Benachrichtigungen",
"notifications.mark_as_read": "Alle Benachrichtigungen als gelesen markieren",
@@ -710,18 +710,18 @@
"notifications.policy.accept": "Akzeptieren",
"notifications.policy.accept_hint": "In Benachrichtigungen anzeigen",
"notifications.policy.drop": "Ignorieren",
"notifications.policy.drop_hint": "In die Leere senden und nie wieder sehen",
"notifications.policy.drop_hint": "Ins Nirwana befördern und auf Nimmerwiedersehen!",
"notifications.policy.filter": "Filtern",
"notifications.policy.filter_hint": "An gefilterte Benachrichtigungen im Posteingang senden",
"notifications.policy.filter_limited_accounts_hint": "Durch Server-Moderator*innen eingeschränkt",
"notifications.policy.filter_limited_accounts_title": "moderierten Konten",
"notifications.policy.filter_new_accounts.hint": "Innerhalb {days, plural, one {des letzten Tages} other {der letzten # Tagen}} erstellt",
"notifications.policy.filter_new_accounts_title": "neuen Konten",
"notifications.policy.filter_not_followers_hint": "Einschließlich Profilen, die dir seit weniger als {days, plural, one {einem Tag} other {# Tagen}} folgen",
"notifications.policy.filter_hint": "Im separaten Feed „Gefilterte Benachrichtigungen“ anzeigen",
"notifications.policy.filter_limited_accounts_hint": "Durch Server-Moderator*innen eingeschränkte Profile",
"notifications.policy.filter_limited_accounts_title": "eingeschränkten Konten",
"notifications.policy.filter_new_accounts.hint": "Konto {days, plural, one {seit gestern} other {in den vergangenen # Tagen}} registriert",
"notifications.policy.filter_new_accounts_title": "neuen Profilen",
"notifications.policy.filter_not_followers_hint": "Einschließlich Profilen, die mir seit weniger als {days, plural, one {einem Tag} other {# Tagen}} folgen",
"notifications.policy.filter_not_followers_title": "Profilen, die mir nicht folgen",
"notifications.policy.filter_not_following_hint": "Bis du sie manuell genehmigst",
"notifications.policy.filter_not_following_hint": "… bis ich sie manuell genehmige",
"notifications.policy.filter_not_following_title": "Profilen, denen ich nicht folge",
"notifications.policy.filter_private_mentions_hint": "Solange sie keine Antwort auf deine Erwähnung ist oder du dem Profil nicht folgst",
"notifications.policy.filter_private_mentions_hint": "… solange sie keine Antwort auf meine Erwähnungen sind oder ich den Profilen nicht folge",
"notifications.policy.filter_private_mentions_title": "unerwünschten privaten Erwähnungen",
"notifications.policy.title": "Benachrichtigungen verwalten von …",
"notifications_permission_banner.enable": "Aktiviere Desktop-Benachrichtigungen",
@@ -763,17 +763,17 @@
"privacy.public.long": "Alle innerhalb und außerhalb von Mastodon",
"privacy.public.short": "Öffentlich",
"privacy.quote.anyone": "{visibility} alle dürfen zitieren",
"privacy.quote.disabled": "{visibility} niemand darf zitieren",
"privacy.quote.limited": "{visibility} eingeschränktes Zitieren",
"privacy.quote.disabled": "{visibility} Zitieren deaktiviert",
"privacy.quote.limited": "{visibility} nur Follower",
"privacy.unlisted.additional": "Das Verhalten ist wie bei „Öffentlich“, jedoch gibt es einige Einschränkungen. Der Beitrag wird nicht in „Live-Feeds“, „Erkunden“, Hashtags oder über die Mastodon-Suchfunktion auffindbar sein selbst wenn die zugehörige Einstellung aktiviert wurde.",
"privacy.unlisted.long": "Verborgen vor Suchergebnissen, Trends und öffentlichen Timelines",
"privacy.unlisted.long": "Verborgen vor Suchen, Trends und öffentlichen Timelines",
"privacy.unlisted.short": "Öffentlich (still)",
"privacy_policy.last_updated": "Stand: {date}",
"privacy_policy.title": "Datenschutzerklärung",
"quote_error.edit": "Beim Bearbeiten eines Beitrags können keine Zitate hinzugefügt werden.",
"quote_error.poll": "Zitieren ist bei Umfragen nicht gestattet.",
"quote_error.private_mentions": "Das Zitieren ist bei privaten Erwähnungen nicht erlaubt.",
"quote_error.quote": "Es ist jeweils nur ein Zitat zulässig.",
"quote_error.edit": "Beim Bearbeiten eines vorhandenen Beitrags können keine Zitate hinzugefügt werden.",
"quote_error.poll": "Zitieren ist bei Umfragen nicht erlaubt.",
"quote_error.private_mentions": "Zitieren ist bei privaten Erwähnungen nicht erlaubt.",
"quote_error.quote": "Es darf nur ein Beitrag zitiert werden.",
"quote_error.unauthorized": "Du bist nicht berechtigt, diesen Beitrag zu zitieren.",
"quote_error.upload": "Zitieren ist mit Medien-Anhängen nicht möglich.",
"recommended": "Empfohlen",
@@ -783,7 +783,7 @@
"relative_time.days": "{number} T.",
"relative_time.full.days": "vor {number, plural, one {# Tag} other {# Tagen}}",
"relative_time.full.hours": "vor {number, plural, one {# Stunde} other {# Stunden}}",
"relative_time.full.just_now": "gerade eben",
"relative_time.full.just_now": "soeben",
"relative_time.full.minutes": "vor {number, plural, one {# Minute} other {# Minuten}}",
"relative_time.full.seconds": "vor {number, plural, one {1 Sekunde} other {# Sekunden}}",
"relative_time.hours": "{number} Std.",
@@ -793,13 +793,13 @@
"relative_time.today": "heute",
"remove_quote_hint.button_label": "Verstanden",
"remove_quote_hint.message": "Klicke dafür im Beitrag auf „{icon} Mehr“.",
"remove_quote_hint.title": "Möchtest du aus dem zitierten Beitrag entfernt werden?",
"remove_quote_hint.title": "Deinen zitierten Beitrag aus diesem Beitrag entfernen?",
"reply_indicator.attachments": "{count, plural, one {# Anhang} other {# Anhänge}}",
"reply_indicator.cancel": "Abbrechen",
"reply_indicator.poll": "Umfrage",
"report.block": "Blockieren",
"report.block_explanation": "Du wirst keine Beiträge mehr von diesem Konto sehen. Das blockierte Konto wird deine Beiträge nicht mehr sehen oder dir folgen können. Die Person könnte mitbekommen, dass du sie blockiert hast.",
"report.categories.legal": "Rechtlich",
"report.categories.legal": "Rechtliches",
"report.categories.other": "Andere",
"report.categories.spam": "Spam",
"report.categories.violation": "Der Inhalt verletzt eine oder mehrere Serverregeln",
@@ -808,9 +808,9 @@
"report.category.title_account": "Profil",
"report.category.title_status": "Beitrag",
"report.close": "Fertig",
"report.comment.title": "Gibt es etwas anderes, was wir wissen sollten?",
"report.forward": "Meldung zusätzlich an {target} weiterleiten",
"report.forward_hint": "Dieses Konto gehört zu einem anderen Server. Soll eine anonymisierte Kopie der Meldung auch dorthin gesendet werden?",
"report.comment.title": "Gibt es noch etwas, das wir wissen sollten?",
"report.forward": "Meldung auch an den externen Server {target} weiterleiten",
"report.forward_hint": "Das gemeldete Konto befindet sich auf einem anderen Server. Soll zusätzlich eine anonymisierte Kopie deiner Meldung an diesen Server geschickt werden?",
"report.mute": "Stummschalten",
"report.mute_explanation": "Du wirst keine Beiträge mehr von diesem Konto sehen. Das stummgeschaltete Konto wird dir weiterhin folgen und deine Beiträge sehen können. Die Person wird nicht mitbekommen, dass du sie stummgeschaltet hast.",
"report.next": "Weiter",
@@ -826,9 +826,9 @@
"report.reasons.violation": "Das verstößt gegen Serverregeln",
"report.reasons.violation_description": "Du bist dir sicher, dass eine bestimmte Regel gebrochen wurde",
"report.rules.subtitle": "Wähle alle zutreffenden Inhalte aus",
"report.rules.title": "Welche Regeln werden verletzt?",
"report.rules.title": "Gegen welche Regeln wurde verstoßen?",
"report.statuses.subtitle": "Wähle alle zutreffenden Inhalte aus",
"report.statuses.title": "Gibt es Beiträge, die diese Meldung bekräftigen?",
"report.statuses.title": "Gibt es Beiträge, die diese Meldung stützen?",
"report.submit": "Senden",
"report.target": "{target} melden",
"report.thanks.take_action": "Das sind deine Möglichkeiten zu bestimmen, was du auf Mastodon sehen möchtest:",
@@ -845,7 +845,7 @@
"report_notification.categories.spam": "Spam",
"report_notification.categories.spam_sentence": "Spam",
"report_notification.categories.violation": "Regelverstoß",
"report_notification.categories.violation_sentence": "Regelverletzung",
"report_notification.categories.violation_sentence": "Verstoß gegen die Serverregeln",
"report_notification.open": "Meldung öffnen",
"search.clear": "Suchanfrage löschen",
"search.no_recent_searches": "Keine früheren Suchanfragen",
@@ -885,13 +885,13 @@
"status.admin_account": "@{name} moderieren",
"status.admin_domain": "{domain} moderieren",
"status.admin_status": "Beitrag moderieren",
"status.all_disabled": "Teilen und Zitieren von Beiträgen ist deaktiviert",
"status.all_disabled": "Teilen und Zitieren sind deaktiviert",
"status.block": "@{name} blockieren",
"status.bookmark": "Lesezeichen setzen",
"status.cancel_reblog_private": "Beitrag nicht mehr teilen",
"status.cannot_quote": "Beitrag kann nicht zitiert werden",
"status.cannot_quote": "Diesen Beitrag darfst du nicht zitieren",
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
"status.contains_quote": "Enthält Zitat",
"status.contains_quote": "Enthält zitierten Beitrag",
"status.context.loading": "Weitere Antworten laden",
"status.context.loading_error": "Weitere Antworten konnten nicht geladen werden",
"status.context.loading_success": "Neue Antworten geladen",
@@ -907,10 +907,10 @@
"status.direct_indicator": "Private Erwähnung",
"status.edit": "Beitrag bearbeiten",
"status.edited": "Zuletzt am {date} bearbeitet",
"status.edited_x_times": "{count, plural, one {{count}-mal} other {{count}-mal}} bearbeitet",
"status.edited_x_times": "{count, plural, one {{count} ×} other {{count} ×}} bearbeitet",
"status.embed": "Code zum Einbetten",
"status.favourite": "Favorisieren",
"status.favourites_count": "{count, plural, one {{counter} Mal favorisiert} other {{counter} Mal favorisiert}}",
"status.favourites_count": "{count, plural, one {{counter} × favorisiert} other {{counter} × favorisiert}}",
"status.filter": "Beitrag filtern",
"status.history.created": "{name} erstellte {date}",
"status.history.edited": "{name} bearbeitete {date}",
@@ -945,14 +945,14 @@
"status.quotes.empty": "Diesen Beitrag hat bisher noch niemand zitiert. Sobald es jemand tut, wird das Profil hier erscheinen.",
"status.quotes.local_other_disclaimer": "Durch Autor*in abgelehnte Zitate werden nicht angezeigt.",
"status.quotes.remote_other_disclaimer": "Nur Zitate von {domain} werden hier garantiert angezeigt. Durch Autor*in abgelehnte Zitate werden nicht angezeigt.",
"status.quotes_count": "{count, plural, one {{counter} Mal zitiert} other {{counter} Mal zitiert}}",
"status.quotes_count": "{count, plural, one {{counter} × zitiert} other {{counter} × zitiert}}",
"status.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen",
"status.reblog_or_quote": "Teilen oder zitieren",
"status.reblog_private": "Erneut mit deinen Followern teilen",
"status.reblogged_by": "{name} teilte",
"status.reblogs.empty": "Diesen Beitrag hat bisher noch niemand geteilt. Sobald es jemand tut, wird das Profil hier erscheinen.",
"status.reblogs_count": "{count, plural, one {{counter} Mal geteilt} other {{counter} Mal geteilt}}",
"status.reblogs_count": "{count, plural, one {{counter} × geteilt} other {{counter} × geteilt}}",
"status.redraft": "Löschen und neu erstellen",
"status.remove_bookmark": "Lesezeichen entfernen",
"status.remove_favourite": "Aus Favoriten entfernen",
@@ -1036,9 +1036,9 @@
"visibility_modal.helper.unlisted_quoting": "Sollten dich andere zitieren, werden ihre zitierten Beiträge ebenfalls nicht in den Trends und öffentlichen Timelines angezeigt.",
"visibility_modal.instructions": "Lege fest, wer mit diesem Beitrag interagieren darf. Du hast auch die Möglichkeit, diese Einstellung auf alle zukünftigen Beiträge anzuwenden. Gehe zu: <link>Einstellungen > Standardeinstellungen für Beiträge</link>",
"visibility_modal.privacy_label": "Sichtbarkeit",
"visibility_modal.quote_followers": "Nur Follower",
"visibility_modal.quote_followers": "Nur meine Follower dürfen mich zitieren",
"visibility_modal.quote_label": "Wer darf mich zitieren?",
"visibility_modal.quote_nobody": "Nur ich selbst",
"visibility_modal.quote_public": "Alle",
"visibility_modal.quote_nobody": "Niemand darf mich zitieren",
"visibility_modal.quote_public": "Alle dürfen mich zitieren",
"visibility_modal.save": "Speichern"
}

View File

@@ -117,26 +117,44 @@
"annual_report.announcement.action_view": "View my Wrapstodon",
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
"annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "The lurker",
"annual_report.summary.archetype.oracle": "The oracle",
"annual_report.summary.archetype.pollster": "The pollster",
"annual_report.summary.archetype.replier": "The social butterfly",
"annual_report.summary.followers.followers": "followers",
"annual_report.summary.followers.total": "{count} total",
"annual_report.summary.here_it_is": "Here is your {year} in review:",
"annual_report.summary.highlighted_post.by_favourites": "most favourited post",
"annual_report.summary.highlighted_post.by_reblogs": "most boosted post",
"annual_report.summary.highlighted_post.by_replies": "post with the most replies",
"annual_report.summary.highlighted_post.possessive": "{name}'s",
"annual_report.shared_page.donate": "Donate",
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
"annual_report.shared_page.sign_up": "Sign up",
"annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.name": "The Archer",
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
"annual_report.summary.archetype.lurker.desc_public": "We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.",
"annual_report.summary.archetype.lurker.desc_self": "We know you were out there, somewhere, enjoying Mastodon in your own quiet way.",
"annual_report.summary.archetype.lurker.name": "The Stoic",
"annual_report.summary.archetype.oracle.desc_public": "{name} created new posts more than replies, keeping Mastodon fresh and future-facing.",
"annual_report.summary.archetype.oracle.desc_self": "You created new posts more than replies, keeping Mastodon fresh and future-facing.",
"annual_report.summary.archetype.oracle.name": "The Oracle",
"annual_report.summary.archetype.pollster.desc_public": "{name} created more polls than other post types, cultivating curiosity on Mastodon.",
"annual_report.summary.archetype.pollster.desc_self": "You created more polls than other post types, cultivating curiosity on Mastodon.",
"annual_report.summary.archetype.pollster.name": "The Wonderer",
"annual_report.summary.archetype.replier.desc_public": "{name} frequently replied to other peoples posts, pollinating Mastodon with new discussions.",
"annual_report.summary.archetype.replier.desc_self": "You frequently replied to other peoples posts, pollinating Mastodon with new discussions.",
"annual_report.summary.archetype.replier.name": "The Butterfly",
"annual_report.summary.archetype.reveal": "Reveal my archetype",
"annual_report.summary.archetype.reveal_description": "Thanks for being part of Mastodon! Time to find out which archetype you embodied in {year}.",
"annual_report.summary.archetype.title_public": "{name}'s archetype",
"annual_report.summary.archetype.title_self": "Your archetype",
"annual_report.summary.close": "Close",
"annual_report.summary.followers.new_followers": "{count, plural, one {new follower} other {new followers}}",
"annual_report.summary.highlighted_post.boost_count": "This post was boosted {count, plural, one {once} other {# times}}.",
"annual_report.summary.highlighted_post.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.",
"annual_report.summary.highlighted_post.reply_count": "This post got {count, plural, one {one reply} other {# replies}}.",
"annual_report.summary.highlighted_post.title": "Most popular post",
"annual_report.summary.most_used_app.most_used_app": "most used app",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag",
"annual_report.summary.most_used_hashtag.none": "None",
"annual_report.summary.most_used_hashtag.used_count": "You included this hashtag in {count, plural, one {one post} other {# posts}}.",
"annual_report.summary.most_used_hashtag.used_count_public": "{name} included this hashtag in {count, plural, one {one post} other {# posts}}.",
"annual_report.summary.new_posts.new_posts": "new posts",
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
"annual_report.summary.share_message": "I got the {archetype} archetype!",
"annual_report.summary.thanks": "Thanks for being part of Mastodon!",
"annual_report.summary.share_on_mastodon": "Share on Mastodon",
"attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio",
"block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.",

View File

@@ -3,7 +3,7 @@
"about.contact": "Kontakt:",
"about.default_locale": "Vaikimisi",
"about.disclaimer": "Mastodon on tasuta ja vaba tarkvara ning Mastodon gGmbH kaubamärk.",
"about.domain_blocks.no_reason_available": "Põhjus teadmata",
"about.domain_blocks.no_reason_available": "Põhjus on teadmata",
"about.domain_blocks.preamble": "Mastodon lubab tavaliselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest fediversumi serverist. Need on erandid, mis on paika pandud sellel kindlal serveril.",
"about.domain_blocks.silenced.explanation": "Sa ei näe üldiselt profiile ja sisu sellelt serverilt, kui sa just tahtlikult seda ei otsi või jälgimise moel nõusolekut ei anna.",
"about.domain_blocks.silenced.title": "Piiratud",
@@ -113,6 +113,10 @@
"alt_text_modal.describe_for_people_with_visual_impairments": "Kirjelda seda nägemispuudega inimeste jaoks…",
"alt_text_modal.done": "Valmis",
"announcement.announcement": "Teadaanne",
"annual_report.announcement.action_build": "Koosta kokkuvõte minu tegevusest Mastodonis",
"annual_report.announcement.action_view": "Vaata kokkuvõtet minu tegevusest Mastodonis",
"annual_report.announcement.description": "Vaata teavet oma suhestumise kohta Mastodonis eelmisel aastal.",
"annual_report.announcement.title": "{year}. aasta Mastodoni kokkuvõte on valmis",
"annual_report.summary.archetype.booster": "Ägesisu küttija",
"annual_report.summary.archetype.lurker": "Hiilija",
"annual_report.summary.archetype.oracle": "Oraakel",
@@ -131,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "uus postitus",
"annual_report.summary.percentile.text": "<topLabel>See paneb su top</topLabel><percentage></percentage><bottomLabel> {domain} kasutajate hulka.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "Vägev.",
"annual_report.summary.share_message": "Minu arhetüüp on {archetype}!",
"annual_report.summary.thanks": "Tänud olemast osa Mastodonist!",
"attachments_list.unprocessed": "(töötlemata)",
"audio.hide": "Peida audio",

View File

@@ -4,7 +4,7 @@
"about.default_locale": "پیش‌گزیده",
"about.disclaimer": "ماستودون نرم‌افزار آزاد و نشان تجاری یک شرکت غیر انتفاعی با مسئولیت محدود آلمانی است.",
"about.domain_blocks.no_reason_available": "دلیلی موجود نیست",
"about.domain_blocks.preamble": "ماستودون عموماً می‌گذارد محتوا را از از هر کارساز دیگری در دنیای شبکه‌های اجتماعی غیرمتمرکز دیده و با آنان برهم‌کنش داشته باشید. این‌ها استثناهایی هستند که روی این کارساز خاص وضع شده‌اند.",
"about.domain_blocks.preamble": "ماستودون عموماً می‌گذارد محتوا را از هر کارساز دیگری در دنیای شبکه‌های اجتماعی غیرمتمرکز دیده و با آنان برهم‌کنش داشته باشید. این‌ها استثناهایی هستند که روی این کارساز خاص وضع شده‌اند.",
"about.domain_blocks.silenced.explanation": "عموماً نمایه‌ها و محتوا از این کارساز را نمی‌بینید، مگر این که به طور خاص دنبالشان گشته یا با پی گیری، داوطلب دیدنشان شوید.",
"about.domain_blocks.silenced.title": "محدود",
"about.domain_blocks.suspended.explanation": "هیچ داده‌ای از این کارساز پردازش، ذخیره یا مبادله نخواهد شد، که هرگونه برهم‌کنش یا ارتباط با کاربران این کارساز را غیرممکن خواهد کرد.",
@@ -121,7 +121,7 @@
"annual_report.summary.archetype.lurker": "کم‌پیدا",
"annual_report.summary.archetype.oracle": "غیب‌گو",
"annual_report.summary.archetype.pollster": "نظرسنج",
"annual_report.summary.archetype.replier": "پاسخگو",
"annual_report.summary.archetype.replier": "پاسخگو",
"annual_report.summary.followers.followers": "دنبال کننده",
"annual_report.summary.followers.total": "در مجموع {count}",
"annual_report.summary.here_it_is": "بازبینی {year} تان:",
@@ -135,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "فرستهٔ جدید",
"annual_report.summary.percentile.text": "<topLabel>بین کاربران {domain} جزو</topLabel><percentage></percentage><bottomLabel>برتر هستید.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "به برنی خبر نمی‌دهیم.",
"annual_report.summary.share_message": "من کهن‌الگوی {archetype} را گرفتم!",
"annual_report.summary.thanks": "سپاس که بخشی از ماستودون هستید!",
"attachments_list.unprocessed": "(پردازش نشده)",
"audio.hide": "نهفتن صدا",

View File

@@ -364,7 +364,7 @@
"error.no_hashtag_feed_access": "Liity tai kirjaudu sisään, niin voit tarkastella ja seurata tätä aihetunnistetta.",
"error.unexpected_crash.explanation": "Sivua ei voida näyttää oikein ohjelmointivirheen tai selaimen yhteensopivuusvajeen vuoksi.",
"error.unexpected_crash.explanation_addons": "Sivua ei voitu näyttää oikein. Tämä virhe johtuu todennäköisesti selaimen lisäosasta tai automaattisista käännöstyökaluista.",
"error.unexpected_crash.next_steps": "Kokeile päivittää sivu. Jos se ei auta, voi Mastodonin käyttö ehkä onnistua eri selaimella tai natiivisovelluksella.",
"error.unexpected_crash.next_steps": "Kokeile päivittää sivu. Jos se ei auta, Mastodonin käyttö voi ehkä onnistua eri selaimella tai natiivisovelluksella.",
"error.unexpected_crash.next_steps_addons": "Yritä poistaa ne käytöstä, ja virkistä sitten sivunlataus. Mikäli ongelma jatkuu, voit mahdollisesti käyttää Mastodonia eri selaimella tai natiivilla sovelluksella.",
"errors.unexpected_crash.copy_stacktrace": "Kopioi pinon jäljitys leikepöydälle",
"errors.unexpected_crash.report_issue": "Ilmoita ongelmasta",
@@ -867,7 +867,7 @@
"search_results.all": "Kaikki",
"search_results.hashtags": "Aihetunnisteet",
"search_results.no_results": "Ei tuloksia.",
"search_results.no_search_yet": "Koeta hakea julkaisuja, profiileja tai aihetunnisteita.",
"search_results.no_search_yet": "Kokeile hakea julkaisuja, profiileja tai aihetunnisteita.",
"search_results.see_all": "Näytä kaikki",
"search_results.statuses": "Julkaisut",
"search_results.title": "Haku ”{q}”",

View File

@@ -135,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "nýggir postar",
"annual_report.summary.percentile.text": "<topLabel>Tað fær teg í topp</topLabel><percentage></percentage><bottomLabel>av {domain} brúkarum.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "Vit fara ikki at fortelja Bernie tað.",
"annual_report.summary.share_message": "Eg fekk {archetype} frumsniðið!",
"annual_report.summary.thanks": "Takk fyri at tú er partur av Mastodon!",
"attachments_list.unprocessed": "(óviðgjørt)",
"audio.hide": "Fjal ljóð",

View File

@@ -36,7 +36,7 @@
"account.familiar_followers_two": "{name1} ir {name2} seka",
"account.featured": "Rodomi",
"account.featured.accounts": "Profiliai",
"account.featured.hashtags": "Saitažodžiai",
"account.featured.hashtags": "Grotažymės",
"account.featured_tags.last_status_at": "Paskutinis įrašas {date}",
"account.featured_tags.last_status_never": "Nėra įrašų",
"account.follow": "Sekti",
@@ -130,7 +130,7 @@
"annual_report.summary.highlighted_post.by_replies": "įrašas su daugiausiai atsakymų",
"annual_report.summary.highlighted_post.possessive": "{name}",
"annual_report.summary.most_used_app.most_used_app": "labiausiai naudota programa",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "labiausiai naudotas saitažodis",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "labiausiai naudota grotažymė",
"annual_report.summary.most_used_hashtag.none": "Nieko",
"annual_report.summary.new_posts.new_posts": "nauji įrašai",
"annual_report.summary.percentile.text": "<topLabel>Tai reiškia, kad esate tarp</topLabel><percentage></percentage><bottomLabel>populiariausių {domain} naudotojų.</bottomLabel>",
@@ -209,7 +209,7 @@
"compose.saved.body": "Įrašas išsaugotas.",
"compose_form.direct_message_warning_learn_more": "Sužinoti daugiau",
"compose_form.encryption_warning": "„Mastodon“ įrašai nėra visapusiškai šifruojami. Per „Mastodon“ nesidalyk jokia slapta informacija.",
"compose_form.hashtag_warning": "Šis įrašas nebus įtrauktas į jokį saitažodį, nes ji nėra vieša. Tik viešų įrašų galima ieškoti pagal saitažodį.",
"compose_form.hashtag_warning": "Šis įrašas nebus įtrauktas į jokį grotažodį, nes jis nėra viešas. Tik vieši įrašai gali būti ieškomi pagal grotažymę.",
"compose_form.lock_disclaimer": "Tavo paskyra nėra {locked}. Bet kas gali sekti tave ir peržiūrėti tik sekėjams skirtus įrašus.",
"compose_form.lock_disclaimer.lock": "užrakinta",
"compose_form.placeholder": "Kas tavo mintyse?",
@@ -337,15 +337,15 @@
"emoji_button.search_results": "Paieškos rezultatai",
"emoji_button.symbols": "Simboliai",
"emoji_button.travel": "Kelionės ir vietos",
"empty_column.account_featured.me": "Jūs dar nieko neparyškinote. Ar žinojote, kad savo profilyje galite parodyti dažniausiai naudojamas žymes ir netgi savo draugų paskyras?",
"empty_column.account_featured.other": "{acct} dar nieko neparyškino. Ar žinojote, kad savo profilyje galite pateikti dažniausiai naudojamus žymes ir netgi savo draugų paskyras?",
"empty_column.account_featured.me": "Jūs dar nieko neparyškinote. Ar žinojote, kad savo profilyje galite parodyti dažniausiai naudojamas grotažymes ir netgi savo draugų paskyras?",
"empty_column.account_featured.other": "{acct} dar nieko neparyškino. Ar žinojote, kad savo profilyje galite pateikti dažniausiai naudojamus grotžymes ir netgi savo draugų paskyras?",
"empty_column.account_featured_other.unknown": "Ši paskyra dar nieko neparodė.",
"empty_column.account_hides_collections": "Šis (-i) naudotojas (-a) pasirinko nepadaryti šią informaciją prieinamą.",
"empty_column.account_suspended": "Paskyra pristabdyta.",
"empty_column.account_timeline": "Nėra čia įrašų.",
"empty_column.account_unavailable": "Profilis neprieinamas.",
"empty_column.blocks": "Dar neužblokavai nė vieno naudotojo.",
"empty_column.bookmarked_statuses": "Dar neturi nė vienos įrašo pridėtos žymės. Kai vieną iš jų pridėsi į žymes, jis bus rodomas čia.",
"empty_column.bookmarked_statuses": "Dar neturi nė vienos įrašo su žyma. Kai vieną žymų pridėsi prie įrašo, jis bus rodomas čia.",
"empty_column.community": "Vietinė laiko skalė yra tuščia. Parašyk ką nors viešai, kad pradėtum sąveikauti.",
"empty_column.direct": "Dar neturi jokių privačių paminėjimų. Kai išsiųsi arba gausi vieną iš jų, jis bus rodomas čia.",
"empty_column.disabled_feed": "Šis srautas buvo išjungtas jūsų serverio administratorių.",
@@ -354,8 +354,8 @@
"empty_column.favourited_statuses": "Dar neturi mėgstamų įrašų. Kai vieną iš jų pamėgsi, jis bus rodomas čia.",
"empty_column.favourites": "Šio įrašo dar niekas nepamėgo. Kai kas nors tai padarys, jie bus rodomi čia.",
"empty_column.follow_requests": "Dar neturi jokių sekimo prašymų. Kai gausi tokį prašymą, jis bus rodomas čia.",
"empty_column.followed_tags": "Dar neseki jokių saitažodžių. Kai tai padarysi, jie bus rodomi čia.",
"empty_column.hashtag": "Nėra nieko šiame saitažodyje kol kas.",
"empty_column.followed_tags": "Dar neseki jokių grotažymių. Kai tai padarysi, jos bus rodomos čia.",
"empty_column.hashtag": "Šioje gratažymėje kol kas nieko nėra.",
"empty_column.home": "Tavo pagrindinio laiko skalė tuščia. Sek daugiau žmonių, kad ją užpildytum.",
"empty_column.list": "Šiame sąraše dar nieko nėra. Kai šio sąrašo nariai paskelbs naujus įrašus, jie bus rodomi čia.",
"empty_column.mutes": "Dar nesi nutildęs (-usi) nė vieno naudotojo.",
@@ -373,7 +373,7 @@
"explore.title": "Populiaru",
"explore.trending_links": "Naujienos",
"explore.trending_statuses": "Įrašai",
"explore.trending_tags": "Saitažodžiai",
"explore.trending_tags": "Grotažymės",
"featured_carousel.current": "<sr>Įrašas</sr> {current, number} / {max, number}",
"featured_carousel.header": "{count, plural, one {Iškeltas įrašas} few {Iškelti įrašai} many {Iškeltų įrašų} other {Iškelti įrašai}}",
"featured_carousel.slide": "Įrašas {current, number} iš {max, number}",
@@ -417,7 +417,7 @@
"follow_suggestions.similar_to_recently_followed_longer": "Panašūs į profilius, kuriuos neseniai seki",
"follow_suggestions.view_all": "Peržiūrėti viską",
"follow_suggestions.who_to_follow": "Ką sekti",
"followed_tags": "Sekami saitažodžiai",
"followed_tags": "Sekamos grotažymės",
"footer.about": "Apie",
"footer.about_this_server": "Apie",
"footer.directory": "Profilių katalogas",
@@ -429,26 +429,26 @@
"footer.terms_of_service": "Paslaugų sąlygos",
"generic.saved": "Išsaugota",
"getting_started.heading": "Kaip pradėti",
"hashtag.admin_moderation": "Atverti prižiūrėjimo sąsają saitažodžiui #{name}",
"hashtag.admin_moderation": "Atverti moderavimo langą #{name}",
"hashtag.browse": "Naršyti įrašus su #{hashtag}",
"hashtag.browse_from_account": "Naršyti @{name} įrašus su žyma #{hashtag}",
"hashtag.browse_from_account": "Naršyti @{name} įrašus su grotažyme #{hashtag}",
"hashtag.column_header.tag_mode.all": "ir {additional}",
"hashtag.column_header.tag_mode.any": "ar {additional}",
"hashtag.column_header.tag_mode.none": "be {additional}",
"hashtag.column_settings.select.no_options_message": "Pasiūlymų nerasta.",
"hashtag.column_settings.select.placeholder": "Įvesti saitažodžius…",
"hashtag.column_settings.select.placeholder": "Įvesk grotažymes…",
"hashtag.column_settings.tag_mode.all": "Visi šie",
"hashtag.column_settings.tag_mode.any": "Bet kuris šių",
"hashtag.column_settings.tag_mode.none": "Nė vienas šių",
"hashtag.column_settings.tag_toggle": "Įtraukti papildomas šio stulpelio žymes",
"hashtag.counter_by_accounts": "{count, plural, one {{counter} dalyvis} few {{counter} dalyviai} many {{counter} dalyvio} other {{counter} dalyvių}}",
"hashtag.column_settings.tag_mode.any": "Bet kuris šių",
"hashtag.column_settings.tag_mode.none": "Nei vienas šių",
"hashtag.column_settings.tag_toggle": "Įtraukti papildomas žymas į šį stulpelį",
"hashtag.counter_by_accounts": "{count, plural, one {{counter} dalyvis} few {{counter} dalyviai} many {{counter} dalyvių} other {{counter} dalyviai}}",
"hashtag.counter_by_uses": "{count, plural, one {{counter} įrašas} few {{counter} įrašai} many {{counter} įrašo} other {{counter} įrašų}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} įrašas} few {{counter} įrašai} many {{counter} įrašo} other {{counter} įrašų}} šiandien",
"hashtag.feature": "Rodyti profilyje",
"hashtag.follow": "Sekti saitažodį",
"hashtag.follow": "Sekti grotažymę",
"hashtag.mute": "Nutildyti žymą #{hashtag}",
"hashtag.unfeature": "Neberodyti profilyje",
"hashtag.unfollow": "Nebesekti saitažodį",
"hashtag.unfollow": "Nebesekti grotažymės",
"hashtags.and_other": "…ir {count, plural, one {# daugiau} few {# daugiau} many {# daugiau}other {# daugiau}}",
"hints.profiles.followers_may_be_missing": "Sekėjai šiai profiliui gali būti nepateikti.",
"hints.profiles.follows_may_be_missing": "Sekimai šiai profiliui gali būti nepateikti.",
@@ -489,7 +489,7 @@
"intervals.full.minutes": "{number, plural, one {# minutė} few {# minutes} many {# minutės} other {# minučių}}",
"keyboard_shortcuts.back": "Naršyti atgal",
"keyboard_shortcuts.blocked": "Atidaryti užblokuotų naudotojų sąrašą",
"keyboard_shortcuts.boost": "Pakelti įrašą",
"keyboard_shortcuts.boost": "Dalintis įrašu",
"keyboard_shortcuts.column": "Fokusuoti stulpelį",
"keyboard_shortcuts.compose": "Fokusuoti rengykles teksto sritį",
"keyboard_shortcuts.description": "Aprašymas",
@@ -587,7 +587,7 @@
"navigation_bar.favourites": "Mėgstami",
"navigation_bar.filters": "Nutildyti žodžiai",
"navigation_bar.follow_requests": "Sekimo prašymai",
"navigation_bar.followed_tags": "Sekami saitažodžiai",
"navigation_bar.followed_tags": "Sekamos grotažymės",
"navigation_bar.follows_and_followers": "Sekimai ir sekėjai",
"navigation_bar.import_export": "Importas ir eksportas",
"navigation_bar.lists": "Sąrašai",
@@ -604,7 +604,7 @@
"navigation_bar.search_trends": "Paieška / Populiaru",
"navigation_panel.collapse_followed_tags": "Sutraukti sekamų žymių meniu",
"navigation_panel.collapse_lists": "Sutraukti sąrašo meniu",
"navigation_panel.expand_followed_tags": "Išskleisti sekamų žymių meniu",
"navigation_panel.expand_followed_tags": "Išskleisti sekamų grotažymių meniu",
"navigation_panel.expand_lists": "Išskleisti sąrašo meniu",
"not_signed_in_indicator.not_signed_in": "Norint pasiekti šį išteklį, reikia prisijungti.",
"notification.admin.report": "{name} pranešė {target}",
@@ -635,16 +635,16 @@
"notification.moderation_warning": "Gavai prižiūrėjimo įspėjimą",
"notification.moderation_warning.action_delete_statuses": "Kai kurie tavo įrašai buvo pašalintos.",
"notification.moderation_warning.action_disable": "Tavo paskyra buvo išjungta.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Kai kurie tavo įrašai buvo pažymėtos kaip jautrios.",
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Kai kurie tavo įrašai buvo pažymėti kaip turintys jautrią informaciją.",
"notification.moderation_warning.action_none": "Tavo paskyra gavo prižiūrėjimo įspėjimą.",
"notification.moderation_warning.action_sensitive": "Nuo šiol tavo įrašai bus pažymėti kaip jautrūs.",
"notification.moderation_warning.action_sensitive": "Nuo šiol tavo įrašai bus pažymėti kaip su jautria informacija.",
"notification.moderation_warning.action_silence": "Tavo paskyra buvo apribota.",
"notification.moderation_warning.action_suspend": "Tavo paskyra buvo sustabdyta.",
"notification.own_poll": "Tavo apklausa baigėsi",
"notification.poll": "Baigėsi apklausa, kurioje balsavai",
"notification.quoted_update": "{name} redagavo jūsų cituotą įrašą",
"notification.reblog": "{name} pakėlė tavo įrašą",
"notification.reblog.name_and_others_with_link": "{name} ir <a>{count, plural,one {dar kažkas} few {# kiti} other {# kitų}}</a> paryškino tavo įrašą",
"notification.reblog": "{name} dalinosi tavo įrašu",
"notification.reblog.name_and_others_with_link": "{name} ir <a>{count, plural,one {dar kažkas} few {# kiti} other {# kitų}}</a> pasidalino tavo įrašu",
"notification.relationships_severance_event": "Prarasti sąryšiai su {name}",
"notification.relationships_severance_event.account_suspension": "Administratorius iš {from} sustabdė {target}, todėl jūs nebegalėsite gauti jų naujienų ar bendrauti su jais.",
"notification.relationships_severance_event.domain_block": "Administratorius iš {from} užblokavo {target}, įskaitant {followersCount} iš tavo sekėjų ir {followingCount, plural,one {# žmogų, kurį} few {# žmones, kuriuos} other {# žmonių, kuriuos}} seki.",
@@ -687,7 +687,7 @@
"notifications.column_settings.poll": "Balsavimo rezultatai:",
"notifications.column_settings.push": "Tiesioginiai pranešimai",
"notifications.column_settings.quote": "Paminėjimai:",
"notifications.column_settings.reblog": "Pakėlimai:",
"notifications.column_settings.reblog": "Pasidalinimai:",
"notifications.column_settings.show": "Rodyti stulpelyje",
"notifications.column_settings.sound": "Paleisti garsą",
"notifications.column_settings.status": "Nauji įrašai:",
@@ -695,7 +695,7 @@
"notifications.column_settings.unread_notifications.highlight": "Paryškinti neperskaitytus pranešimus",
"notifications.column_settings.update": "Redagavimai:",
"notifications.filter.all": "Visi",
"notifications.filter.boosts": "Pakėlimai",
"notifications.filter.boosts": "Pasidalinimai",
"notifications.filter.favourites": "Mėgstami",
"notifications.filter.follows": "Sekimai",
"notifications.filter.mentions": "Paminėjimai",
@@ -765,7 +765,7 @@
"privacy.quote.anyone": "{visibility}, kiekvienas gali cituoti",
"privacy.quote.disabled": "{visibility}, paminėjimai išjungti",
"privacy.quote.limited": "{visibility}, paminėjimai apriboti",
"privacy.unlisted.additional": "Tai veikia lygiai taip pat, kaip ir vieša, tik įrašas nebus rodomas tiesioginiuose srautuose, saitažodžiose, naršyme ar Mastodon paieškoje, net jei esi įtraukęs (-usi) visą paskyrą.",
"privacy.unlisted.additional": "Tai veikia lygiai taip pat, kaip ir vieša, tik įrašas nebus rodomas tiesioginiuose srautuose, grotažymėse, naršyme ar Mastodon paieškoje, net jei esi įtraukęs (-usi) visą paskyrą.",
"privacy.unlisted.long": "Paslėptas nuo „Mastodon“ paieškos rezultatų, tendencijų ir viešų įrašų sienų",
"privacy.unlisted.short": "Tyliai vieša",
"privacy_policy.last_updated": "Paskutinį kartą atnaujinta {date}",
@@ -866,9 +866,9 @@
"search_popout.user": "naudotojas",
"search_results.accounts": "Profiliai",
"search_results.all": "Visi",
"search_results.hashtags": "Saitažodžiai",
"search_results.hashtags": "Grotažymės",
"search_results.no_results": "Nėra rezultatų.",
"search_results.no_search_yet": "Pabandykite ieškoti įrašų, profilių arba saitažodžių.",
"search_results.no_search_yet": "Pabandykite ieškoti įrašų, profilių arba grotažymių.",
"search_results.see_all": "Žiūrėti viską",
"search_results.statuses": "Įrašai",
"search_results.title": "Paieška užklausai „{q}“",
@@ -885,10 +885,10 @@
"status.admin_account": "Atidaryti prižiūrėjimo sąsają @{name}",
"status.admin_domain": "Atidaryti prižiūrėjimo sąsają {domain}",
"status.admin_status": "Atidaryti šį įrašą prižiūrėjimo sąsajoje",
"status.all_disabled": "Įrašo pakėlimai ir paminėjimai išjungti",
"status.all_disabled": "Įrašo dalinimaisi ir paminėjimai išjungti",
"status.block": "Blokuoti @{name}",
"status.bookmark": "Pridėti į žymės",
"status.cancel_reblog_private": "Nebepasidalinti",
"status.bookmark": "Žymė",
"status.cancel_reblog_private": "Nesidalinti",
"status.cannot_quote": "Jums neleidžiama paminėti šio įrašo",
"status.cannot_reblog": "Šis įrašas negali būti pakeltas.",
"status.contains_quote": "Turi citatą",
@@ -910,6 +910,7 @@
"status.edited_x_times": "Redaguota {count, plural, one {{count} kartą} few {{count} kartus} many {{count} karto} other {{count} kartų}}",
"status.embed": "Gaukite įterpimo kodą",
"status.favourite": "Pamėgti",
"status.favourites_count": "{count, plural, one {{counter} patiko} few {{counter} patiko} many {{counter} patiko} other {{counter} patiko}}",
"status.filter": "Filtruoti šį įrašą",
"status.history.created": "{name} sukurta {date}",
"status.history.edited": "{name} redaguota {date}",
@@ -944,11 +945,14 @@
"status.quotes.empty": "Šio įrašo dar niekas nepaminėjo. Kai kas nors tai padarys, jie bus rodomi čia.",
"status.quotes.local_other_disclaimer": "Autoriaus atmesti įrašo paminėjimai nebus rodomi.",
"status.quotes.remote_other_disclaimer": "Čia bus rodoma tik paminėjimai iš {domain}. Autoriaus atmesti įrašo paminėjimai nebus rodomi.",
"status.quotes_count": "{count, plural, one {{counter} paminėjimas} few {{counter} paminėjimai} many {{counter} paminėjimai} other {{counter} paminėjimai}}",
"status.read_more": "Skaityti daugiau",
"status.reblog": "Pakelti",
"status.reblog_or_quote": "Paryškinti arba cituoti",
"status.reblogged_by": "{name} pakėlė",
"status.reblogs.empty": "Šio įrašo dar niekas nepakėlė. Kai kas nors tai padarys, jie bus rodomi čia.",
"status.reblog": "Dalintis",
"status.reblog_or_quote": "Dalintis arba cituoti",
"status.reblog_private": "Vėl pasidalinkite su savo sekėjais",
"status.reblogged_by": "{name} pasidalino",
"status.reblogs.empty": "Šiuo įrašu dar niekas nesidalino. Kai kas nors tai padarys, jie bus rodomi čia.",
"status.reblogs_count": "{count, plural, one {{counter} pasidalinimas} few {{counter} pasidalinimai} many {{counter} pasidalinimų} other {{counter} pasidalinimai}}",
"status.redraft": "Ištrinti ir parengti iš naujo",
"status.remove_bookmark": "Pašalinti žymę",
"status.remove_favourite": "Šalinti iš mėgstamų",
@@ -975,6 +979,7 @@
"subscribed_languages.save": "Išsaugoti pakeitimus",
"subscribed_languages.target": "Keisti prenumeruojamas kalbas {target}",
"tabs_bar.home": "Pagrindinis",
"tabs_bar.menu": "Meniu",
"tabs_bar.notifications": "Pranešimai",
"tabs_bar.publish": "Naujas įrašas",
"tabs_bar.search": "Paieška",
@@ -1023,10 +1028,13 @@
"visibility_modal.button_title": "Nustatyti matomumą",
"visibility_modal.direct_quote_warning.text": "Jei išsaugosite dabartinius nustatymus, įterpta citata bus konvertuota į nuorodą.",
"visibility_modal.direct_quote_warning.title": "Cituojami įrašai negali būti įterpiami į privačius paminėjimus",
"visibility_modal.header": "Matomumas ir sąveika",
"visibility_modal.helper.direct_quoting": "Privatūs paminėjimai, parašyti platformoje „Mastodon“, negali būti cituojami kitų.",
"visibility_modal.helper.privacy_editing": "Matomumo nustatymai negali būti keičiami po to, kai įrašas yra paskelbtas.",
"visibility_modal.helper.privacy_private_self_quote": "Privačių įrašų paminėjimai negali būti skelbiami viešai.",
"visibility_modal.helper.private_quoting": "Tik sekėjams skirti įrašai, parašyti platformoje „Mastodon“, negali būti cituojami kitų.",
"visibility_modal.helper.unlisted_quoting": "Kai žmonės jus cituos, jų įrašai taip pat bus paslėpti iš populiariausių naujienų srauto.",
"visibility_modal.instructions": "Kontroliuokite, kas gali bendrauti su šiuo įrašu. Taip pat galite taikyti nustatymus visiems būsimiems įrašams, pereidami į <link>Preferences > Posting defaults</link>.",
"visibility_modal.privacy_label": "Matomumas",
"visibility_modal.quote_followers": "Tik sekėjai",
"visibility_modal.quote_label": "Kas gali cituoti",

View File

@@ -113,6 +113,10 @@
"alt_text_modal.describe_for_people_with_visual_impairments": "請替看有困難ê敘述tsit ê內容…",
"alt_text_modal.done": "做好ah",
"announcement.announcement": "公告",
"annual_report.announcement.action_build": "建立我ê Wrapstodon",
"annual_report.announcement.action_view": "看我ê Wrapstodon",
"annual_report.announcement.description": "發現其他關係lí佇最近tsi̍t年參與Mastodon ê狀況。",
"annual_report.announcement.title": "Wrapstodon {year} 已經kàu ah",
"annual_report.summary.archetype.booster": "追求趣味ê",
"annual_report.summary.archetype.lurker": "有讀無PO ê",
"annual_report.summary.archetype.oracle": "先知",
@@ -131,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "新ê PO文",
"annual_report.summary.percentile.text": "<topLabel>Tse 予lí變做 {domain} ê用戶ê </topLabel><percentage></percentage><bottomLabel></bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "Gún bē kā Bernie講。",
"annual_report.summary.share_message": "我得著 {archetype} ê典型!",
"annual_report.summary.thanks": "多謝成做Mastodon ê成員!",
"attachments_list.unprocessed": "Iáu bē處理",
"audio.hide": "Tshàng聲音",

View File

@@ -135,6 +135,7 @@
"annual_report.summary.new_posts.new_posts": "nieuwe berichten",
"annual_report.summary.percentile.text": "<topLabel>Hiermee behoor je tot de top</topLabel><percentage></percentage><bottomLabel> van {domain}.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "We zullen Bernie niets vertellen.",
"annual_report.summary.share_message": "Ik heb het archetype {archetype}!",
"annual_report.summary.thanks": "Bedankt dat je deel uitmaakt van Mastodon!",
"attachments_list.unprocessed": "(niet verwerkt)",
"audio.hide": "Audio verbergen",

View File

@@ -31,6 +31,8 @@
"account.edit_profile_short": "แก้ไข",
"account.enable_notifications": "แจ้งเตือนฉันเมื่อ @{name} โพสต์",
"account.endorse": "แสดงในโปรไฟล์",
"account.familiar_followers_one": "ติดตามโดย {name1}",
"account.familiar_followers_two": "ติดตามโดย {name1} และ {name2}",
"account.featured": "น่าสนใจ",
"account.featured.accounts": "โปรไฟล์",
"account.featured.hashtags": "แฮชแท็ก",
@@ -65,6 +67,7 @@
"account.mute_short": "ซ่อน",
"account.muted": "ซ่อนอยู่",
"account.muting": "กำลังซ่อน",
"account.mutual": "คุณติดตามกันและกัน",
"account.no_bio": "ไม่ได้ให้คำอธิบาย",
"account.open_original_page": "เปิดหน้าดั้งเดิม",
"account.posts": "โพสต์",
@@ -232,6 +235,7 @@
"confirmations.missing_alt_text.secondary": "โพสต์ต่อไป",
"confirmations.missing_alt_text.title": "เพิ่มข้อความแสดงแทน?",
"confirmations.mute.confirm": "ซ่อน",
"confirmations.private_quote_notify.confirm": "เผยแพร่โพสต์",
"confirmations.quiet_post_quote_info.dismiss": "ไม่ต้องเตือนฉันอีก",
"confirmations.quiet_post_quote_info.got_it": "เข้าใจแล้ว",
"confirmations.redraft.confirm": "ลบแล้วร่างใหม่",
@@ -381,6 +385,7 @@
"follow_suggestions.who_to_follow": "ติดตามใครดี",
"followed_tags": "แฮชแท็กที่ติดตาม",
"footer.about": "เกี่ยวกับ",
"footer.about_this_server": "เกี่ยวกับ",
"footer.directory": "ไดเรกทอรีโปรไฟล์",
"footer.get_app": "รับแอป",
"footer.keyboard_shortcuts": "แป้นพิมพ์ลัด",
@@ -417,6 +422,7 @@
"hints.profiles.see_more_followers": "ดูผู้ติดตามเพิ่มเติมใน {domain}",
"hints.profiles.see_more_follows": "ดูการติดตามเพิ่มเติมใน {domain}",
"hints.profiles.see_more_posts": "ดูโพสต์เพิ่มเติมใน {domain}",
"home.column_settings.show_quotes": "แสดงการอ้างอิง",
"home.column_settings.show_reblogs": "แสดงการดัน",
"home.column_settings.show_replies": "แสดงการตอบกลับ",
"home.hide_announcements": "ซ่อนประกาศ",
@@ -461,6 +467,7 @@
"keyboard_shortcuts.home": "เปิดเส้นเวลาหน้าแรก",
"keyboard_shortcuts.hotkey": "ปุ่มลัด",
"keyboard_shortcuts.legend": "แสดงคำอธิบายนี้",
"keyboard_shortcuts.load_more": "โฟกัสปุ่ม \"โหลดเพิ่มเติม\"",
"keyboard_shortcuts.local": "เปิดเส้นเวลาในเซิร์ฟเวอร์",
"keyboard_shortcuts.mention": "กล่าวถึงผู้สร้าง",
"keyboard_shortcuts.muted": "เปิดรายการผู้ใช้ที่ซ่อนอยู่",
@@ -469,6 +476,7 @@
"keyboard_shortcuts.open_media": "เปิดสื่อ",
"keyboard_shortcuts.pinned": "เปิดรายการโพสต์ที่ปักหมุด",
"keyboard_shortcuts.profile": "เปิดโปรไฟล์ของผู้สร้าง",
"keyboard_shortcuts.quote": "อ้างอิงโพสต์",
"keyboard_shortcuts.reply": "ตอบกลับโพสต์",
"keyboard_shortcuts.requests": "เปิดรายการคำขอติดตาม",
"keyboard_shortcuts.search": "โฟกัสแถบค้นหา",
@@ -546,6 +554,8 @@
"navigation_bar.follows_and_followers": "การติดตามและผู้ติดตาม",
"navigation_bar.import_export": "การนำเข้าและการส่งออก",
"navigation_bar.lists": "รายการ",
"navigation_bar.live_feed_local": "ฟีดสด (ในเซิร์ฟเวอร์)",
"navigation_bar.live_feed_public": "ฟีดสด (สาธารณะ)",
"navigation_bar.logout": "ออกจากระบบ",
"navigation_bar.moderation": "การกลั่นกรอง",
"navigation_bar.more": "เพิ่มเติม",
@@ -554,6 +564,7 @@
"navigation_bar.preferences": "การกำหนดลักษณะ",
"navigation_bar.privacy_and_reach": "ความเป็นส่วนตัวและการเข้าถึง",
"navigation_bar.search": "ค้นหา",
"navigation_bar.search_trends": "ค้นหา / กำลังนิยม",
"navigation_panel.collapse_lists": "ยุบเมนูรายการ",
"navigation_panel.expand_lists": "ขยายเมนูรายการ",
"not_signed_in_indicator.not_signed_in": "คุณจำเป็นต้องเข้าสู่ระบบเพื่อเข้าถึงทรัพยากรนี้",
@@ -576,6 +587,7 @@
"notification.label.mention": "การกล่าวถึง",
"notification.label.private_mention": "การกล่าวถึงแบบส่วนตัว",
"notification.label.private_reply": "การตอบกลับแบบส่วนตัว",
"notification.label.quote": "{name} ได้อ้างอิงโพสต์ของคุณ",
"notification.label.reply": "การตอบกลับ",
"notification.mention": "การกล่าวถึง",
"notification.mentioned_you": "{name} ได้กล่าวถึงคุณ",
@@ -590,6 +602,7 @@
"notification.moderation_warning.action_suspend": "ระงับบัญชีของคุณแล้ว",
"notification.own_poll": "การสำรวจความคิดเห็นของคุณได้สิ้นสุดแล้ว",
"notification.poll": "การสำรวจความคิดเห็นที่คุณได้ลงคะแนนได้สิ้นสุดแล้ว",
"notification.quoted_update": "{name} ได้แก้ไขโพสต์ที่คุณได้อ้างอิง",
"notification.reblog": "{name} ได้ดันโพสต์ของคุณ",
"notification.reblog.name_and_others_with_link": "{name} และ <a>{count, plural, other {# อื่น ๆ}}</a> ได้ดันโพสต์ของคุณ",
"notification.relationships_severance_event": "สูญเสียการเชื่อมต่อกับ {name}",
@@ -633,6 +646,7 @@
"notifications.column_settings.mention": "การกล่าวถึง:",
"notifications.column_settings.poll": "ผลลัพธ์การสำรวจความคิดเห็น:",
"notifications.column_settings.push": "การแจ้งเตือนแบบผลัก",
"notifications.column_settings.quote": "การอ้างอิง:",
"notifications.column_settings.reblog": "การดัน:",
"notifications.column_settings.show": "แสดงในคอลัมน์",
"notifications.column_settings.sound": "เล่นเสียง",
@@ -708,7 +722,11 @@
"privacy.private.short": "ผู้ติดตาม",
"privacy.public.long": "ใครก็ตามที่อยู่ในและนอก Mastodon",
"privacy.public.short": "สาธารณะ",
"privacy.quote.anyone": "{visibility}, ใครก็ตามสามารถอ้างอิง",
"privacy.quote.disabled": "{visibility}, ปิดใช้งานการอ้างอิงแล้ว",
"privacy.quote.limited": "{visibility}, จำกัดการอ้างอิงอยู่",
"privacy.unlisted.additional": "สิ่งนี้ทำงานเหมือนกับสาธารณะทุกประการ ยกเว้นโพสต์จะไม่ปรากฏในฟีดสดหรือแฮชแท็ก, การสำรวจ หรือการค้นหา Mastodon แม้ว่าคุณได้เลือกรับทั่วทั้งบัญชีก็ตาม",
"privacy.unlisted.long": "ซ่อนจากผลลัพธ์การค้นหา, กำลังนิยม และเส้นเวลาสาธารณะของ Mastodon",
"privacy.unlisted.short": "สาธารณะแบบเงียบ",
"privacy_policy.last_updated": "อัปเดตล่าสุดเมื่อ {date}",
"privacy_policy.title": "นโยบายความเป็นส่วนตัว",
@@ -849,6 +867,8 @@
"status.mute_conversation": "ซ่อนการสนทนา",
"status.open": "ขยายโพสต์นี้",
"status.pin": "ปักหมุดในโปรไฟล์",
"status.quote_error.limited_account_hint.action": "แสดงต่อไป",
"status.quote_post_author": "อ้างอิงโพสต์โดย @{name}",
"status.read_more": "อ่านเพิ่มเติม",
"status.reblog": "ดัน",
"status.reblogged_by": "{name} ได้ดัน",
@@ -916,5 +936,11 @@
"video.pause": "หยุดชั่วคราว",
"video.play": "เล่น",
"video.unmute": "เลิกปิดเสียง",
"visibility_modal.button_title": "ตั้งการมองเห็น",
"visibility_modal.header": "การมองเห็นและการโต้ตอบ",
"visibility_modal.privacy_label": "การมองเห็น",
"visibility_modal.quote_followers": "ผู้ติดตามเท่านั้น",
"visibility_modal.quote_nobody": "แค่ฉัน",
"visibility_modal.quote_public": "ใครก็ตาม",
"visibility_modal.save": "บันทึก"
}

View File

@@ -319,8 +319,8 @@
"domain_pill.your_handle": "您的帳號:",
"domain_pill.your_server": "您數位世界的家,您所有的嘟文都在這裡。不喜歡這台伺服器嗎?您能隨時搬家至其他伺服器並且仍保有您的跟隨者。",
"domain_pill.your_username": "您於您的伺服器中獨一無二的識別。於不同的伺服器上可能找到具有相同帳號的使用者。",
"dropdown.empty": "選項",
"embed.instructions": "若您欲於您的網站嵌入此嘟文,請複製以下程式碼。",
"dropdown.empty": "請選擇一個選項",
"embed.instructions": "請複製以下程式碼以於您的網站嵌入此嘟文。",
"embed.preview": "它將顯示成這樣:",
"emoji_button.activity": "活動",
"emoji_button.clear": "清除",
@@ -344,7 +344,7 @@
"empty_column.account_suspended": "帳號已被停權",
"empty_column.account_timeline": "這裡還沒有嘟文!",
"empty_column.account_unavailable": "無法取得個人檔案",
"empty_column.blocks": "您還沒有封鎖任何使用者。",
"empty_column.blocks": "您尚未封鎖任何使用者。",
"empty_column.bookmarked_statuses": "您還沒有新增任何書籤。當您新增書籤時,它將於此顯示。",
"empty_column.community": "本站時間軸是空的。快公開嘟些文搶頭香啊!",
"empty_column.direct": "您還沒有收到任何私訊。當您私訊別人或收到私訊時,它將於此顯示。",
@@ -356,12 +356,12 @@
"empty_column.follow_requests": "您還沒有收到任何跟隨請求。當您收到的跟隨請求時,它將於此顯示。",
"empty_column.followed_tags": "您還沒有跟隨任何主題標籤。當您跟隨主題標籤時,它們將於此顯示。",
"empty_column.hashtag": "這個主題標籤下什麼也沒有。",
"empty_column.home": "您的首頁時間軸是空的!跟隨更多人將它填滿吧!",
"empty_column.home": "您的首頁時間軸是空的!跟隨更多人將它填滿吧!",
"empty_column.list": "這份列表下什麼也沒有。當此列表的成員嘟出新的嘟文時,它們將顯示於此。",
"empty_column.mutes": "您尚未靜音任何使用者。",
"empty_column.notification_requests": "清空啦!已經沒有任何推播通知。當您收到新推播通知時,它們將依照您的設定於此顯示。",
"empty_column.notifications": "您還沒有收到任何推播通知,當您與別人開始互動時,它將於此顯示。",
"empty_column.public": "這裡什麼都沒有!嘗試寫些公開的嘟文,或跟隨其他伺服器的使用者後就會有嘟文出現了",
"empty_column.public": "這裡什麼都沒有!嘗試寫些公開的嘟文,或著自己跟隨其他伺服器的使用者後就會有嘟文出現了",
"error.no_hashtag_feed_access": "加入或登入 Mastodon 以檢視與跟隨此主題標籤。",
"error.unexpected_crash.explanation": "由於發生系統故障或瀏覽器相容性問題,無法正常顯示此頁面。",
"error.unexpected_crash.explanation_addons": "此頁面無法被正常顯示,這可能是由瀏覽器附加元件或網頁自動翻譯工具造成的。",

Some files were not shown because too many files have changed in this diff Show More