th: Merge remote-tracking branch 'glitch/main'

th-downstream
Kouhai 2 years ago
commit a590a2a093

@ -12,6 +12,7 @@ on:
- Dockerfile
permissions:
contents: read
packages: write
jobs:
build-image:

@ -0,0 +1,138 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
#
name: Test chart
on:
pull_request:
paths:
- "chart/**"
- "!**.md"
- ".github/workflows/test-chart.yml"
push:
paths:
- "chart/**"
- "!**.md"
- ".github/workflows/test-chart.yml"
branches-ignore:
- "dependabot/**"
workflow_dispatch:
permissions:
contents: read
defaults:
run:
working-directory: chart
jobs:
lint-templates:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install dependencies (yamllint)
run: pip install yamllint
- run: helm dependency update
- name: helm lint
run: |
helm lint . \
--values dev-values.yaml
- name: helm template
run: |
helm template . \
--values dev-values.yaml \
--output-dir rendered-templates
- name: yamllint (only on templates we manage)
run: |
rm -rf rendered-templates/mastodon/charts
yamllint rendered-templates \
--config-data "{rules: {indentation: {spaces: 2}, line-length: disable}}"
# This job helps us validate that rendered templates are valid k8s resources
# against a k8s api-server, via "helm template --validate", but also that a
# basic configuration can be used to successfully startup mastodon.
#
test-install:
runs-on: ubuntu-22.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# k3s-channel reference: https://update.k3s.io/v1-release/channels
- k3s-channel: latest
- k3s-channel: stable
# This represents the oldest configuration we test against.
#
# The k8s version chosen is based on the oldest still supported k8s
# version among two managed k8s services, GKE, EKS.
# - GKE: https://endoflife.date/google-kubernetes-engine
# - EKS: https://endoflife.date/amazon-eks
#
# The helm client's version can influence what helper functions is
# available for use in the templates, currently we need v3.6.0 or
# higher.
#
- k3s-channel: v1.21
helm-version: v3.6.0
steps:
- uses: actions/checkout@v3
# This action starts a k8s cluster with NetworkPolicy enforcement and
# installs both kubectl and helm.
#
# ref: https://github.com/jupyterhub/action-k3s-helm#readme
#
- uses: jupyterhub/action-k3s-helm@v3
with:
k3s-channel: ${{ matrix.k3s-channel }}
helm-version: ${{ matrix.helm-version }}
metrics-enabled: false
traefik-enabled: false
docker-enabled: false
- run: helm dependency update
# Validate rendered helm templates against the k8s api-server
- name: helm template --validate
run: |
helm template --validate mastodon . \
--values dev-values.yaml
- name: helm install
run: |
helm install mastodon . \
--values dev-values.yaml \
--timeout 10m
# This actions provides a report about the state of the k8s cluster,
# providing logs etc on anything that has failed and workloads marked as
# important.
#
# ref: https://github.com/jupyterhub/action-k8s-namespace-report#readme
#
- name: Kubernetes namespace report
uses: jupyterhub/action-k8s-namespace-report@v1
if: always()
with:
important-workloads: >-
deploy/mastodon-sidekiq
deploy/mastodon-streaming
deploy/mastodon-web
job/mastodon-assets-precompile
job/mastodon-chewy-upgrade
job/mastodon-create-admin
job/mastodon-db-migrate

3
.gitignore vendored

