Compare commits

...
Sign in to create a new pull request.

172 commits

Author SHA1 Message Date
e6c0d35d29 Merge pull request 'notification: fix code usage on mobile' (#492) from Yonle/akkoma-fe:mobilenotif-fix1 into develop
Reviewed-on: AkkomaGang/akkoma-fe#492
2026-03-16 13:41:01 +00:00
8f5cf700f8
module(users): remove unnecessary check on getNotificationPermission 2026-03-16 15:11:46 +07:00
Oneric
efe15c98c6 lists: ensure all properties exist after creation
This used to cause null errors e.g. when initialising the accounts for a
newly created list, which also prevented a post-creation redirect to the
new list’s page from occuring.

Co-authored-by: Yonle <yonle@proton.me>

Fixes: AkkomaGang/akkoma-fe#367
Fixes: AkkomaGang/akkoma-fe#368
2026-03-15 00:00:00 +00:00
Oneric
a734eda0d9 Bump version for release 2026-03-14 00:00:00 +00:00
51caf0430f
notification: fix code usage on mobile
on mobile (especially PWA), window.Notification is illegal to use. so if possible, consider using serviceWorker instead.
2026-03-10 12:47:18 +07:00
48905a4431 Merge pull request 'a fix for nsfw warnings display on webkit' (#488) from mkljczk/akkoma-fe:webkit-fix into develop
Reviewed-on: AkkomaGang/akkoma-fe#488
2026-03-06 16:30:12 +00:00
c465cb0a35 components/attachment: fix display of nsfw overlays on webkit 2026-03-06 00:00:00 +00:00
Oneric
affbc240d1 changelog: add everything since 3.17 (2025.12) 2026-03-02 00:00:00 +00:00
a123b41a2f Merge pull request 'Fix HTML attribute parsing for escaped quotes' (#480) from mkljczk/akkoma-fe:get-attrs-fix into develop
Reviewed-on: AkkomaGang/akkoma-fe#480
Reviewed-by: Oneric <oneric@noreply.akkoma>
2026-02-19 12:31:59 +00:00
4ab3424508 Fix HTML attribute parsing for escaped quotes
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-16 13:36:15 +01:00
b04e4810f8 Fix HTML attribute parsing, discard attributes not strating with a letter
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-16 13:30:52 +01:00
fc8debd2c4 Merge pull request 'components/quote_button: show for local and own private posts' (#478) from Oneric/akkoma-fe:more-quoting into develop
Reviewed-on: AkkomaGang/akkoma-fe#478
2026-02-07 22:40:22 +00:00
Oneric
8227c84aa2 components/quote_button: show for local and own private posts
Aligning to AkkomaGang/akkoma#1059
2026-02-07 00:00:00 +00:00
Oneric
42595fcb2c cosmetic: fix linter complaints
Mostly just reordering, whitespace changes
and removing superfluous "this".

eslint really wants us to add :key to the UserAvatar list in DM
conversation cards. With :key Vue will reorder elements instead
of patching their contents on list changes, allowing input state
of elements to be preserved. This doesn’t really seem relevant
here since USerAvatars do not have a state, but also not harmful.

One lint complaint about using double quotes at the outer level
was purposefully ignored as it results in needing to quote
double quotes within the string making it rather unreadable.
2026-01-26 00:00:00 +00:00
Oneric
e3a72827ef side_drawer: add entry for bookmarks
It was not easily available in the narrow "mobile" interface
until now since both the desktop_nav and top nav panel are hidden.
Placing bookmarks after lists is consistent with the top nav panel
(though the top nav panel also puts interactions before both).

The recently removed "direct" timeline was similarly unavailable,
but its replacement, dm conversations, was already added to the
side drawer upon its introduction.

Fixes: AkkomaGang/akkoma-fe#474
2026-01-25 00:00:00 +00:00
34e4928754 Merge pull request 'polls: don't continuously refresh closed polls and refresh less frequently' (#472) from Oneric/akkoma-fe:poll-upd-frequency-reduction into develop
Reviewed-on: AkkomaGang/akkoma-fe#472
2026-01-24 18:29:59 +00:00
Oneric
9bfd3936d6 polls: do not fetch updates for closed polls 2026-01-14 00:00:00 +00:00
Oneric
8d8e6d979a polls: reduce frequency of update fetches
Thirty seconds is much quicker than any other auto-refreshes in the
interface. Emitting a request for every users and tab with the poll
loaded this frequently can add up to a noteworthy total on the backend.

This is significantly worsened by our backend currently synchronously
fetching and updating the status of remote polls when queried about
while initiating a remote fetch for every incoming request inbetween
the last refetch passing the  age threshold and the first current fetch
suceeding and completing its db transaction.
2026-01-14 00:00:00 +00:00
e52157042d Merge pull request 'migrate to conversation API (replaces "direct" timeline)' (#470) from Oneric/akkoma-fe:conversations-api into develop
Reviewed-on: AkkomaGang/akkoma-fe#470
2026-01-11 15:55:01 +00:00
Oneric
9b45a382b0 Show total count of unread DM conversations in sidebar
In preparation to an eventual switch to native Masto API format,
as well as flexibility to backends without the extension,
the entity normaliser just copies the count paramter to the same path
2025-12-30 00:00:00 +00:00
Oneric
d73d7a2a0d Automatically update follow request count shown in sidebar
The current user info fetcher will also be used for
total unread DM counts in the future.

To avoid any future mishaps with improperly stopped fetchers etc,
this  does not use the existing 'setCurrentUser' method but a new
update method with a safeguard against accidentally changing the
identity.
2025-12-30 00:00:00 +00:00
Oneric
252f8c5e2d components/side_drawer: drop call to non-existent action
This is the only place such an action is referenced
2025-12-30 00:00:00 +00:00
Oneric
8d24b877e0 Allow editing DM conversation recipients
Unfortunately the backend currently only accepts query parameters here
instead of JSON bodies as preferred by almost all oter endpoints.
2025-12-30 00:00:00 +00:00
Oneric
b3b998fd1f components/dm_conv_timeline: show conversation details in header
This also allows marking the conversation
as read etc from its timeline view.
2025-12-30 00:00:00 +00:00
Oneric
2e53ee6536 components/dm_conv_card: make compactness and status preview configurable 2025-12-30 00:00:00 +00:00
Oneric
9fdf2d22a7 By default require confirmation to delete a dm conversation 2025-12-30 00:00:00 +00:00
Oneric
df9bb44e14 Allow deleting conversations from overview 2025-12-30 00:00:00 +00:00
Oneric
3d4c79b344 Allow marking conversations as read 2025-12-30 00:00:00 +00:00
Oneric
0da1a32767 Add basic UI for conversation API
This replaces the removed "direct" timeline.
Curtnetly this is a read-only interface missing ways to
mark conversations as read, dismiss/delete conversations
or modify the core members of a conversation.

Future work may also add QoL stuff like automatic implicit addressing of
core members or (provided another backend extension) add messages to a
conversation without replying to something particular.
2025-12-30 00:00:00 +00:00
3a20ec5162 Merge pull request 'show more reblog info' (#468) from Oneric/akkoma-fe:reblog-info into develop
Reviewed-on: AkkomaGang/akkoma-fe#468
2025-12-22 16:48:21 +00:00
Oneric
28d0a30888 Remove DM timeline
It has been long deprecated and even already removed from Mastodon
and our existing implementation suffers from bugs (at least on
large/some instances), see:
   AkkomaGang/akkoma#798

Except for pleroma-derived web frontends, other clients generally don't
seem to make use of this timeline either, often also omitting an
interface for the conversations API.

Even if it worked properly, this isn’t the best way to present
DMs as all threads with different participants or topics are mixed
together in one linear timeline. The conversations API which suceeded
it in Mastodon and our backend already supports offers a much better
interface.
2025-12-22 00:00:00 +00:00
Oneric
2fb38a597c service/api: fix encoding of arrays in query parameters 2025-12-22 00:00:00 +00:00
2760495b54 Update README.md 2025-12-21 03:57:46 +00:00
2ef333dafc Merge pull request 'Fix setting persistence to local browser storage' (#469) from Oneric/akkoma-fe:fix-settings-local-persistence into develop
Reviewed-on: AkkomaGang/akkoma-fe#469
2025-12-19 19:20:14 +00:00
6d260c08c0 Merge pull request 'Improve still image detction without media proxy' (#466) from Oneric/akkoma-fe:still-image-accuracy into develop
Reviewed-on: AkkomaGang/akkoma-fe#466
2025-12-19 17:39:25 +00:00
Oneric
7456b8b02f components/status: show when a reblog happened 2025-12-19 00:00:00 +00:00
Oneric
d83fd8b1cd Fix setting persistence to local browser storage
Fixes regression in f2c55423fd.
Ideally, this would only set state for what was actually changed
rather than rewriting the entire storage each time, which would also
have avoided this issue.

The fix is somehwat hacky not working with an empty path list or
parsed/internal fields not at the top-level of a path, but in practice
this seems to be always called with well bheaving paths. Should this ever
become an issue, this should migrating to the overall saner updating
approach described in the preceding paragraph.

Note: the cloneDeep call was originally added in
a97c07bfdf as part of
https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1385
Though it is not clear why setState would mangle its data argument
as it supposedly does to necessitate this copying.
2025-12-19 00:00:00 +00:00
Oneric
8f948d52f5 still-image: remove excessive pixelArt detection log 2025-12-19 00:00:00 +00:00
tea
9a671325ad components/status: display repeat visibility
Cherry-picked-from: ac9c512c48
2025-12-19 00:00:00 +00:00
Oneric
26a4188620 still-image: always fetch local content for animaton detection
Fixes: AkkomaGang/akkoma-fe#381
2025-12-19 00:00:00 +00:00
Oneric
b839a25060 still-image: detect animated APNG by extension
If they use the canonical apng extension specified by IANA.
2025-11-26 00:00:00 +00:00
09fba6bada Merge pull request 'readme: Update corepack install command' (#463) from norm/akkoma-fe:readme-corepack into develop
Reviewed-on: AkkomaGang/akkoma-fe#463
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-11-23 11:50:03 +00:00
4d85f0a074 readme: Update corepack install command
As of Node 25, corepack will no longer be included by default in
the distribution:
https://github.com/nodejs/TSC/pull/1697

A separate corepack package is already available in npm, which
should work the same as the corepack that was included in Node 24
and prior.

The rest of the build instructions should remain the same.
2025-11-17 11:38:01 -05:00
a1e83062b4 Merge pull request 'Allow using regex filters for mutes' (#461) from Oneric/akkoma-fe:regex-mute-filter into develop
Reviewed-on: AkkomaGang/akkoma-fe#461
2025-11-09 13:16:04 +00:00
Oneric
4315788019 Add support for regex mute filters
This makes any filter that starts and ends in forward slashes act as a
regex filter instead of a simple substring filter.
In case any existing plain-text rule already matches this
it will need to be updated adding an additional layer of slashes
and escapes as needed. However, this is thought to be sufficiently uncommon.

Instead of using trailing flags as modifier complicating parsing further,
any modifications to regex matching must be done via local modifiers. See
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier
2025-10-26 00:00:00 +00:00
Oneric
f2c55423fd config: add infrastructure to cache parsed config values
Using muteWords as an example.
Currently this doesn’t help much
but the subsequent commit will extend muteWord capabilities
making parsing more costly
2025-10-26 00:00:00 +00:00
9bbc68536c Merge pull request 'Fix internal boost visibility scope' (#455) from NixLynx/akkoma-fe:boost-fix into develop
Reviewed-on: AkkomaGang/akkoma-fe#455
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-10-25 18:17:30 +00:00
Oneric
c5f068d6fa scope_utils: replace compare with isSubScope
Visibility scopes have no total order, but the function signature
of a compare function implies/requires such.

This led to the scope selector showing "local" as a possible option
on some original post scopes it was not actually compatible with.
The negotiated initially selected value was already correct though.
2025-10-25 00:00:00 +00:00
Nix Lynx
98826e8462 Fix internal boost visibility scope
Fixes d617a9596a
2025-10-25 00:00:00 +00:00
b13ecbcf6f Merge pull request 'Various small fixes' (#459) from Oneric/akkoma-fe:varfixes-20251018 into develop
Reviewed-on: AkkomaGang/akkoma-fe#459
2025-10-24 18:56:42 +00:00
66b026561f Ensure attachment descriptions fill full width
Was noticeable by e.g. the scrollbar being shown somewhere inside the
container and not being able to scroll near the edges if each line of
text was sufficiently short.

Directly implements the suggesstion from
AkkomaGang/akkoma-fe#456 (comment)
2025-10-24 00:00:00 +00:00
Oneric
c925f7f91b Fix failed media uploads bricking the frontend
The upload call hid errors and the entity normaliser lets them pass
through as well (assuming it must be an already "correctly" formatted
legacy qvitter response), which led to errors being added to the the
draft as if it was a valid attachment object.
On later use of this error exceptions occur breaking the frontend and
due to being saved as part of a draft this could only be recovered by
clearing local client data.

Fixes: AkkomaGang/akkoma-fe#339
2025-10-24 00:00:00 +00:00
Oneric
c3673eb53a Always use AP ID when copying post link
This is the canonical reference to a post and works best for lookups on
other servers. Previously this canonical ID was not accessible from the
frontend at all.

The "source" button continues to redirect to the preferred display URL
though (if set), since this is as the name suggests the preferred URL
for viewing the post in a browser (it might however not work when the
source instance restricts unauthenticated access even to local content).

This lifts the redundancy between the "source" and "copy link" buttons.

Since the legac qvitter API is still accounted for in the entity
normaliser, we just assume its external_url serves as both the canoncial
location and preferred display URL. It is dubious however, if this
codepath can even still be triggered at all and it likely makes more
sense to purge all remnants of the legacy API from the codebase.

Resolves: AkkomaGang/akkoma-fe#166
2025-10-18 00:00:00 +00:00
Oneric
f0c149950c Fix image attachments overflowing their container
This reverts commit e1b4d8f59a
and obsoletes commit c2db0e66ef
which already unset min-height in notifications where this
caused images to become effectively invisible.

Image previews are currently designed to always show the full image
scaled down as needed. With min-height though they were allowed to
take the full width even if it caused overflows in vertical direction.
This happened to look kind of fine with only easy-to-miss overflows
in the main post view if each preview row had the same amount of
columns, but creates jarring overlaps otherwise.

Fixes: AkkomaGang/akkoma-fe#456
2025-10-18 00:00:00 +00:00
3d54c8274f Merge pull request 'media_modal: fix image load handler wiring' (#454) from novenary/akkoma-fe:media_modal/load_handler into develop
Reviewed-on: AkkomaGang/akkoma-fe#454
2025-10-14 12:40:54 +00:00
novenary
f6bf484d4b media_modal: fix image load handler wiring
The media modal was changed to use still_image, but the attribute
pointing to the image load event handler wasn't updated.
This causes the modal to remain in the loading state forever.

Fixes: f48138c979
2025-10-14 15:36:22 +03:00
57a809946c Merge pull request 'Don’t litter tokens and Iceshrimp.NET support' (#452) from Oneric/akkoma-fe:frugal-tokens-and-iceshrimp.net into develop
Reviewed-on: AkkomaGang/akkoma-fe#452
2025-10-13 12:22:33 +00:00
2f64931d5b Merge pull request 'Show inline link for unresolvble posts and quoted quotes' (#453) from Oneric/akkoma-fe:quote-doesnt-exist-and-cant-hurt-you into develop
Reviewed-on: AkkomaGang/akkoma-fe#453
2025-10-13 12:17:48 +00:00
Hannah Ward
c2db0e66ef unset min-height for attachments in notifications 2025-10-13 13:07:57 +01:00
762676e105 and again 2025-10-13 10:49:35 +01:00
1fa242232e bump version 2025-10-13 10:48:46 +01:00
Oneric
e71da57845 Show inline link for unresolvble posts and quoted quotes
Previously those two cases just weren’t recognisable as quotes at all.

Fixes: AkkomaGang/akkoma-fe#310
2025-10-13 00:00:00 +00:00
Oneric
877dde80c9 user_card: don't set replied-to-status id to a boolean
Instead, just also prefill the text wuth the user handle
for pure mentions.
This lead to us sending a boolean status id to the backend
which presumably only didn't cause problems in *oma backends
because API spec validation dropped the value with a mismatched type.
On an Iceshrimp.NET backend this caused errors to pop up.

There are more conditions gated by replyTo in the post form but
no longer triggering them doesn't seem to have any noticeable effect.
The one noticeable change being mentions now share a draft save slot
with regular message composing (before all mention messages shared the
same reply:true slot), but this seems sensible enough.
2025-10-12 00:00:00 +00:00
Oneric
f885728ccd Lock package manager version
At least modern versions of corepack'ed yarn
(as build instructions tell use to use)
reaaally want to add this to the lockfile,
so let it do so to not need to constantly
be wary of uncommited changes here.
2025-10-12 00:00:00 +00:00
Oneric
4cb74a3fbe Clean up app tokens on logout
We don’t need them after initial account registration
and they just clutter the database otherwise

Together with the preceding commit this should get the
flood of garbage tokens into our database under control.
(We still want to fix the backends cleanup of old,
 already existing tokens though)

Fixes: AkkomaGang/akkoma-fe#429
2025-10-12 00:00:00 +00:00
Kopper
e79916e78e Do not create OAuth app until login
Original commit amended with a fix for registrations;
they need to (now) create an app and fetch an app-only token
before doing anything else, as this endpoint requires such a token.

Co-authored-by: Oneric <oneric@onierc.stub>

Cherry-picked-from: 0e25b94186
2025-10-12 00:00:00 +00:00
Kopper
3881f87c79 Properly pass credentials for follow requests and followed hashtags
This only worked in *oma due to legacy session-cookie auth kicking in
(which we should really get around to fully purge).

Cherry-picked-from: e8896fad15
2025-10-11 00:00:00 +00:00
Kopper
82647e8e98 Accept full URLs for /api/v1/pleroma/emoji
The frontend assumes these URLs will be relative to the target instance,
which may not be the case for non-*oma backends like Iceshrimp.NET
due to the backend storing emoji in an external object storage depending
on configuration.

Cherry-picked-from: c147c2aeb3
2025-10-11 00:00:00 +00:00
Weblate
539977de9d Merge branch 'origin/develop' into Weblate. 2025-10-04 22:05:59 +00:00
d2995ada16 Merge pull request 'Misc fixes' (#416) from novenary/akkoma-fe:misc-fixes/2024-09-17 into develop
Reviewed-on: AkkomaGang/akkoma-fe#416
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-10-04 22:05:56 +00:00
Weblate
bcd15ef858 Merge branch 'origin/develop' into Weblate. 2025-10-04 22:04:27 +00:00
900ac68ca6 Merge pull request 'Use pixelated (up)scaling for custom emoji' (#448) from Riedler/akkoma-fe:pixemoji into develop
Reviewed-on: AkkomaGang/akkoma-fe#448
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-10-04 22:04:24 +00:00
Weblate
34bbcef83e Merge branch 'origin/develop' into Weblate. 2025-10-04 22:04:15 +00:00
37ce8352a9 Merge pull request 'fix multiline alt texts for generic attachments (e.g. zip files)' (#446) from Riedler/akkoma-fe:fix-attachalt into develop
Reviewed-on: AkkomaGang/akkoma-fe#446
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-10-04 22:04:11 +00:00
f48138c979 oops! added it to media_modal as well 2025-09-24 18:04:43 +02:00
5baa2ce40f apply pixel art detection to more places 2025-09-24 17:28:29 +02:00
novenary
fef531b8a0 conversation: scrollIntoView when collapsed
This helps find the original context when collapsing a thread on the
timeline.
2025-09-24 15:37:06 +03:00
bf0c137057 conditionally render small emojis as pixelated 2025-09-24 13:49:16 +02:00
5a50ceb3aa Use pixelated (up)scaling for custom emoji 2025-09-24 13:13:30 +02:00
f08a961199 fix: some days I hate CSS 2025-09-22 17:56:16 +02:00
d252e10543 fix: scrollable gallery rows for if contents are too long
like my peanits
2025-09-22 16:23:33 +02:00
novenary
7c84854b10 post_status_form: inherit language from parent
If I'm replying to a post in Klingon, chances are I'm going to write in
Klingon. This reduces friction for properly marking post language in a
conversation.
2025-09-20 11:10:01 +03:00
novenary
ab606c6160 post_status_form: reset all to defaults on clear 2025-09-20 11:10:01 +03:00
novenary
38d8a9751a emoji_picker: select recents tab by default
This saves a click to get at your most commonly used emoji.
2025-09-20 11:09:59 +03:00
novenary
2c92467dcd post_status_form: fix enter key in subject field
This fixes random actions being triggered by the enter key while the
subject field is focused.

When pressing enter, the browser simulates a click on the first "submit"
button it finds in the form.
A submit button is a button without `type="button"` set.
Remediate this by setting the type attribute on all but the "Post"
button.

Additionally, inhibit the enter key in the subject field (ctrl+enter
still works).
2025-09-17 23:43:55 +03:00
bb71635d12 fix: no multiline alt text in post popovers 2025-09-10 03:13:13 +02:00
e1b4d8f59a fix: minor overflow issue in draft attachment alt text 2025-09-10 02:35:14 +02:00
2455bb70f3 feat: since I'm already here, let's do some basic styling 2025-09-10 02:25:02 +02:00
fbc6cd59bc fix: multiline alt text no longer flows into itself 2025-09-10 02:10:38 +02:00
Weblate
873048de2e Translated using Weblate (Japanese (ja_EASY))
Currently translated at 72.0% (758 of 1052 strings)

Co-authored-by: Deleted User <noreply+21@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/ja_EASY/
Translation: Pleroma fe/pleroma-fe
2025-09-07 01:23:49 +00:00
Weblate
7a4e2a8644 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1052 of 1052 strings)

Co-authored-by: Poesty Li <poesty7450@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/zh_Hans/
Translation: Pleroma fe/pleroma-fe
2025-09-07 01:23:49 +00:00
Weblate
a1d92ffd86 Translated using Weblate (German)
Currently translated at 98.5% (1037 of 1052 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/de/
Translation: Pleroma fe/pleroma-fe
2025-09-07 01:23:49 +00:00
Oneric
b60f42b959 woodpecker: fix obsolete secrets usage
The existing definition now only errors out
2025-09-07 00:00:00 +00:00
55dff3a9bd Merge pull request 'Fix sensitive by default option' (#443) from norm/akkoma-fe:fix-sensitive into develop
Reviewed-on: AkkomaGang/akkoma-fe#443
Reviewed-by: Oneric <oneric@noreply.akkoma>
2025-09-07 00:50:15 +00:00
9ef8effeed Clarify the sensitiveIfSUbject setting description 2025-09-06 08:31:26 -04:00
9c15db16a6 Fix sensitive by default option
Fixes AkkomaGang/akkoma-fe#442
2025-09-03 20:54:12 -04:00
Weblate
674a816453 Merge branch 'origin/develop' into Weblate. 2025-08-04 00:27:08 +00:00
Weblate
be2207fa42 Translated using Weblate (French)
Currently translated at 100.0% (1051 of 1051 strings)

Co-authored-by: Thomate <thomas@burdick.fr>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/fr/
Translation: Pleroma fe/pleroma-fe
2025-07-21 13:47:34 +00:00
Weblate
3f3ea32f81 Translated using Weblate (Greek)
Currently translated at 18.3% (193 of 1051 strings)

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: getimiskon <getimiskon@disroot.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/el/
Translation: Pleroma fe/pleroma-fe
2025-07-21 13:47:34 +00:00
Weblate
abc6b299e0 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (1051 of 1051 strings)

Co-authored-by: Poesty Li <poesty7450@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/zh_Hans/
Translation: Pleroma fe/pleroma-fe
2025-07-21 13:47:34 +00:00
Weblate
b8b18c67b1 Translated using Weblate (Turkish)
Currently translated at 19.6% (207 of 1051 strings)

Co-authored-by: Hasan Yıldız <hasanyildiz0@yaani.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/tr/
Translation: Pleroma fe/pleroma-fe
2025-07-21 13:47:34 +00:00
4cf4b5e2d0 Merge pull request 'Support selectable visibility of repeats' (#440) from Oneric/akkoma-fe:boost-scopes into develop
Reviewed-on: AkkomaGang/akkoma-fe#440
2025-06-10 18:37:59 +00:00
d617a9596a Add support selectable visibility of repeat
Co-authored-by: Oneric <oneric@oneric.stub>
2025-05-18 22:52:55 +02:00
Oneric
4734e9668d refactor: extract scope logic into shared module 2025-05-18 22:52:55 +02:00
9787f43343 Merge pull request 'Check for canvas extract permission when initializing favicon service' (#432) from mkljczk/akkoma-fe:check-canvas-extract-permission into develop
Reviewed-on: AkkomaGang/akkoma-fe#432
2025-05-09 18:34:53 +00:00
61bdedc82f Merge pull request 'remove some jank in emoji reacts component' (#435) from Riedler/akkoma-fe:fix-reacts into develop
Reviewed-on: AkkomaGang/akkoma-fe#435
2025-05-09 18:30:07 +00:00
a4eddc7f1c Merge pull request 'polls: base fractions on voters for multiple choice polls' (#436) from Oneric/akkoma-fe:poll-percentages into develop
Reviewed-on: AkkomaGang/akkoma-fe#436
2025-05-09 18:29:54 +00:00
94c5998593 Apply wordfilters to attachment alt-texts
EDITED to apply review suggestions:
  - short circuit search and immediately return once match found
  - Array.some() instead of for loop
2025-05-05 22:39:43 +02:00
Oneric
851dd263c0 docs/sticker: fix example setup 2025-04-25 00:45:04 +02:00
Oneric
473ba89355 polls: base fractions on voters for multiple choice polls
This allows discerning how many voters agreed
with an option and aligns with other clients.
However, a backend bug makes this impossible for
remote multiple choice polls, so retain current
behaviour for anything affected.
2025-04-04 19:27:30 +02:00
4ce8ffcec1 fix: shrink unicode emojis in reactions slightly
some large ones exceeded container boundaries before
2025-03-26 09:09:56 +01:00
e62b154228 fix: uniform height sizing and layouting 2025-03-26 07:39:54 +01:00
e87a9ced61 fix: no more emojis bleeding into button borders 2025-03-26 07:12:45 +01:00
7245775b27 fix: picked reactions should be positioned identically 2025-03-26 06:56:32 +01:00
6373c5a05d Check for canvas extract permission when initializing favicon service 2025-03-05 15:02:16 +00:00
2914eaf1ca Revert "reduce gallery size"
This reverts commit 06ba190e2e.
2025-03-01 16:14:55 +00:00
0bf9cb0660 Merge pull request 'Optional widened main column' (#402) from Riedler/akkoma-fe:wide-columns-for-upstream into develop
Reviewed-on: AkkomaGang/akkoma-fe#402
2025-03-01 12:00:33 +00:00
65cb3b95e0 Merge pull request 'Use FEP-c16b: Formatting MFM functions' (#410) from ilja/akkoma-fe:use_fep-c16b_formatting_mfm_functions into develop
Reviewed-on: AkkomaGang/akkoma-fe#410
2025-02-27 12:04:41 +00:00
f15b94d566 made widenTimeline false by default 2025-02-07 03:50:44 +01:00
06ba190e2e reduce gallery size 2025-02-07 03:49:57 +01:00
2086522d64 Merge pull request '(arguably) improved layouting of user profile page' (#403) from Riedler/akkoma-fe:user-profile-changes into develop
Reviewed-on: AkkomaGang/akkoma-fe#403
2025-01-15 21:47:55 +00:00
Weblate
fa294e0003 Merge branch 'origin/develop' into Weblate. 2025-01-05 15:52:29 +00:00
d3fa5cfad0 Merge pull request 'post_status_form: enable sync flush for watcher' (#414) from novenary/akkoma-fe:sticky-drafts into develop
Reviewed-on: AkkomaGang/akkoma-fe#414
2025-01-05 15:52:26 +00:00
Weblate
9552287442 Merge branch 'origin/develop' into Weblate. 2025-01-05 15:52:19 +00:00
6b7c8f0def Merge pull request 'Allow using custom source URLs' (#421) from Oneric/akkoma-fe:custom-source into develop
Reviewed-on: AkkomaGang/akkoma-fe#421
2025-01-05 15:52:15 +00:00
Weblate
3386692e26 Merge branch 'origin/develop' into Weblate. 2025-01-05 15:51:50 +00:00
ad6bb47003 Merge pull request 'Add visual feedback when clicking translate' (#423) from ilja/akkoma-fe:provide_visual_feedback_when_clicking_translate_button into develop
Reviewed-on: AkkomaGang/akkoma-fe#423
2025-01-05 15:51:47 +00:00
ilja
9838545904 Add visual feedback when clicking translate
In a status, we can choose to translate the status (assuming there's a translator enabled on the backend)

It will translate, in practice generally according to detected language, and also provide an option to override the source language.

Translating can take a while, and there wasn't really a visual feedback when it was translating.
Now the translate button will be dissabled while translating.
2024-12-01 14:04:49 +01:00
ilja space
868c6e41ac Improve readability for MFM styles code
The code to turn mdm-data-* attributes into a value for the style attribute is complex.
I wrapped it in it's own function now for better code readability.
A comment was already provided with what the code intents to do and why, this information has also been moved
to this function.
2024-12-01 12:24:23 +01:00
Weblate
b3f25e5d84 Translated using Weblate (Polish)
Currently translated at 99.7% (1046 of 1049 strings)

Translated using Weblate (Polish)

Currently translated at 99.7% (1046 of 1049 strings)

Co-authored-by: ? <akkoma@mkljczk.pl>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: subtype <subtype@hollow.capital>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/pl/
Translation: Pleroma fe/pleroma-fe
2024-11-22 04:56:24 +00:00
Weblate
248509073e Translated using Weblate (Italian)
Currently translated at 93.8% (985 of 1049 strings)

Co-authored-by: Steffo <akkoma@steffo.eu>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/it/
Translation: Pleroma fe/pleroma-fe
2024-11-22 04:56:24 +00:00
Weblate
a7d6235131 Translated using Weblate (Lithuanian)
Currently translated at 8.1% (86 of 1049 strings)

Translated using Weblate (Lithuanian)

Currently translated at 5.5% (58 of 1049 strings)

Translated using Weblate (Lithuanian)

Currently translated at 1.9% (20 of 1049 strings)

Added translation using Weblate (Lithuanian)

Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.akkoma.dev/projects/akkoma/pleroma-fe/lt/
Translation: Pleroma fe/pleroma-fe
2024-11-22 04:56:24 +00:00
ilja space
177d96f977 Improve how scaling is done
During code review a much better way was pointed out to do the emoji scaling, by using `em`.

*key uses 2em for emoji, which is smaller than Akkoma has. I now kept the 38px for Akkoma,
but when "zoom" (ie x2, x3, x4, tada) happens, we set to 2em and zoom from there.
2024-11-01 14:25:22 +01:00
Oneric
42ba77ebf4 Allow using custom source URLs 2024-10-26 16:32:14 +02:00
4a50b1273d Merge pull request 'fix panel z-index conflicting with heading popover' (#422) from tea/akkoma-fe:fix/panel-z-index into develop
Reviewed-on: AkkomaGang/akkoma-fe#422
2024-10-26 03:42:48 +00:00
c76dc6d79e Merge pull request 'Fix redirect on logout' (#420) from Oneric/akkoma-fe:logout-redirect into develop
Reviewed-on: AkkomaGang/akkoma-fe#420
2024-10-26 03:42:23 +00:00
cb4c581cde Merge pull request 'Add proper autocomplete prop for TOTP login field' (#424) from tudbut/akkoma-fe:develop into develop
Reviewed-on: AkkomaGang/akkoma-fe#424
2024-10-26 03:41:15 +00:00
TudbuT
8231c8f0b6
add proper autocomplete prop for TOTP login field 2024-10-19 19:19:15 +02:00
novenary
ef242a1ddd post_status_form: enable sync flush for watcher
This fixes drafts not clearing after posting a reply.

Vue 3.3.11 changed watchers to stop firing after component unmount.
After posting a reply, the post form is removed, now causing the queued
event to be discarded.
Synchronous flush causes the handler to be called immediately when
changes happen, solving the problem.

The performance impact of this change seems non-existent. Even before,
typing would generate an event for each keystroke. Pasting is atomic.

See: https://github.com/vuejs/core/pull/7181
See: 80e2128d52
Fixes: a7dea2f70f
Fixes: #413
2024-10-15 00:16:45 +03:00
tea
35cf3327c8 fix panel z-index conflicting with heading popover
resolves #342
2024-10-05 10:59:46 +02:00
Oneric
1ae09458c6 Fix redirect on logout
An instance may restrict access to the public timeline (among others)
to authenticated users and there already is a setting to decide which page
to show authenticated and unauthenticated viewers by default each.
However, the logout redirect didn't honour this setting
leading to potentially broken pages and errors on logout
2024-09-28 17:47:28 +02:00
ilja
25681cf5f6 Don't require # in the data-mfm-color attribute
For colour in MFM attributes, we expected a `#`, but that's apparently wrong. The BE
translates the `color` attribute in `$[fg.color=000 text]` into `data-mfm-color=000`.
But for the SCSS to work, we need to put it in the style attribute as `--mfm-color: #000`.

Generally we just add the attribute value as-is in the `style` attribute, but now we
have a special exception for color so we add a `#` before the value.
2024-08-18 15:48:22 +02:00
ilja
6666a273a4 MFM only use sanitised data-* attribute values
We take the value from a data-* attribute and then add this to the style attribute.
This will probably be OK in most cases, but just to be sure, we check for "weird" characters first.
For now we only allow letters, numbers, dot, hash, and plus and minus sign, because those are the ones I currently know of who are used in MFM.
The data-* attribute remains because it was already considered proper HTML as-is.
2024-08-11 18:11:03 +02:00
ilja
3210873d7f MFM make all supported tags suggested
When typing MFM, a sugestor drop-down appears so you can see and/or choose what MFM function to use
The new MFM functions we support have now also been added
2024-08-10 13:55:52 +02:00
ilja
f5f9949253 Fix mfm-position and mfm-scale
The `span`'s needed an inline-block for the transform to wrok
I also added an `overflow: hidden;` because these functions can make the text go beyond the borders of the StatusBody
With `overflow: hidden;`, it won't show outside of the borders
2024-08-10 13:13:47 +02:00
ilja
ba4ae5badb Fix MFM functions x2, x3, and x4
These now work for the new, FEP-c16b compliant, representation
Nesting also works

It already worked for text and "normal" emoji, but now it also works for custom emoji
2024-08-10 12:45:37 +02:00
ilja
56a59e1b55 fill in data-mfm- variables
Things like `speed=0.1s` now work

I also noticed a class was set on StatusBody, but we don't use it, we use StatusContent.
Therefor I removed it now.

We do still pass the setting through StatusBody to RichContent bc it's used there to decide to not show greentext for arrows when MFM was used.
Note that while this setting still works
* You have to refresh the page to see it working (was already like this, so I didn't touch it here)
* It explicitly checks for content type. If womeone provides MFM-like HTML, then it will still show as greentext if that option is enabled
  I think it's a bit inconsistent, but otoh, the inconsistency to me seems more that we ignore the greentext option for one input type specifically

I do still notice generall bugs with MFM.
* Position doesn't seem to work, neither does scale.
* There also seems to be a regression where custom emojis don't become larger any more with e.g. `$[x2 :hehe: ]`

I don't assume the regression is made in this commit, so I add this already. The rest needs to be fixed before merging.
2024-08-05 17:23:15 +02:00
ilja
3065416c93 Make new SCSS work for non-variables
The SCSS that we took from Foundkey in a previous commit, is now working
The settings for disabling MFM or only show animation on hover are working
The previous representation also works and it's clearly marked in the code what is legacy
All the MFM SCSS is now located in one file specifically for MFM, ./src/components/status_content/mfm.scss

This is only SCSS:
* The variables who are provided as data-attributes are not working yet
* `sparkle` also doesn't work
2024-08-04 19:10:25 +02:00
94141dcb3c Message from commiter: Add Foundkey MFM stylesheet
This is part of a bigger work to fix MFM in Akkoma
See <AkkomaGang/akkoma#381>

Here we add the MFM stylesheet as it is used by Foundkey
See <b22e627089>

Foundkey uses MFM and both the Founkey and Akkoma projects and communities, have historically been closely related
As such it makes sense to start with feature-parity with Foundkey

This commit only adds the stylesheet so that correct attribution is given
Properly integrating and making it work will happen in later commits
2024-08-04 17:55:32 +02:00
ilja
92e278d406 Move MFM SCSS to separate SCSS
MFM was defined in three places.
There was ./src/components/status_body/status_body.scss => I moved this to ./src/components/status_content/mfm.scss
There was ./src/components/status_content/status_content.vue => I moved this to ./src/components/status_content/mfm.scss
There's ./static/mfm.css => I kept this as-is

./src/components/status_content/mfm.scss is now being loaded in ./src/components/status_content/status_content.vue

I added a comment in both ./src/components/status_content/mfm.scss and ./static/mfm.css referencing each other

Note that this is just a first step in an overhoal of how MFM is handled. It seemed easier to do this as a first step and then build further on that.
2024-08-04 17:44:21 +02:00
94ed0991bc reverted 2e83ccefdc and clarified that compact user info is only used with enough room 2024-07-06 14:54:24 +02:00
e955eb4503 oops, unfucked username placement 2024-07-03 18:58:50 +02:00
c39d9fa64b fixed stuff overflowing in user popup e.g. in notifs 2024-07-03 18:30:51 +02:00
a74a631793 stopped user handle from overflowing from its boundaries in user card 2024-07-03 17:45:40 +02:00
2e83ccefdc disabled "compact user info" setting in mobile layout 2024-07-03 17:35:13 +02:00
cf11b2523e disabled compact user card in mobile layout 2024-07-03 17:26:09 +02:00
85001814a2 added setting for user info compactness 2024-06-26 18:09:13 +02:00
c902219997 added setting to switch between center and left-aligned user bio 2024-06-26 17:20:50 +02:00
2e2e87db75 expand underlay to screen edges when TL is widened 2024-06-26 16:43:32 +02:00
b2af067fd3 reverted visual changes to underlay 2024-06-26 16:39:04 +02:00
754cd2fa57 slightly adjusted edit button spacing 2024-06-16 17:15:04 +02:00
31055fb4f2 removed min-width statements that were messing up my layouts 2024-06-16 17:14:59 +02:00
918b0e3770 stopped username from wrapping… 2024-06-16 17:14:14 +02:00
88aae1706a oops, removed unneeded spacing 2024-06-16 17:14:08 +02:00
3d2a8a3ca2 left-aligned bio text
why the fuck was it centered in the first place?!?
2024-06-16 17:14:03 +02:00
a24fff5d5b moved user stats to between user info and user actions 2024-06-16 17:14:00 +02:00
4abddf5e6a made wide column layout optional 2024-06-16 16:37:33 +02:00
1b4df9e79d reverted audio attachments to 4:1 aspect ratio 2024-06-16 16:37:30 +02:00
45fe334cd7 fixed sizing issues with attachments in some non-status containers 2024-06-16 16:37:26 +02:00
dd32a33d59 fixed media attachment heights 2024-06-16 16:37:22 +02:00
74b651a3a2 made attached images max size scale with font size
meta-comment: eliminated corner-case weirdness by replaced cursed CSS with slightly less cursed CSS
2024-06-16 16:37:07 +02:00
21fe7d76d3 made columns use more space, fixed minor bug 2024-06-16 16:35:46 +02:00
112 changed files with 2872 additions and 896 deletions

View file

@ -42,10 +42,13 @@ steps:
- develop
- stable
image: node:20
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
environment:
SCW_ACCESS_KEY:
from_secret: SCW_ACCESS_KEY
SCW_SECRET_KEY:
from_secret: SCW_SECRET_KEY
SCW_DEFAULT_ORGANIZATION_ID:
from_secret: SCW_DEFAULT_ORGANIZATION_ID
commands:
- apt-get update && apt-get install -y rclone wget zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64
@ -64,11 +67,13 @@ steps:
- stable
environment:
CI: "true"
SCW_ACCESS_KEY:
from_secret: SCW_ACCESS_KEY
SCW_SECRET_KEY:
from_secret: SCW_SECRET_KEY
SCW_DEFAULT_ORGANIZATION_ID:
from_secret: SCW_DEFAULT_ORGANIZATION_ID
image: python:3.10-slim
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
commands:
- apt-get update && apt-get install -y rclone wget git zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64

View file

@ -3,12 +3,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
## Unreleased (3.19)
## 2026.03 (3.18.0) - 2026-03-14
### REMOVED
- dropped obsolete and buggy dm timeline
### Added
- UI for conversations API, replacing the DM timeline.
Here each thread (conversation) has its own timeline and read markers instead of mixing everything together.
- Boosts now show when and with which visibility they were boosted
- bookmarks are now accessible via the narrow/mobile UI
### Fixed
- fixed saving fallback cop yof settings to local browser storage
- improve image animation detection further
- fix status content parsing for mention and hashtag detection; this could lock the UI until reload
- fix display of nsfw attachment overlays on webkit
## Between 2022.09 (3.2.0) and 2025.12 (3.17.0)
A whole lot of stuff, but we forgot to update the changelog besides the one entry below, oopsi
- Implemented remote interaction with statuses
## 2022.09 - 2022-09-10
## 2022.09 (3.2.0) - 2022-09-10
### Added
- Automatic post translations. Must be configured on the backend in order to work.
- Post editing, including a log of previous edits.

View file

@ -24,7 +24,7 @@ Make sure you have [Node.js](https://nodejs.org/) installed. You can check `/.wo
``` bash
# install dependencies
corepack enable
npm install -g corepack
yarn
# serve with hot reload at localhost:8080
@ -55,3 +55,4 @@ Edit config.json for configuration.
### Login methods
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.

View file

@ -15,12 +15,13 @@ put a file that looks like this
```json
{
"myPack": "/static/stickers/myPack"
"myPack": "/static/stickers/myPack/"
}
```
This file is a mapping from name to pack directory location. It says "we have a pack called myPack, look for
it at `/static/stickers/myPack`". You can add as many packs as you like in this manner.
it inside `/static/stickers/myPack`". You can add as many packs as you like in this manner.
Note that a single leading and a trailing slash are **required** to work correctly!
## Creating the pack

View file

@ -6,7 +6,6 @@
<title>Akkoma</title>
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<link rel="stylesheet" href="/static/custom.css">
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta-->

View file

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "3.10.0",
"version": "3.18.0",
"description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
@ -128,5 +128,6 @@
"engines": {
"node": ">= 16.0.0",
"npm": ">= 3.0.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -59,7 +59,8 @@ export default {
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown
'-has-new-post-button': this.newPostButtonShown,
'-wide-timeline': this.widenTimeline
},
'-' + this.layoutType
]
@ -93,6 +94,9 @@ export default {
newPostButtonShown () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
widenTimeline () {
return this.$store.getters.mergedConfig.widenTimeline
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
layoutType () { return this.$store.state.interface.layoutType },

View file

@ -172,6 +172,10 @@ nav {
background-color: rgba(0, 0, 0, 0.15);
background-color: var(--underlay, rgba(0, 0, 0, 0.15));
z-index: -1000;
.-wide-timeline & {
margin:0 calc(var(--columnGap) / -2);
}
}
.app-layout {
@ -187,12 +191,17 @@ nav {
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
padding: 0 calc(var(--columnGap) / 2);
align-content: flex-start;
flex-wrap: wrap;
justify-content: center;
min-height: 100vh;
overflow-x: clip;
&.-wide-timeline {
--maxiColumn: minmax(var(--miniColumn), 1fr);
}
.column {
--___columnMargin: var(--columnGap);

View file

@ -12,7 +12,6 @@ import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
@ -183,6 +182,12 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('renderMisskeyMarkdown')
copyInstanceOption('sidebarRight')
if (config.backendCommitUrl)
copyInstanceOption('backendCommitUrl')
if (config.frontendCommitUrl)
copyInstanceOption('frontendCommitUrl')
return store.dispatch('setTheme', config['theme'])
}
@ -247,17 +252,6 @@ const getStickers = async ({ store }) => {
}
}
const getAppSecret = async ({ store }) => {
const { state, commit } = store
const { oauth, instance } = state
return getOrCreateApp({ ...oauth, instance: instance.server, commit })
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
@ -345,7 +339,7 @@ const setConfig = async ({ store }) => {
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {

View file

@ -1,12 +1,14 @@
import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import DMConvTimeline from 'components/dm_conv_timeline/dm_conv_timeline.vue'
import DMConvList from 'components/dm_conv_list/dm_conv_list.vue'
import DMConvRecipients from 'components/dm_conv_recipients/dm_conv_recipients.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
@ -47,6 +49,9 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'bubble-timeline', path: '/main/bubble', component: BubbleTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/main/conversations', component: DMConvList, beforeEnter: validateAuthenticatedRoute },
{ name: 'dm_conversation', path: '/main/conversations/:id', component: DMConvTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'dm-conversation-recipients', path: '/main/conversations/:id/recipients', component: DMConvRecipients },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
@ -62,7 +67,6 @@ export default (store) => {
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile, meta: { dontScroll: true } },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-request-sent', path: '/registration-request-sent', component: RegistrationRequestSent },
{ name: 'awaiting-email-confirmation', path: '/awaiting-email-confirmation', component: AwaitingEmailConfirmation },

View file

@ -19,6 +19,17 @@
height: 200px;
position: relative;
overflow: hidden;
align-content: center;
.status-popover & {
height: 200px;
}
}
&.-nsfw-placeholder {
.attachment-wrapper {
align-content: unset;
}
}
.description-container {
@ -115,6 +126,24 @@
align-items: center;
justify-content: center;
padding-top: 0.5em;
p {
line-height: 1.5;
padding: 0 0.5em;
white-space: pre-line;
text-align: center;
max-height: 200px;
overflow-y: auto;
scrollbar-color: var(--border) #0000;
width: 100%;
box-sizing: border-box;
.status-popover & {
text-overflow: ellipsis;
overflow: hidden;
height: 1lh;
}
}
}

View file

@ -267,11 +267,11 @@ const conversation = {
},
replies () {
let i = 1
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
const irid = in_reply_to_status_id
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
@ -414,6 +414,14 @@ const conversation = {
},
toggleExpanded () {
this.expanded = !this.expanded
const navHeight = document.getElementById("nav").offsetHeight
const headingHeight = document.getElementsByClassName("timeline-heading")[0].offsetHeight
document.documentElement.style.setProperty("--timeline-scroll-margin-top", `${navHeight + headingHeight}px`)
this.$nextTick(() => {
if (!this.expanded) {
this.$el.scrollIntoView({ block: 'nearest' })
}
})
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]

View file

@ -278,5 +278,7 @@
&.-expanded.status-fadein {
margin: calc(var(--status-margin, $status-margin) / 2);
}
scroll-margin-block-start: var(--timeline-scroll-margin-top);
}
</style>

View file

@ -105,6 +105,18 @@
v-if="(currentUser || !privateMode) && showNavShortcuts"
class="nav-items right"
>
<router-link
v-if="currentUser"
class="nav-icon"
:to="{ name: 'dms' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="envelope"
:title="$t('nav.dm_conversations')"
/>
</router-link>
<router-link
v-if="currentUser"
class="nav-icon"

View file

@ -0,0 +1,80 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
const DMConvCard = {
components: {
ConfirmModal,
Status,
UserAvatar
},
props: {
conversation: {
type: Object,
required: true
},
compact: {
type: Boolean,
default: true
},
showLastStatus: {
type: Boolean,
default: true
},
showFullControls: {
type: Boolean,
default: false
}
},
emits: ['deleted'],
data () {
return {
showingDeleteConfirmDialogue: false
}
},
computed: {
shouldConfirmDelete() {
return this.$store.getters.mergedConfig.modalOnDeleteDMConversation;
},
membersTruncated() {
// XXX: this should ideally adapt to panel width
const maxLen = 11
const full = this.conversation.accounts
const truncated = full.length > maxLen
const truncList = truncated ? full.slice(0, maxLen) : full
return {
truncated: truncated,
users: truncList
}
},
last_status_text() {
return this.conversation.last_status?.content
}
},
methods: {
markRead() {
this.$store.dispatch('markDMConversationAsRead', { id: this.conversation.id })
},
showDeleteConfirmModal() {
this.showingDeleteConfirmDialogue = true
},
hideDeleteConfirmModal() {
this.showingDeleteConfirmDialogue = false
},
deleteConversation() {
if (this.shouldConfirmDelete) {
this.showDeleteConfirmModal()
} else {
this.doDeleteConversation()
}
},
doDeleteConversation() {
this.$store.dispatch('deleteDMConversation', { id: this.conversation.id })
this.hideDeleteConfirmModal()
this.$emit('deleted')
}
}
}
export default DMConvCard

View file

@ -0,0 +1,134 @@
<template>
<div class="dm-conv-card">
<router-link
:to="{ name: 'dm_conversation', params: {id: conversation.id }}"
>
<div class="heading">
<div class="title-bar">
<div class="title-bar-left">
<div
v-if="conversation.unread"
class="unread"
>
<span
class="badge badge-notification"
role="figure"
:title="$t('dm_conv.unread_msg')"
:alt="$t('dm_conv.unread_msg')"
>
!
</span>
<button
class="button-unstyled"
:title="$t('dm_conv.mark_single_read_tooltip')"
@click.stop.prevent="markRead()"
>
<FAIcon
icon="check"
class="fa-scale-110 fa-old-padding dm-conv-mark-read"
/>
</button>
&nbsp;
</div>
<h4>{{ $t('dm_conv.default_name', {id: conversation.id}) }}</h4>
</div>
<div class="title-bar-right">
<button
class="button-unstyled button-delete"
:title="$t('dm_conv.delete_tooltip')"
@click.stop.prevent="deleteConversation()"
>
<FAIcon
icon="trash-alt"
class="fa-scale-110 fa-old-padding dm-conv-delete"
/>
</button>
</div>
</div>
</div>
<div class="members">
<UserAvatar
v-for="user in membersTruncated.users"
:key="user.id"
:user="user"
:compact="compact"
/>
<div
v-if="membersTruncated.truncated"
class="ellipsis"
>
...
</div>
</div>
</router-link>
<div
v-if="showLastStatus"
class="last-message"
>
<div class="last-message-title">
{{ $t('dm_conv.last_message_title') }}:
</div>
<Status
:statusoid="conversation.last_status"
:compact="true"
:is-preview="true"
/>
</div>
<div
v-if="showFullControls"
class="controls"
>
<button
class="btn button-default"
:title="$t('dm_conv.recipients_edit_mode_button_tooltip')"
@click.once="$router.push({ name: 'dm-conversation-recipients', params: { id: conversation.id }})"
>
{{ $t('dm_conv.recipients_edit_mode_button') }}
</button>
</div>
<teleport to="#modal">
<confirm-modal
v-if="showingDeleteConfirmDialogue"
:title="$t('dm_conv.delete_confirm_title')"
:confirm-text="$t('dm_conv.delete_confirm_accept_button')"
:cancel-text="$t('dm_conv.delete_confirm_cancel_button')"
@accepted="doDeleteConversation"
@cancelled="hideDeleteConfirmModal"
>
{{ $t('dm_conv.delete_confirm', { identifier: conversation.id }) }}
</confirm-modal>
</teleport>
</div>
</template>
<script src="./dm_conv_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.dm-conv-card {
.heading, .title-bar, .title-bar-left, .members {
display: flex;
flex-wrap: nowrap;
overflow-x: hidden;
}
.title-bar {
width: 100%;
justify-content: space-between;
}
.controls {
text-align: center;
}
.members {
padding: 6px 0;
}
.last-message-title {
font-style: italic;
color: var(--faint);
}
}
</style>

View file

@ -0,0 +1,33 @@
import DMConvCard from '../dm_conv_card/dm_conv_card.vue'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
const PaginatedDMConvList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchDMConversationList'),
select: (props, $store) => $store.state.dmConversations.allDMConversations || [],
destroy: (props, $store) => $store.dispatch('clearDMConversations'),
childPropName: 'items',
additionalPropNames: []
})(List)
const DMConvList = {
components: {
PaginatedDMConvList,
DMConvCard
},
data () {
return {}
},
computed: {
conversations() {
return this.$store.state.dmConversations.allDMConversations
}
},
methods: {
markAllRead() {
this.$store.dispatch('markAllDMConversationsAsRead')
}
}
}
export default DMConvList

View file

@ -0,0 +1,47 @@
<template>
<div class="settings panel panel-default dm-conv-panel">
<div class="panel-heading">
<div class="title">
{{ $t('nav.dm_conv_list') }}
</div>
</div>
<div class="panel-controls">
<button
class="btn button-default mark-all-read-button"
@click="markAllRead()"
>
{{ $t('dm_conv.mark_all_read_button') }}
</button>
</div>
<div class="panel-body dm-conv-list">
<PaginatedDMConvList>
<template #item="{item}">
<DMConvCard :conversation="item" />
</template>
</PaginatedDMConvList>
</div>
</div>
</template>
<script src="./dm_conv_list.js"></script>
<style lang="scss">
.dm-conv-panel {
.dm-conv-list {
margin: 0 1em;
.dm-conv-card {
margin: 2.5em 0;
}
}
.panel-controls {
margin-top: 0.5em;
text-align: center;
}
.mark-all-read-button {
display: inline-block;
}
}
</style>

View file

@ -0,0 +1,57 @@
import { mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ListUserSearch from '../list_user_search/list_user_search.vue'
const DMConvRecipients = {
data () {
return {
conversationId: null,
conversation: null,
recipients: [],
suggestions: []
}
},
components: {
BasicUserCard,
ListUserSearch
},
computed: {
conversationTitle () {
return this.$i18n.t('dm_conv.default_name', {id: this.conversationId})
},
...mapGetters(['findUser'])
},
methods: {
toggleUser (user) {
if (this.isRecipient(user)) {
this.recipients.filter((r) => r.id !== user.id)
} else {
this.recipients.push(user)
}
},
isRecipient (user) {
return this.recipients.some((r) => r.id == user.id)
},
onResults (results) {
this.suggestions = results.map((id) => this.findUser(id)).filter(user => user)
},
updateRecipients () {
const recipientIds = this.recipients.map((u) => u.id)
this.$store.dispatch('setDMConversationDetails', {id: this.conversationId, recipients: recipientIds })
.then((updateConv) => {
this.conversation = updateConv
this.recipients = updateConv.accounts
})
}
},
created () {
this.conversationId = this.$route.params.id
this.$store.dispatch('fetchDMConversationDetails', { id: this.conversationId })
.then((data) => {
this.conversation = data
this.recipients = data.accounts
})
},
}
export default DMConvRecipients

View file

@ -0,0 +1,82 @@
<template>
<div class="panel-default panel dm-conv-recipients-edit">
<div
ref="header"
class="panel-heading"
>
<div class="title">
{{ $t('dm_conv.recipients_edit_title', {conversation_name: conversationTitle}) }}
</div>
<button
class="btn button-default"
@click="$router.back"
>
{{ $t('nav.back') }}
</button>
</div>
<h4>
{{ $t('dm_conv.recipients_edit_current_title') }}
</h4>
<div class="member-list current-recipients">
<div
v-for="user in recipients"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
class="selected"
@click.capture.prevent="toggleUser(user)"
/>
</div>
</div>
<h4>
{{ $t('dm_conv.recipients_edit_add_new_title') }}
</h4>
<ListUserSearch @results="onResults" />
<div class="member-list">
<div
v-for="user in suggestions"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isRecipient(user) ? 'selected' : ''"
@click.capture.prevent="toggleUser(user)"
/>
</div>
</div>
<button
class="btn button-default"
@click="updateRecipients"
>
{{ $t('dm_conv.recipients_save') }}
</button>
</div>
</template>
<script src="./dm_conv_recipients.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.dm-conv-recipients-edit {
.member-list {
padding-bottom: 0.7rem;
}
.current-recipients {
margin-bottom: 1.5em;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
}
</style>

View file

@ -0,0 +1,34 @@
import DMConvCard from '../dm_conv_card/dm_conv_card.vue'
import Timeline from '../timeline/timeline.vue'
const DMConvTimeline = {
data () {
return {
conversationId: null
}
},
components: {
DMConvCard,
Timeline
},
computed: {
conversation () { return this.$store.getters.getDMConversationById(this.conversationId) },
timeline () { return this.$store.state.statuses.timelines.dmConv }
},
methods: {
forceLeave () {
this.$router.push('/')
}
},
created () {
this.conversationId = this.$route.params.id
this.$store.dispatch('fetchDMConversationDetails', { id: this.conversationId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'dmConv', conversationId: this.conversationId })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'dmConv')
this.$store.commit('clearTimeline', { timeline: 'dmConv' })
}
}
export default DMConvTimeline

View file

@ -0,0 +1,24 @@
<template>
<Timeline
title="$t('dm_conv.page_header')"
:timeline="timeline"
:conversation-id="conversationId"
timeline-name="dmConv"
>
<template
#extraHeading
>
<DMConvCard
v-if="conversation"
:conversation="conversation"
:compact="false"
:show-full-controls="true"
:show-last-status="false"
:link-to-timeline="false"
@deleted="forceLeave"
/>
</template>
</Timeline>
</template>
<script src="./dm_conv_timeline.js"></script>

View file

@ -1,14 +0,0 @@
import Timeline from '../timeline/timeline.vue'
const DMs = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.dms
}
},
components: {
Timeline
}
}
export default DMs

View file

@ -1,9 +0,0 @@
<template>
<Timeline
:title="$t('nav.dms')"
:timeline="timeline"
:timeline-name="'dms'"
/>
</template>
<script src="./dm_timeline.js"></script>

View file

@ -1,3 +1,5 @@
import StillImage from '../still-image/still-image.vue'
const EMOJI_SIZE = 32 + 8
const GROUP_TITLE_HEIGHT = 24
const BUFFER_SIZE = 3 * EMOJI_SIZE
@ -17,6 +19,9 @@ const EmojiGrid = {
resizeObserver: null
}
},
components: {
StillImage
},
mounted () {
const rect = this.$refs.container.getBoundingClientRect()
this.containerWidth = rect.width

View file

@ -34,10 +34,11 @@
@click.stop.prevent="onEmoji(item.emoji)"
>
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
<img
<StillImage
v-else
:src="item.emoji.imageUrl"
>
no-stop-gifs="true"
/>
</span>
</template>
</div>

View file

@ -1,5 +1,6 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import StillImage from '../still-image/still-image.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -120,7 +121,8 @@ const EmojiInput = {
}
},
components: {
EmojiPicker
EmojiPicker,
StillImage
},
computed: {
padEmoji () {

View file

@ -20,6 +20,7 @@
ref="picker"
show-keep-open
:class="{ hide: !showPicker }"
:visible="showPicker"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@ -47,10 +48,11 @@
v-if="!suggestion.mfm"
class="image"
>
<img
<StillImage
v-if="suggestion.img"
:src="suggestion.img"
>
no-stop-gifs="true"
/>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">

View file

@ -1,4 +1,4 @@
const MFM_TAGS = ['blur', 'bounce', 'flip', 'font', 'jelly', 'jump', 'rainbow', 'rotate', 'shake', 'sparkle', 'spin', 'tada', 'twitch', 'x2', 'x3', 'x4']
const MFM_TAGS = ['bg', 'blur', 'bounce', 'center', 'fg', 'flip', 'font', 'jelly', 'jump', 'position', 'rainbow', 'rotate', 'scale', 'shake', 'sparkle', 'spin', 'tada', 'twitch', 'x2', 'x3', 'x4']
.map(tag => ({ displayText: tag, detailText: '$[' + tag + ' ]', replacement: '$[' + tag + ' ]', mfm: true }))
/**

View file

@ -1,6 +1,7 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiGrid from '../emoji_grid/emoji_grid.vue'
import StillImage from '../still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
@ -26,12 +27,17 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
},
visible: {
required: false,
type: Boolean,
default: true
}
},
data () {
return {
keyword: '',
activeGroup: 'standard',
activeGroup: this.getDefaultGroup(),
showingStickers: false,
keepOpen: false
}
@ -39,7 +45,8 @@ const EmojiPicker = {
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox,
EmojiGrid
EmojiGrid,
StillImage
},
methods: {
debouncedSearch: debounce(function (e) {
@ -82,6 +89,11 @@ const EmojiPicker = {
return list.filter(emoji => {
return (regex.test(emoji.displayText) || (!emoji.imageUrl && emoji.replacement === this.keyword))
})
},
getDefaultGroup () {
if (!this.visible) return null
const recentEmojis = this.$store.getters.recentEmojis
return recentEmojis.length === 0 ? 'standard' : 'recent'
}
},
computed: {
@ -148,6 +160,13 @@ const EmojiPicker = {
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0 && this.enableStickerPicker
}
},
watch: {
visible (val, oldVal) {
if (val && this.activeGroup === null) {
this.activeGroup = this.getDefaultGroup()
}
}
}
}

View file

@ -18,10 +18,11 @@
@click.prevent="highlight(group.id)"
>
<span v-if="!group.first.imageUrl">{{ group.first.replacement }}</span>
<img
<StillImage
v-else
:src="group.first.imageUrl"
>
no-stop-gifs="true"
/>
</span>
<span
v-if="stickerPickerEnabled"

View file

@ -11,7 +11,7 @@
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
<span
<template
v-if="reaction.url !== null"
>
<StillImage
@ -19,16 +19,15 @@
:title="reaction.name"
:alt="reaction.name"
class="reaction-emoji"
height="2.55em"
/>
{{ reaction.count }}
</span>
<span v-else>
</template>
<template v-else>
<span class="reaction-emoji unicode-emoji">
{{ reaction.name }}
</span>
<span>{{ reaction.count }}</span>
</span>
</template>
</button>
</UserListPopover>
<a
@ -53,23 +52,26 @@
container-type: inline-size;
}
.unicode-emoji {
font-size: 210%;
}
.emoji-reaction {
padding: 0 0.5em;
padding: 2px 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
align-items: end;
.reaction-emoji {
width: auto;
max-width: 96cqw;
height: 2.55em !important;
margin-right: 0.25em;
&.still-image {
height: 2.55em;
}
&.unicode-emoji {
display: inline-block;
font-size: 2.125em; // assuming default line height of 1.2rem and emojis that don't exceed line height
line-height: 2.55rem;
}
}
&:focus {
outline: none;
@ -97,9 +99,9 @@
}
.button-default.picked-reaction {
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
&, &:hover {
box-shadow: inset 0 0 0 1px var(--accent, $fallback--link);
}
}
</style>

View file

@ -91,7 +91,7 @@ const ExtraButtons = {
.catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.statusLink)
navigator.clipboard.writeText(this.status.canonical_id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
@ -187,13 +187,6 @@ const ExtraButtons = {
noTranslationTargetSet () {
return this.$store.getters.mergedConfig.translationLanguage === undefined
},
statusLink () {
if (this.status.is_local) {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
} else {
return this.status.external_url
}
},
shouldConfirmDelete () {
return this.$store.getters.mergedConfig.modalOnDelete
},

View file

@ -88,10 +88,8 @@ const Gallery = {
set(this.sizes, id, { width, height })
},
rowStyle (row) {
if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
if (!row.audio && !row.minimal && !row.grid) {
return { 'aspect-ratio': `1/${(1 / (row.items.length + 0.6))}` }
}
},
itemStyle (id, row) {

View file

@ -96,9 +96,15 @@
.gallery-row {
position: relative;
height: 0;
width: 100%;
flex-grow: 1;
.Status & {
max-height: 30em;
}
&.-audio {
aspect-ratio: 4/1; // this is terrible, but it's how it was before so I'm not changing it >:(
}
&:not(:first-child) {
margin-top: 0.5em;

View file

@ -43,7 +43,7 @@ const LoginForm = {
}
oauthApi.getOrCreateApp(data)
.then((app) => { oauthApi.login({ ...app, ...data }) })
.then((app) => { oauthApi.login({ ...data, ...app }) })
},
submitPassword () {
const { clientId } = this.oauth

View file

@ -24,14 +24,15 @@
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<img
<StillImage
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
:image-load-handler="onImageLoaded"
no-stop-gifs="true"
/>
</PinchZoom>
</SwipeClick>
<VideoAttachment

View file

@ -42,8 +42,14 @@ const mediaUpload = {
.then((fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
}, (error) => {
self.$emit('upload-failed', 'default')
}, (error) => {
var msg = typeof error === "string" ? error : error.message
if (msg) {
self.$emit('upload-failed', 'message', [msg])
} else {
self.$emit('upload-failed', 'default')
}
console.warn(`Failed to upload media: ${error}`)
self.decreaseUploadCount()
})
},

View file

@ -18,6 +18,7 @@
<input
id="code"
v-model="code"
autocomplete="one-time-code"
class="form-control"
>
</div>

View file

@ -53,6 +53,9 @@ const NavPanel = {
federating: state => state.instance.federating,
}),
...mapGetters(['unreadAnnouncementCount']),
unreadDMConversationsCount () {
return this.$store.state.users.currentUser?.pleroma?.unread_conversation_count || 0
},
followRequestCount () {
return this.$store.state.users.currentUser.follow_requests_count
}

View file

@ -25,6 +25,24 @@
<TimelineMenuContent class="timelines" />
</div>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms' }"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="envelope"
/>{{ $t("nav.dm_conversations") }}
<span
v-if="unreadDMConversationsCount > 0"
class="badge badge-notification"
>
{{ unreadDMConversationsCount }}
</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"

View file

@ -50,6 +50,13 @@ export default {
totalVotesCount () {
return this.poll.votes_count
},
totalFractionBase () {
// Due to a backend bug, we might not have any voter count info for remote polls
// in this case, fall back to count of votes even for multiple cjoice polls
// to be able to at least display _something_
const total_base = this.poll.multiple ? this.poll.voters_count : this.poll.votes_count
return total_base > 0 ? total_base : this.poll.votes_count
},
containerClass () {
return {
loading: this.loading
@ -70,10 +77,11 @@ export default {
},
methods: {
percentageForOption (count) {
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
const total = this.totalFractionBase
return total === 0 ? 0 : Math.round(count / total * 100)
},
resultTitle (option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
return `${option.votes_count}/${this.totalFractionBase} ${this.$t('polls.votes')}`
},
fetchPoll () {
this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })

View file

@ -24,6 +24,7 @@
<button
v-if="options.length > 2"
class="delete-option button-unstyled -hover-highlight"
type="button"
@click="deleteOption(index)"
>
<FAIcon icon="times" />
@ -32,6 +33,7 @@
<button
v-if="options.length < maxOptions"
class="add-option faint button-unstyled -hover-highlight"
type="button"
@click="addOption"
>
<FAIcon

View file

@ -10,6 +10,7 @@ import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy, debounce } from 'lodash'
import { usePostLanguageOptions } from 'src/lib/post_language'
import scopeUtils from 'src/lib/scope_utils.js'
import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
@ -85,6 +86,7 @@ const PostStatusForm = {
'quoteId',
'repliedUser',
'attentions',
'copyMessageLanguage',
'copyMessageScope',
'subject',
'disableSubject',
@ -148,13 +150,12 @@ const PostStatusForm = {
const preset = this.$route.query.message
let statusText = preset || ''
if (this.replyTo || this.quoteId) {
if (this.replyTo || this.quoteId || this.repliedUser) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const { postContentType: contentType, postLanguage: defaultPostLanguage, sensitiveByDefault, sensitiveIfSubject, interfaceLanguage, alwaysShowSubjectInput } = this.$store.getters.mergedConfig
const postLanguage = defaultPostLanguage || interfaceToISOLanguage(interfaceLanguage)
const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject, alwaysShowSubjectInput } = this.$store.getters.mergedConfig
let statusParams = {
spoilerText: this.subject || '',
@ -165,7 +166,7 @@ const PostStatusForm = {
poll: {},
mediaDescriptions: {},
visibility: this.suggestedVisibility(),
language: postLanguage,
language: this.suggestedLanguage(),
contentType
}
@ -180,7 +181,7 @@ const PostStatusForm = {
poll: this.statusPoll || {},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || this.suggestedVisibility(),
language: this.statusLanguage || postLanguage,
language: this.statusLanguage || this.suggestedLanguage(),
contentType: statusContentType
}
}
@ -329,6 +330,7 @@ const PostStatusForm = {
watch: {
'newStatus': {
deep: true,
flush: 'sync',
handler () {
this.statusChanged()
}
@ -341,17 +343,22 @@ const PostStatusForm = {
this.saveDraft()
},
clearStatus () {
const newStatus = this.newStatus
const config = this.$store.getters.mergedConfig
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
language: newStatus.language,
nsfw: !!config.sensitiveByDefault,
visibility: this.suggestedVisibility(),
contentType: config.postContentType,
language: this.suggestedLanguage(),
poll: {},
mediaDescriptions: {}
}
const scopeselector = this.$refs.scopeselector
if (scopeselector) {
scopeselector.currentScope = this.newStatus.visibility
}
this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
@ -511,7 +518,7 @@ const PostStatusForm = {
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
if (this.$store.getters.mergedConfig.sensitiveIfSubject && this.newStatus.spoilerText !== '') {
if (this.$store.getters.mergedConfig.sensitiveIfSubject && this.newStatus.spoilerText !== '' || !!this.$store.getters.mergedConfig.sensitiveByDefault) {
this.newStatus.nsfw = true
}
this.$emit('resize', { delayed: true })
@ -760,16 +767,19 @@ const PostStatusForm = {
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
},
suggestedVisibility () {
if (this.copyMessageScope) {
if (this.copyMessageScope === 'direct') {
return this.copyMessageScope
}
if (this.copyMessageScope !== 'public' && this.$store.state.users.currentUser.default_scope !== 'private') {
return this.copyMessageScope
}
suggestedLanguage () {
// Make sure the inherited language is actually valid
if (this.postLanguageOptions.find(o => o.value === this.copyMessageLanguage)) {
return this.copyMessageLanguage
}
return this.$store.state.users.currentUser.default_scope
const { postLanguage: defaultPostLanguage, interfaceLanguage } = this.$store.getters.mergedConfig
const postLanguage = defaultPostLanguage || interfaceToISOLanguage(interfaceLanguage)
return postLanguage
},
suggestedVisibility () {
const maxScope = this.copyMessageScope
const defaultScope = this.$store.state.users.currentUser.default_scope
return scopeUtils.negotiate(defaultScope, maxScope)
}
}
}

View file

@ -18,6 +18,7 @@
>
<button
class="button-unstyled -link"
type="button"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
@ -136,6 +137,7 @@
class="form-post-subject"
@input="onSubjectInput"
@focus="focusSubjectInput()"
@keydown.exact.enter.prevent
>
</EmojiInput>
<i18n-t
@ -194,6 +196,7 @@
>
<scope-selector
v-if="!disableVisibilitySelector"
ref="scopeselector"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:initial-scope="newStatus.visibility"
@ -201,10 +204,11 @@
/>
<div
class="format-selector-container">
class="format-selector-container"
>
<div
class="format-selector"
>
>
<Select
id="post-language"
v-model="newStatus.language"
@ -272,6 +276,7 @@
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
type="button"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
@ -281,6 +286,7 @@
class="poll-icon button-unstyled"
:class="{ selected: pollFormVisible }"
:title="$t('polls.add_poll')"
type="button"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
@ -290,6 +296,7 @@
class="spoiler-icon button-unstyled"
:class="{ selected: subjectVisible }"
:title="$t('post_status.toggle_content_warning')"
type="button"
@click="toggleSubjectVisible"
>
<FAIcon icon="eye-slash" />

View file

@ -7,8 +7,16 @@ const QuoteButton = {
name: 'QuoteButton',
props: ['status', 'quoting', 'visibility'],
computed: {
loggedIn () {
return !!this.$store.state.users.currentUser
showButton () {
const currentUserId = this.$store.state.users.currentUser?.id
if (!currentUserId)
return false
if (['public', 'unlisted', 'local'].includes(this.visibility))
return true
return (this.visibility === 'private' && currentUserId == this.status.user.id)
}
}
}

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="(visibility === 'public' || visibility === 'unlisted') && loggedIn"
v-if="showButton"
class="QuoteButton"
>
<button

View file

@ -1,4 +1,6 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import scopeUtils from 'src/lib/scope_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faRetweet } from '@fortawesome/free-solid-svg-icons'
@ -7,12 +9,16 @@ library.add(faRetweet)
const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'],
components: {
ConfirmModal
ConfirmModal,
ScopeSelector
},
data () {
const maxScope = this.status.visibility
const defaultScope = this.$store.state.users.currentUser.default_scope
return {
animated: false,
showingConfirmDialog: false
showingConfirmDialog: false,
retweetVisibility: scopeUtils.negotiate(defaultScope, maxScope)
}
},
methods: {
@ -25,7 +31,7 @@ const RetweetButton = {
},
doRetweet () {
if (!this.status.repeated) {
this.$store.dispatch('retweet', { id: this.status.id })
this.$store.dispatch('retweet', { id: this.status.id, visibility: this.retweetVisibility })
} else {
this.$store.dispatch('unretweet', { id: this.status.id })
}
@ -40,6 +46,9 @@ const RetweetButton = {
},
hideConfirmDialog () {
this.showingConfirmDialog = false
},
changeVis (visibility) {
this.retweetVisibility = visibility
}
},
computed: {
@ -54,7 +63,13 @@ const RetweetButton = {
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
},
userDefaultScope () {
return this.$store.state.users.currentUser.default_scope
},
statusScope () {
return this.status.visibility
},
}
}

View file

@ -49,6 +49,12 @@
@cancelled="hideConfirmDialog"
>
{{ $t('status.repeat_confirm') }}
<scope-selector
:user-default="userDefaultScope"
:original-scope="statusScope"
:initial-scope="retweetVisibility"
:on-scope-change="changeVis"
/>
</confirm-modal>
</teleport>
</div>

View file

@ -121,6 +121,19 @@ export default {
}
}
const mfmStyleFromDataAttributes = (attributes) => {
// CSS selectors can check if a data-* attribute is true, but can't use other values, so we want to add them to the style attribute
// Here we turn e.g. `{'data-mfm-some': '1deg', 'data-mfm-thing': '5s'}` to "--mfm-some: 1deg;--mfm-thing: 5s;"
// Note that we only add the value to `style` when they contain only letters, numbers, dot, or minus signs
// At the moment of writing, this should be enough for legitimate purposes and reduces the chance of injection by using special characters
// There is a special case for the `color` value, who is provided without `#`, but requires this in the `style` attribute
return Object.keys(attributes).filter(
(key) => key.startsWith('data-mfm-') && attributes[key] !== true && /^[a-zA-Z0-9.\-]*$/.test(attributes[key])
).map(
(key) => '--mfm-' + key.substr(9) + (key === 'data-mfm-color' ? ': #' : ': ') + attributes[key] + ';'
).reduce((a,v) => a+v, '')
}
// Processor to use with html_tree_converter
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
@ -191,6 +204,15 @@ export default {
if (this.handleLinks && attrs?.['class']?.includes?.('h-card')) {
return ['', children.map(processItem), '']
}
let mfm_style = mfmStyleFromDataAttributes(attrs)
if (mfm_style !== '') {
return [
opener.slice(0,-1) + ' style="' + mfm_style + '">',
children.map(processItem),
closer
]
}
}
if (children !== undefined) {

View file

@ -6,6 +6,8 @@ import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
import scopeUtils from 'src/lib/scope_utils.js'
library.add(
faEnvelope,
faGlobe,
@ -13,18 +15,11 @@ library.add(
faLockOpen
)
const SCOPE_LEVELS = {
'direct': 0,
'private': 1,
'local': 2,
'unlisted': 2,
'public': 3
}
const ScopeSelector = {
props: [
'showAll',
'userDefault',
// scope of parent object
'originalScope',
'initialScope',
'onScopeChange'
@ -39,16 +34,16 @@ const ScopeSelector = {
return !this.showPublic && !this.showUnlisted && !this.showPrivate && !this.showDirect
},
showPublic () {
return this.originalScope !== 'direct' && this.shouldShow('public')
return this.shouldShow('public')
},
showLocal () {
return this.originalScope !== 'direct' && this.shouldShow('local')
return this.shouldShow('local')
},
showUnlisted () {
return this.originalScope !== 'direct' && this.shouldShow('unlisted')
return this.shouldShow('unlisted')
},
showPrivate () {
return this.originalScope !== 'direct' && this.shouldShow('private')
return this.shouldShow('private')
},
showDirect () {
return this.shouldShow('direct')
@ -65,15 +60,10 @@ const ScopeSelector = {
},
methods: {
shouldShow (scope) {
if (!this.originalScope) {
if (!this.originalScope)
return true
}
if (this.originalScope === 'local') {
return scope === 'direct' || scope === 'local'
}
return SCOPE_LEVELS[scope] <= SCOPE_LEVELS[this.originalScope]
else
return scopeUtils.isSubScope(this.originalScope, scope)
},
changeVis (scope) {
this.currentScope = scope

View file

@ -69,7 +69,7 @@ const SettingsModal = {
this.$store.dispatch('closeSettingsModal')
},
logout () {
this.$router.replace('/main/public')
this.$router.replace(this.$store.state.instance.redirectRootNoLogin || '/main/all')
this.$store.dispatch('closeSettingsModal')
this.$store.dispatch('logout')
},

View file

@ -159,6 +159,16 @@
{{ $t('settings.show_page_backgrounds') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="centerAlignBio">
{{ $t('settings.center_align_bio') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="compactUserInfo">
{{ $t('settings.compact_user_info') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }}
@ -269,6 +279,11 @@
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="widenTimeline">
{{ $t('settings.widen_timeline') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
@ -320,6 +335,11 @@
{{ $t('settings.confirm_dialogs_deny_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDeleteDMConversation">
{{ $t('settings.confirm_dialogs_delete_dm_conv') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>

View file

@ -1,22 +1,25 @@
import { extractCommit } from 'src/services/version/version.service'
const pleromaFeCommitUrl = 'https://akkoma.dev/AkkomaGang/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://akkoma.dev/AkkomaGang/akkoma/commit/'
function joinURL(base, subpath) {
return URL.parse(subpath, base)?.href || "invalid base URL"
}
const VersionTab = {
data () {
const instance = this.$store.state.instance
return {
backendCommitUrl: instance.backendCommitUrl,
backendVersion: instance.backendVersion,
frontendCommitUrl: instance.frontendCommitUrl,
frontendVersion: instance.frontendVersion
}
},
computed: {
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
return joinURL(this.frontendCommitUrl, this.frontendVersion)
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
return joinURL(this.backendCommitUrl, extractCommit(this.backendVersion))
}
}
}

View file

@ -9,6 +9,7 @@ import {
faHome,
faComments,
faBolt,
faBookmark,
faUserPlus,
faBullhorn,
faSearch,
@ -25,6 +26,7 @@ library.add(
faHome,
faComments,
faBolt,
faBookmark,
faUserPlus,
faBullhorn,
faSearch,
@ -43,10 +45,6 @@ const SideDrawer = {
}),
created () {
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: { UserCard },
computed: {
@ -59,6 +57,9 @@ const SideDrawer = {
unseenNotificationsCount () {
return this.unseenNotifications.length
},
unreadDMConversationsCount () {
return this.$store.state.users.currentUser?.pleroma?.unread_conversation_count || 0
},
suggestionsEnabled () {
return this.$store.state.instance.suggestionsEnabled
},

View file

@ -55,6 +55,24 @@
/> {{ $t("nav.timelines") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'dms' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="envelope"
/> {{ $t("nav.dm_conversations") }}
<span
v-if="unreadDMConversationsCount > 0"
class="badge badge-notification"
>
{{ unreadDMConversationsCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
@ -67,6 +85,18 @@
/> {{ $t("nav.lists") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'bookmarks' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bookmark"
/> {{ $t("nav.bookmarks") }}
</router-link>
</li>
</ul>
<ul v-if="currentUser">
<li @click="toggleDrawer">

View file

@ -169,8 +169,8 @@ const Status = {
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'quoting', 'mediaPlaying']),
muteWords () {
return this.mergedConfig.muteWords
muteWordRules () {
return this.$store.getters.parsedConfigVal('muteWords')
},
showReasonMutedThread () {
return (
@ -215,6 +215,7 @@ const Status = {
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
retweeterVisibility () { return this.statusoid.visibility },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
@ -230,7 +231,7 @@ const Status = {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
return muteWordHits(this.status, this.muteWordRules)
},
rtBotStatus () {
return this.statusoid.user.bot
@ -440,6 +441,9 @@ const Status = {
visibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
retweeterVisibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.statusoid.visibility)
},
isEdited () {
return this.status.edited_at !== null
},

View file

@ -99,20 +99,43 @@
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
>
{{ retweeter }}
</router-link>
</div>
{{ ' ' }}
<div
class="repeat-tooltip"
>
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
<span
v-if="retweeterVisibility"
class="visibility-icon"
:title="retweeterVisibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(retweeterVisibility)"
/>
</span>
{{ ' ' }}
<Timeago
class="timeago"
:time="statusoid.created_at"
:with-direction="!compact"
:auto-update="60"
/>
</div>
</div>
@ -519,6 +542,7 @@
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-language="status.language"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
@ -533,6 +557,7 @@
:quote-id="status.id"
:attentions="[status.user]"
:replied-user="status.user"
:copy-message-language="status.language"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleQuoting"

View file

@ -41,7 +41,8 @@ const StatusContent = {
postLength: this.status.text.length,
parseReadyDone: false,
renderMisskeyMarkdown,
translateFrom: null
translateFrom: null,
translating: false
}
},
computed: {
@ -135,7 +136,10 @@ const StatusContent = {
},
translateStatus () {
const translateTo = this.$store.getters.mergedConfig.translationLanguage || this.$store.state.instance.interfaceLanguage
this.$store.dispatch('translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom })
this.translating = true
this.$store.dispatch(
'translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom }
).finally(() => { this.translating = false })
}
}
}

View file

@ -3,6 +3,7 @@
.StatusBody {
display: flex;
flex-direction: column;
overflow: hidden;
.translation {
border: 1px solid var(--accent, $fallback--link);
@ -23,24 +24,6 @@
transition: 0.05s;
}
._mfm_x2_ {
.emoji {
height: 100px;
}
}
._mfm_x3_ {
.emoji {
height: 150px;
}
}
._mfm_x4_ {
.emoji {
height: 200px;
}
}
.attachments {
margin-top: 0.5em;
}

View file

@ -1,7 +1,7 @@
<template>
<div
class="StatusBody"
:class="{ '-compact': compact, 'mfm-disabled': !renderMisskeyMarkdown }"
:class="{ '-compact': compact }"
>
<div class="body">
<div
@ -91,6 +91,7 @@
{{ ' ' }}
<button
class="btn button-default"
:disabled="translating"
@click="translateStatus"
>
{{ $t('status.translate') }}

View file

@ -0,0 +1,423 @@
/**
* "FEP-c16b: Formatting MFM functions" attributes that Akkoma supports
*/
.StatusContent:not(.mfm-disabled) {
/* The following are the non-animated MFM */
.mfm-center {
display: block;
text-align: center;
}
.mfm-flip {
display: inline-block;
transform: scaleX(-1);
}
.mfm-flip[data-mfm-v] {
transform: scaleY(-1);
}
.mfm-flip[data-mfm-v][data-mfm-h] {
transform: scale(-1, -1);
}
.mfm-font[data-mfm-serif] {
font-family: serif;
}
.mfm-font[data-mfm-monospace] {
font-family: monospace;
}
.mfm-font[data-mfm-cursive] {
font-family: cursive;
}
.mfm-font[data-mfm-fantasy] {
font-family: fantasy;
}
.mfm-font[data-mfm-emoji] {
font-family: emoji;
}
.mfm-font[data-mfm-math] {
font-family: math;
}
.mfm-blur {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0);
}
}
.mfm-rotate {
display: inline-block;
transform: rotate(calc(var(--mfm-deg, 90) * 1deg));
transform-origin: center center;
}
.mfm-x2 {
--mfm-zoom-size: 200%;
}
.mfm-x3 {
--mfm-zoom-size: 400%;
}
.mfm-x4 {
--mfm-zoom-size: 600%;
}
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
.emoji {
--emoji-size: 2em;
}
font-size: var(--mfm-zoom-size);
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
/* only half effective */
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
/* disabled */
font-size: 100%;
}
}
}
.mfm-position {
display: inline-block;
transform: translate(calc(var(--mfm-x, 0) * 1em), calc(var(--mfm-y, 0) * 1em));
}
.mfm-scale {
display: inline-block;
transform: scale(var(--mfm-x, 1), var(--mfm-y, 1));
}
.mfm-fg {
color: var(--mfm-color, #f00);
}
.mfm-bg {
background-color: var(--mfm-color, #0f0);
}
/* The following are the animated MFM */
/* .mfm-hover means that we should only play animation when hovering over the StatusContent
* So either StatusContent does not have this class,
* or it has the class and we are hovering over StatusContent
*/
&:not(.mfm-hover:not(:hover)) {
.mfm-jelly {
display: inline-block;
animation: mfm-rubberBand var(--mfm-speed, 1s) linear infinite both;
}
.mfm-twitch {
display: inline-block;
animation: mfm-twitch var(--mfm-speed, 0.5s) ease infinite;
}
.mfm-shake {
display: inline-block;
animation: mfm-shake var(--mfm-speed, 0.5s) ease infinite;
}
.mfm-spin {
display: inline-block;
animation: mfm-spin var(--mfm-speed, 1.5s) linear infinite;
}
.mfm-spin[data-mfm-y] {
animation-name: mfm-spinY;
}
.mfm-spin[data-mfm-x] {
animation-name: mfm-spinX;
}
.mfm-spin[data-mfm-alternate] {
animation-direction: alternate;
}
.mfm-spin[data-mfm-left] {
animation-direction: reverse;
}
.mfm-jump {
display: inline-block;
animation: mfm-jump var(--mfm-speed, 0.75s) linear infinite;
}
.mfm-bounce {
display: inline-block;
animation: mfm-bounce var(--mfm-speed, 0.75s) linear infinite;
transform-origin: center bottom;
}
.mfm-rainbow {
animation: mfm-rainbow var(--mfm-speed, 1s) linear infinite;
}
.mfm-tada {
display: inline-block;
animation: mfm-tada var(--mfm-speed, 1s) linear infinite both;
--mfm-zoom-size: 150%;
}
}
/* animation keyframes */
@keyframes mfm-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mfm-spinX {
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
}
@keyframes mfm-spinY {
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
}
@keyframes mfm-jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes mfm-bounce {
0% { transform: translateY(0) scale(1, 1); }
25% { transform: translateY(-16px) scale(1, 1); }
50% { transform: translateY(0) scale(1, 1); }
75% { transform: translateY(0) scale(1.5, 0.75); }
100% { transform: translateY(0) scale(1, 1); }
}
@keyframes mfm-twitch {
0% { transform: translate(7px, -2px); }
5% { transform: translate(-3px, 1px); }
10% { transform: translate(-7px, -1px); }
15% { transform: translate(0, -1px); }
20% { transform: translate(-8px, 6px); }
25% { transform: translate(-4px, -3px); }
30% { transform: translate(-4px, -6px); }
35% { transform: translate(-8px, -8px); }
40% { transform: translate(4px, 6px); }
45% { transform: translate(-3px, 1px); }
50% { transform: translate(2px, -10px); }
55% { transform: translate(-7px, 0); }
60% { transform: translate(-2px, 4px); }
65% { transform: translate(3px, -8px); }
70% { transform: translate(6px, 7px); }
75% { transform: translate(-7px, -2px); }
80% { transform: translate(-7px, -8px); }
85% { transform: translate(9px, 3px); }
90% { transform: translate(-3px, -2px); }
95% { transform: translate(-10px, 2px); }
100% { transform: translate(-2px, -6px); }
}
@keyframes mfm-shake {
0% { transform: translate(-3px, -1px) rotate(-8deg); }
5% { transform: translate(0, -1px) rotate(-10deg); }
10% { transform: translate(1px, -3px) rotate(0deg); }
15% { transform: translate(1px, 1px) rotate(11deg); }
20% { transform: translate(-2px, 1px) rotate(1deg); }
25% { transform: translate(-1px, -2px) rotate(-2deg); }
30% { transform: translate(-1px, 2px) rotate(-3deg); }
35% { transform: translate(2px, 1px) rotate(6deg); }
40% { transform: translate(-2px, -3px) rotate(-9deg); }
45% { transform: translate(0, -1px) rotate(-12deg); }
50% { transform: translate(1px, 2px) rotate(10deg); }
55% { transform: translate(0, -3px) rotate(8deg); }
60% { transform: translate(1px, -1px) rotate(8deg); }
65% { transform: translate(0, -1px) rotate(-7deg); }
70% { transform: translate(-1px, -3px) rotate(6deg); }
75% { transform: translate(0, -2px) rotate(4deg); }
80% { transform: translate(-2px, -1px) rotate(3deg); }
85% { transform: translate(1px, -3px) rotate(-10deg); }
90% { transform: translate(1px, 0) rotate(3deg); }
95% { transform: translate(-2px, 0) rotate(-3deg); }
100% { transform: translate(2px, 1px) rotate(2deg); }
}
@keyframes mfm-rubberBand {
0% { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
100% { transform: scale3d(1, 1, 1); }
}
@keyframes mfm-rainbow {
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
}
@keyframes mfm-tada {
0%,
100% { transform: scale3d(1, 1, 1); }
10%,
20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
30%,
50%,
70%,
90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
40%,
60%,
80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
}
/**
* Legacy MFM
* This is for backwards compatibility with posts formatted on Akkoma before support for FEP-c16b
* Note that it uses the keyframes as defined above for the FEP-c16b compatible MFM representation
*/
.mfm {
display: inline-block;
}
/* The following are the legacy non-animated MFM */
._mfm_flip_[data-h][data-v] {
transform: scale(-1, -1);
}
._mfm_flip_[data-v] {
transform: scaleY(-1);
}
._mfm_flip_:not([data-v]) {
transform: scaleX(-1);
}
._mfm_x2_ {
font-size: 200%;
}
._mfm_x3_ {
font-size: 400%;
}
._mfm_x4_ {
font-size: 600%;
}
._mfm_x2_ {
.emoji {
height: 100px;
}
}
._mfm_x3_ {
.emoji {
height: 150px;
}
}
._mfm_x4_ {
.emoji {
height: 200px;
}
}
._mfm_blur_ {
filter: blur(6px);
transition: filter 0.3s;
}
._mfm_blur_:hover {
filter: blur(0);
}
._mfm_rotate_ {
transform: rotate(90deg);
transform-origin: center center;
}
/* The following are the legacy animated MFM */
/* .mfm-hover means that we should only play animation when hovering over the StatusContent
* So either StatusContent does not have this class,
* or it has the class and we are hovering over StatusContent
*/
&:not(.mfm-hover:not(:hover)) {
._mfm_tada_ {
font-size: 150%;
animation: mfm-tada 1s linear infinite both;
}
._mfm_jelly_ {
animation: mfm-rubberBand 1s linear infinite both;
}
._mfm_twitch_ {
animation: mfm-twitch 0.5s ease infinite;
}
._mfm_shake_ {
animation: mfm-shake 0.5s ease infinite;
}
._mfm_spin_ {
animation: mfm-spin 0.5s linear infinite;
}
._mfm_spin_[data-x] {
animation-name: mfm-spinX;
}
._mfm_spin_[data-y] {
animation-name: mfm-spinY;
}
._mfm_spin_[left] {
animation-direction: reverse;
}
._mfm_spin_[alternate] {
animation-direction: alternate;
}
._mfm_jump_ {
animation: mfm-jump 0.75s linear infinite;
}
._mfm_bounce_ {
animation: mfm-bounce 0.75s linear infinite;
transform-origin: center bottom;
}
._mfm_rainbow_ {
animation: mfm-rainbow 1s linear infinite;
}
}
}

View file

@ -106,6 +106,9 @@ const StatusContent = {
renderMisskeyMarkdown () {
return this.mergedConfig.renderMisskeyMarkdown
},
hasResolvedQuote () {
return !!this.status.quote
},
...mapGetters(['mergedConfig']),
...mapState({
currentUser: state => state.users.currentUser

View file

@ -1,7 +1,7 @@
<template>
<div
class="StatusContent"
:class="{ '-compact': compact, 'mfm-hover': renderMfmOnHover, 'mfm-disabled': !renderMisskeyMarkdown }"
:class="{ '-compact': compact, 'mfm-hover': renderMfmOnHover, 'mfm-disabled': !renderMisskeyMarkdown, 'quote-resolved': hasResolvedQuote }"
>
<slot name="header" />
<StatusBody
@ -64,6 +64,7 @@
</template>
<script src="./status_content.js"></script>
<style lang="scss" src="./mfm.scss" />
<style lang="scss">
.StatusContent {
flex: 1;
@ -75,28 +76,15 @@
height: 50px;
}
}
&.mfm-hover:not(:hover) {
.mfm {
animation: none !important;
}
}
&.mfm-disabled {
span {
font-size: 100% !important;
}
.mfm {
animation: none !important;
}
.emoji {
height: 32px !important;
}
}
}
.quote-inline,
.quote-resolved .quote-inline,
.quote + .link-preview {
display: none;
display: none;
}
.quote-resolved .quote .quote-inline {
display: inline;
}
</style>

View file

@ -7,12 +7,19 @@ const StillImage = {
'imageLoadHandler',
'alt',
'height',
'width'
'width',
'noStopGifs'
],
data () {
return {
stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches,
stopGifs:
!this.noStopGifs
&& (
this.$store.getters.mergedConfig.stopGifs
|| window.matchMedia('(prefers-reduced-motion: reduce)').matches
),
isAnimated: false,
isPixelArt: false,
imageTypeLabel: ''
}
},
@ -34,11 +41,18 @@ const StillImage = {
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
this.detectAnimation(image)
this.detectPixelArt(image)
this.drawThumbnail()
},
onError () {
this.imageLoadError && this.imageLoadError()
},
detectPixelArt (image) {
// Safe maximum: 32x32 image, equivalent or smaller
this.isPixelArt ||= image.naturalHeight * image.naturalWidth <= 32 * 32;
// Common size for oldweb badges.
this.isPixelArt ||= image.naturalWidth == 88 && image.naturalHeight == 31;
},
detectAnimation (image) {
const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable
@ -59,39 +73,51 @@ const StillImage = {
},
detectAnimationWithFetch (image) {
// Browser Cache should ensure image doesn't get loaded twice if cache exists
fetch(image.src, {
return fetch(image.src, {
referrerPolicy: 'same-origin'
})
.then(data => {
// We don't need to read the whole file so only call it once
data.body.getReader().read()
return data.body.getReader().read()
.then(reader => {
// Ordered from least to most intensive
if (this.isGIF(reader.value)) {
this.isAnimated = true
this.setLabel('GIF')
return
return true
}
if (this.isAnimatedWEBP(reader.value)) {
this.isAnimated = true
this.setLabel('WEBP')
return
return true
}
if (this.isAnimatedPNG(reader.value)) {
this.isAnimated = true
this.setLabel('APNG')
return true
}
return false
})
})
.catch(() => {
// this.imageLoadError && this.imageLoadError()
return null
})
},
detectWithMediaProxy (image) {
this.detectAnimationWithFetch(image)
},
detectWithoutMediaProxy (image) {
// We'll just assume that gifs and webp are animated
async detectWithoutMediaProxy (image) {
// If media is local, we can still fetch,
// otherwise CORS wont allow it and we fall back to checking extensions
// (XXX: ideally wed _only_ fetch if its an allowed domain;
// i.e. local media or proxy, but we currently have no way of knowing)
const classifiedAsAnim = await this.detectAnimationWithFetch(image)
if (classifiedAsAnim != null) {
return
}
// Otherwise we'll just make a guess based on extension
const extension = image.src.split('.').pop().toLowerCase()
if (extension === 'gif') {
@ -104,18 +130,15 @@ const StillImage = {
this.setLabel('WEBP')
return
}
// Beware the apng! use this if ye dare
// if (extension === 'png') {
// this.isAnimated = true
// this.setLabel('PNG')
// return
// }
// Hail mary for extensionless
if (extension.includes('/')) {
// Don't mind the CORS error barrage
this.detectAnimationWithFetch(image)
// Beware: APNGs also sometimes use just a plain png extension!
// (but this would mislabel too many images as "animated")
if (extension === 'apng') {
this.isAnimated = true
this.setLabel('APNG')
return
}
// Hail mary for extensionless files we cannot fetch
},
setLabel (name) {
this.imageTypeLabel = name;
@ -207,7 +230,7 @@ const StillImage = {
}
context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image
context.imageSmoothingEnabled = true;
context.imageSmoothingEnabled = !this.isPixelArt;
context.imageSmoothingQuality = 'high';
// Draw the good one for realsies

View file

@ -2,7 +2,7 @@
<div
ref="still-image"
class="still-image"
:class="{ animated: animated }"
:class="{ animated: animated, pixelart: isPixelArt }"
:style="style"
>
<div
@ -95,5 +95,8 @@
visibility: visible;
}
}
&.pixelart {
image-rendering: pixelated;
}
}
</style>

View file

@ -22,6 +22,7 @@ const Timeline = {
'title',
'userId',
'listId',
'conversationId',
'tag',
'embedded',
'count',
@ -119,6 +120,7 @@ const Timeline = {
showImmediately,
userId: this.userId,
listId: this.listId,
conversationId: this.conversationId,
tag: this.tag
})
},
@ -181,6 +183,7 @@ const Timeline = {
showImmediately: true,
userId: this.userId,
listId: this.listId,
conversationId: this.conversationId,
tag: this.tag
}).then(({ statuses }) => {
if (statuses && statuses.length === 0) {

View file

@ -14,6 +14,13 @@
z-index: 2;
}
.timeline-extra-heading {
width: 100%;
padding-top: 0.5em;
padding-bottom: 0.5em;
background-color: var(--panel, #182230);
}
&.-nonpanel {
.timeline-heading {
text-align: center;

View file

@ -52,6 +52,12 @@
</button>
</div>
</div>
<div
v-if="$slots.extraHeading"
class="timeline-extra-heading"
>
<slot name="extraHeading" />
</div>
<div :class="classes.body">
<div
ref="timeline"

View file

@ -13,7 +13,7 @@ export const timelineNames = () => {
return {
'friends': 'nav.home_timeline',
'bookmarks': 'nav.bookmarks',
'dms': 'nav.dms',
'dm_conversation': 'nav.dm_conversation',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
'bubble-timeline': 'nav.bubble_timeline'

View file

@ -80,22 +80,6 @@
>{{ $t("nav.bookmarks") }}</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="envelope"
/>
<span
:title="$t('nav.dms')"
:aria-label="$t('nav.dms')"
>{{ $t("nav.dms") }}</span>
</router-link>
</li>
</ul>
</template>

View file

@ -80,22 +80,6 @@
>{{ $t("nav.bookmarks") }}</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="envelope"
/>
<span
:title="$t('nav.dms')"
:aria-label="$t('nav.dms')"
>{{ $t("nav.dms") }}</span>
</router-link>
</li>
</ul>
</template>

View file

@ -19,7 +19,6 @@ export const timelineNames = () => {
return {
'friends': 'nav.home_timeline',
'bookmarks': 'nav.bookmarks',
'dms': 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
'bubble-timeline': 'nav.bubble_timeline'

View file

@ -117,6 +117,11 @@ export default {
shouldConfirmMute () {
return this.mergedConfig.modalOnMute
},
compactUserInfo () {
return this.$store.getters.mergedConfig.compactUserInfo
&& (this.$store.state.interface.layoutType !== 'mobile')
&& this.switcher
},
...mapGetters(['mergedConfig'])
},
components: {
@ -192,7 +197,7 @@ export default {
this.$store.dispatch('setCurrentMedia', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
this.$store.dispatch('openPostStatusModal', { repliedUser: this.user })
}
}
}

View file

@ -21,6 +21,13 @@
position: relative;
}
.user-buttons {
grid-area: edit;
display: flex;
padding: .5em 0 .5em 0;
justify-self: end;
}
.panel-body {
word-wrap: break-word;
border-bottom-right-radius: inherit;
@ -53,7 +60,6 @@
}
&-bio {
text-align: center;
display: block;
line-height: 1.3;
padding: 1em;
@ -100,15 +106,14 @@
padding: 0 26px;
.container {
min-width: 0;
padding: 16px 0 6px;
display: flex;
align-items: flex-start;
max-height: 56px;
> * {
min-width: 0;
}
display: grid;
grid-template-areas:
"pfp name edit"
"pfp summary summary"
"stats stats stats";
grid-template-columns: auto 1fr auto;
align-items: start;
.Avatar {
--_avatarShadowBox: var(--avatarShadow);
@ -123,6 +128,7 @@
}
&-avatar-link {
grid-area: pfp;
position: relative;
cursor: pointer;
@ -153,8 +159,8 @@
.external-link-button, .edit-profile-button {
cursor: pointer;
width: 2.5em;
text-align: center;
width: 2.3em;
text-align: right;
margin: -0.5em 0;
padding: 0.5em 0;
@ -165,12 +171,16 @@
}
.user-summary {
display: block;
grid-area: summary;
display: grid;
grid-template-areas:
"name name name name name"
"hand role lock avg _";
grid-template-columns:
auto auto auto auto 1fr;
justify-items: start;
margin-left: 0.6em;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
// This is so that text doesn't get overlapped by avatar's shadow if it has
// big one
z-index: 1;
@ -178,56 +188,82 @@
--emoji-size: 1.7em;
.top-line,
.bottom-line {
display: flex;
}
}
.user-name {
text-overflow: ellipsis;
overflow: hidden;
flex: 1 1 auto;
margin-right: 1em;
font-size: 1.1em;
}
.bottom-line {
font-weight: light;
font-size: 1.1em;
align-items: baseline;
.lock-icon {
.user-locked {
margin-left: 0.5em;
grid-area: lock;
}
.user-screen-name {
min-width: 1px;
flex: 0 1 auto;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
grid-area: hand;
}
.dailyAvg {
min-width: 1px;
flex: 0 0 auto;
margin-left: 1em;
font-size: 0.7em;
color: $fallback--text;
color: var(--text, $fallback--text);
grid-area: avg;
}
.user-role {
flex: none;
color: $fallback--text;
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--alertNeutral, $fallback--fg);
.user-roles {
display: flex;
grid-area: role;
.user-role {
color: $fallback--text;
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--alertNeutral, $fallback--fg);
}
}
}
.user-counts {
grid-area: stats;
display: flex;
line-height:16px;
padding-top: 0.5em;
text-align: center;
justify-content: space-around;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
align-self: center;
.user-count {
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}
}
.user-name {
text-align: start;
text-overflow: ellipsis;
overflow: hidden;
margin-left: 0.6em;
font-size: 1.1em;
grid-area: name;
align-self: center;
white-space: nowrap;
max-width: 100%;
z-index: 1; // so shadow from user avatar doesn't overlap it
}
.user-meta {
margin-bottom: .15em;
display: flex;
@ -290,34 +326,21 @@
margin: 0;
}
}
&.-compact {
.container {
grid-template-areas:
"pfp name stats edit"
"pfp summary stats edit";
grid-template-columns: auto auto 1fr auto;
}
.user-counts {
padding-top: 0;
justify-content: space-evenly;
}
}
}
.sidebar .edit-profile-button {
display: none;
}
.user-counts {
display: flex;
line-height:16px;
padding: .5em 1.5em 0em 1.5em;
text-align: center;
justify-content: space-between;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
}
.user-count {
flex: 1 0 auto;
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}

View file

@ -9,7 +9,10 @@
class="background-image"
/>
<div class="panel-heading -flexible-height">
<div class="user-info">
<div
class="user-info"
:class="{ '-compact': compactUserInfo }"
>
<div class="container">
<a
v-if="allowZoomingAvatar"
@ -29,6 +32,7 @@
</a>
<router-link
v-else
class="user-info-avatar-link"
:to="userProfileLink(user)"
>
<UserAvatar
@ -36,94 +40,124 @@
:user="user"
/>
</router-link>
<RichContent
:title="user.name"
class="user-name"
:html="user.name"
:emoji="user.emoji"
/>
<div class="user-summary">
<div class="top-line">
<RichContent
:title="user.name"
class="user-name"
:html="user.name"
:emoji="user.emoji"
/>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
<router-link
class="user-screen-name"
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
@{{ user.screen_name_ui }}
</router-link>
<span
v-if="!hideBio && (user.deactivated || !!visibleRole || user.bot)"
class="user-roles"
>
<span
v-if="user.deactivated"
class="alert user-role"
>
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="external-link-alt"
/>
</a>
<a
v-if="isOtherUser"
:href="user.statusnet_profile_url + '.rss'"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="rss"
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div>
<div class="bottom-line">
<router-link
class="user-screen-name"
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
@{{ user.screen_name_ui }}
</router-link>
<template v-if="!hideBio">
<span
v-if="user.deactivated"
class="alert user-role"
>
{{ $t('user_card.deactivated') }}
</span>
<span
v-if="!!visibleRole"
class="alert user-role"
>
{{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
{{ $t('user_card.bot') }}
</span>
</template>
<span v-if="user.locked">
<FAIcon
class="lock-icon"
icon="lock"
size="sm"
/>
{{ $t('user_card.deactivated') }}
</span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
v-if="!!visibleRole"
class="alert user-role"
>
{{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
{{ $t('user_card.bot') }}
</span>
</span>
<span
v-if="user.locked"
class="user-locked"
>
<FAIcon
class="lock-icon"
icon="lock"
size="sm"
/>
</span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
</div>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<div class="user-buttons">
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="external-link-alt"
/>
</a>
<a
v-if="isOtherUser"
:href="user.statusnet_profile_url + '.rss'"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="rss"
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div>
</div>
<div class="user-meta">
@ -269,38 +303,13 @@
v-if="!hideBio"
class="panel-body"
>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<RichContent
v-if="!hideBio"
class="user-card-bio"
:html="user.description_html"
:emoji="user.emoji"
:handle-links="true"
:style='{"text-align": $store.getters.mergedConfig.centerAlignBio ? "center" : "start"}'
/>
</div>
<teleport to="#modal">

View file

@ -486,6 +486,7 @@
"cGreen": "Grün (Retweet)",
"cOrange": "Orange (Favorisieren)",
"cRed": "Rot (Abbrechen)",
"center_align_bio": "Zentrale Textausrichtung in der Bio",
"change_email": "Ändere Email",
"change_email_error": "Es trat ein Problem auf beim Versuch, deine Email Adresse zu ändern.",
"change_password": "Passwort ändern",
@ -496,6 +497,7 @@
"checkboxRadius": "Auswahlfelder",
"collapse_subject": "Beiträge mit Inhaltswarnungen einklappen",
"columns": "Spalten",
"compact_user_info": "Kompakte Benutzerinfos wenn genug Platz",
"composing": "Verfassen",
"confirm_dialogs": "Bestätigung erforderlich für:",
"confirm_dialogs_approve_follow": "Annehmen einer Followanfrage",
@ -934,6 +936,7 @@
"title": "Version"
},
"virtual_scrolling": "Anzeige der Zeitleiste optimieren",
"widen_timeline": "Zeitleiste verbreitern, um horizontalen Platz zu füllen",
"word_filter": "Wortfilter",
"wordfilter": "Wortfilter"
},

View file

@ -7,15 +7,21 @@
"replace": "Αντικατάσταση"
},
"mrf_policies": "Ενεργοποιημένες πολιτικές MRF",
"mrf_policies_desc": "",
"mrf_policies_desc": "Οι πολιτικές MRF επηρεάζουν τη συμπεριφορά του instance. Οι ακόλουθες πολιτικές είναι ενεργοποιημένες:",
"simple": {
"accept": "Αποδοχή",
"accept_desc": "Αυτό το instance αποδέχεται μηνύματα μόνο από τα ακόλουθα instances:",
"ftl_removal": "Αφαίρεση από το χρονολόγιο \"Γνωστού Δίκτυου\"",
"ftl_removal_desc": "Αυτό το instance αφαιρεί αυτά τα instances από το χρονολόγιο \"Γνωστού Δικτύου\":",
"media_nsfw": "Επιβολή ορισμού μέσων ως ευαίσθητων",
"media_nsfw_desc": "Αυτό το instance αναγκάζει τα μέσα να ορίζονται ως ευαίσθητα στις αναρτήσεις στα ακόλουθα instances:",
"media_removal": "Αφαίρεση Μέσων",
"media_removal_desc": "Αυτό το instance αφαιρεί τα μέσα από τις αναρτήσεις των ακόλουθων instances:",
"quarantine": "Καραντίνα",
"quarantine_desc": "Αυτό το instance δε θα στέλνει αναρτήσεις στα ακόλουθα instances:",
"reason": "Λόγος",
"reject": "Απόρριψη",
"reject_desc": "Αυτό το instance δε θα δέχεται μηνύματα από τα παρακάτω instances:",
"simple_policies": "Πολιτικές του instance"
}
},
@ -32,11 +38,20 @@
"inactive_message": "Αυτή η ανακοίνωση είναι ανενεργή",
"page_header": "Ανακοινώσεις",
"post_action": "Ανάρτηση",
"post_error": "Σφάλμα: {error}",
"post_form_header": "Ανάρτηση ανακοίνωσης",
"post_placeholder": "Περιεχόμενο ανακοίνωσης",
"published_time_display": "Δημοσιεύτηκε {time}",
"start_time_display": "Ξεκινάει από {time}",
"title": "Ανακοίνωση"
},
"chats": {
"chats": "Συνομιλίες",
"delete": "Διαγραφή",
"delete_confirm": "Θέλετε σίγουρα να διαγράψετε αυτό το μήνυμα;",
"empty_chat_list_placeholder": "Δεν έχετε καμία συνομιλία. Ξεκινήστε μια νέα συνομιλία!",
"empty_message_error": "Δε μπορεί να σταλεί κενό μήνυμα",
"error_loading_chat": "Κάτι δεν πήγε καλά κατά τη φόρτωση της συνομιλίας.",
"error_sending_message": "Κάτι πήγε λάθος κατά την αποστολή του μηνύματος.",
"message_user": "Στείλε μήνυμα στον/στην {nickname}",
"more": "Περισσότερα",
@ -47,11 +62,15 @@
"today": "Σήμερα"
},
"domain_mute_card": {
"mute": "Σίγαση"
"mute": "Σίγαση",
"mute_progress": "Σίγαση…",
"unmute": "Αφαίρεση σίγασης",
"unmute_progress": "Αφαίρεση σίγασης…"
},
"emoji": {
"add_emoji": "Εισαγωγή emoji",
"load_all": "Φόρτωση όλων των {emojiAmount} emoji",
"load_all_hint": "Φορτώθηκαν τα πρώτα {saneAmount} emoji, η φόρτωση όλων των emoji μπορεί να προκαλέσει θέματα απόδοσης.",
"recent": "Χρησιμοποιήθηκαν πρόσφατα",
"search_emoji": "Αναζήτηση για ένα emoji",
"stickers": "Αυτοκόλλητα"
@ -63,7 +82,10 @@
"export": "Εξαγωγή"
},
"features_panel": {
"text_limit": "Όριο κειμένου"
"media_proxy": "Διαμεσολαβητής μέσων",
"text_limit": "Όριο κειμένου",
"title": "Δυνατότητες",
"upload_limit": "Όριο upload"
},
"file_type": {
"audio": "Ήχος",
@ -79,6 +101,8 @@
"enable": "Ενεργοποίηση",
"error_retry": "Παρακαλώ δοκιμάστε ξανά",
"flash_content": "Κάντε κλικ για την εμφάνιση Flash περιεχομένου με τη χρήση του Ruffle (Πειραματικό, μπορεί να μη λειτουργεί).",
"flash_fail": "Η φόρτωση περιεχομένου flash απέτυχε, δείτε στην κονσόλα για λεπτομέρειες.",
"generic_error": "Προέκυψε ένα σφάλμα",
"loading": "Φόρτωση…",
"more": "Περισσότερα",
"optional": "προαιρετικό",
@ -104,6 +128,7 @@
"save_without_cropping": "Αποθήκευση χωρίς περικοπή"
},
"importer": {
"error": "Προέκυψε ένα σφάλμα κατά την εισαγωγή αυτού του αρχείου.",
"success": "Εισήχθη επιτυχώς."
},
"languages": {

View file

@ -243,6 +243,27 @@
"search": "Search users",
"title": "List title"
},
"dm_conv": {
"default_name": "Conversation {id}",
"delete_confirm": "Are you sure you want to remove the conversation {identifier}?",
"delete_confirm_accept_button": "Yes, remove",
"delete_confirm_cancel_button": "No, keep",
"delete_confirm_title": "Remove DM conversation",
"delete_tooltip": "remove conversation from list",
"last_active_date": "Last activity",
"last_active_member": "Last active",
"last_message_title": "Last message",
"mark_all_read_button": "Mark all as read",
"mark_single_read_tooltip": "mark as read",
"page_header": "Direct Conversation",
"recipients_edit_add_new_title": "Add new member:",
"recipients_edit_current_title": "Core members besides yourself:",
"recipients_edit_mode_button": "Edit core members",
"recipients_edit_mode_button_tooltip": "Edit core members",
"recipients_edit_title": "Core members of {conversation_name}",
"recipients_save": "Save changes to core members",
"unread_msg": "has unread messages"
},
"login": {
"authentication_code": "Authentication code",
"description": "Log in with OAuth",
@ -306,6 +327,9 @@
"bubble_timeline": "Bubble timeline",
"bubble_timeline_description": "Posts from instances close to yours, as recommended by the admins",
"chats": "Chats",
"dm_conv_list": "Direct Conversations",
"dm_conversation": "Direct Conversation",
"dm_conversations": "Direct Conversations",
"dms": "Direct messages",
"friend_requests": "Follow requests",
"home_timeline": "Home timeline",
@ -488,6 +512,7 @@
"blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons",
"center_align_bio": "Center text in user bio",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
@ -502,11 +527,13 @@
"checkboxRadius": "Checkboxes",
"collapse_subject": "Collapse posts with content warnings",
"columns": "Columns",
"compact_user_info": "Compact user info when enough space",
"composing": "Composing",
"confirm_dialogs": "Require confirmation for:",
"confirm_dialogs_approve_follow": "Accepting a follow request",
"confirm_dialogs_block": "Blocking someone",
"confirm_dialogs_delete": "Deleting a post",
"confirm_dialogs_delete_dm_conv": "Deleting a DM conversation",
"confirm_dialogs_deny_follow": "Rejecting a follow request",
"confirm_dialogs_mute": "Muting someone",
"confirm_dialogs_repeat": "Repeating a post",
@ -726,7 +753,7 @@
"security": "Security",
"security_tab": "Security",
"sensitive_by_default": "Mark posts as sensitive by default",
"sensitive_if_subject": "Automatically mark images as sensitive if a content warning is specified",
"sensitive_if_subject": "Automatically mark post as sensitive if a content warning is specified",
"set_new_avatar": "Set new avatar",
"set_new_mascot": "Set new mascot",
"set_new_profile_background": "Set new profile background",
@ -948,6 +975,7 @@
},
"virtual_scrolling": "Optimize timeline rendering",
"use_blurhash": "Use blurhashes for NSFW thumbnails",
"widen_timeline": "Widen the Timeline to fill horizontal space",
"word_filter": "Word filter",
"wordfilter": "Wordfilter"
},

View file

@ -407,7 +407,8 @@
"private": "Ce statut sera visible par seulement vos abonné⋅e⋅s",
"public": "Ce statut sera visible par tout le monde",
"unlisted": "Ce statut ne sera pas visible dans les flux publics ni les flux fédérés"
}
},
"toggle_content_warning": "Activer/désactiver l'avertissement"
},
"registration": {
"awaiting_email_confirmation": "Votre compte a été enregistré et un courriel envoyé à votre adresse. Veuillez consulter votre boîte mail pour terminer la registration.",
@ -491,6 +492,7 @@
"cGreen": "Vert (partager)",
"cOrange": "Orange (aimer)",
"cRed": "Rouge (annuler)",
"center_align_bio": "Centrer le texte de la biographie",
"change_email": "Changer de courriel",
"change_email_error": "Il y a eu un problème pour changer votre courriel.",
"change_password": "Changez votre mot de passe",
@ -501,6 +503,7 @@
"checkboxRadius": "Cases à cocher",
"collapse_subject": "Réduire les messages avec des avertissements",
"columns": "Colonnes",
"compact_user_info": "Utiliser l'affichage compacte des biographies quand possible",
"composing": "Composition",
"confirm_dialogs": "Demander confirmation :",
"confirm_dialogs_approve_follow": "Accepter une demande de suivi",
@ -536,6 +539,8 @@
"enable_web_push_notifications": "Activer les notifications de push web",
"enter_current_password_to_confirm": "Entrez votre mot de passe actuel pour confirmer votre identité",
"expert_mode": "Avancé",
"expire_posts_enabled": "Supprimer les statuts après le nombre de jours demandé",
"expire_posts_input_placeholder": "Nombre de jours",
"export_theme": "Enregistrer le thème",
"file_export_import": {
"backup_restore": "Sauvegarde des Paramètres",
@ -599,7 +604,7 @@
"list_backups_error": "Erreur pendant la sauvegarde des listes: {error}",
"lock_account_description": "Limitez votre compte aux abonnés acceptés uniquement",
"loop_video": "Vidéos en boucle",
"loop_video_silent_only": "Boucle uniquement les vidéos sans le son (les « gifs » de Mastodon)",
"loop_video_silent_only": "Boucle uniquement les vidéos sans son (les « gifs » de Mastodon)",
"mascot": "Mascotte de l'interface Mastodon",
"max_depth_in_thread": "Nombre maximum de niveaux à afficher dans les fils par défaut",
"max_thumbnails": "Nombre maximum de miniatures par statuts",
@ -677,7 +682,9 @@
"pad_emoji": "Entourer les émoji d'espaces après leur sélections",
"panelRadius": "Fenêtres",
"pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas actif",
"permit_followback_description": "Accepter toutes les demandes de suivi envoyées par les comptes que vous suivez",
"play_videos_in_modal": "Jouer les vidéos directement dans le visionneur de médias",
"post_language": "Langue par défaut des statuts",
"post_look_feel": "Apparence des status",
"post_status_content_type": "Type de contenu du statuts",
"posts": "Statuts",
@ -745,6 +752,7 @@
"show_admin_badge": "Afficher le badge d'Admin sur mon profil",
"show_moderator_badge": "Afficher le badge de Modo' sur mon profil",
"show_nav_shortcuts": "Afficher plus de raccourcis de navigations dans le panneau supérieur",
"show_page_backgrounds": "Afficher des fonds d'écran propres aux pages, ex : les biographies",
"show_panel_nav_shortcuts": "Afficher les raccourcis de navigation du flux dans le panneau supérieur",
"show_scrollbars": "Afficher la barre de défilement",
"show_wider_shortcuts": "Plus d'espace entre les raccourcis dans le panneau supérieur",
@ -920,8 +928,13 @@
"upload_a_photo": "Envoyer une photo",
"useStreamingApi": "Recevoir les messages et notifications en temps réel",
"useStreamingApiWarning": "(Non recommandé, expérimental, connu pour rater des messages)",
"use_blurhash": "Flouter les miniatures des images sensibles",
"use_contain_fit": "Ne pas rogner les miniatures des pièces-jointes",
"use_one_click_nsfw": "Ouvrir les pièces-jointes sensibles avec un seul clic",
"user_accepts_direct_messages_from": "Accepter les messages directs de",
"user_accepts_direct_messages_from_everybody": "Tout le monde",
"user_accepts_direct_messages_from_nobody": "Personne",
"user_accepts_direct_messages_from_people_i_follow": "Comptes que je suis",
"user_mutes": "Comptes",
"user_profile_default_tab": "Onglet affiché par défaut dans les profils",
"user_profiles": "Profils utilisateurs",
@ -1044,6 +1057,7 @@
"collapse": "Fermer",
"conversation": "Conversation",
"error": "Erreur lors de l'affichage du flux : {0}",
"follow_tag": "Suivre le hashtag",
"load_older": "Afficher des status plus ancien",
"no_more_statuses": "Pas plus de statuts",
"no_retweet_hint": "Le message est marqué en abonnés-seulement ou direct et ne peut pas être partagé",
@ -1053,6 +1067,7 @@
"show_new": "Afficher plus",
"socket_broke": "Connexion temps-réel perdue : CloseEvent code {0}",
"socket_reconnected": "Connexion temps-réel établie",
"unfollow_tag": "Désabonner du hashtag",
"up_to_date": "À jour"
},
"toast": {
@ -1117,6 +1132,7 @@
"block_confirm_title": "Bloquer l'utilisateur",
"block_progress": "Blocage…",
"blocked": "Bloqué !",
"blocks_you": "Vous bloque !",
"bot": "Robot",
"deactivated": "Désactivé",
"deny": "Rejeter",
@ -1131,7 +1147,10 @@
"follow_cancel": "Annuler la demande d'abonnement",
"follow_progress": "Demande en cours…",
"follow_sent": "Demande envoyée !",
"follow_tag": "Suivre le hashtag",
"follow_unfollow": "Désabonner",
"followed_tags": "Hashtags suivis",
"followed_users": "Utilisateurs suivis",
"followees": "Abonnements",
"followers": "Abonné·es",
"following": "Suivi !",
@ -1156,12 +1175,14 @@
"mute_domain": "Bloquer la domaine",
"mute_progress": "Masquage…",
"muted": "Masqué",
"not_following_any_hashtags": "Aucun hashtag suivi",
"note": "Note privée",
"per_day": "par jour",
"remote_follow": "Suivre d'une autre instance",
"remove_follower": "Désabonner",
"replies": "Statuts et réponses",
"report": "Signalement",
"requested_by": "Vous a envoyé une demande de suivi",
"show_repeats": "Montrer les partages",
"statuses": "Statuts",
"subscribe": "Abonner",
@ -1171,11 +1192,13 @@
"unfollow_confirm_accept_button": "Oui : me désabonner",
"unfollow_confirm_cancel_button": "Non : garder l'abonnement",
"unfollow_confirm_title": "Désabonner",
"unfollow_tag": "Désabonner du hashtag",
"unmute": "Démasquer",
"unmute_progress": "Démasquage…",
"unsubscribe": "Désabonner"
},
"user_profile": {
"field_validated": "Lien vérifié",
"profile_does_not_exist": "Désolé, ce profil n'existe pas.",
"profile_loading_error": "Désolé, il y a eu une erreur au chargement du profil.",
"timeline_title": "Flux du compte"

View file

@ -503,7 +503,13 @@
"columns": "Colonne",
"composing": "Composizione",
"confirm_new_password": "Conferma la nuova password",
"conversation_display": "Stile di visualizzazione delle conversazioni",
"conversation_display_linear": "Stile lineare",
"conversation_display_tree": "Stile ad albero",
"conversation_other_replies_button": "Mostra il bottone \"altre risposte\"",
"conversation_other_replies_button_below": "Sotto i post",
"current_avatar": "La tua icona attuale",
"current_mascot": "La tua mascotte attuale",
"current_password": "La tua password attuale",
"data_import_export_tab": "Importa o esporta dati",
"default_vis": "Visibilità predefinita dei messaggi",
@ -511,11 +517,15 @@
"delete_account_description": "Elimina definitivamente i tuoi dati e disattiva il tuo profilo.",
"delete_account_error": "C'è stato un problema durante l'eliminazione del tuo profilo. Se il problema persiste contatta l'amministratore della tua stanza.",
"delete_account_instructions": "Digita la tua password nel campo sottostante per eliminare il tuo profilo.",
"disable_sticky_headers": "Non fissare i titoli delle colonne in cima allo schermo",
"discoverable": "Permetti la scoperta di questo profilo a servizi di ricerca ed altro",
"domain_mutes": "Domini",
"download_backup": "Scarica",
"email_language": "Lingua delle email ricevute dal server",
"emoji_reactions_on_timeline": "Mostra reazioni nelle sequenze",
"enable_web_push_notifications": "Abilita notifiche web push",
"enter_current_password_to_confirm": "Inserisci la tua password per identificarti",
"expert_mode": "Mostra avanzate",
"export_theme": "Salva impostazioni",
"file_export_import": {
"backup_restore": "Archiviazione impostazioni",
@ -543,18 +553,23 @@
"hide_all_muted_posts": "Nascondi messaggi silenziati",
"hide_attachments_in_convo": "Nascondi gli allegati presenti nelle conversazioni",
"hide_attachments_in_tl": "Nascondi gli allegati presenti nelle sequenze",
"hide_bot_indication": "Nascondi indicatore bot nei post",
"hide_favorites_description": "Non mostrare la lista dei miei preferiti (gli utenti verranno comunque notificati)",
"hide_filtered_statuses": "Nascondi messaggi filtrati",
"hide_followers_count_description": "Non mostrare quanti seguaci ho",
"hide_followers_description": "Non mostrare i miei seguaci",
"hide_follows_count_description": "Non mostrare quanti utenti seguo",
"hide_follows_description": "Non mostrare chi seguo",
"hide_isp": "Nascondi pannello della stanza",
"hide_list_aliases_error_action": "Chiudi",
"hide_media_previews": "Nascondi anteprime",
"hide_muted_posts": "Nascondi messaggi degli utenti silenziati",
"hide_muted_threads": "Nascondi conversazioni silenziate",
"hide_post_stats": "Nascondi statistiche dei messaggi (es. il numero di preferenze)",
"hide_shoutbox": "Nascondi muro dei graffiti",
"hide_user_stats": "Nascondi statistiche dell'utente (es. il numero di seguaci)",
"hide_wallpaper": "Nascondi sfondo della stanza",
"hide_wordfiltered_statuses": "Nascondi post filtrati per parola",
"import_blocks_from_a_csv_file": "Importa blocchi da un file CSV",
"import_followers_from_a_csv_file": "Importa una lista di chi segui da un file CSV",
"import_mutes_from_a_csv_file": "Importa silenziati da un file CSV",
@ -567,10 +582,14 @@
"invalid_theme_imported": "Il file selezionato non è un tema supportato da Pleroma. Il tuo tema non è stato modificato.",
"limited_availability": "Non disponibile nel tuo browser",
"links": "Collegamenti",
"list_aliases_error": "Errore nel recupero degli alias: {error}",
"list_backups_error": "Errore nel recupero della lista dei backup: {error}",
"lock_account_description": "Vaglia manualmente i nuovi seguaci",
"loop_video": "Riproduci video in ciclo continuo",
"loop_video_silent_only": "Riproduci solo video muti in ciclo continuo (es. le \"gif\" di Mastodon)",
"mascot": "Mascotte di MastodonFE",
"max_thumbnails": "Numero massimo di anteprime per messaggio",
"mention_links": "Collegamenti delle menzioni",
"mfa": {
"authentication_methods": "Metodi di accesso",
"confirm_and_enable": "Conferma ed abilita OTP",
@ -594,6 +613,12 @@
},
"minimal_scopes_mode": "Riduci opzioni di visibilità",
"more_settings": "Altre impostazioni",
"move_account": "Sposta account",
"move_account_error": "Errore nello spostamento dell'account: {error}",
"move_account_notes": "Se vuoi spostare questo account da qualche altra parte, devi andare all'account di destinazione e aggiungere un alias che punta qui.",
"move_account_target": "Account di destinazione (es. {example})",
"moved_account": "Account spostato.",
"mute_bot_posts": "Silenzia post dei bot",
"mute_export": "Esporta silenziati",
"mute_export_button": "Esporta i silenziati in un file CSV",
"mute_import": "Carica silenziati",
@ -603,6 +628,7 @@
"mutes_tab": "Silenziati",
"name": "Nome",
"name_bio": "Nome ed introduzione",
"new_alias_target": "Aggiungi nuovo alias (es. {example})",
"new_email": "Nuova email",
"new_password": "Nuova password",
"no_blocks": "Nessun utente bloccato",
@ -620,6 +646,7 @@
"notification_visibility_likes": "Preferiti",
"notification_visibility_mentions": "Menzioni",
"notification_visibility_moves": "Migrazioni utenti",
"notification_visibility_polls": "Termine dei poll in cui hai votato",
"notification_visibility_repeats": "Condivisioni",
"notifications": "Notifiche",
"nsfw_clickthrough": "Fai click per visualizzare gli allegati offuscati",
@ -628,7 +655,9 @@
"panelRadius": "Pannelli",
"pause_on_unfocused": "Interrompi l'aggiornamento continuo mentre la scheda è in secondo piano",
"play_videos_in_modal": "Riproduci video in un riquadro a sbalzo",
"post_look_feel": "Aspetto dei post",
"post_status_content_type": "Tipo di contenuto dei messaggi",
"posts": "Post",
"preload_images": "Precarica immagini",
"presets": "Valori predefiniti",
"profile_background": "Sfondo del tuo profilo",
@ -642,6 +671,8 @@
"profile_tab": "Profilo",
"radii_help": "Imposta il raggio degli angoli (in pixel)",
"refresh_token": "Aggiorna token",
"remove_alias": "Rimuovi questo alias",
"remove_backup": "Elimina",
"replies_in_timeline": "Risposte nelle sequenze",
"reply_visibility_all": "Mostra tutte le risposte",
"reply_visibility_following": "Mostra solo le risposte rivolte a me o agli utenti che seguo",
@ -666,12 +697,15 @@
"security_tab": "Sicurezza",
"sensitive_by_default": "Tutti i miei messaggi sono scabrosi",
"set_new_avatar": "Scegli una nuova icona",
"set_new_mascot": "Imposta nuova mascotte",
"set_new_profile_background": "Scegli un nuovo sfondo",
"set_new_profile_banner": "Scegli un nuovo gonfalone",
"setting_changed": "Valore personalizzato",
"setting_server_side": "Questa impostazione è legata al tuo profilo e ha effetto su tutte le sessioni e tutti i client",
"settings": "Impostazioni",
"show_admin_badge": "Mostra l'insegna di amministratore sul mio profilo",
"show_moderator_badge": "Mostra l'insegna di moderatore sul mio profilo",
"show_scrollbars": "Mostra le barre di scorrimento delle colonne laterali",
"stop_gifs": "Riproduci GIF al passaggio del cursore",
"streaming": "Mostra automaticamente i nuovi messaggi quando sei in cima alla pagina",
"style": {
@ -780,66 +814,80 @@
},
"filter_hint": {
"always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta.",
"avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
"inset_classic": "Le ombre incluse usano {0}",
"spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre"
"avatar_inset": "Tieni presente che combinare ombre (sia incavate che non) sulle icone utente potrebbe dare risultati strani con avatar trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} con la keyword {2}.",
"inset_classic": "Le ombre incavate usano {0}",
"spread_zero": "Le ombre con espansione maggiore di zero appariranno come se l'espansione fosse zero"
},
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore.",
"inset": "Includi",
"override": "Sostituisci",
"shadow_id": "Ombra numero {value}",
"spread": "Spandi"
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per usare l'altro slot colore.",
"inset": "Incavatura",
"override": "Sovrascrivi",
"shadow_id": "Ombra #{value}",
"spread": "Espansione"
},
"switcher": {
"clear_all": "Azzera tutto",
"clear_opacity": "Rimuovi opacità",
"clear_opacity": "Azzera opacità",
"help": {
"fe_downgraded": "L'interfaccia è stata portata ad una versione precedente.",
"fe_upgraded": "Lo schema dei temi è stato aggiornato insieme all'interfaccia.",
"future_version_imported": "Il tema importato è stato creato per una versione più recente dell'interfaccia.",
"migration_napshot_gone": "Anteprima del tema non trovata, non tutto potrebbe essere come ricordi.",
"migration_snapshot_ok": "Ho caricato l'anteprima del tema. Puoi provare a caricarne i contenuti.",
"older_version_imported": "Il tema importato è stato creato per una versione precedente dell'interfaccia.",
"snapshot_missing": "Il tema non è provvisto di anteprima, quindi potrebbe essere diverso da come appare.",
"fe_downgraded": "La versione di PleromaFE è riportata ad una versione precedente.",
"fe_upgraded": "Il motore dei temi di PleromaFE è stato aggiornato insieme all'interfaccia.",
"future_version_imported": "Il tema importato è stato creato per una versione più nuova del frontend.",
"migration_napshot_gone": "Per qualche motivo non è stata trovata l'anteprima del tema, non tutto potrebbe essere come ricordi.",
"migration_snapshot_ok": "Per sicurezza, è stata caricata l'anteprima del tema. Puoi provare a caricarne i contenuti.",
"older_version_imported": "Il file importato è stato creato per una versione precedente del frontend.",
"snapshot_missing": "Il file non è provvisto di anteprima, quindi potrebbe essere diverso da come appare.",
"snapshot_present": "Tutti i valori sono sostituiti dall'anteprima del tema. Puoi invece caricare i suoi contenuti.",
"snapshot_source_mismatch": "Conflitto di versione: probabilmente l'interfaccia è stata portata indietro e poi aggiornata di nuovo. Se hai modificato il tema con una vecchia versione usa il tema precedente, altrimenti puoi usare il nuovo.",
"upgraded_from_v2": "L'interfaccia è stata aggiornata, il tema potrebbe essere diverso da come lo ricordi.",
"v2_imported": "Il tema importato è stato creato per una vecchia interfaccia. Non tutto potrebbe essere come inteso."
"snapshot_source_mismatch": "Conflitto di versione: probabilmente il frontend è stato deaggiornato e poi aggiornato di nuovo. Se hai modificato il tema con una vecchia versione usa il tema precedente, altrimenti usa quello nuovo.",
"upgraded_from_v2": "PleromaFE è stato aggiornato, il tema potrebbe essere un pochino diverso da come lo ricordi.",
"v2_imported": "Il file importato è stato creato per un vecchio frontend. Cerchiamo di massimizzare la compatibilità, ma potrebbero esserci inconsistenze."
},
"keep_as_is": "Mantieni tal quale",
"keep_as_is": "Mantieni com'è",
"keep_color": "Mantieni colori",
"keep_fonts": "Mantieni font",
"keep_opacity": "Mantieni opacità",
"keep_roundness": "Mantieni vertici",
"keep_shadows": "Mantieni ombre",
"load_theme": "Carica tema",
"reset": "Reimposta",
"reset": "Azzera",
"save_load_hint": "Le opzioni \"mantieni\" conservano le impostazioni correnti quando selezioni o carichi un tema, e le salvano quando ne esporti uno. Quando nessuna casella è selezionata, tutte le impostazioni correnti saranno salvate nel tema.",
"use_snapshot": "Versione precedente",
"use_source": "Nuova versione"
}
},
"subject_input_always_show": "Mostra sempre il campo Oggetto",
"subject_line_behavior": "Copia oggetto quando rispondi",
"subject_line_email": "Come nelle email: \"re: oggetto\"",
"subject_line_mastodon": "Come in Mastodon: copia tal quale",
"subject_input_always_show": "Mostra sempre il campo avvertenza sul contenuto",
"subject_line_behavior": "Copia avvertenza sul contenuto quando rispondi",
"subject_line_email": "Come nelle email: \"re: avvertenza\"",
"subject_line_mastodon": "Come su Mastodon: copia com'è",
"subject_line_noop": "Non copiare",
"text": "Testo",
"theme": "Tema",
"theme_help": "Usa colori esadecimali (#rrggbb) per personalizzare il tuo schema di colori.",
"theme_help_v2_1": "Puoi anche forzare colore ed opacità di alcuni elementi selezionando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le forzature.",
"theme_help_v2_2": "Le icone vicino alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se usani trasparenze, questi indicatori mostrano il peggior caso possibile.",
"theme_help": "Usa colori esadecimali (#rrvvbb) per personalizzare il tuo tema colori.",
"theme_help_v2_1": "Puoi anche sovrascrivere colore ed opacità di alcuni elementi spuntando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le sovrascritture.",
"theme_help_v2_2": "Le icone vicino alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se usano la trasparenza, questi indicatori mostrano come sarebbero nel peggior caso possibile.",
"third_column_mode": "Quando c'è abbastanza spazio, mostra una terza colonna contenente",
"third_column_mode_none": "Non mostrare proprio la terza colonna",
"third_column_mode_notifications": "Colonna notifiche",
"third_column_mode_postform": "Modulo post principale e navigazione",
"token": "Token",
"tooltipRadius": "Suggerimenti/avvisi",
"tooltipRadius": "Suggerimenti/allerte",
"translation_language": "Lingua finale di traduzione automatica",
"tree_advanced": "Mostra bottoni aggiuntivi per aprire e chiudere catene di risposte nelle conversazioni",
"tree_fade_ancestors": "Mostra antenati del post corrente in testo semitrasparente",
"type_domains_to_mute": "Cerca domini da silenziare",
"upload_a_photo": "Carica un'immagine",
"upload_a_photo": "Carica una foto",
"useStreamingApi": "Ricevi messaggi e notifiche in tempo reale",
"useStreamingApiWarning": "(Sconsigliato, sperimentale, può saltare messaggi)",
"useStreamingApiWarning": "",
"use_blurhash": "Usa blurhash per anteprime NSFW",
"use_contain_fit": "Non ritagliare le anteprime degli allegati",
"use_one_click_nsfw": "Apri media offuscati con un solo click",
"use_one_click_nsfw": "Apri allegati NSFW con un solo click",
"user_accepts_direct_messages_from": "Accetta post «diretti» da",
"user_accepts_direct_messages_from_everybody": "Tutti",
"user_accepts_direct_messages_from_nobody": "Nessuno",
"user_accepts_direct_messages_from_people_i_follow": "Persone che seguo",
"user_mutes": "Utenti",
"user_settings": "Impostazioni Utente",
"user_profile_default_tab": "Scheda predefinita sul profilo degli utenti",
"user_profiles": "Profili utente",
"user_settings": "Impostazioni utente",
"valid_until": "Valido fino a",
"values": {
"false": "no",
@ -847,86 +895,141 @@
},
"version": {
"backend_version": "Versione backend",
"frontend_version": "Versione interfaccia",
"frontend_version": "Versione frontend",
"title": "Versione"
},
"virtual_scrolling": "Velocizza l'elaborazione delle sequenze",
"word_filter": "Parole filtrate"
"virtual_scrolling": "Velocizza rendering sequenze",
"word_filter": "Filtro per parola",
"wordfilter": "Filtro per parola"
},
"settings_profile": {
"creating": "Creazione del nuovo profilo di impostazioni \"{profile}\"…",
"synchronization_error": "Non è stato possibile sincronizzare le impostazioni: {err}",
"synchronized": "Impostazioni sincronizzate!",
"synchronizing": "Sincronizzazione del profilo di impostazioni \"{profile}\"…"
},
"status": {
"ancestor_follow": "Vedi {numReplies} altra risposta sotto questo post | Vedi {numReplies} altre risposte sotto questo post",
"ancestor_follow_with_icon": "{icon} {text}",
"attachment_stop_flash": "Ferma Flash player",
"bookmark": "Aggiungi segnalibro",
"copy_link": "Copia collegamento",
"delete": "Elimina messaggio",
"delete_confirm": "Vuoi veramente eliminare questo messaggio?",
"collapse_attachments": "Riduci allegati",
"copy_link": "Copia collegamento al post",
"delete": "Elimina post",
"delete_confirm": "Vuoi davvero eliminare questo post?",
"delete_confirm_accept_button": "Sì, eliminalo",
"delete_confirm_cancel_button": "No, tienilo",
"delete_confirm_title": "Conferma eliminazione",
"edit": "Modifica",
"edit_history": "Cronologia modifiche",
"edit_history_modal_title": "Modificato {historyCount} volta | Modificato {historyCount} volte",
"edited_at": "Modificato {time}",
"expand": "Espandi",
"external_source": "Vai all'origine",
"external_source": "Fonte originale",
"favorites": "Preferiti",
"hide_content": "Nascondi contenuti",
"hide_full_subject": "Nascondi oggetto intero",
"hide_attachment": "Nascondi allegato",
"hide_content": "Nascondi contenuto",
"hide_full_subject": "Nascondi avvertenza sul contenuto intera",
"many_attachments": "Il post ha {number} allegato | Il post ha {number} allegati",
"mentions": "Menzioni",
"move_down": "Muovi allegato a destra",
"move_up": "Muovi allegato a sinistra",
"mute_conversation": "Silenzia conversazione",
"nsfw": "DISDICEVOLE",
"pin": "Intesta al profilo",
"pinned": "Intestato",
"nsfw": "NSFW",
"open_gallery": "Apri galleria",
"override_translation_source_language": "Sovrascrivi lingua di origine",
"pin": "Fissa in cima al profilo",
"pinned": "Fissato",
"plus_more": "+{number} altri",
"repeats": "Condivisi",
"redraft": "Elimina e correggi",
"redraft_confirm": "Vuoi davvero eliminare e correggere questo post? Le interazioni al post originale non saranno mantenute.",
"redraft_confirm_accept_button": "Sì, elimina e correggi",
"redraft_confirm_cancel_button": "No, tieni l'originale",
"redraft_confirm_title": "Conferma elimina e correggi",
"remove_attachment": "Rimuovi allegato",
"repeat_confirm": "Vuoi davvero condividere questo post?",
"repeat_confirm_accept_button": "Sì, condividilo",
"repeat_confirm_cancel_button": "No, non condividere",
"repeat_confirm_title": "Conferma condivisione",
"repeats": "Condivisioni",
"replies_list": "Risposte:",
"replies_list_with_others": "Mostra {numReplies} altra risposta | Mostra {numReplies} altre risposte",
"reply_to": "In risposta a",
"show_content": "Mostra contenuti",
"show_full_subject": "Mostra oggetto intero",
"status_deleted": "Questo messagio è stato cancellato",
"status_unavailable": "Messaggio non disponibile",
"thread_muted": "Discussione silenziata",
"show_all_attachments": "Mostra tutti gli allegati",
"show_all_conversation": "Mostra conversazione intera ({numStatus} altro post) | Mostra conversazione intera ({numStatus} altri post)",
"show_all_conversation_with_icon": "{icon} {text}",
"show_attachment_description": "Anteprima descrizione (apri l'allegato per la descrizione intera)",
"show_attachment_in_modal": "Mostra allegato in una finestra",
"show_content": "Mostra contenuto",
"show_full_subject": "Mostra tutta l'avvertenza sul contenuto",
"show_only_conversation_under_this": "Mostra solo le risposte a questo post",
"status_deleted": "Questo post è stato eliminato",
"status_unavailable": "Post non disponibile",
"thread_follow": "Visualizza {numStatus} altra risposta | Visualizza {numStatus} altre risposte",
"thread_follow_with_icon": "{icon} {text}",
"thread_hide": "Nascondi questa conversazione",
"thread_muted": "Conversazione silenziata",
"thread_muted_and_words": ", contiene:",
"thread_show": "Mostra questa conversazione",
"thread_show_full": "Mostra {numStatus} risposta | Mostra tutte e {numStatus} le risposte",
"thread_show_full_with_icon": "{icon} {text}",
"translate": "Traduci",
"translated_from": "Tradotto da {language}",
"unbookmark": "Rimuovi segnalibro",
"unmute_conversation": "Riabilita conversazione",
"unpin": "De-intesta",
"unmute_conversation": "Desilenzia conversazione",
"unpin": "Rimuovi dalla cima del profilo",
"you": "(Tu)"
},
"time": {
"in_future": "fra {0}",
"in_past": "{0} fa",
"now": "adesso",
"now_short": "adesso",
"now": "proprio adesso",
"now_short": "ora",
"unit": {
"days": "{0} giorni",
"days": "{0} giorno | {0} giorni",
"days_short": "{0} g",
"hours": "{0} ore",
"hours_short": "{0} h",
"minutes": "{0} minuti",
"hours": "{0} ora | {0} ore",
"hours_short": "{0} ora | {0} ore",
"minutes": "{0} minuto | {0} minuti",
"minutes_short": "{0} min",
"months": "{0} mesi",
"months_short": "{0} mes",
"seconds": "{0} secondi",
"months": "{0} mese | {0} mesi",
"months_short": "{0} mese | {0} mesi",
"seconds": "{0} secondo | {0} secondi",
"seconds_short": "{0} sec",
"weeks": "{0} settimane",
"weeks_short": "{0} stm",
"years": "{0} anni",
"weeks": "{0} settimana | {0} settimane",
"weeks_short": "{0} sett",
"years": "{0} anno | {0} anni",
"years_short": "{0} a"
}
},
"timeline": {
"collapse": "Ripiega",
"collapse": "Riduci",
"conversation": "Conversazione",
"error": "Errore nel caricare la sequenza: {0}",
"load_older": "Carica messaggi precedenti",
"no_more_statuses": "Fine dei messaggi",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
"no_statuses": "Nessun messaggio",
"follow_tag": "Segui hashtag",
"load_older": "Carica post precedenti",
"no_more_statuses": "Non ci sono altri post",
"no_retweet_hint": "Il messaggio è «solo per follower» o «diretto», quindi non può essere condiviso",
"no_statuses": "Nessun post",
"reload": "Ricarica",
"repeated": "ha condiviso",
"show_new": "Mostra nuovi",
"socket_broke": "Connessione tempo reale interrotta: codice {0}",
"socket_broke": "Connessione tempo reale interrotta: CloseEvent codice {0}",
"socket_reconnected": "Connesso in tempo reale",
"unfollow_tag": "Smetti di seguire hashtag",
"up_to_date": "Aggiornato"
},
"toast": {
"no_translation_target_set": "Nessuna lingua finale di traduzione impostata: la traduzione potrebbe fallire. Imposta una lingua finale di traduzione nelle tue impostazioni."
},
"tool_tip": {
"accept_follow_request": "Accetta seguace",
"add_reaction": "Reagisci",
"accept_follow_request": "Accetta richiesta di follow",
"add_reaction": "Aggiungi reazione",
"bookmark": "Aggiungi segnalibro",
"favorite": "Gradisci",
"media_upload": "Carica allegati",
"reject_follow_request": "Rifiuta seguace",
"favorite": "Rendi preferito",
"media_upload": "Carica media",
"quote": "Cita",
"reject_follow_request": "Rifiuta richiesta di follow",
"repeat": "Condividi",
"reply": "Rispondi",
"user_settings": "Impostazioni utente"
@ -934,7 +1037,7 @@
"upload": {
"error": {
"base": "Caricamento fallito.",
"default": "Riprova in seguito",
"default": "Riprova più tardi",
"file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"message": "Caricamento fallito: {0}"
},
@ -948,79 +1051,115 @@
},
"user_card": {
"admin_menu": {
"activate_account": "Attiva profilo",
"deactivate_account": "Disattiva profilo",
"delete_account": "Elimina profilo",
"activate_account": "Riattiva account",
"deactivate_account": "Disattiva account",
"delete_account": "Elimina account",
"delete_user": "Elimina utente",
"disable_any_subscription": "Rendi utente non seguibile",
"disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze",
"force_nsfw": "Oscura tutti i messaggi",
"force_unlisted": "Nascondi tutti i messaggi",
"grant_admin": "Crea Amministratore",
"grant_moderator": "Crea Moderatore",
"delete_user_data_and_deactivate_confirmation": "Questo eliminerà permanentemente i dati dall'account e lo disattiverà. Sei sicuro al 100%?",
"disable_any_subscription": "Proibisci a tutti di seguire l'utente",
"disable_remote_subscription": "Proibisci ad istanze remote di seguire l'utente",
"force_nsfw": "Marca tutti i post come NSFW",
"force_unlisted": "Rendi tutti i post «non in elenco»",
"grant_admin": "Rendi amministratore",
"grant_moderator": "Rendi moderatore",
"moderation": "Moderazione",
"quarantine": "I messaggi non arriveranno alle altre stanze",
"revoke_admin": "Divesti Amministratore",
"revoke_moderator": "Divesti Moderatore",
"sandbox": "Rendi tutti i messaggi solo per seguaci",
"strip_media": "Rimuovi ogni allegato ai messaggi"
"quarantine": "Impedisci la federazione dei post degli utenti",
"revoke_admin": "Rimuovi da amministratore",
"revoke_moderator": "Rimuovi da moderatore",
"sandbox": "Rendi tutti i messaggi \"solo per follower\"",
"strip_media": "Rimuovi media dai messaggi"
},
"approve": "Approva",
"approve": "Accetta",
"approve_confirm": "Sei sicuro di voler permettere a questo utente di seguirti?",
"approve_confirm_accept_button": "Sì, accetta",
"approve_confirm_cancel_button": "No, annulla",
"approve_confirm_title": "Accetta richiesta di follow",
"block": "Blocca",
"block_confirm": "Sei sicuro di voler bloccare {user}?",
"block_confirm_accept_button": "Sì, blocca",
"block_confirm_cancel_button": "No, non bloccare",
"block_confirm_title": "Blocca utente",
"block_progress": "Blocco…",
"blocked": "Bloccato!",
"blocks_you": "Ti blocca!",
"bot": "Bot",
"deny": "Nega",
"deactivated": "Disattivato",
"deny": "Rifiuta",
"deny_confirm": "Sei sicuro di voler rifiutare la richiesta di follow di questo utente?",
"deny_confirm_accept_button": "Sì, rifiuta",
"deny_confirm_cancel_button": "No, annulla",
"deny_confirm_title": "Rifiuta richiesta di follow",
"domain_muted": "Sblocca dominio",
"edit_profile": "Modifica profilo",
"favorites": "Preferiti",
"follow": "Segui",
"follow_cancel": "Annulla richiesta",
"follow_progress": "Richiedo…",
"follow_sent": "Richiesta inviata!",
"follow_unfollow": "Disconosci",
"followees": "Segue",
"followers": "Seguaci",
"following": "Seguìto!",
"follow_tag": "Segui l'hashtag",
"follow_unfollow": "Smetti di seguire",
"followed_tags": "Hashtag seguiti",
"followed_users": "Utenti seguiti",
"followees": "Seguiti",
"followers": "Follower",
"following": "Seguito!",
"follows_you": "Ti segue!",
"hidden": "Nascosto",
"hide_repeats": "Nascondi condivisioni",
"highlight": {
"disabled": "Nessun risalto",
"side": "Nastro a lato",
"solid": "Un colore",
"striped": "A righe"
"disabled": "Nessuno sfondo",
"side": "Striscia laterale",
"solid": "Sfondo monocolore",
"striped": "Sfondo a righe"
},
"its_you": "Sei tu!",
"media": "Media",
"mention": "Menziona",
"message": "Contatta",
"mute": "Silenzia",
"mute_progress": "Silenzio…",
"mute_confirm": "Sei sicuro di voler silenziare {user}?",
"mute_confirm_accept_button": "Sì, silenzia",
"mute_confirm_cancel_button": "No, non silenziare",
"mute_confirm_title": "Silenzia utente",
"mute_domain": "Blocca dominio",
"mute_progress": "Silenziando…",
"muted": "Silenziato",
"not_following_any_hashtags": "Non stai seguendo nessun hashtag",
"note": "Nota privata",
"per_day": "al giorno",
"remote_follow": "Segui da remoto",
"remove_follower": "Rimuovi follower",
"replies": "Con risposte",
"report": "Segnala",
"requested_by": "Ha chiesto di seguirti",
"show_repeats": "Mostra condivisioni",
"statuses": "Messaggi",
"subscribe": "Abbònati",
"statuses": "Post",
"subscribe": "Iscriviti",
"unblock": "Sblocca",
"unblock_progress": "Sblocco…",
"unmute": "Riabilita",
"unmute_progress": "Riabilito…",
"unsubscribe": "Disdici"
"unfollow_confirm": "Sei sicuro di voler smettere di seguire {user}?",
"unfollow_confirm_accept_button": "Sì, smetti di seguire",
"unfollow_confirm_cancel_button": "No, non smettere di seguire",
"unfollow_confirm_title": "Smetti di seguire l'utente",
"unfollow_tag": "Smetti di seguire l'hashtag",
"unmute": "Desilenzia",
"unmute_progress": "Desilenziamento…",
"unsubscribe": "Disiscriviti"
},
"user_profile": {
"field_validated": "Collegamento verificato",
"profile_does_not_exist": "Spiacente, questo profilo non esiste.",
"profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.",
"timeline_title": "Sequenza dell'utente"
},
"user_reporting": {
"add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:",
"additional_comments": "Osservazioni accessorie",
"forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?",
"add_comment_description": "La segnalazione sarà inviata ai moderatori della tua istanza. Puoi fornire una motivazione per cui stai segnalando questo account qui sotto:",
"additional_comments": "Commenti aggiuntivi",
"forward_description": "Il profilo appartiene ad un altro server. Inviare la segnalazione anche a quello?",
"forward_to": "Inoltra a {0}",
"generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.",
"submit": "Invia",
"title": "Segnalo {0}"
"title": "Segnala {0}"
},
"who_to_follow": {
"more": "Altro",

View file

@ -1,6 +1,7 @@
{
"about": {
"bubble_instances": "ローカルバブルインスタンス",
"bubble_instances_description": "かんりしゃがだいひょうするためにえらんだインスタンス",
"mrf": {
"federation": "フェデレーション",
"keyword": {

124
src/i18n/lt.json Normal file
View file

@ -0,0 +1,124 @@
{
"about": {
"bubble_instances": "Vietiniai burbulo serveriai",
"bubble_instances_description": "Administratorių parinkti serveriai, kurie atstovauja šios serverio vietinę teritoriją",
"mrf": {
"federation": "Federacija",
"keyword": {
"ftl_removal": "Pašalinimas iš „Viso žinomo tinklo“ laiko skalės",
"is_replaced_by": "→",
"keyword_policies": "Raktažodžių politika",
"reject": "Atmesti",
"replace": "Pakeisti"
},
"mrf_policies": "Įjungta MRF politika",
"mrf_policies_desc": "MRF politika valdo serverio federacijos elgseną. Įjungtos toliau nurodytos politikos:",
"simple": {
"accept": "Priimti",
"accept_desc": "Šis serveris priima žinutes tik iš toliau nurodytų serverių:",
"ftl_removal": "Pašalinimas iš „Žinomo tinklo“ laiko skalės",
"ftl_removal_desc": "Šis serveris pašalina šiuos serverius iš „Žinomo tinklo“ laiko skalės:",
"instance": "Serveris",
"media_nsfw": "Medija priverstinai nustatyta kaip jautri",
"media_nsfw_desc": "Šis serveris priverčia nustatyti mediją kaip jautrią toliau nurodytų serverių įrašuose:",
"media_removal": "Medijos pašalinimas",
"media_removal_desc": "Šis serveris pašalina mediją iš toliau nurodytų serverių įrašų:",
"not_applicable": "Nėra",
"quarantine": "Karantinas",
"quarantine_desc": "Šis serveris nesiųs įrašų į toliau nurodytus serverius:",
"reason": "Priežastis",
"reject": "Atmesti",
"reject_desc": "Šis serveris nepriims žinučių iš toliau nurodytų serverių:",
"simple_policies": "Konkretaus serverio politika"
}
},
"staff": "Personalas"
},
"announcements": {
"all_day_prompt": "Tai visos dienos renginys",
"cancel_edit_action": "Atsisakyti",
"close_error": "Užverti",
"delete_action": "Ištrinti",
"edit_action": "Redaguoti",
"end_time_display": "Pasibaigia {time}",
"end_time_prompt": "Pabaigos laikas: ",
"inactive_message": "Šis skelbimas neaktyvus",
"mark_as_read_action": "Žymėti kaip skaitytą",
"page_header": "Skelbimai",
"post_action": "Siųsti",
"post_error": "Klaida: {error}",
"post_form_header": "Skelbti skelbimą"
},
"chats": {
"chats": "Pokalbiai",
"delete": "Ištrinti",
"more": "Daugiau",
"new": "Naujas pokalbis",
"you": "Jūs:"
},
"display_date": {
"today": "Šiandien"
},
"domain_mute_card": {
"mute": "Nutildyti",
"mute_progress": "Nutildoma…",
"unmute": "Atšaukti nutildymą",
"unmute_progress": "Atšaukiamas nutildymas…"
},
"emoji": {
"add_emoji": "Įterpti jaustuką",
"custom": "Pasirinktinis jaustukas",
"emoji": "Jaustukas",
"stickers": "Lipdukai",
"unicode": "Unikodo jaustukas"
},
"exporter": {
"export": "Eksportuoti"
},
"file_type": {
"audio": "Garso įrašas",
"file": "Failas",
"image": "Vaizdas",
"video": "Vaizdo įrašas"
},
"general": {
"more": "Daugiau",
"scope_in_timeline": {
"direct": "Tiesioginis",
"local": "Vietinis šį įrašą gali matyti tik jūsų serveris",
"private": "Tik sekėjams",
"public": "Vieša",
"unlisted": "Neįtrauktas į sąrašą"
},
"show_less": "Rodyti mažiau",
"show_more": "Rodyti daugiau",
"submit": "Pateikti",
"verify": "Patvirtinti"
},
"image_cropper": {
"cancel": "Atšaukti"
},
"importer": {
"submit": "Pateikti"
},
"user_card": {
"follow_tag": "Sekti saitažodį",
"not_following_any_hashtags": "Nesekate jokių saitažodžių.",
"unfollow_confirm_accept_button": "Taip, nebesekti",
"unfollow_confirm_cancel_button": "Ne, nenaikinti sekimą",
"unfollow_confirm_title": "Nebesekti naudotoją",
"unfollow_tag": "Nebesekti saitažodį"
},
"user_reporting": {
"additional_comments": "Papildomi komentarai",
"forward_description": "Paskyra yra iš kito serverio. Siųsti ataskaitos kopiją ir ten?",
"forward_to": "Persiųsti į {0}",
"generic_error": "Įvyko klaida apdorojant jūsų užklausą.",
"submit": "Pateikti",
"title": "Pranešama apie {0}"
},
"who_to_follow": {
"more": "Daugiau",
"who_to_follow": "Ką sekti"
}
}

View file

@ -599,7 +599,7 @@
"links": "Łącza",
"list_aliases_error": "Błąd pobierania aliasów: {error}",
"list_backups_error": "Błąd pobierania listy kopii zapasowych: {error}",
"lock_account_description": "Spraw, by konto mogli wyświetlać tylko zatwierdzeni obserwujący",
"lock_account_description": "Wymagaj potwierdzenia nowych śledzących",
"loop_video": "Zapętlaj filmy",
"loop_video_silent_only": "Zapętlaj tylko filmy bez dźwięku (np. mastodonowe „gify”)",
"mascot": "Maskotka Mastodon FE",
@ -679,6 +679,7 @@
"pad_emoji": "Dodaj odstęp z obu stron emoji podczas dodawania selektorem",
"panelRadius": "Panele",
"pause_on_unfocused": "Wstrzymuj strumieniowanie kiedy karta nie jest aktywna",
"permit_followback_description": "Automatycznie potwierdź śledzenie przez użytkowników którch już śledzisz",
"play_videos_in_modal": "Odtwarzaj filmy bezpośrednio w przeglądarce mediów",
"post_look_feel": "Wygląd wpisów",
"post_status_content_type": "Domyślny typ zawartości wpisów",
@ -1148,7 +1149,7 @@
"followed_users": "Śledzeni użytkownicy",
"followees": "Obserwowani",
"followers": "Obserwujący",
"following": "Obserwowany!",
"following": "Obserwujesz!",
"follows_you": "Obserwuje cię!",
"hidden": "Ukryte",
"hide_repeats": "Ukryj powtórzenia",

View file

@ -129,6 +129,131 @@
"generic_error": "Bir hata oluştu",
"loading": "Yükleniyor…",
"more": "Daha",
"optional": "Seçenek"
"optional": "Seçenek",
"peek": "Göz at",
"retry": "Tekrar deneyin",
"role": {
"admin": "Yönetici",
"moderator": "Moderatör"
},
"scope_in_timeline": {
"direct": "Doğrudan",
"local": "Yerel - bu gönderiyi yalnızca sizin örneğiniz görebilir",
"private": "Yalnızca takipçiler",
"public": "Herkese açık",
"unlisted": "Listelenmemiş"
},
"show_less": "Daha az göster",
"show_more": "Daha fazla göster",
"submit": "Gönder",
"verify": "Doğrulama"
},
"image_cropper": {
"cancel": "İptal",
"crop_picture": "Resmi kırp",
"save": "Kaydet",
"save_without_cropping": "Kırpmadan kaydet"
},
"importer": {
"error": "Bu dosya içe aktarılırken bir hata oluştu.",
"submit": "Gönder",
"success": "Başarıyla içe aktarıldı."
},
"interactions": {
"favs_repeats": "Tekrarlar ve favoriler",
"follows": "Yeni takipler",
"load_older": "Eski etkileşimleri yükle",
"moves": "Kullanıcı taşıma"
},
"languages": {
"ar": "Arabic",
"az": "Azerbaycan Türkçesi",
"bg": "Bulgarian",
"cs": "Czech",
"da": "Danish",
"de": "German",
"el": "Greek",
"en": "English",
"eo": "Esperanto",
"es": "Spanish",
"fa": "Persian",
"fi": "Finnish",
"fr": "French",
"ga": "Irish",
"he": "Hebrew",
"hi": "Hindi",
"hu": "Hungarian",
"id": "Indonesian",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"lt": "Lithuanian",
"lv": "Latvian",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"ru": "Russian",
"sk": "Slovak",
"sv": "Swedish",
"tr": "Türkçe",
"translated_from": {
"ar": "@:languages.ar adresinden çevrildi",
"az": "@:languages.az adresinden çevrildi",
"bg": "@:languages.bg adresinden çevrildi",
"cs": "@:languages.cs adresinden çevrildi",
"da": "@:languages.da adresinden çevrildi",
"de": "@:languages.de adresinden çevrildi",
"el": "@:languages.el adresinden çevrildi",
"en": "@:languages.en adresinden çevrildi",
"eo": "@:languages.eo adresinden çevrildi",
"es": "@:languages.es adresinden çevrildi",
"fa": "@:languages.fa adresinden çevrildi",
"fi": "@:languages.fi adresinden çevrildi",
"fr": "@:languages.fr adresinden çevrildi",
"ga": "@:languages.ga adresinden çevrildi",
"he": "@:languages.he adresinden çevrildi",
"hi": "@:languages.hi adresinden çevrildi",
"hu": "@:languages.hu adresinden çevrildi",
"id": "@:languages.id adresinden çevrildi",
"it": "@:languages.it adresinden çevrildi",
"ja": "@:languages.ja adresinden çevrildi",
"ko": "@:languages.ko adresinden çevrildi",
"lt": "@:languages.lt adresinden çevrildi",
"lv": "@:languages.lv adresinden çevrildi",
"nl": "@:languages.nl adresinden çevrildi",
"pl": "@:languages.pl adresinden çevrildi",
"pt": "@:languages.pt adresinden çevrildi",
"ru": "@:languages.ru adresinden çevrildi",
"sk": "@:languages.sk adresinden çevrildi",
"sv": "@:languages.sv adresinden çevrildi",
"tr": "@:languages.tr adresinden çevrildi",
"uk": "@:languages.uk adresinden çevrildi",
"zh": "@:languages.zh adresinden çevrildi"
},
"uk": "Ukrainian",
"zh": "Chinese"
},
"lists": {
"create": "Oluştur",
"delete": "Listeyi sil",
"following_only": "Takip Etmeyi Sınırla",
"lists": "Listeler",
"new": "Yeni Liste",
"save": "Değişiklikleri kaydet",
"search": "Kullanıcıları ara",
"title": "Liste başlığı"
},
"login": {
"authentication_code": "Kimlik doğrulama kodu",
"description": "OAuth ile oturum açın",
"enter_recovery_code": "Bir kurtarma kodu girin",
"enter_two_factor_code": "İki faktörlü bir kod girin",
"heading": {
"recovery": "İki faktörlü kurtarma",
"totp": "İki faktörlü kimlik doğrulama"
},
"hint": "Tartışmaya katılmak için giriş yapın",
"login": "Giriş yap",
"logout": ıkış yap"
}
}

View file

@ -492,6 +492,7 @@
"cGreen": "绿色(转发)",
"cOrange": "橙色(喜欢)",
"cRed": "红色(取消)",
"center_align_bio": "使用户简介中的文本居中",
"change_email": "更改邮箱",
"change_email_error": "更改你的邮箱时发生错误。",
"change_password": "更改密码",
@ -502,6 +503,7 @@
"checkboxRadius": "复选框",
"collapse_subject": "折叠带内容警告的帖文",
"columns": "分栏",
"compact_user_info": "空间充足时显示紧凑用户信息",
"composing": "写作",
"confirm_dialogs": "需要确认当:",
"confirm_dialogs_approve_follow": "接受关注请求",
@ -682,6 +684,7 @@
"pause_on_unfocused": "在离开页面时暂停时间线推送",
"permit_followback_description": "自动批准已关注用户的关注请求",
"play_videos_in_modal": "在弹出框内播放视频",
"post_language": "默认发布的语言",
"post_look_feel": "文章的样子跟感受",
"post_status_content_type": "默认发布的内容类型",
"posts": "帖文",
@ -947,6 +950,7 @@
"title": "版本"
},
"virtual_scrolling": "优化时间线渲染",
"widen_timeline": "加宽时间线以填充水平空间",
"word_filter": "词语过滤",
"wordfilter": "词语过滤器"
},

View file

@ -4,9 +4,20 @@ import { each, get, set, cloneDeep } from 'lodash'
let loaded = false
// use this to avoid cloning and persisitng private runtime state
// (runtime state can be reconstructed and may include non-cloneable objects like functions)
const clonePublicKeys = (state) => {
const clonedState = {}
for (const key in state) {
if (!key.startsWith('__'))
set(clonedState, key, cloneDeep(state[key]))
}
return clonedState
}
const defaultReducer = (state, paths) => (
paths.length === 0 ? state : paths.reduce((substate, path) => {
set(substate, path, get(state, path))
paths.length === 0 ? clonePublicKeys(state) : paths.reduce((substate, path) => {
set(substate, path, clonePublicKeys(get(state, path)))
return substate
}, {})
)
@ -19,7 +30,7 @@ const saveImmedeatelyActions = [
'setOption',
'setClientData',
'setToken',
'clearToken',
'clearTokens',
'emojiUsed',
]
@ -70,7 +81,7 @@ export default function createPersistedState ({
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
setState(key, reducer(cloneDeep(state), paths), storage)
setState(key, reducer(state, paths), storage)
.then(success => {
if (typeof success !== 'undefined') {
if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') {

28
src/lib/scope_utils.js Normal file
View file

@ -0,0 +1,28 @@
const SCOPE_LEVELS = {
'direct': 0,
'private': 1,
'unlisted': 2,
'local': 3,
'public': 3
}
export default {
negotiate: (defaultScope, maxScope) => {
if (!maxScope)
return defaultScope;
if (maxScope === 'local')
return defaultScope === 'direct' ? defaultScope : 'local';
if (SCOPE_LEVELS[defaultScope] <= SCOPE_LEVELS[maxScope])
return defaultScope;
else
return maxScope;
},
isSubScope: (original, subscope) => {
if (original === 'local')
return (subscope === 'direct' || subscope === original);
return SCOPE_LEVELS[subscope] <= SCOPE_LEVELS[original];
}
}

View file

@ -7,6 +7,7 @@ import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js'
import statusesModule from './modules/statuses.js'
import listsModule from './modules/lists.js'
import dmConversationsModule from './modules/dm_conversations.js'
import usersModule from './modules/users.js'
import apiModule from './modules/api.js'
import configModule from './modules/config.js'
@ -87,6 +88,7 @@ const persistedStateOptions = {
users: usersModule,
statuses: statusesModule,
lists: listsModule,
dmConversations: dmConversationsModule,
api: apiModule,
config: configModule,
serverSideConfig: serverSideConfigModule,

View file

@ -229,7 +229,7 @@ const api = {
// Timelines
startFetchingTimeline(
store,
{ timeline = "friends", tag = false, userId = false, listId = false },
{ timeline = "friends", tag = false, userId = false, listId = false, conversationId = false },
) {
if (store.state.fetchers[timeline]) return;
@ -238,6 +238,7 @@ const api = {
store,
userId,
listId,
conversationId,
tag,
});
store.commit("addFetcher", { fetcherName: timeline, fetcher });
@ -347,6 +348,20 @@ const api = {
store.commit("removeFetcher", { fetcherName: "reports", fetcher });
},
// info of current user; e.g. count of pending follow requests
startFetchingCurrentUserInfo(store, { id }) {
if (store.state.fetchers["current_user"]) return;
const fetcher = store.state.backendInteractor.startFetchingCurrentUserInfo({
store, id
});
store.commit("addFetcher", { fetcherName: "current_user", fetcher });
},
stopFetchingCurrentUserInfo(store) {
const fetcher = store.state.fetchers.current_user;
if (!fetcher) return;
store.commit("removeFetcher", { fetcherName: "current_user", fetcher });
},
getSupportedTranslationlanguages(store) {
store.state.backendInteractor
.getSupportedTranslationlanguages({ store })

View file

@ -56,6 +56,8 @@ export const defaultState = {
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
displayPageBackgrounds: true,
centerAlignBio: false,
compactUserInfo: true,
stopGifs: undefined,
replyVisibility: 'all',
thirdColumnMode: 'notifications',
@ -77,6 +79,7 @@ export const defaultState = {
hideScopeNotice: false,
useStreamingApi: false,
sidebarRight: undefined, // instance default
widenTimeline: undefined, // instance default
subjectLineBehavior: undefined, // instance default
alwaysShowSubjectInput: undefined, // instance default
postContentType: undefined, // instance default
@ -90,6 +93,7 @@ export const defaultState = {
modalOnLogout: undefined, // instance default
modalOnApproveFollow: undefined, // instance default
modalOnDenyFollow: undefined, // instance default
modalOnDeleteDMConversation: undefined, // instance default
playVideosInModal: false,
useOneClickNsfw: false,
useContainFit: true,
@ -141,9 +145,38 @@ function updateLocalSettings(store, settingEntries, version = null) {
store.commit('setOption', { name: 'profileVersion', value: version })
}
/**
* Parses the raw mute-word string into an array of mute rules
* containing the original filter defintion and a predicator function
* and optionally (for optimisation purposes when checking against many mute rules)
* the lower-cased version of the text as arguments.
*
* muteRule = { name: string, predicate: (string, string) => boolean }
*/
const parseMuteWords = (rawList) => {
const regexStart = '/'
const regexEnd = '/'
return rawList.map((word) => {
let predicate
if (word.startsWith(regexStart) && word.endsWith(regexEnd)) {
// case-insenstitive by default, but regex modifiers can locally reenable case sensitivity
const regex = new RegExp(word.slice(regexStart.length, -regexEnd.length), 'i')
predicate = (text, _textLowCased) => regex.test(text)
} else {
const muteWord = word.toLowerCase()
predicate = (text, textLowCased) => (textLowCased || text?.toLowerCase())?.includes(muteWord)
}
return { name: word, predicate: predicate }
})
}
const settingParsers = {
muteWords: parseMuteWords,
}
const config = {
state: { ...defaultState },
state: { ...defaultState, '__parsed_cache': {} },
getters: {
defaultConfig (state, getters, rootState, rootGetters) {
const { instance } = rootState
@ -158,14 +191,32 @@ const config = {
const { defaultConfig } = rootGetters
return {
...defaultConfig,
// Do not override with undefined
...Object.fromEntries(Object.entries(state).filter(([k, v]) => v !== undefined))
// Do not override with undefined and exclude private caches
...Object.fromEntries(Object.entries(state).filter(([k, v]) => v !== undefined && k !== '__parsed_cache'))
}
},
parsedConfigVal (state, getters, rootState, rootGetters) {
return (key) => {
let cached = state['__parsed_cache'][key]
if (cached !== undefined) return cached;
const parser = settingParsers[key]
if (!parser) return undefined;
// we can't use mergedConfig yet here
const rawVal = state[key] || rootState.instance?.[key] || defaultState[key];
const parsed = parser(rawVal);
state['__parsed_cache'][key] = parsed
return parsed
}
}
},
mutations: {
setOption (state, { name, value }) {
state[name] = value
if (settingParsers.hasOwnProperty(name)) {
state['__parsed_cache'][name] = undefined
}
},
setHighlight (state, { user, color, type }) {
const data = this.state.config.highlight[user]
@ -186,8 +237,9 @@ const config = {
timeout: 5000
}
store.dispatch('pushGlobalNotice', notice)
let {'__parsed_cache': _, ...currentSettings} = store.state
store.rootState.api.backendInteractor.saveSettingsProfile({
settings: store.state, profileName: store.state.profile, version: store.state.profileVersion
settings: currentSettings, profileName: store.state.profile, version: store.state.profileVersion
}).then(() => {
store.dispatch('removeGlobalNotice', notice)
store.dispatch('pushGlobalNotice', {

View file

@ -0,0 +1,106 @@
import { remove, find } from 'lodash'
export const defaultState = {
allDMConversations: [],
dmConversationsPagination: {}
}
export const getters = {
getDMConversationById: (state) => (id) => {
return find(state.allDMConversations, { id })
}
}
const mutations = {
clearDMConversations(state) {
state.allDMConversations = []
state.dmConversationsPagination = undefined
},
saveDMConversationPagination(state, pagination) {
state.dmConversationsPagination = pagination
},
addDMConversations(state, list) {
state.allDMConversations.push(...list)
},
updateDMConversation(state, conv) {
const idx = state.allDMConversations.findIndex(c => c.id == conv.id)
if (idx >= 0)
state.allDMConversations[idx] = conv
else
state.allDMConversations.push(conv)
},
updateDMConversations(state, list) {
for (const conv of list) {
const idx = state.allDMConversations.findIndex(c => c.id == conv.id)
if (idx >= 0)
state.allDMConversations[idx] = conv
else
state.allDMConversations.push(conv)
}
},
deleteDMConversation(state, id) {
remove(state.allDMConversations, { id })
}
}
const actions = {
fetchDMConversationList({ rootState, commit }) {
const savedPagination = rootState.dmConversations.dmConversationsPagination
return rootState.api.backendInteractor
.fetchDMConversationList({ pagination: savedPagination })
.then(({ data, pagination }) => {
commit('addDMConversations', data)
commit('saveDMConversationPagination', pagination)
return data
})
},
fetchDMConversationDetails({ rootState, commit }, { id }) {
return rootState.api.backendInteractor
.fetchDMConversationDetails({ id })
.then(data => {
commit('updateDMConversation', data)
return data
})
},
setDMConversationDetails({ rootState, commit }, { id, ...params }) {
return rootState.api.backendInteractor
.setDMConversationDetails({ id, ...params })
.then(data => {
commit('updateDMConversation', data)
return data
})
},
clearDMConversations({ commit }) {
commit('clearDMConversations')
},
markDMConversationAsRead({ rootState, commit }, { id }) {
rootState.api.backendInteractor
.markDMConversationAsRead({ id })
.then(data => {
commit('updateDMConversation', data)
commit('decrementUnreadDMConversationsCount')
})
},
markAllDMConversationsAsRead({ rootState, commit }) {
rootState.api.backendInteractor
.markAllDMConversationsAsRead()
.then(data => {
commit('updateDMConversations', data)
commit('clearUnreadDMConversationsCount')
})
},
deleteDMConversation({ rootState, commit }, { id }) {
rootState.api.backendInteractor
.deleteDMConversation({ id })
.then(data => commit('deleteDMConversation', id))
}
}
const dmConversations = {
state: defaultState,
mutations,
actions,
getters
}
export default dmConversations

View file

@ -47,6 +47,7 @@ const defaultState = {
modalOnLogout: true,
modalOnApproveFollow: false,
modalOnDenyFollow: false,
modalOnDeleteDMConversation: true,
loginMethod: 'password',
logo: '/static/logo.svg',
logoMargin: '.2em',
@ -61,6 +62,7 @@ const defaultState = {
showNavShortcuts: true,
showWiderShortcuts: true,
sidebarRight: false,
widenTimeline: false,
subjectLineBehavior: 'email',
theme: 'pleroma-dark',
virtualScrolling: true,
@ -73,6 +75,8 @@ const defaultState = {
conversationOtherRepliesButton: 'below',
conversationTreeFadeAncestors: false,
maxDepthInThread: 6,
backendCommitUrl: 'https://akkoma.dev/AkkomaGang/akkoma/commit/',
frontendCommitUrl: 'https://akkoma.dev/AkkomaGang/pleroma-fe/commit/',
// Nasty stuff
customEmoji: [],
@ -182,10 +186,13 @@ const instance = {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = value.image_url
let imageUrl = value.image_url
if (typeof imageUrl == 'string' && imageUrl.startsWith('/'))
imageUrl = state.server + imageUrl;
return {
displayText: key,
imageUrl: imageUrl ? state.server + imageUrl : value,
imageUrl: imageUrl ? imageUrl : value,
tags: imageUrl ? value.tags.sort((a, b) => a > b ? 1 : 0) : ['utf'],
replacement: `:${key}: `
}

View file

@ -8,22 +8,33 @@ export const defaultState = {
export const mutations = {
setLists (state, value) {
state.allLists = value
// Stub out fields for newly fetched lists
for (const list of state.allLists) {
if (!list.accountIds) list.accountIds = []
}
},
setList (state, { id, title }) {
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
}
state.allListsObject[id].title = title
if (!find(state.allLists, { id })) {
state.allLists.push({ id, title })
const list = state.allListsObject[id]
list.title = title
// newly created list
if (!list.accountIds) list.accountIds = []
const listEntry = find(state.allLists, { id })
if (!listEntry) {
state.allLists.push({ id, ...list })
} else {
find(state.allLists, { id }).title = title
Object.assign(listEntry, list)
}
},
setListAccounts (state, { id, accountIds }) {
// XXX: this shouldnt happen in the first place...
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
state.allListsObject[id] = { title: "" }
}
state.allListsObject[id].accountIds = accountIds
},

View file

@ -3,8 +3,7 @@ const oauth = {
clientId: false,
clientSecret: false,
/* App token is authentication for app without any user, used mostly for
* MastoAPI's registration of new users, stored so that we can fall back to
* it on logout
* MastoAPI's registration of new users and unnecessary otherwise
*/
appToken: false,
/* User token is authentication for app with user, this is for every calls
@ -23,8 +22,9 @@ const oauth = {
setToken (state, token) {
state.userToken = token
},
clearToken (state) {
clearTokens (state) {
state.userToken = false
state.appToken = false
// state.token is userToken with older name, coming from persistent state
// let's clear it as well, since it is being used as a fallback of state.userToken
delete state.token

View file

@ -1,5 +1,7 @@
import { merge } from 'lodash'
const POLL_UPDATE_FREQUENCY = 150_000;
const polls = {
state: {
// Contains key = id, value = number of trackers for this poll
@ -12,6 +14,9 @@ const polls = {
// Make expired-state change trigger re-renders properly
poll.expired = Date.now() > Date.parse(poll.expires_at)
if (existingPoll) {
if (poll.expired) {
state.trackedPolls[poll.id] = 0
}
state.pollsObject[poll.id] = merge(existingPoll, poll)
} else {
state.pollsObject[poll.id] = poll
@ -44,13 +49,16 @@ const polls = {
if (rootState.polls.trackedPolls[pollId]) {
dispatch('updateTrackedPoll', pollId)
}
}, 30 * 1000)
}, POLL_UPDATE_FREQUENCY)
commit('mergeOrAddPoll', poll)
})
},
trackPoll ({ rootState, commit, dispatch }, pollId) {
if (rootState.polls.pollsObject[pollId]?.expired)
return;
if (!rootState.polls.trackedPolls[pollId]) {
setTimeout(() => dispatch('updateTrackedPoll', pollId), 30 * 1000)
setTimeout(() => dispatch('updateTrackedPoll', pollId), POLL_UPDATE_FREQUENCY)
}
commit('trackPoll', pollId)
},

View file

@ -61,9 +61,9 @@ export const defaultState = () => ({
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl(),
dmConv: emptyTl(),
bubble: emptyTl(),
replies: emptyTl()
}
@ -205,14 +205,6 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
@ -663,10 +655,10 @@ const statuses = {
return rootState.api.backendInteractor.unmuteConversation({ id: statusId })
.then((status) => commit('setMutedStatus', status))
},
retweet ({ rootState, commit }, status) {
retweet ({ rootState, commit }, {id, visibility}) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
rootState.api.backendInteractor.retweet({ id: status.id })
commit('setRetweeted', { status: {id: id}, value: true })
rootState.api.backendInteractor.retweet({ id: id, visibility: visibility })
.then(status => commit('setRetweetedConfirm', { status: status.retweeted_status, user: rootState.users.currentUser }))
},
unretweet ({ rootState, commit }, status) {

View file

@ -3,6 +3,7 @@ import { windowWidth, windowHeight } from '../services/window_utils/window_utils
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
import { getClientToken } from '../services/new_api/oauth.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item, key = 'id') => {
@ -30,12 +31,12 @@ const mergeArrayLength = (oldValue, newValue) => {
}
}
const getNotificationPermission = () => {
const getNotificationPermission = async () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default') return Notification.requestPermission()
return Promise.resolve(Notification.permission)
if (!Notification) return null
if (Notification.permission === 'default') return await Notification.requestPermission()
return Notification.permission
}
const blockUser = (store, id) => {
@ -112,6 +113,17 @@ const setNote = (store, { id, note }) => {
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
const getAppSecret = async ({ store }) => {
const { rootState, commit } = store
const { oauth, instance } = rootState
return oauthApi.getOrCreateApp({ ...oauth, instance: instance.server, commit })
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
export const mutations = {
tagUser (state, { user: { id }, tag }) {
const user = state.usersObject[id]
@ -143,6 +155,10 @@ export const mutations = {
state.lastLoginName = user.screen_name
state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
},
updateCurrentUser (state, user) {
if (user.id !== state.currentUser?.id) return
state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
},
clearCurrentUser (state) {
state.currentUser = false
state.lastLoginName = false
@ -277,6 +293,14 @@ export const mutations = {
state.signUpPending = false
state.signUpErrors = errors
},
clearUnreadDMConversationsCount (store) {
if (store.currentUser.pleroma)
store.currentUser.pleroma.unread_conversation_count = 0
},
decrementUnreadDMConversationsCount (store) {
if (store.currentUser.pleroma?.unread_conversation_count)
store.currentUser.pleroma.unread_conversation_count--
},
decrementFollowRequestsCount (store) {
store.currentUser.follow_requests_count--
},
@ -568,6 +592,8 @@ const users = {
let rootState = store.rootState
try {
// registration can only be done with an app token
await getAppSecret({ store })
let data = await rootState.api.backendInteractor.register(
{ params: { ...userInfo } }
)
@ -603,18 +629,25 @@ const users = {
return oauthApi.getOrCreateApp(data)
.then((app) => {
const params = {
app,
instance: data.instance,
token: oauth.userToken
}
// Clear both OAuth token (used in every login session)
// and app token (only used by us during registration)
for (const token of [oauth.userToken, oauth.appToken]) {
if (!token) continue
return oauthApi.revokeToken(params)
const params = {
app,
instance: data.instance,
token: token
}
oauthApi.revokeToken(params)
}
})
.then(() => {
store.dispatch('stopFetchingCurrentUserInfo')
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.commit('clearToken')
store.commit('clearTokens')
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications')
@ -683,6 +716,7 @@ const users = {
store.dispatch('listSettingsProfiles')
store.dispatch('startFetchingConfig')
store.dispatch('startFetchingAnnouncements')
store.dispatch('startFetchingCurrentUserInfo', { id: user.id })
if (user.role === 'admin' || user.role === 'moderator') {
store.dispatch('startFetchingReports')
}

View file

@ -18,7 +18,6 @@
bottom: 0;
left: 0;
right: 0;
z-index: 5;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
pointer-events: none;
@ -60,7 +59,6 @@
padding: 0.6em;
height: var(--__panel-heading-height);
line-height: var(--__panel-heading-height-inner);
z-index: 4;
&.-flexible-height {
--__panel-heading-height: auto;
@ -131,6 +129,7 @@
color: var(--panelText);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
z-index: 4;
&::after {
background-color: $fallback--fg;

View file

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash'
import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseDMConversation, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
import { Url } from 'url'
@ -50,10 +50,15 @@ const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = id => `/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = id => `/api/v1/follow_requests/${id}/reject`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = '/api/v1/timelines/direct'
const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const AKKOMA_BUBBLE_TIMELINE_URL = '/api/v1/timelines/bubble'
const MASTODON_USER_CONVERSATIONS_URL = '/api/v1/conversations'
const MASTODON_CONVERSATION_DELETE = id => `/api/v1/conversations/${id}`
const MASTODON_CONVERSATION_MARK_READ = id => `/api/v1/conversations/${id}/read`
const PLEROMA_CONVERSATION_MARK_ALL_READ = '/api/v1/pleroma/conversations/read'
const PLEROMA_CONVERSATION_DETAILS = id => `/api/v1/pleroma/conversations/${id}`
const PLEROMA_CONVERSATION_TIMELINE = id => `/api/v1/pleroma/conversations/${id}/statuses`
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source`
@ -124,6 +129,21 @@ let fetch = (url, options) => {
return oldfetch(fullUrl, options)
}
// TODO: integrate directly into above adapting callers as needed
const getJsonIfSuccess = (response, url, options) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
return resolve(json)
})
.catch((error) => {
return reject(new StatusCodeError(response.status, error, { url, options }, response))
})
)
}
const promisedRequest = ({ method, url, params, payload, credentials, headers = {} }) => {
const options = {
method,
@ -135,7 +155,13 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
}
if (params) {
url += '?' + Object.entries(params)
.map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
.map(([key, value]) => {
if (!Array.isArray(value)) {
return (encodeURIComponent(key) + '=' + encodeURIComponent(value))
} else {
return value.map((v) => encodeURIComponent(key) + '[]=' + encodeURIComponent(v)).join('&')
}
})
.join('&')
}
if (payload) {
@ -148,19 +174,7 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
}
}
return fetch(url, options)
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
return resolve(json)
})
.catch((error) => {
return reject(new StatusCodeError(response.status, error, { url, options }, response))
})
)
})
.then((response) => getJsonIfSuccess(response, url, options))
}
const updateNotificationSettings = ({ credentials, settings }) => {
@ -511,6 +525,80 @@ const fetchConversation = ({ id, credentials }) => {
}))
}
const fetchDMConversationList = ({ credentials, pagination: savedPagination }) => {
// We always start fetching from most recent and move back in time
const queryParams = new URLSearchParams()
if (savedPagination?.maxId) {
queryParams.append('max_id', savedPagination.maxId)
}
let url = `${MASTODON_USER_CONVERSATIONS_URL}?${queryParams.toString()}`
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
pagination = parseLinkHeaderPagination(response.headers.get('Link'), {
flakeId: true
})
return getJsonIfSuccess(response, url, {})
})
.then((data) => {
return {
pagination,
data: data.map(c => parseDMConversation(c))
}
})
}
const fetchDMConversationDetails = ({ id, credentials }) => {
let url = PLEROMA_CONVERSATION_DETAILS(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => getJsonIfSuccess(response, url, {}))
.then((data) => parseDMConversation(data))
}
const setDMConversationDetails = ({id, credentials, ...params }) => {
let url = PLEROMA_CONVERSATION_DETAILS(id)
return promisedRequest({
url: url,
method: 'PATCH',
credentials,
// XXX: backend should start also accepting JSON request bodies as preferred by other endpoints
params: params
})
.then((data) => parseDMConversation(data))
}
const markDMConversationAsRead = ({ id, credentials }) => {
let url = MASTODON_CONVERSATION_MARK_READ(id)
return fetch(url, {
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => getJsonIfSuccess(response, url, {}))
.then((data) => parseDMConversation(data))
}
const markAllDMConversationsAsRead = ({ credentials }) => {
let url = PLEROMA_CONVERSATION_MARK_ALL_READ
return fetch(url, {
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => getJsonIfSuccess(response, url, {}))
.then((data) => data.map(c => parseDMConversation(c)))
}
const deleteDMConversation = ({ id, credentials }) => {
let url = MASTODON_CONVERSATION_DELETE(id)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials)
})
.then((response) => getJsonIfSuccess(response, url, {}))
}
const fetchStatus = ({ id, credentials }) => {
let url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
@ -693,6 +781,7 @@ const fetchTimeline = ({
until = false,
userId = false,
listId = false,
conversationId = false,
tag = false,
withMuted = false,
replyVisibility = 'all'
@ -701,7 +790,7 @@ const fetchTimeline = ({
public: MASTODON_PUBLIC_TIMELINE,
bubble: AKKOMA_BUBBLE_TIMELINE_URL,
friends: MASTODON_USER_HOME_TIMELINE_URL,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
dmConv: PLEROMA_CONVERSATION_TIMELINE,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
'publicAndExternal': MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
@ -725,6 +814,10 @@ const fetchTimeline = ({
url = url(listId)
}
if (timeline === 'dmConv') {
url = url(conversationId)
}
if (since) {
params.push(['since_id', since])
}
@ -822,8 +915,8 @@ const unfavorite = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
const retweet = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', credentials })
const retweet = ({ id, visibility, credentials }) => {
return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', payload: { visibility }, credentials })
.then((data) => parseStatus(data))
}
@ -988,7 +1081,7 @@ const uploadMedia = ({ formData, credentials }) => {
method: 'POST',
headers: authHeaders(credentials)
})
.then((data) => data.json())
.then((response) => getJsonIfSuccess(response, MASTODON_MEDIA_UPLOAD_URL, {}))
.then((data) => parseAttachment(data))
}
@ -1588,7 +1681,7 @@ const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => {
const url = `${MASTODON_FOLLOWED_TAGS_URL}?${queryParams.toString()}`
let pagination = {};
return fetch(url, {
credentials
headers: authHeaders(credentials),
}).then((data) => {
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: false
@ -1610,7 +1703,7 @@ const getFollowRequests = ({ credentials, pagination: savedPagination }) => {
const url = `${MASTODON_FOLLOW_REQUESTS_URL}?${queryParams.toString()}`
let pagination = {};
return fetch(url, {
credentials
headers: authHeaders(credentials),
}).then((data) => {
pagination = parseLinkHeaderPagination(data.headers.get('Link'), { flakeId: true });
return data.json()
@ -1746,6 +1839,12 @@ const apiService = {
fetchTimeline,
fetchPinnedStatuses,
fetchConversation,
fetchDMConversationList,
fetchDMConversationDetails,
setDMConversationDetails,
markDMConversationAsRead,
markAllDMConversationsAsRead,
deleteDMConversation,
fetchStatus,
fetchStatusSource,
fetchStatusHistory,

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