@ -44,6 +44,9 @@
/redis
/elasticsearch
# ignore Helm charts
/chart/*.tgz
# ignore Helm dependency charts
/chart/charts/*.tgz

File diff suppressed because it is too large Load Diff

@ -3,7 +3,12 @@ Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [4.0.1] - 2022-11-14
### Fixed
- Fix nodes order being sometimes mangled when rewriting emoji ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20677))
## [4.0.0] - 2022-11-14
Some of the features in this release have been funded through the [NGI0 Discovery](https://nlnet.nl/discovery) Fund, a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 825322.
@ -13,7 +18,7 @@ Some of the features in this release have been funded through the [NGI0 Discover
- **Add ability to follow hashtags** ([Gargron](https://github.com/mastodon/mastodon/pull/18809), [Gargron](https://github.com/mastodon/mastodon/pull/18862), [Gargron](https://github.com/mastodon/mastodon/pull/19472), [noellabo](https://github.com/mastodon/mastodon/pull/18924))
- Add ability to filter individual posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18945))
- **Add ability to translate posts** ([Gargron](https://github.com/mastodon/mastodon/pull/19218), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19433), [Gargron](https://github.com/mastodon/mastodon/pull/19453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19434), [Gargron](https://github.com/mastodon/mastodon/pull/19388), [ykzts](https://github.com/mastodon/mastodon/pull/19244), [Gargron](https://github.com/mastodon/mastodon/pull/19245))
- Add featured tags to web UI ([noellabo](https://github.com/mastodon/mastodon/pull/19408), [noellabo](https://github.com/mastodon/mastodon/pull/19380), [noellabo](https://github.com/mastodon/mastodon/pull/19358), [noellabo](https://github.com/mastodon/mastodon/pull/19409), [Gargron](https://github.com/mastodon/mastodon/pull/19382), [ykzts](https://github.com/mastodon/mastodon/pull/19418), [noellabo](https://github.com/mastodon/mastodon/pull/19403), [noellabo](https://github.com/mastodon/mastodon/pull/19404), [Gargron](https://github.com/mastodon/mastodon/pull/19398), [Gargron](https://github.com/mastodon/mastodon/pull/19712))
- Add featured tags to web UI ([noellabo](https://github.com/mastodon/mastodon/pull/19408), [noellabo](https://github.com/mastodon/mastodon/pull/19380), [noellabo](https://github.com/mastodon/mastodon/pull/19358), [noellabo](https://github.com/mastodon/mastodon/pull/19409), [Gargron](https://github.com/mastodon/mastodon/pull/19382), [ykzts](https://github.com/mastodon/mastodon/pull/19418), [noellabo](https://github.com/mastodon/mastodon/pull/19403), [noellabo](https://github.com/mastodon/mastodon/pull/19404), [Gargron](https://github.com/mastodon/mastodon/pull/19398), [Gargron](https://github.com/mastodon/mastodon/pull/19712), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20018))
- **Add support for language preferences for trending statuses and links** ([Gargron](https://github.com/mastodon/mastodon/pull/18288), [Gargron](https://github.com/mastodon/mastodon/pull/19349), [ykzts](https://github.com/mastodon/mastodon/pull/19335))
- Previously, you could only see trends in your current language
- For less popular languages, that meant empty trends
@ -21,6 +26,7 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Add server rules to sign-up flow ([Gargron](https://github.com/mastodon/mastodon/pull/19296))
- Add privacy icons to report modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19190))
- Add `noopener` to links to remote profiles in web UI ([shleeable](https://github.com/mastodon/mastodon/pull/19014))
- Add option to open original page in dropdowns of remote content in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20299))
- Add warning for sensitive audio posts in web UI ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/17885))
- Add language attribute to posts in web UI ([tribela](https://github.com/mastodon/mastodon/pull/18544))
- Add support for uploading WebP files ([Saiv46](https://github.com/mastodon/mastodon/pull/18506))
@ -43,22 +49,27 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Add admin API for managing domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18247))
- Add admin API for managing e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19066))
- Add admin API for managing canonical e-mail blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19067))
- Add admin API for managing IP blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19065))
- Add admin API for managing IP blocks ([Gargron](https://github.com/mastodon/mastodon/pull/19065), [trwnh](https://github.com/mastodon/mastodon/pull/20207))
- Add `sensitized` attribute to accounts in admin REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20094))
- Add `services` and `metadata` to the NodeInfo endpoint ([MFTabriz](https://github.com/mastodon/mastodon/pull/18563))
- Add `--remove-role` option to `tootctl accounts modify` ([Gargron](https://github.com/mastodon/mastodon/pull/19477))
- Add `--days` option to `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/18425))
- Add `EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18642))
- Add `IP_RETENTION_PERIOD` and `SESSION_RETENTION_PERIOD` environment variables ([kescherCode](https://github.com/mastodon/mastodon/pull/18757))
- Add `http_hidden_proxy` environment variable ([tribela](https://github.com/mastodon/mastodon/pull/18427))
- Add caching for payload serialization during fan-out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19637), [Gargron](https://github.com/mastodon/mastodon/pull/19642), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19746), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19747))
- Add `ENABLE_STARTTLS` environment variable ([erbridge](https://github.com/mastodon/mastodon/pull/20321))
- Add caching for payload serialization during fan-out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19637), [Gargron](https://github.com/mastodon/mastodon/pull/19642), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19746), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19747), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19963))
- Add assets from Twemoji 14.0 ([Gargron](https://github.com/mastodon/mastodon/pull/19733))
- Add reputation and followers score boost to SQL-only account search ([Gargron](https://github.com/mastodon/mastodon/pull/19251))
- Add Scots, Balaibalan, Láadan, Lingua Franca Nova, Lojban, Toki Pona to languages list ([VyrCossont](https://github.com/mastodon/mastodon/pull/20168))
- Set autocomplete hints for e-mail, password and OTP fields ([rcombs](https://github.com/mastodon/mastodon/pull/19833), [offbyone](https://github.com/mastodon/mastodon/pull/19946), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20071))
- Add support for DigitalOcean Spaces in setup wizard ([v-aisac](https://github.com/mastodon/mastodon/pull/20573))
### Changed
- **Change brand color and logotypes** ([Gargron](https://github.com/mastodon/mastodon/pull/18592), [Gargron](https://github.com/mastodon/mastodon/pull/18639), [Gargron](https://github.com/mastodon/mastodon/pull/18691), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18634), [Gargron](https://github.com/mastodon/mastodon/pull/19254), [mayaeh](https://github.com/mastodon/mastodon/pull/18710))
- **Change post editing to be enabled in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/19103))
- **Change web UI to work for logged-out users** ([Gargron](https://github.com/mastodon/mastodon/pull/18961), [Gargron](https://github.com/mastodon/mastodon/pull/19250), [Gargron](https://github.com/mastodon/mastodon/pull/19294), [Gargron](https://github.com/mastodon/mastodon/pull/19306), [Gargron](https://github.com/mastodon/mastodon/pull/19315), [ykzts](https://github.com/mastodon/mastodon/pull/19322), [Gargron](https://github.com/mastodon/mastodon/pull/19412), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19437), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19415), [Gargron](https://github.com/mastodon/mastodon/pull/19348), [Gargron](https://github.com/mastodon/mastodon/pull/19295), [Gargron](https://github.com/mastodon/mastodon/pull/19422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19414), [Gargron](https://github.com/mastodon/mastodon/pull/19319), [Gargron](https://github.com/mastodon/mastodon/pull/19345), [Gargron](https://github.com/mastodon/mastodon/pull/19310), [Gargron](https://github.com/mastodon/mastodon/pull/19301), [Gargron](https://github.com/mastodon/mastodon/pull/19423), [ykzts](https://github.com/mastodon/mastodon/pull/19471), [ykzts](https://github.com/mastodon/mastodon/pull/19333), [ykzts](https://github.com/mastodon/mastodon/pull/19337), [ykzts](https://github.com/mastodon/mastodon/pull/19272), [ykzts](https://github.com/mastodon/mastodon/pull/19468), [Gargron](https://github.com/mastodon/mastodon/pull/19466), [Gargron](https://github.com/mastodon/mastodon/pull/19457), [Gargron](https://github.com/mastodon/mastodon/pull/19426), [Gargron](https://github.com/mastodon/mastodon/pull/19427), [Gargron](https://github.com/mastodon/mastodon/pull/19421), [Gargron](https://github.com/mastodon/mastodon/pull/19417), [Gargron](https://github.com/mastodon/mastodon/pull/19413), [Gargron](https://github.com/mastodon/mastodon/pull/19397), [Gargron](https://github.com/mastodon/mastodon/pull/19387), [Gargron](https://github.com/mastodon/mastodon/pull/19396), [Gargron](https://github.com/mastodon/mastodon/pull/19385), [ykzts](https://github.com/mastodon/mastodon/pull/19334), [ykzts](https://github.com/mastodon/mastodon/pull/19329), [Gargron](https://github.com/mastodon/mastodon/pull/19324), [Gargron](https://github.com/mastodon/mastodon/pull/19318), [Gargron](https://github.com/mastodon/mastodon/pull/19316), [Gargron](https://github.com/mastodon/mastodon/pull/19263), [trwnh](https://github.com/mastodon/mastodon/pull/19305), [ykzts](https://github.com/mastodon/mastodon/pull/19273), [Gargron](https://github.com/mastodon/mastodon/pull/19801), [Gargron](https://github.com/mastodon/mastodon/pull/19790), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19773), [Gargron](https://github.com/mastodon/mastodon/pull/19798), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19724), [Gargron](https://github.com/mastodon/mastodon/pull/19709), [Gargron](https://github.com/mastodon/mastodon/pull/19514), [Gargron](https://github.com/mastodon/mastodon/pull/19562))
- **Change web UI to work for logged-out users** ([Gargron](https://github.com/mastodon/mastodon/pull/18961), [Gargron](https://github.com/mastodon/mastodon/pull/19250), [Gargron](https://github.com/mastodon/mastodon/pull/19294), [Gargron](https://github.com/mastodon/mastodon/pull/19306), [Gargron](https://github.com/mastodon/mastodon/pull/19315), [ykzts](https://github.com/mastodon/mastodon/pull/19322), [Gargron](https://github.com/mastodon/mastodon/pull/19412), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19437), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19415), [Gargron](https://github.com/mastodon/mastodon/pull/19348), [Gargron](https://github.com/mastodon/mastodon/pull/19295), [Gargron](https://github.com/mastodon/mastodon/pull/19422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19414), [Gargron](https://github.com/mastodon/mastodon/pull/19319), [Gargron](https://github.com/mastodon/mastodon/pull/19345), [Gargron](https://github.com/mastodon/mastodon/pull/19310), [Gargron](https://github.com/mastodon/mastodon/pull/19301), [Gargron](https://github.com/mastodon/mastodon/pull/19423), [ykzts](https://github.com/mastodon/mastodon/pull/19471), [ykzts](https://github.com/mastodon/mastodon/pull/19333), [ykzts](https://github.com/mastodon/mastodon/pull/19337), [ykzts](https://github.com/mastodon/mastodon/pull/19272), [ykzts](https://github.com/mastodon/mastodon/pull/19468), [Gargron](https://github.com/mastodon/mastodon/pull/19466), [Gargron](https://github.com/mastodon/mastodon/pull/19457), [Gargron](https://github.com/mastodon/mastodon/pull/19426), [Gargron](https://github.com/mastodon/mastodon/pull/19427), [Gargron](https://github.com/mastodon/mastodon/pull/19421), [Gargron](https://github.com/mastodon/mastodon/pull/19417), [Gargron](https://github.com/mastodon/mastodon/pull/19413), [Gargron](https://github.com/mastodon/mastodon/pull/19397), [Gargron](https://github.com/mastodon/mastodon/pull/19387), [Gargron](https://github.com/mastodon/mastodon/pull/19396), [Gargron](https://github.com/mastodon/mastodon/pull/19385), [ykzts](https://github.com/mastodon/mastodon/pull/19334), [ykzts](https://github.com/mastodon/mastodon/pull/19329), [Gargron](https://github.com/mastodon/mastodon/pull/19324), [Gargron](https://github.com/mastodon/mastodon/pull/19318), [Gargron](https://github.com/mastodon/mastodon/pull/19316), [Gargron](https://github.com/mastodon/mastodon/pull/19263), [trwnh](https://github.com/mastodon/mastodon/pull/19305), [ykzts](https://github.com/mastodon/mastodon/pull/19273), [Gargron](https://github.com/mastodon/mastodon/pull/19801), [Gargron](https://github.com/mastodon/mastodon/pull/19790), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19773), [Gargron](https://github.com/mastodon/mastodon/pull/19798), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19724), [Gargron](https://github.com/mastodon/mastodon/pull/19709), [Gargron](https://github.com/mastodon/mastodon/pull/19514), [Gargron](https://github.com/mastodon/mastodon/pull/19562), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19981), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19978), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20148), [Gargron](https://github.com/mastodon/mastodon/pull/20302), [cutls](https://github.com/mastodon/mastodon/pull/20400))
- The web app can now be accessed without being logged in
- No more `/web` prefix on web app paths
- Profiles, posts, and other public pages now use the same interface for logged in and logged out users
@ -74,14 +85,13 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Change label of publish button to be "Publish" again in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18583))
- Change language to be carried over on reply in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18557))
- Change "Unfollow" to "Cancel follow request" when request still pending in web UI ([prplecake](https://github.com/mastodon/mastodon/pull/19363))
- **Change post filtering system** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18058), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19050), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18894), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19051), [noellabo](https://github.com/mastodon/mastodon/pull/18923), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18744))
- **Change post filtering system** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18058), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19050), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18894), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19051), [noellabo](https://github.com/mastodon/mastodon/pull/18923), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18744), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/19878), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/20567))
- Filtered keywords and phrases can now be grouped into named categories
- Filtered posts show which exact filter was hit
- Individual posts can be added to a filter
- You can peek inside filtered posts anyway
- Change path of privacy policy page from `/terms` to `/privacy-policy` ([Gargron](https://github.com/mastodon/mastodon/pull/19249))
- Change how hashtags are normalized ([Gargron](https://github.com/mastodon/mastodon/pull/18795), [Gargron](https://github.com/mastodon/mastodon/pull/18863), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18854))
- Change public (but not hashtag) timelines to be filtered by current locale by default ([Gargron](https://github.com/mastodon/mastodon/pull/19291), [Gargron](https://github.com/mastodon/mastodon/pull/19563))
- Change settings area to be separated into categories in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/19407), [Gargron](https://github.com/mastodon/mastodon/pull/19533))
- Change "No accounts selected" errors to use the appropriate noun in admin UI ([prplecake](https://github.com/mastodon/mastodon/pull/19356))
- Change e-mail domain blocks to match subdomains of blocked domains ([Gargron](https://github.com/mastodon/mastodon/pull/18979))
@ -95,6 +105,14 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Change mentions of blocked users to not be processed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19725))
- Change max. thumbnail dimensions to 640x360px (360p) ([Gargron](https://github.com/mastodon/mastodon/pull/19619))
- Change post-processing to be deferred only for large media types ([Gargron](https://github.com/mastodon/mastodon/pull/19617))
- Change link verification to only work for https links without unicode ([Gargron](https://github.com/mastodon/mastodon/pull/20304), [Gargron](https://github.com/mastodon/mastodon/pull/20295))
- Change account deletion requests to spread out over time ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20222))
- Change larger reblogs/favourites numbers to be shortened in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/20303))
- Change incoming activity processing to happen in `ingress` queue ([Gargron](https://github.com/mastodon/mastodon/pull/20264))
- Change notifications to not link show preview cards in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20335))
- Change amount of replies returned for logged out users in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20355))
- Change in-app links to keep you in-app in web UI ([trwnh](https://github.com/mastodon/mastodon/pull/20540), [Gargron](https://github.com/mastodon/mastodon/pull/20628))
- Change table header to be sticky in admin UI ([sk22](https://github.com/mastodon/mastodon/pull/20442))
### Removed
@ -107,6 +125,28 @@ Some of the features in this release have been funded through the [NGI0 Discover
### Fixed
- Fix rules with same priority being sorted non-deterministically ([Gargron](https://github.com/mastodon/mastodon/pull/20623))
- Fix error when invalid domain name is submitted ([Gargron](https://github.com/mastodon/mastodon/pull/19474))
- Fix icons having an image role ([Gargron](https://github.com/mastodon/mastodon/pull/20600))
- Fix connections to IPv6-only servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20108))
- Fix unnecessary service worker registration and preloading when logged out in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20341))
- Fix unnecessary and slow regex construction ([raggi](https://github.com/mastodon/mastodon/pull/20215))
- Fix `mailers` queue not being used for mailers ([Gargron](https://github.com/mastodon/mastodon/pull/20274))
- Fix error in webfinger redirect handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20260))
- Fix report category not being set to `violation` if rule IDs are provided ([trwnh](https://github.com/mastodon/mastodon/pull/20137))
- Fix nodeinfo metadata attribute being an array instead of an object ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20114))
- Fix account endorsements not being idempotent ([trwnh](https://github.com/mastodon/mastodon/pull/20118))
- Fix status and rule IDs not being strings in admin reports REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20122))
- Fix error on invalid `replies_policy` in REST API ([trwnh](https://github.com/mastodon/mastodon/pull/20126))
- Fix redrafting a currently-editing post not leaving edit mode in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20023))
- Fix performance by avoiding method cache busts ([raggi](https://github.com/mastodon/mastodon/pull/19957))
- Fix opening the language picker scrolling the single-column view to the top in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19983))
- Fix content warning button missing `aria-expanded` attribute in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19975))
- Fix redundant `aria-pressed` attributes in web UI ([Brawaru](https://github.com/mastodon/mastodon/pull/19912))
- Fix crash when external auth provider has no display name set ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19962))
- Fix followers count not being updated when migrating follows ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19998))
- Fix double button to clear emoji search input in web UI ([sunny](https://github.com/mastodon/mastodon/pull/19888))
- Fix missing null check on applications on strike disputes ([kescherCode](https://github.com/mastodon/mastodon/pull/19851))
- Fix featured tags not saving preferred casing ([Gargron](https://github.com/mastodon/mastodon/pull/19732))
- Fix language not being saved when editing status ([Gargron](https://github.com/mastodon/mastodon/pull/19543))
- Fix not being able to input featured tag with hash symbol ([Gargron](https://github.com/mastodon/mastodon/pull/19535))
@ -118,7 +158,7 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Fix account action type validation ([Gargron](https://github.com/mastodon/mastodon/pull/19476))
- Fix upload progress not communicating processing phase in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19530))
- Fix wrong host being used for custom.css when asset host configured ([Gargron](https://github.com/mastodon/mastodon/pull/19521))
- Fix account migration form ever using outdated account data ([Gargron](https://github.com/mastodon/mastodon/pull/18429))
- Fix account migration form ever using outdated account data ([Gargron](https://github.com/mastodon/mastodon/pull/18429), [nightpool](https://github.com/mastodon/mastodon/pull/19883))
- Fix error when uploading malformed CSV import ([Gargron](https://github.com/mastodon/mastodon/pull/19509))
- Fix avatars not using image tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/19488))
- Fix handling of duplicate and out-of-order notifications in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19693))
@ -156,6 +196,15 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Fix `CDN_HOST` not being used in some asset URLs ([tribela](https://github.com/mastodon/mastodon/pull/18662))
- Fix `CAS_DISPLAY_NAME`, `SAML_DISPLAY_NAME` and `OIDC_DISPLAY_NAME` being ignored ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18568))
- Fix various typos in comments throughout the codebase ([luzpaz](https://github.com/mastodon/mastodon/pull/18604))
- Fix CSV import error when rows include unicode characters ([HamptonMakes](https://github.com/mastodon/mastodon/pull/20592))
### Security
- Fix being able to spoof link verification ([Gargron](https://github.com/mastodon/mastodon/pull/20217))
- Fix emoji substitution not applying only to text nodes in backend code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20641))
- Fix emoji substitution not applying only to text nodes in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20640))
- Fix rate limiting for paths with formats ([Gargron](https://github.com/mastodon/mastodon/pull/20675))
- Fix out-of-bound reads in blurhash transcoder ([delroth](https://github.com/mastodon/mastodon/pull/20388))
## [3.5.3] - 2022-05-26
### Added
@ -276,7 +325,7 @@ Some of the features in this release have been funded through the [NGI0 Discover
### Fixed
- Fix error resposes for `from` search prefix ([single-right-quote](https://github.com/mastodon/mastodon/pull/17963))
- Fix error responses for `from` search prefix ([single-right-quote](https://github.com/mastodon/mastodon/pull/17963))
- Fix dangling language-specific trends ([Gargron](https://github.com/mastodon/mastodon/pull/17997))
- Fix extremely rare race condition when deleting a status or account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17994))
- Fix trends returning less results per page when filtered in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17996))
@ -411,7 +460,7 @@ Some of the features in this release have been funded through the [NGI0 Discover
- Remove profile directory link from main navigation panel in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17688))
- **Remove language detection through cld3** ([Gargron](https://github.com/mastodon/mastodon/pull/17478), [ykzts](https://github.com/mastodon/mastodon/pull/17539), [Gargron](https://github.com/mastodon/mastodon/pull/17496), [Gargron](https://github.com/mastodon/mastodon/pull/17722))
- cld3 is very inaccurate on short-form content even with unique alphabets
- Post language can be overriden individually using `language` param
- Post language can be overridden individually using `language` param
- Otherwise, it defaults to the user's interface language
- Remove support for `OAUTH_REDIRECT_AT_SIGN_IN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17287))
- Use `OMNIAUTH_ONLY` instead

@ -76,6 +76,8 @@ It is not always possible to phrase every change in such a manner, but it is des
- Code style rules (rubocop, eslint)
- Normalization of locale files (i18n-tasks)
**Note**: You may need to log in and authorise the GitHub account your fork of this repository belongs to with CircleCI to enable some of the automated checks to run.
## Documentation
The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation).

@ -92,7 +92,7 @@ gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2022'
gem 'webpacker', '~> 5.4'
gem 'webpush', git: 'https://github.com/ClearlyClaire/webpush.git', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9'
gem 'webpush', github: 'ClearlyClaire/webpush', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9'
gem 'webauthn', '~> 2.5'
gem 'json-ld'
@ -122,6 +122,7 @@ group :test do
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.18'
gem 'rspec_junit_formatter', '~> 0.6'
gem 'rack-test', '~> 2.0'
end
group :development do
@ -152,7 +153,6 @@ end
gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'hcaptcha', '~> 7.1'

@ -818,6 +818,7 @@ DEPENDENCIES
rack (~> 2.2.4)
rack-attack (~> 6.6)
rack-cors (~> 1.1)
rack-test (~> 2.0)
rails (~> 6.1.7)
rails-controller-testing (~> 1.0)
rails-i18n (~> 6.0)

@ -1,6 +1,6 @@
# Security Policy
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at <hello@joinmastodon.org>.
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at <security@joinmastodon.org>.
You should *not* report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.

@ -79,8 +79,13 @@
"description": "SMTP server certificate verification mode. Defaults is 'peer'.",
"required": false
},
"SMTP_ENABLE_STARTTLS": {
"description": "Enable STARTTLS? Default is 'auto'.",
"value": "auto",
"required": false
},
"SMTP_ENABLE_STARTTLS_AUTO": {
"description": "Enable STARTTLS if SMTP server supports it? Default is true.",
"description": "Enable STARTTLS if SMTP server supports it? Deprecated by SMTP_ENABLE_STARTTLS.",
"required": false
}
},

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::OtherController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_other_path
end
end

@ -57,7 +57,7 @@ class Api::BaseController < ApplicationController
render json: { error: I18n.t('errors.429') }, status: 429
end
rescue_from ActionController::ParameterMissing do |e|
rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e|
render json: { error: e.to_s }, status: 400
end

@ -8,7 +8,7 @@ class Api::V1::Accounts::PinsController < Api::BaseController
before_action :set_account
def create
AccountPin.create!(account: current_account, target_account: @account)
AccountPin.find_or_create_by!(account: current_account, target_account: @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end

@ -7,6 +7,10 @@ class Api::V1::ListsController < Api::BaseController
before_action :require_user!
before_action :set_list, except: [:index, :create]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def index
@lists = List.where(account: current_account).all
render json: @lists, each_serializer: REST::ListSerializer

@ -18,14 +18,29 @@ class Api::V1::StatusesController < Api::BaseController
# than this anyway
CONTEXT_LIMIT = 4_096
# This remains expensive and we don't want to show everything to logged-out users
ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20
def show
@status = cache_collection([@status], Status).first
render json: @status, serializer: REST::StatusSerializer
end
def context
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(CONTEXT_LIMIT, current_account)
descendants_results = @status.descendants(CONTEXT_LIMIT, current_account)
ancestors_limit = CONTEXT_LIMIT
descendants_limit = CONTEXT_LIMIT
descendants_depth_limit = nil
if current_account.nil?
ancestors_limit = ANCESTORS_LIMIT
descendants_limit = DESCENDANTS_LIMIT
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
end
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)

@ -24,7 +24,7 @@ class Api::V1::TagsController < Api::BaseController
private
def set_or_create_tag
return not_found unless /\A(#{Tag::HASHTAG_NAME_RE})\z/.match?(params[:id])
return not_found unless Tag::HASHTAG_NAME_RE.match?(params[:id])
@tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id])
end
end

@ -35,7 +35,6 @@ class Api::V1::Timelines::PublicController < Api::BaseController
def public_feed
PublicFeed.new(
current_account,
locale: content_locale,
local: truthy_param?(:local),
remote: truthy_param?(:remote),
only_media: truthy_param?(:only_media),

@ -33,7 +33,7 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
end
def filter_params
params.permit(*FILTER_PARAMS)
params.permit(*FILTER_PARAMS, role_ids: [])
end
def pagination_params(core_params)

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Api::V1::Filters::KeywordsController < Api::BaseController
class Api::V2::Filters::KeywordsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
before_action :require_user!

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Api::V1::Filters::StatusesController < Api::BaseController
class Api::V2::Filters::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
before_action :require_user!

@ -1,87 +0,0 @@
# frozen_string_literal: true
module StatusControllerConcern
extend ActiveSupport::Concern
ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20
def create_descendant_thread(starting_depth, statuses)
depth = starting_depth + statuses.size
if depth < DESCENDANTS_DEPTH_LIMIT
{
statuses: statuses,
starting_depth: starting_depth,
}
else
next_status = statuses.pop
{
statuses: statuses,
starting_depth: starting_depth,
next_status: next_status,
}
end
end
def set_ancestors
@ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
@next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
end
def set_descendants
@max_descendant_thread_id = params[:max_descendant_thread_id]&.to_i
@since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i
descendants = cache_collection(
@status.descendants(
DESCENDANTS_LIMIT,
current_account,
@max_descendant_thread_id,
@since_descendant_thread_id,
DESCENDANTS_DEPTH_LIMIT
),
Status
)
@descendant_threads = []
if descendants.present?
statuses = [descendants.first]
starting_depth = 0
descendants.drop(1).each_with_index do |descendant, index|
if descendants[index].id == descendant.in_reply_to_id
statuses << descendant
else
@descendant_threads << create_descendant_thread(starting_depth, statuses)
# The thread is broken, assume it's a reply to the root status
starting_depth = 0
# ... unless we can find its ancestor in one of the already-processed threads
@descendant_threads.reverse_each do |descendant_thread|
statuses = descendant_thread[:statuses]
index = statuses.find_index do |thread_status|
thread_status.id == descendant.in_reply_to_id
end
if index.present?
starting_depth = descendant_thread[:starting_depth] + index + 1
break
end
end
statuses = [descendant]
end
end
@descendant_threads << create_descendant_thread(starting_depth, statuses)
end
@max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT
end
end

@ -2,7 +2,6 @@
class StatusesController < ApplicationController
include WebAppControllerConcern
include StatusControllerConcern
include SignatureAuthentication
include Authorization
include AccountOwnedConcern

@ -1,4 +1,5 @@
# frozen_string_literal: true
# rubocop:disable Metrics/ModuleLength, Style/WordArray
module LanguagesHelper
ISO_639_1 = {
@ -189,8 +190,14 @@ module LanguagesHelper
ISO_639_3 = {
ast: ['Asturian', 'Asturianu'].freeze,
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze,
kab: ['Kabyle', 'Taqbaylit'].freeze,
kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze,
ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
sco: ['Scots', 'Scots'].freeze,
tok: ['Toki Pona', 'toki pona'].freeze,
zba: ['Balaibalan', 'باليبلن'].freeze,
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
}.freeze
@ -259,3 +266,5 @@ module LanguagesHelper
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
end
end
# rubocop:enable Metrics/ModuleLength, Style/WordArray

@ -43,7 +43,7 @@ export const fetchFilters = () => (dispatch, getState) => {
export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterStatusRequest());
api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
api(getState).post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => {
dispatch(createFilterStatusSuccess(response.data));
if (onSuccess) onSuccess();
}).catch(error => {

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedNumber } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'flavours/glitch/initial_state';
@ -51,7 +51,7 @@ export default class AnimatedNumber extends React.PureComponent {
const { direction } = this.state;
if (reduceMotion) {
return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
}
const styles = [{
@ -65,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}

@ -157,7 +157,6 @@ class ColumnHeader extends React.PureComponent {
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>

@ -18,7 +18,6 @@ export default class IconButton extends React.PureComponent {
onKeyPress: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
@ -111,7 +110,6 @@ export default class IconButton extends React.PureComponent {
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
counter,
@ -156,7 +154,6 @@ export default class IconButton extends React.PureComponent {
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}

@ -687,7 +687,7 @@ class Status extends ImmutablePureComponent {
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
background = attachments.getIn([0, 'preview_url']);
}
} else if (status.get('card') && settings.get('inline_preview_cards')) {
} else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
media.push(
<Card
onOpenMedia={this.handleOpenMedia}

@ -42,6 +42,7 @@ const messages = defineMessages({
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
export default @injectIntl
@ -182,22 +183,8 @@ class StatusActionBar extends ImmutablePureComponent {
}
handleCopy = () => {
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} finally {
document.body.removeChild(textarea);
}
const url = this.props.status.get('url');
navigator.clipboard.writeText(url);
}
handleHideClick = () => {
@ -216,6 +203,7 @@ class StatusActionBar extends ImmutablePureComponent {
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
let menu = [];
let reblogIcon = 'retweet';
@ -225,6 +213,9 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
@ -315,10 +306,10 @@ class StatusActionBar extends ImmutablePureComponent {
counter={showReplyCount ? status.get('replies_count') : undefined}
obfuscateCount
/>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{filterButton}

@ -1,62 +0,0 @@
import React, { Fragment } from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from 'flavours/glitch/store/configureStore';
import { hydrateStore } from 'flavours/glitch/actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from 'mastodon/locales';
import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
import initialState from 'flavours/glitch/initial_state';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
if (initialState) {
store.dispatch(hydrateStore(initialState));
}
export default class TimelineContainer extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
hashtag: PropTypes.string,
local: PropTypes.bool,
};
static defaultProps = {
local: !initialState.settings.known_fediverse,
};
render () {
const { locale, hashtag, local } = this.props;
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else {
timeline = <PublicTimeline local={local} />;
}
return (
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<Fragment>
{timeline}
{ReactDOM.createPortal(
<ModalContainer />,
document.getElementById('modal-container'),
)}
</Fragment>
</Provider>
</IntlProvider>
);
}
}

@ -183,25 +183,18 @@ class About extends React.PureComponent {
<>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<table className='about__domain-blocks'>
<thead>
<tr>
<th><FormattedMessage id='about.domain_blocks.domain' defaultMessage='Domain' /></th>
<th><FormattedMessage id='about.domain_blocks.severity' defaultMessage='Severity' /></th>
<th><FormattedMessage id='about.domain_blocks.comment' defaultMessage='Reason' /></th>
</tr>
</thead>
<tbody>
{domainBlocks.get('items').map(block => (
<tr key={block.get('domain')}>
<td><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></td>
<td><span title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span></td>
<td>{block.get('comment')}</td>
</tr>
))}
</tbody>
</table>
<div className='about__domain-blocks'>
{domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain' key={block.get('domain')}>
<div className='about__domain-blocks__domain__header'>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div>
))}
</div>
</>
) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>

@ -53,6 +53,7 @@ const messages = defineMessages({
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
const titleFromAccount = account => {
@ -97,6 +98,7 @@ class Header extends ImmutablePureComponent {
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -132,6 +134,13 @@ class Header extends ImmutablePureComponent {
}
}
handleAvatarClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onOpenAvatar();
}
}
render () {
const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity;
@ -142,7 +151,9 @@ class Header extends ImmutablePureComponent {
const accountNote = account.getIn(['relationship', 'note']);
const suspended = account.get('suspended');
const suspended = account.get('suspended');
const isRemote = account.get('acct') !== account.get('username');
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
let info = [];
let actionBtn = '';
@ -199,6 +210,11 @@ class Header extends ImmutablePureComponent {
menu.push(null);
}
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
menu.push(null);
}
if ('share' in navigator && !suspended) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null);
@ -253,15 +269,13 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
}
if (signedIn && account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
if (signedIn && isRemote) {
menu.push(null);
if (account.getIn(['relationship', 'domain_blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
} else {
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
}
}
@ -299,12 +313,10 @@ class Header extends ImmutablePureComponent {
<div className='account__header__bar'>
<div className='account__header__tabs'>
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
<Avatar account={suspended || hidden ? undefined : account} size={90} />
</a>
<div className='spacer' />
{!suspended && (
<div className='account__header__tabs__buttons'>
{!hidden && (

@ -25,6 +25,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -102,6 +103,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onInteractionModal(this.props.account);
}
handleOpenAvatar = () => {
this.props.onOpenAvatar(this.props.account);
}
render () {
const { account, hidden, hideTabs } = this.props;
@ -130,6 +135,7 @@ export default class Header extends ImmutablePureComponent {
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
onOpenAvatar={this.handleOpenAvatar}
domain={this.props.domain}
hidden={hidden}
/>

@ -161,6 +161,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onOpenAvatar (account) {
dispatch(openModal('IMAGE', {
src: account.get('avatar'),
alt: account.get('acct'),
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

@ -103,7 +103,7 @@ class ToggleOption extends ImmutablePureComponent {
<React.Fragment>
<Toggle checked={checked} onChange={this.handleChange} />
<div className='content'>
<div className='privacy-dropdown__option__content'>
<strong>{text}</strong>
{meta}
</div>

@ -18,7 +18,6 @@ export default class Upload extends ImmutablePureComponent {
media: ImmutablePropTypes.map.isRequired,
onUndo: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
isEditingStatus: PropTypes.bool.isRequired,
};
handleUndoClick = e => {
@ -32,7 +31,7 @@ export default class Upload extends ImmutablePureComponent {
}
render () {
const { intl, media, isEditingStatus } = this.props;
const { media } = this.props;
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
@ -45,10 +44,10 @@ export default class Upload extends ImmutablePureComponent {
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__actions'>
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{!isEditingStatus && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
{!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
</div>
{(media.get('description') || '').length === 0 && (
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
<div className='compose-form__upload__warning'>
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div>

@ -5,7 +5,6 @@ import { submitCompose } from 'flavours/glitch/actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
isEditingStatus: state.getIn(['compose', 'id']) !== null,
});
const mapDispatchToProps = dispatch => ({

@ -19,15 +19,26 @@ const emojiFilename = (filename) => {
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
};
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
const domParser = new DOMParser();
const emojifyTextNode = (node, customEmojis) => {
let str = node.textContent;
const fragment = new DocumentFragment();
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || useSystemEmojiFont || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
let match, i = 0;
if (customEmojis === null) {
while (i < str.length && (useSystemEmojiFont || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
} else {
while (i < str.length && str[i] !== ':' && (useSystemEmojiFont || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
}
let rend, replacement = '';
if (i === str.length) {
break;
@ -35,8 +46,6 @@ const emojify = (str, customEmojis = {}) => {
if (!(() => {
rend = str.indexOf(':', i + 1) + 1;
if (!rend) return false; // no pair of ':'
const lt = str.indexOf('<', i + 1);
if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
const shortname = str.slice(i, rend);
// now got a replacee as ':shortname:'
// if you want additional emoji handler, add statements below which set replacement and return true.
@ -47,29 +56,6 @@ const emojify = (str, customEmojis = {}) => {
}
return false;
})()) rend = ++i;
} else if (tag >= 0) { // <, &
rend = str.indexOf('>;'[tag], i + 1) + 1;
if (!rend) {
break;
}
if (tag === 0) {
if (invisible) {
if (str[i + 1] === '/') { // closing tag
if (!--invisible) {
tagChars = tagCharsWithEmojis;
}
} else if (str[rend - 2] !== '/') { // opening tag
invisible++;
}
} else {
if (str.startsWith('<span class="invisible">', i)) {
// avoid emojifying on invisible text
invisible = 1;
tagChars = tagCharsWithoutEmojis;
}
}
}
i = rend;
} else if (!useSystemEmojiFont) { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
@ -80,10 +66,43 @@ const emojify = (str, customEmojis = {}) => {
rend += 1;
}
}
rtn += str.slice(0, i) + replacement;
fragment.append(document.createTextNode(str.slice(0, i)));
if (replacement) {
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
}
node.textContent = str.slice(0, i);
str = str.slice(rend);
}
return rtn + str;
fragment.append(document.createTextNode(str));
node.parentElement.replaceChild(fragment, node);
};
const emojifyNode = (node, customEmojis) => {
for (const child of node.childNodes) {
switch(child.nodeType) {
case Node.TEXT_NODE:
emojifyTextNode(child, customEmojis);
break;
case Node.ELEMENT_NODE:
if (!child.classList.contains('invisible'))
emojifyNode(child, customEmojis);
break;
}
}
};
const emojify = (str, customEmojis = {}) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;
if (!Object.keys(customEmojis).length)
customEmojis = null;
emojifyNode(wrapper, customEmojis);
return wrapper.innerHTML;
};
export default emojify;

@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following');
followButton = (
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}>
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
</button>
);

@ -131,7 +131,6 @@ class HomeTimeline extends React.PureComponent {
className={classNames('column-header__button', { 'active': showAnnouncements })}
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-pressed={showAnnouncements ? 'true' : 'false'}
onClick={this.handleToggleAnnouncementsClick}
>
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />

@ -150,7 +150,7 @@ class InteractionModal extends React.PureComponent {
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Simply copy and paste this URL into the search bar of your favourite app or the web interface where you are signed in.' /></p>
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Copy and paste this URL into the search field of your favourite Mastodon app or the web interface of your Mastodon server.' /></p>
<Copypaste value={url} />
</div>
</div>

@ -207,8 +207,8 @@ class Footer extends ImmutablePureComponent {
return (
<div className='picture-in-picture__footer'>
{replyButton}
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
</div>
);

@ -35,6 +35,7 @@ const messages = defineMessages({
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
export default @injectIntl
@ -132,22 +133,8 @@ class ActionBar extends React.PureComponent {
}
handleCopy = () => {
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} finally {
document.body.removeChild(textarea);
}
const url = this.props.status.get('url');
navigator.clipboard.writeText(url);
}
render () {
@ -158,10 +145,15 @@ class ActionBar extends React.PureComponent {
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
let menu = [];
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);

@ -648,7 +648,7 @@ class Status extends ImmutablePureComponent {
showBackButton
multiColumn={multiColumn}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
<button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
)}
/>

@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class ImageModal extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
navigationHidden: false,
};
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
render () {
const { intl, src, alt, onClose } = this.props;
const { navigationHidden } = this.state;
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<ImageLoader
src={src}
width={400}
height={400}
alt={alt}
onClick={this.toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
</div>
</div>
);
}
}

@ -15,6 +15,7 @@ import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import DeprecatedSettingsModal from './deprecated_settings_modal';
import ImageModal from './image_modal';
import {
OnboardingModal,
MuteModal,
@ -38,6 +39,7 @@ const MODAL_COMPONENTS = {
'ONBOARDING': OnboardingModal,
'VIDEO': () => Promise.resolve({ default: VideoModal }),
'AUDIO': () => Promise.resolve({ default: AudioModal }),
'IMAGE': () => Promise.resolve({ default: ImageModal }),
'BOOST': () => Promise.resolve({ default: BoostModal }),
'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
'DOODLE': () => Promise.resolve({ default: DoodleModal }),

@ -303,7 +303,7 @@ class UI extends React.Component {
this.dragTargets.push(e.target);
}
if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
this.setState({ draggingOver: true });
}
}
@ -330,7 +330,7 @@ class UI extends React.Component {
this.setState({ draggingOver: false });
this.dragTargets = [];
if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) {
if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore && this.context.identity.signedIn) {
this.props.dispatch(uploadCompose(e.dataTransfer.files));
}
}

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
import Mastodon, { store } from 'flavours/glitch/containers/mastodon';
import { me } from 'flavours/glitch/initial_state';
import ready from 'flavours/glitch/ready';
const perf = require('flavours/glitch/performance');
@ -19,23 +20,19 @@ function main() {
ReactDOM.render(<Mastodon {...props} />, mountNode);
store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
const [{ Workbox }, { me }] = await Promise.all([
import('workbox-window'),
import('flavours/glitch/initial_state'),
]);
if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window');
const wb = new Workbox('/sw.js');
/** @type {ServiceWorkerRegistration} */
let registration;
try {
await wb.register();
registration = await wb.register();
} catch (err) {
console.error(err);
return;
}
if (me) {
if (registration) {
const registerPushNotifications = await import('flavours/glitch/actions/push_notifications');
store.dispatch(registerPushNotifications.register());

@ -21,3 +21,12 @@ es:
skins:
glitch:
default: Predeterminado
ja:
flavours:
glitch:
description: GlitchSocインスタンスのデフォルトフレーバーです。
name: Glitch Edition
skins:
glitch:
default: デフォルト

@ -222,7 +222,7 @@ function appendMedia(state, media, file) {
if (media.get('type') === 'image') {
media = media.set('file', file);
}
map.update('media_attachments', list => list.push(media));
map.update('media_attachments', list => list.push(media.set('unattached', true)));
map.set('is_uploading', false);
map.set('is_processing', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
@ -547,7 +547,7 @@ export default function compose(state = initialState, action) {
.setIn(['media_modal', 'dirty'], false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return fromJS(action.media);
return fromJS(action.media).set('unattached', true);
}
return item;
@ -563,7 +563,7 @@ export default function compose(state = initialState, action) {
map.set('content_type', action.content_type || 'text/plain');
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());

@ -204,7 +204,9 @@
}
.account-role,
.simple_form .recommended {
.simple_form .recommended,
.simple_form .not_recommended,
.simple_form .glitch_only {
display: inline-block;
padding: 4px 6px;
cursor: default;
@ -229,6 +231,18 @@
}
}
.simple_form .not_recommended {
color: lighten($error-red, 12%);
background-color: rgba(lighten($error-red, 12%), 0.1);
border-color: rgba(lighten($error-red, 12%), 0.5);
}
.simple_form .glitch_only {
color: lighten($warning-red, 12%);
background-color: rgba(lighten($warning-red, 12%), 0.1);
border-color: rgba(lighten($warning-red, 12%), 0.5);
}
.account__header__fields {
max-width: 100vw;
padding: 0;

@ -247,28 +247,45 @@
&__domain-blocks {
margin-top: 30px;
width: 100%;
border-collapse: collapse;
break-inside: auto;
background: darken($ui-base-color, 4%);
border: 1px solid lighten($ui-base-color, 4%);
border-radius: 4px;
th {
text-align: left;
font-weight: 500;
&__domain {
border-bottom: 1px solid lighten($ui-base-color, 4%);
padding: 10px;
font-size: 15px;
color: $darker-text-color;
}
thead tr,
tbody tr {
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
&:nth-child(2n) {
background: darken($ui-base-color, 2%);
}
tbody tr:last-child {
border-bottom: 0;
}
&:last-child {
border-bottom: 0;
}
&__header {
display: flex;
gap: 10px;
justify-content: space-between;
font-weight: 500;
margin-bottom: 4px;
}
th,
td {
padding: 8px;
h6 {
color: $secondary-text-color;
font-size: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

@ -535,8 +535,11 @@
&__tabs {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 7px 10px;
margin-top: -55px;
gap: 8px;
overflow: hidden;
&__buttons {
display: flex;
@ -545,6 +548,15 @@
padding-top: 55px;
overflow: hidden;
.button {
flex-shrink: 1;
white-space: nowrap;
@media screen and (max-width: $no-gap-breakpoint) {
min-width: 0;
}
}
.icon-button {
border: 1px solid lighten($ui-base-color, 12%);
border-radius: 4px;

@ -659,6 +659,7 @@
display: inline-block;
font-weight: 500;
font-size: 12px;
line-height: 17px;
margin-left: 6px;
}

@ -103,7 +103,9 @@ code {
}
}
.recommended {
.recommended,
.not_recommended,
.glitch_only {
position: absolute;
margin: 0 4px;
margin-top: -2px;
@ -1058,11 +1060,18 @@ code {
&:last-child {
border-bottom: 0;
padding-bottom: 0;
}
}
}
// Only remove padding when listing applications, to prevent styling issues on
// the Authorization page.
.applications-list {
.permissions-list__item:last-child {
padding-bottom: 0;
}
}
.keywords-table {
thead {
th {

@ -78,7 +78,7 @@ html {
.column-header__back-button,
.column-header__button,
.column-header__button.active,
.account__header__bar {
.account__header {
background: $white;
}
@ -203,7 +203,8 @@ html {
// Change the colors used in compose-form
.compose-form {
.compose-form__modifiers {
.compose-form__upload__actions .icon-button {
.compose-form__upload__actions .icon-button,
.compose-form__upload__warning .icon-button {
color: lighten($white, 7%);
&:active,
@ -212,14 +213,6 @@ html {
color: $white;
}
}
.compose-form__upload-description input {
color: lighten($white, 7%);
&::placeholder {
color: lighten($white, 7%);
}
}
}
.compose-form__buttons-wrapper {

@ -178,6 +178,9 @@ a.table-action-link {
}
&__toolbar {
position: sticky;
top: 0;
z-index: 1;
border: 1px solid darken($ui-base-color, 8%);
background: $ui-base-color;
border-radius: 4px 0 0;

@ -22,3 +22,12 @@ es:
skins:
vanilla:
default: Predeterminado
ja:
flavours:
vanilla:
description: バニラのMastodonインスタンスで使われるテーマです。このテーマはGlitchSocのすべての機能をサポートしない可能性があります。
name: Vanilla Mastodon
skins:
vanilla:
default: デフォルト

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"><symbol id="mastodon-svg-logo" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" /></symbol></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -43,7 +43,7 @@ export const fetchFilters = () => (dispatch, getState) => {
export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterStatusRequest());
api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
api(getState).post(`/api/v2/filters/${params.filter_id}/statuses`, params).then(response => {
dispatch(createFilterStatusSuccess(response.data));
if (onSuccess) onSuccess();
}).catch(error => {

@ -3,13 +3,13 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import DisplayName from './display_name';
import Permalink from './permalink';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
import { Link } from 'react-router-dom';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -140,11 +140,11 @@ class Account extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
{mute_expires_at}
<DisplayName account={account} />
</Permalink>
</Link>
<div className='account__relationship'>
{buttons}

@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
<Hashtag
key={hashtag.name}
name={hashtag.name}
href={`/admin/tags/${hashtag.id}`}
to={`/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedNumber } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state';
@ -51,7 +51,7 @@ export default class AnimatedNumber extends React.PureComponent {
const { direction } = this.state;
if (reduceMotion) {
return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
}
const styles = [{
@ -65,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}

@ -152,7 +152,6 @@ class ColumnHeader extends React.PureComponent {
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>

@ -4,7 +4,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import { Link } from 'react-router-dom';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import classNames from 'classnames';
@ -53,7 +53,6 @@ export const accountsCountRenderer = (displayNumber, pluralReady) => (
export const ImmutableHashtag = ({ hashtag }) => (
<Hashtag
name={hashtag.get('name')}
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
@ -64,12 +63,12 @@ ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => (
const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Permalink href={href} to={to}>
<Link to={to}>
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
</Permalink>
</Link>
{description ? (
<span>{description}</span>
@ -98,7 +97,6 @@ const Hashtag = ({ name, href, to, people, uses, history, className, description
Hashtag.propTypes = {
name: PropTypes.string,
href: PropTypes.string,
to: PropTypes.string,
people: PropTypes.number,
description: PropTypes.node,

@ -14,7 +14,7 @@ export default class Icon extends React.PureComponent {
const { id, className, fixedWidth, ...other } = this.props;
return (
<i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
<i className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
);
}

@ -16,7 +16,6 @@ export default class IconButton extends React.PureComponent {
onKeyPress: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
@ -98,7 +97,6 @@ export default class IconButton extends React.PureComponent {
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
counter,
@ -143,7 +141,6 @@ export default class IconButton extends React.PureComponent {
<button
type='button'
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}

@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class Permalink extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
className: PropTypes.string,
href: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
children: PropTypes.node,
onInterceptClick: PropTypes.func,
};
handleClick = e => {
if (this.props.onInterceptClick && this.props.onInterceptClick()) {
e.preventDefault();
return;
}
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(this.props.to);
}
}
render () {
const { href, children, className, onInterceptClick, ...other } = this.props;
return (
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
{children}
</a>
);
}
}

@ -378,7 +378,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
@ -392,7 +392,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
}
@ -476,7 +476,7 @@ class Status extends ImmutablePureComponent {
</Bundle>
);
}
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
} else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
@ -511,12 +511,12 @@ class Status extends ImmutablePureComponent {
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__info'>
<a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<a onClick={this.handleClick} href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>

@ -45,6 +45,7 @@ const messages = defineMessages({
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
const mapStateToProps = (state, { status }) => ({
@ -221,25 +222,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
handleCopy = () => {
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} finally {
document.body.removeChild(textarea);
}
const url = this.props.status.get('url');
navigator.clipboard.writeText(url);
}
handleHideClick = () => {
this.props.onFilter();
}
@ -254,12 +240,17 @@ class StatusActionBar extends ImmutablePureComponent {
const mutingConversation = status.get('muted');
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
@ -361,8 +352,8 @@ class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{shareButton}

@ -2,13 +2,13 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl } from 'react-intl';
import Permalink from './permalink';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon';
import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
class TranslateButton extends React.PureComponent {
@ -77,38 +77,45 @@ class StatusContent extends React.PureComponent {
return;
}
const { status, onCollapsedToggle } = this.props;
const links = node.querySelectorAll('a');
let link, mention;
for (var i = 0; i < links.length; ++i) {
let link = links[i];
link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
link.setAttribute('href', `/@${mention.get('acct')}`);
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.slice(1)}`);
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
}
if (this.props.status.get('collapsed', null) === null) {
let collapsed =
this.props.collapsable
&& this.props.onClick
&& node.clientHeight > MAX_HEIGHT
&& this.props.status.get('spoiler_text').length === 0;
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsable, onClick } = this.props;
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
const collapsed =
collapsable
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& status.get('spoiler_text').length === 0;
this.props.status.set('collapsed', collapsed);
onCollapsedToggle(collapsed);
}
}
@ -242,9 +249,9 @@ class StatusContent extends React.PureComponent {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink to={`/@${item.get('acct')}`} href={item.get('url')} key={item.get('id')} className='mention'>
<Link to={`/@${item.get('acct')}`} key={item.get('id')} className='mention'>
@<span>{item.get('username')}</span>
</Permalink>
</Link>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;

@ -1,62 +0,0 @@
import React, { Fragment } from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline';
import ModalContainer from '../features/ui/containers/modal_container';
import initialState from '../initial_state';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
if (initialState) {
store.dispatch(hydrateStore(initialState));
}
export default class TimelineContainer extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
hashtag: PropTypes.string,
local: PropTypes.bool,
};
static defaultProps = {
local: !initialState.settings.known_fediverse,
};
render () {
const { locale, hashtag, local } = this.props;
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else {
timeline = <PublicTimeline local={local} />;
}
return (
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<Fragment>
{timeline}
{ReactDOM.createPortal(
<ModalContainer />,
document.getElementById('modal-container'),
)}
</Fragment>
</Provider>
</IntlProvider>
);
}
}

@ -183,25 +183,18 @@ class About extends React.PureComponent {
<>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<table className='about__domain-blocks'>
<thead>
<tr>
<th><FormattedMessage id='about.domain_blocks.domain' defaultMessage='Domain' /></th>
<th><FormattedMessage id='about.domain_blocks.severity' defaultMessage='Severity' /></th>
<th><FormattedMessage id='about.domain_blocks.comment' defaultMessage='Reason' /></th>
</tr>
</thead>
<tbody>
{domainBlocks.get('items').map(block => (
<tr key={block.get('domain')}>
<td><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></td>
<td><span title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span></td>
<td>{block.get('comment')}</td>
</tr>
))}
</tbody>
</table>
<div className='about__domain-blocks'>
{domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain' key={block.get('domain')}>
<div className='about__domain-blocks__domain__header'>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div>
))}
</div>
</>
) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>

@ -39,7 +39,6 @@ class FeaturedTags extends ImmutablePureComponent {
<Hashtag
key={featuredTag.get('name')}
name={featuredTag.get('name')}
href={featuredTag.get('url')}
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
uses={featuredTag.get('statuses_count') * 1}
withGraph={false}

@ -53,6 +53,7 @@ const messages = defineMessages({
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
const titleFromAccount = account => {
@ -97,6 +98,7 @@ class Header extends ImmutablePureComponent {
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -140,6 +142,13 @@ class Header extends ImmutablePureComponent {
}
}
handleAvatarClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onOpenAvatar();
}
}
render () {
const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity;
@ -148,7 +157,9 @@ class Header extends ImmutablePureComponent {
return null;
}
const suspended = account.get('suspended');
const suspended = account.get('suspended');
const isRemote = account.get('acct') !== account.get('username');
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
let info = [];
let actionBtn = '';
@ -200,6 +211,11 @@ class Header extends ImmutablePureComponent {
menu.push(null);
}
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
menu.push(null);
}
if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null);
@ -250,15 +266,13 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
}
if (signedIn && account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
if (signedIn && isRemote) {
menu.push(null);
if (account.getIn(['relationship', 'domain_blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
} else {
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain });
}
}
@ -296,12 +310,10 @@ class Header extends ImmutablePureComponent {
<div className='account__header__bar'>
<div className='account__header__tabs'>
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
<Avatar account={suspended || hidden ? undefined : account} size={90} />
</a>
<div className='spacer' />
{!suspended && (
<div className='account__header__tabs__buttons'>
{!hidden && (

@ -129,7 +129,7 @@ export default class MediaItem extends ImmutablePureComponent {
return (
<div className='account-gallery__item' style={{ width, height }}>
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
<a className='media-gallery__item-thumbnail' href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
<Blurhash
hash={attachment.get('blurhash')}
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}

@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -101,6 +102,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onInteractionModal(this.props.account);
}
handleOpenAvatar = () => {
this.props.onOpenAvatar(this.props.account);
}
render () {
const { account, hidden, hideTabs } = this.props;
@ -129,6 +134,7 @@ export default class Header extends ImmutablePureComponent {
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
onOpenAvatar={this.handleOpenAvatar}
domain={this.props.domain}
hidden={hidden}
/>

@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AvatarOverlay from '../../../components/avatar_overlay';
import DisplayName from '../../../components/display_name';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
export default class MovedNote extends ImmutablePureComponent {
@ -23,12 +23,12 @@ export default class MovedNote extends ImmutablePureComponent {
</div>
<div className='moved-account-banner__action'>
<Permalink href={to.get('url')} to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<Link to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Permalink>
</Link>
<Permalink href={to.get('url')} to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Permalink>
<Link to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Link>
</div>
</div>
);

@ -152,6 +152,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onOpenAvatar (account) {
dispatch(openModal('IMAGE', {
src: account.get('avatar'),
alt: account.get('acct'),
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ActionBar from './action_bar';
import Avatar from '../../../components/avatar';
import Permalink from '../../../components/permalink';
import { Link } from 'react-router-dom';
import IconButton from '../../../components/icon_button';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -19,15 +19,15 @@ export default class NavigationBar extends ImmutablePureComponent {
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<Link to={`/@${this.props.account.get('acct')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={46} />
</Permalink>
</Link>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<Link to={`/@${this.props.account.get('acct')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>
</Link>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>

@ -50,7 +50,7 @@ class ReplyIndicator extends ImmutablePureComponent {
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} />
</a>

@ -17,7 +17,6 @@ export default class Upload extends ImmutablePureComponent {
media: ImmutablePropTypes.map.isRequired,
onUndo: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
isEditingStatus: PropTypes.bool.isRequired,
};
handleUndoClick = e => {
@ -31,7 +30,7 @@ export default class Upload extends ImmutablePureComponent {
}
render () {
const { media, isEditingStatus } = this.props;
const { media } = this.props;
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
@ -44,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__actions'>
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{!isEditingStatus && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
{!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
</div>
{(media.get('description') || '').length === 0 && (
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
<div className='compose-form__upload__warning'>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div>

@ -5,7 +5,6 @@ import { submitCompose } from '../../../actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
isEditingStatus: state.getIn(['compose', 'id']) !== null,
});
const mapDispatchToProps = dispatch => ({

@ -7,7 +7,7 @@ import AttachmentList from 'mastodon/components/attachment_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AvatarComposite from 'mastodon/components/avatar_composite';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
import IconButton from 'mastodon/components/icon_button';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import { HotKeys } from 'react-hotkeys';
@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = {
reply: this.handleReply,

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
import Button from 'mastodon/components/button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
@ -169,7 +169,7 @@ class AccountCard extends ImmutablePureComponent {
return (
<div className='account-card'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
<div className='account-card__header'>
<img
src={
@ -183,7 +183,7 @@ class AccountCard extends ImmutablePureComponent {
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
<DisplayName account={account} />
</div>
</Permalink>
</Link>
{account.get('note').length > 0 && (
<div

@ -11,8 +11,8 @@ describe('emoji', () => {
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).toEqual('hello>');
expect(emojify('<hello')).toEqual('<hello');
expect(emojify('hello>')).toEqual('hello&gt;');
expect(emojify('<hello')).toEqual('');
});
it('works with unclosed shortcodes', () => {
@ -22,23 +22,23 @@ describe('emoji', () => {
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
expect(emojify('👨‍👩‍👧‍👧')).toEqual(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
expect(emojify('\u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
});
it('ignores unicode inside of tags', () => {
@ -46,16 +46,16 @@ describe('emoji', () => {
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg" />');
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
});
it('avoid emojifying on invisible text', () => {
@ -67,26 +67,26 @@ describe('emoji', () => {
it('avoid emojifying on invisible text with nested tags', () => {
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
expect(emojify('<span class="invisible">😄<br/>😴</span>😇'))
.toEqual('<span class="invisible">😄<br/>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
});
it('skips the textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg">');
});
it('does an simple emoji properly', () => {
expect(emojify('♀♂'))
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />');
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
});
it('does an emoji containing ZWJ properly', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />');
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
});
});
});

@ -19,15 +19,26 @@ const emojiFilename = (filename) => {
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
};
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
const domParser = new DOMParser();
const emojifyTextNode = (node, customEmojis) => {
let str = node.textContent;
const fragment = new DocumentFragment();
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
let match, i = 0;
if (customEmojis === null) {
while (i < str.length && !(match = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
} else {
while (i < str.length && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
}
let rend, replacement = '';
if (i === str.length) {
break;
@ -35,8 +46,6 @@ const emojify = (str, customEmojis = {}) => {
if (!(() => {
rend = str.indexOf(':', i + 1) + 1;
if (!rend) return false; // no pair of ':'
const lt = str.indexOf('<', i + 1);
if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
const shortname = str.slice(i, rend);
// now got a replacee as ':shortname:'
// if you want additional emoji handler, add statements below which set replacement and return true.
@ -47,29 +56,6 @@ const emojify = (str, customEmojis = {}) => {
}
return false;
})()) rend = ++i;
} else if (tag >= 0) { // <, &
rend = str.indexOf('>;'[tag], i + 1) + 1;
if (!rend) {
break;
}
if (tag === 0) {
if (invisible) {
if (str[i + 1] === '/') { // closing tag
if (!--invisible) {
tagChars = tagCharsWithEmojis;
}
} else if (str[rend - 2] !== '/') { // opening tag
invisible++;
}
} else {
if (str.startsWith('<span class="invisible">', i)) {
// avoid emojifying on invisible text
invisible = 1;
tagChars = tagCharsWithoutEmojis;
}
}
}
i = rend;
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
@ -80,10 +66,43 @@ const emojify = (str, customEmojis = {}) => {
rend += 1;
}
}
rtn += str.slice(0, i) + replacement;
fragment.append(document.createTextNode(str.slice(0, i)));
if (replacement) {
fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
}
node.textContent = str.slice(0, i);
str = str.slice(rend);
}
return rtn + str;
fragment.append(document.createTextNode(str));
node.parentElement.replaceChild(fragment, node);
};
const emojifyNode = (node, customEmojis) => {
for (const child of node.childNodes) {
switch(child.nodeType) {
case Node.TEXT_NODE:
emojifyTextNode(child, customEmojis);
break;
case Node.ELEMENT_NODE:
if (!child.classList.contains('invisible'))
emojifyNode(child, customEmojis);
break;
}
}
};
const emojify = (str, customEmojis = {}) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;
if (!Object.keys(customEmojis).length)
customEmojis = null;
emojifyNode(wrapper, customEmojis);
return wrapper.innerHTML;
};
export default emojify;

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
import IconButton from 'mastodon/components/icon_button';
import { injectIntl, defineMessages } from 'react-intl';
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
@ -66,13 +66,13 @@ class Account extends ImmutablePureComponent {
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<Link className='account__display-name account__display-name--with-note' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
</Link>
<div className='account__relationship'>
{button}

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from '../../../components/permalink';
import { Link } from 'react-router-dom';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import IconButton from '../../../components/icon_button';
@ -30,10 +30,10 @@ class AccountAuthorize extends ImmutablePureComponent {
return (
<div className='account-authorize__wrapper'>
<div className='account-authorize'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
<DisplayName account={account} />
</Permalink>
</Link>
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
</div>

@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following');
followButton = (
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}>
<button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
</button>
);

@ -130,7 +130,6 @@ class HomeTimeline extends React.PureComponent {
className={classNames('column-header__button', { 'active': showAnnouncements })}
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-pressed={showAnnouncements ? 'true' : 'false'}
onClick={this.handleToggleAnnouncementsClick}
>
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />

@ -150,7 +150,7 @@ class InteractionModal extends React.PureComponent {
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Simply copy and paste this URL into the search bar of your favourite app or the web interface where you are signed in.' /></p>
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Copy and paste this URL into the search field of your favourite Mastodon app or the web interface of your Mastodon server.' /></p>
<Copypaste value={url} />
</div>
</div>

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
import IconButton from 'mastodon/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -42,10 +42,10 @@ class FollowRequest extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
</Link>
<div className='account__relationship'>
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />

@ -10,7 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container';
import Report from './report';
import FollowRequestContainer from '../containers/follow_request_container';
import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
const messages = defineMessages({
@ -378,7 +378,7 @@ class Notification extends ImmutablePureComponent {
const targetAccount = report.get('target_account');
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
return (
<HotKeys handlers={this.getHandlers()}>
@ -403,7 +403,7 @@ class Notification extends ImmutablePureComponent {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
switch(notification.get('type')) {
case 'follow':

@ -182,9 +182,9 @@ class Footer extends ImmutablePureComponent {
return (
<div className='picture-in-picture__footer'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} />}
</div>
);
}

@ -39,6 +39,7 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
const mapStateToProps = (state, { status }) => ({
@ -174,22 +175,8 @@ class ActionBar extends React.PureComponent {
}
handleCopy = () => {
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} finally {
document.body.removeChild(textarea);
}
const url = this.props.status.get('url');
navigator.clipboard.writeText(url);
}
render () {
@ -201,10 +188,15 @@ class ActionBar extends React.PureComponent {
const mutingConversation = status.get('muted');
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
let menu = [];
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);

@ -261,7 +261,7 @@ class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
@ -276,7 +276,7 @@ class DetailedStatus extends ImmutablePureComponent {
{media}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div>

@ -619,7 +619,7 @@ class Status extends ImmutablePureComponent {
showBackButton
multiColumn={multiColumn}
extraButton={(
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
)}
/>

@ -98,12 +98,12 @@ class BoostModal extends ImmutablePureComponent {
<div className='boost-modal__container'>
<div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<a href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name'>
<div className='status__avatar'>
<Avatar account={status.get('account')} size={48} />
</div>

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

Loading…
Cancel
Save