Compare commits

..

26 commits

Author SHA1 Message Date
Oneric
c57e59f8e8 poll: show vote anonymity hint if known
Ref: AkkomaGang/akkoma#1104
2026-04-04 00:00:00 +00:00
061a9ad325 Merge pull request 'Profile media alt texts' (#499) from Oneric/akkoma-fe:profile-media-alt-text into develop
Reviewed-on: AkkomaGang/akkoma-fe#499
2026-04-04 12:15:05 +00:00
Oneric
4d578720e8 settings/profile: allow setting media alt texts
Ideally alt text and image would be handled together, but this is not
straightforward to do with the current profile tab and API interaction
setup.

To somewhat alleviate this, always submit the current matching alt text
too when uploading a new image. This allows an alt text edit before
confirming the upload to immediately take effect without the user
needing to remember to go back to the upper section to hit "save" there.
2026-04-04 00:00:00 +00:00
Oneric
c98962f4b3 user*: expose profile media alt texts
Only avatar alt text is integrated into the UI in an assistive way.
Header and backgrounds are set as CSS backgrounds and I don’t know
of a good way to add alt or aria-label attributes to that. Nor whether
it even makes sense to bake this into the default view since their just
decorative background elements.
The full al text can still be accessed through the new profile media gallery.

Despite many places setting distinct :title and :alt atttributes
for StillImage, it actually only had a :alt attribute used for both.
This is however not what we want here,
so add (back?) :title as a distinct property.

Related backend change: AkkomaGang/akkoma#1034
2026-04-04 00:00:00 +00:00
Oneric
2292381b0a ci: move to ARM runner
Our ARM runner is both faster and less used than the x86 runner.
Nothing here is specific to x86 though, so let’s make use of the ARM one
2026-03-28 00:00:00 +00:00
Oneric
0f695386fe list: expose exclusive parameter
Allows excluding list members from home timeline.
Matches Mastodon and implemented in Akkoma via
AkkomaGang/akkoma#1062
2026-03-28 00:00:00 +00:00
Oneric
7fb67ee723 service/file_type: fallback to generic Masto type
E.g. bridgy doesn’t federate the full MIME type and
it’s attachment URLs also have no extension. Thus
the full MIME type is always just a generic binary,
but since it still federates a more specific AP type
the generic Mastodon type still contains some information
we can use here to display it properly.

While at it, drop unused fileMatchesSomeType function.
Its last users disappeared in e654fead23.

Ref.: https://github.com/snarfed/bridgy-fed/issues/2198

Co-authored-by: Yonle <yonle@proton.me>
2026-03-27 00:00:00 +00:00
Oneric
e80ebc3fac changelog: add missing entries 2026-03-21 00:00:00 +00:00
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
51 changed files with 485 additions and 172 deletions

View file

@ -1,5 +1,5 @@
labels:
platform: linux/amd64
platform: linux/arm64
steps:
lint:
@ -51,8 +51,8 @@ steps:
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
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_arm64
- mv scaleway-cli_2.30.0_linux_arm64 scaleway-cli
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- zip akkoma-fe.zip -r dist
@ -76,8 +76,8 @@ steps:
image: python:3.10-slim
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
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_arm64
- mv scaleway-cli_2.30.0_linux_arm64 scaleway-cli
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- cd docs

View file

@ -3,12 +3,41 @@ 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)
### Added
- lists UI can now read and set the "exclusive" parameter, allowing members to be removed from the home timeline
- user profiles now have a small gallery for profile media
- alt text of user profile media is now exposed and can be edited in profile settings
- if known, polls now show what promise wrt to vote anonymity was made
### Fixed
- fix error on list creation preventing initial accounts from being actually added
- fix notifications on mobile
- fix attachment display for remotes not federating any MIME type indicators if they still indicate a generic type.
This applies to e.g. bridgy
## 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

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "3.12.0",
"version": "3.18.0",
"description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,

View file

@ -54,7 +54,7 @@ const Attachment = {
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
img: fileTypeService.fileType(this.attachment) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
@ -105,7 +105,7 @@ const Attachment = {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
type () {
return fileTypeService.fileType(this.attachment.mimetype)
return fileTypeService.fileType(this.attachment)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden

View file

@ -26,6 +26,12 @@
}
}
&.-nsfw-placeholder {
.attachment-wrapper {
align-content: unset;
}
}
.description-container {
flex: 0 1 0;
display: flex;

View file

@ -6,8 +6,9 @@
<div class="heading">
<div class="title-bar">
<div class="title-bar-left">
<div class="unread"
<div
v-if="conversation.unread"
class="unread"
>
<span
class="badge badge-notification"
@ -29,7 +30,7 @@
</button>
&nbsp;
</div>
<h4>{{ $t('dm_conv.default_name', {id: this.conversation.id}) }}</h4>
<h4>{{ $t('dm_conv.default_name', {id: conversation.id}) }}</h4>
</div>
<div class="title-bar-right">
<button
@ -48,17 +49,21 @@
<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
v-if="membersTruncated.truncated"
class="ellipsis"
>
...
</div>
</div>
</router-link>
<div
class="last-message"
v-if="showLastStatus"
class="last-message"
>
<div class="last-message-title">
{{ $t('dm_conv.last_message_title') }}:
@ -76,7 +81,7 @@
<button
class="btn button-default"
:title="$t('dm_conv.recipients_edit_mode_button_tooltip')"
@click.once="$router.push({ name: 'dm-conversation-recipients', params: { id: this.conversation.id }})"
@click.once="$router.push({ name: 'dm-conversation-recipients', params: { id: conversation.id }})"
>
{{ $t('dm_conv.recipients_edit_mode_button') }}
</button>
@ -90,7 +95,7 @@
@accepted="doDeleteConversation"
@cancelled="hideDeleteConfirmModal"
>
{{ $t('dm_conv.delete_confirm', { identifier: this.conversation.id }) }}
{{ $t('dm_conv.delete_confirm', { identifier: conversation.id }) }}
</confirm-modal>
</teleport>
</div>

View file

@ -6,15 +6,15 @@
timeline-name="dmConv"
>
<template
v-slot:extraHeading
#extraHeading
>
<DMConvCard
v-if="conversation"
:conversation="conversation"
:compact="false"
:showFullControls="true"
:showLastStatus="false"
:linkToTimeline="false"
:show-full-controls="true"
:show-last-status="false"
:link-to-timeline="false"
@deleted="forceLeave"
/>
</template>

View file

@ -37,7 +37,7 @@
<StillImage
v-else
:src="item.emoji.imageUrl"
noStopGifs="true"
no-stop-gifs="true"
/>
</span>
</template>

View file

@ -51,7 +51,7 @@
<StillImage
v-if="suggestion.img"
:src="suggestion.img"
noStopGifs="true"
no-stop-gifs="true"
/>
<span v-else>{{ suggestion.replacement }}</span>
</span>

View file

@ -21,7 +21,7 @@
<StillImage
v-else
:src="group.first.imageUrl"
noStopGifs="true"
no-stop-gifs="true"
/>
</span>
<span

View file

@ -22,13 +22,17 @@ const ListNew = {
data () {
return {
title: '',
exclusive: false,
userIds: [],
selectedUserIds: []
}
},
created () {
this.$store.dispatch('fetchList', { id: this.id })
.then(() => { this.title = this.findListTitle(this.id) })
.then((list) => {
this.title = list.title
this.exclusive = !!list.exclusive
})
this.$store.dispatch('fetchListAccounts', { id: this.id })
.then(() => {
this.selectedUserIds = this.findListAccounts(this.id)
@ -76,7 +80,7 @@ const ListNew = {
this.userIds = results
},
updateList () {
this.$store.dispatch('setList', { id: this.id, title: this.title })
this.$store.dispatch('setList', { id: this.id, title: this.title, exclusive: this.exclusive })
this.$store.dispatch('setListAccounts', { id: this.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'list-timeline', params: { id: this.id } })

View file

@ -21,6 +21,17 @@
:placeholder="$t('lists.title')"
>
</div>
<div class="input-wrap">
<input
type="checkbox"
id="list-exclusive-input"
ref="exclusive"
v-model="exclusive"
>
<label for="list-exclusive-input">
{{ $t('lists.exclusive_description') }}
</label>
</div>
<div class="member-list">
<div
v-for="user in selectedUsers"

View file

@ -22,6 +22,7 @@ const ListNew = {
data () {
return {
title: '',
exclusive: false,
userIds: [],
selectedUserIds: []
}
@ -67,7 +68,7 @@ const ListNew = {
createList () {
// the API has two different endpoints for "creating a list with a name"
// and "updating the accounts on the list".
this.$store.dispatch('createList', { title: this.title })
this.$store.dispatch('createList', { title: this.title, exclusive: this.exclusive })
.then((list) => {
this.$store.dispatch('setListAccounts', { id: list.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'list-timeline', params: { id: list.id } })

View file

@ -21,6 +21,17 @@
:placeholder="$t('lists.title')"
>
</div>
<div class="input-wrap">
<input
type="checkbox"
id="list-exclusive-input"
ref="exclusive"
v-model="exclusive"
>
<label for="list-exclusive-input">
{{ $t('lists.exclusive_description') }}
</label>
</div>
<div class="member-list">
<div

View file

@ -67,7 +67,7 @@ const MediaModal = {
},
methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
return fileTypeService.fileType(media)
},
hide () {
// HACK: Closing immediately via a touch will cause the click

View file

@ -31,7 +31,7 @@
:alt="currentMedia.description"
:title="currentMedia.description"
:image-load-handler="onImageLoaded"
noStopGifs="true"
no-stop-gifs="true"
/>
</PinchZoom>
</SwipeClick>

View file

@ -1,6 +1,10 @@
import Timeago from 'components/timeago/timeago.vue'
import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash'
import {
faCircleCheck,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
export default {
name: 'Poll',

View file

@ -53,6 +53,24 @@
</label>
</div>
</div>
<div class="poll-hint">
<div
v-if="poll.akkoma?.anonymous === true"
class="alert success"
>
<FAIcon icon="check-circle" />
&nbsp;
{{ $t('polls.indicate_anonymous') }}
</div>
<div
v-else-if="poll.akkoma?.anonymous === false"
class="alert warning"
>
<FAIcon icon="triangle-exclamation" />
&nbsp;
{{ $t('polls.indicate_disclosure') }}
</div>
</div>
<div class="footer faint">
<button
v-if="!showResults"
@ -144,6 +162,9 @@
display: flex;
align-items: center;
}
.poll-hint {
margin: 0.25em 0;
}
&.loading * {
cursor: progress;
}

View file

@ -555,7 +555,7 @@ const PostStatusForm = {
this.uploadingFiles = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
return fileTypeService.fileType(fileInfo)
},
paste (e) {
this.autoPreview()

View file

@ -195,8 +195,8 @@
:class="{ 'visibility-tray-edit': isEdit }"
>
<scope-selector
ref="scopeselector"
v-if="!disableVisibilitySelector"
ref="scopeselector"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:initial-scope="newStatus.visibility"
@ -204,10 +204,11 @@
/>
<div
class="format-selector-container">
class="format-selector-container"
>
<div
class="format-selector"
>
>
<Select
id="post-language"
v-model="newStatus.language"

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

@ -35,6 +35,10 @@ const ProfileTab = {
newLocked: this.$store.state.users.currentUser.locked,
newPermitFollowback: this.$store.state.users.currentUser.permit_followback,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
avatar_description: this.$store.state.users.currentUser.avatar_description || '',
header_description: this.$store.state.users.currentUser.header_description || '',
pleroma_background_image_description:
this.$store.state.users.currentUser.pleroma?.background_image_description || '',
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
bot: this.$store.state.users.currentUser.bot,
@ -130,15 +134,16 @@ const ProfileTab = {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
bot: this.bot,
show_role: this.showRole,
status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1,
permit_followback: this.permit_followback,
avatar_description: this.avatar_description,
header_description: this.header_description,
pleroma_background_image_description: this.pleroma_background_image_description,
accepts_direct_messages_from: this.userAcceptsDirectMessagesFrom
}
if (this.emailLanguage) {
@ -152,6 +157,17 @@ const ProfileTab = {
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.$store.dispatch('clearSettingsError')
})
.catch((error) => {
const msg = typeof error === "string" ? error : error.message
console.error(`Failed to update profile settings: ${error}`)
this.$store.dispatch('setSettingsError', {errorData: msg})
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'settings.saving_err_details',
messageArgs: [msg],
level: 'error'
})
})
},
changeVis (visibility) {
@ -173,6 +189,7 @@ const ProfileTab = {
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'upload.error.message',
messageArgs: [
@ -218,10 +235,13 @@ const ProfileTab = {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar, avatarName) {
that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName })
that.$store.state.api.backendInteractor.updateProfileImages({
avatar, avatarName, avatar_description: this.avatar_description
})
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
this.$store.dispatch('clearSettingsError')
resolve()
})
.catch((error) => {
@ -241,10 +261,13 @@ const ProfileTab = {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
this.$store.state.api.backendInteractor.updateProfileImages({
banner, header_description: this.header_description
})
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.$store.dispatch('clearSettingsError')
this.bannerPreview = null
})
.catch(this.displayUploadError)
@ -254,16 +277,21 @@ const ProfileTab = {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
this.$store.state.api.backendInteractor.updateProfileImages({
background,
pleroma_background_image_description: this.pleroma_background_image_description
})
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.$store.dispatch('clearSettingsError')
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
displayUploadError (error) {
this.$store.dispatch('setSettingsError', {errorData: error})
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'upload.error.message',
messageArgs: [error.message],

View file

@ -4,6 +4,11 @@
margin: 0;
}
.setting-item .media-alt {
margin-bottom: 0.75em;
height: 2.5em;
}
.expire-posts-days {
margin-left: 1em;
}

View file

@ -120,6 +120,21 @@
:set-language="val => emailLanguage = val"
/>
</p>
<h3>{{ $t('settings.media_alt_avatar') }}</h3>
<textarea
v-model="avatar_description"
class="media-alt resize-height"
/>
<h3>{{ $t('settings.media_alt_banner') }}</h3>
<textarea
v-model="header_description"
class="media-alt resize-height"
/>
<h3>{{ $t('settings.media_alt_background') }}</h3>
<textarea
v-model="pleroma_background_image_description"
class="media-alt resize-height"
/>
<button
:disabled="newName && newName.length === 0"
class="btn button-default"

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,

View file

@ -85,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

@ -99,19 +99,21 @@
<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

View file

@ -81,7 +81,7 @@ const StatusContent = {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
return this.status.attachments.map(file => fileType.fileType(file))
},
translationLanguages () {
return (this.$store.state.instance.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))

View file

@ -6,6 +6,7 @@ const StillImage = {
'imageLoadError',
'imageLoadHandler',
'alt',
'title',
'height',
'width',
'noStopGifs'
@ -27,6 +28,16 @@ const StillImage = {
animated () {
return this.stopGifs && this.isAnimated
},
titleText () {
return this.title || this.alt
},
ariaLabel () {
// if the title (a UI hint) differs from the alt text (describing image content),
// we wat to add an aria-label to pass along the UI hint to screen readers
if (this.title && this.alt != this.title)
return this.title
return undefined
},
style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
return {

View file

@ -4,6 +4,7 @@
class="still-image"
:class="{ animated: animated, pixelart: isPixelArt }"
:style="style"
:aria-label="ariaLabel"
>
<div
v-if="animated && imageTypeLabel"
@ -20,7 +21,7 @@
ref="src"
:key="src"
:alt="alt"
:title="alt"
:title="titleText"
:src="src"
:referrerpolicy="referrerpolicy"
@load="onLoad"

View file

@ -56,7 +56,7 @@
v-if="$slots.extraHeading"
class="timeline-extra-heading"
>
<slot name="extraHeading"></slot>
<slot name="extraHeading" />
</div>
<div :class="classes.body">
<div

View file

@ -6,7 +6,7 @@
<StillImage
v-if="user"
class="avatar"
:alt="user.screen_name_ui"
:alt="user.avatar_description || user.screen_name_ui"
:title="user.screen_name_ui"
:src="imgSrc(user.profile_image_url_original)"
:image-load-error="imageLoadError"

View file

@ -12,6 +12,7 @@ import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faImages,
faRss,
faSearchPlus,
faExternalLinkAlt,
@ -21,6 +22,7 @@ import {
library.add(
faRss,
faBell,
faImages,
faSearchPlus,
faExternalLinkAlt,
faEdit
@ -28,7 +30,7 @@ library.add(
export default {
props: [
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar', 'showMediaButton'
],
data () {
return {
@ -122,6 +124,10 @@ export default {
&& (this.$store.state.interface.layoutType !== 'mobile')
&& this.switcher
},
hasProfileMedia() {
const user = this.user
return (user && (user.profile_image_url_original || user.cover_photo || user.background_image))
},
...mapGetters(['mergedConfig'])
},
components: {
@ -191,11 +197,51 @@ export default {
zoomAvatar () {
const attachment = {
url: this.user.profile_image_url_original,
description: this.user.avatar_description,
mimetype: 'image'
}
this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrentMedia', attachment)
},
makeMediaObject(link, altText) {
// Pseudo media attachment object with just the fields relevant for gallery display
return {
type: "image",
mimetype: "image/something",
url: link,
// proper media attachments omit field if no alt, but Mastodon decided in
// user responses to return empty string for "no alt". Normalise this here
description: altText || undefined
}
},
showProfileMedia () {
const user = this.user
const media = []
let startMedia = undefined
if (user.profile_image_url_original) {
const avatar = this.makeMediaObject(user.profile_image_url_original, user.avatar_description)
media.push(avatar)
if (!startMedia) startMedia = avatar
}
if (user.cover_photo) {
const header = this.makeMediaObject(user.cover_photo, user.header_description)
media.push(header)
if (!startMedia) startMedia = header
}
if (user.background_image) {
const background = this.makeMediaObject(user.background_image, user.pleroma?.background_image_description)
media.push(background)
if (!startMedia) startMedia = background
}
if (startMedia) {
this.$store.dispatch('setMedia', media)
this.$store.dispatch('setCurrentMedia', startMedia)
}
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { repliedUser: this.user })
}

View file

@ -28,6 +28,10 @@
justify-self: end;
}
.user-profile-media-button {
margin-left: .5em;
}
.panel-body {
word-wrap: break-word;
border-bottom-right-radius: inherit;

View file

@ -11,7 +11,7 @@
<div class="panel-heading -flexible-height">
<div
class="user-info"
:class="{ '-compact': this.compactUserInfo }"
:class="{ '-compact': compactUserInfo }"
>
<div class="container">
<a
@ -54,7 +54,10 @@
>
@{{ user.screen_name_ui }}
</router-link>
<span class="user-roles" v-if="!hideBio && (user.deactivated || !!visibleRole || user.bot)">
<span
v-if="!hideBio && (user.deactivated || !!visibleRole || user.bot)"
class="user-roles"
>
<span
v-if="user.deactivated"
class="alert user-role"
@ -74,7 +77,10 @@
{{ $t('user_card.bot') }}
</span>
</span>
<span class="user-locked" v-if="user.locked">
<span
v-if="user.locked"
class="user-locked"
>
<FAIcon
class="lock-icon"
icon="lock"
@ -114,44 +120,44 @@
</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"
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">
@ -212,6 +218,23 @@
</option>
</Select>
</div>
<div
class="user-profile-media-button"
v-if="showMediaButton"
>
<button
class="btn button-default"
:title="$i18n.t('user_card.show_profile_media')"
:aria-label="$i18n.t('user_card.show_profile_media')"
:disabled="!hasProfileMedia"
@click="showProfileMedia"
>
<FAIcon
icon="images"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
@ -303,7 +326,7 @@
:html="user.description_html"
:emoji="user.emoji"
:handle-links="true"
:style='{"text-align": this.$store.getters.mergedConfig.centerAlignBio ? "center" : "start"}'
:style='{"text-align": $store.getters.mergedConfig.centerAlignBio ? "center" : "start"}'
/>
</div>
<teleport to="#modal">

View file

@ -9,6 +9,7 @@
:switcher="true"
:selected="timeline.viewing"
:allow-zooming-avatar="true"
:show-media-button="true"
rounded="top"
/>
<div

View file

@ -236,6 +236,7 @@
"lists": {
"create": "Create",
"delete": "Delete list",
"exclusive_description": "Remove users on list from home timeline",
"following_only": "Limit to Following",
"lists": "Lists",
"new": "New List",
@ -382,6 +383,8 @@
"expired": "Poll ended {0} ago",
"expires_in": "Poll ends in {0}",
"expiry": "Poll age",
"indicate_disclosure": "Source instance will publish voter indentity and their votes",
"indicate_anonymous": "Source instance pledged to keep voter identity anonymous",
"multiple_choices": "Multiple choices",
"not_enough_options": "Too few unique options in poll",
"option": "Option",
@ -634,6 +637,9 @@
"mascot": "Mastodon FE Mascot",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"media_alt_avatar": "Avatar alt text",
"media_alt_background": "Background alt text",
"media_alt_banner": "Banner alt text",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"mention_link_display": "Display mention links",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
@ -746,6 +752,7 @@
"right_sidebar": "Reverse order of columns",
"save": "Save changes",
"saving_err": "Error saving settings",
"saving_err_details": "Error saving settings: {0}",
"saving_ok": "Settings saved",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"search_user_to_block": "Search whom you want to block",
@ -1208,6 +1215,7 @@
"replies": "With Replies",
"report": "Report",
"requested_by": "Has requested to follow you",
"show_profile_media": "Show profile images and their alt text",
"show_repeats": "Show repeats",
"statuses": "Posts",
"subscribe": "Subscribe",

View file

@ -36,6 +36,12 @@ const interfaceMod = {
state.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setSettingsError(state, { errorData }) {
state.settings.currentSaveStateNotice = { error: true, errorData: errorData }
},
clearSettingsError(state) {
state.settings.currentSaveStateNotice = { error: false, data: "manual reset" }
},
setNotificationPermission (state, permission) {
state.notificationPermission = permission
},
@ -113,6 +119,12 @@ const interfaceMod = {
settingsSaved ({ commit, dispatch }, { success, error }) {
commit('settingsSaved', { success, error })
},
setSettingsError ({ commit }, { errorData }) {
commit('setSettingsError', { errorData })
},
clearSettingsError ({ commit }) {
commit('clearSettingsError')
},
setNotificationPermission ({ commit }, permission) {
commit('setNotificationPermission', permission)
},

View file

@ -8,22 +8,34 @@ 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 }) {
setList (state, { id, title, exclusive }) {
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
list.exclusive = exclusive
// 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: "", exclusive: false }
}
state.allListsObject[id].accountIds = accountIds
},
@ -37,24 +49,27 @@ const actions = {
setLists ({ commit }, value) {
commit('setLists', value)
},
createList ({ rootState, commit }, { title }) {
return rootState.api.backendInteractor.createList({ title })
createList ({ rootState, commit }, { title, exclusive }) {
return rootState.api.backendInteractor.createList({ title, exclusive })
.then((list) => {
commit('setList', { id: list.id, title })
commit('setList', list)
return list
})
},
fetchList ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getList({ id })
.then((list) => commit('setList', { id: list.id, title: list.title }))
.then((list) => {
commit('setList', list)
return list
})
},
fetchListAccounts ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getListAccounts({ id })
.then((accountIds) => commit('setListAccounts', { id, accountIds }))
},
setList ({ rootState, commit }, { id, title }) {
rootState.api.backendInteractor.updateList({ id, title })
commit('setList', { id, title })
setList ({ rootState, commit }, { id, title, exclusive }) {
rootState.api.backendInteractor.updateList({ id, title, exclusive })
.then((list) => commit('setList', list))
},
setListAccounts ({ rootState, commit }, { id, accountIds }) {
const saved = rootState.lists.allListsObject[id].accountIds

View file

@ -22,7 +22,7 @@ const mediaViewer = {
actions: {
setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype)
const type = fileTypeService.fileType(attachment)
return supportedTypes.has(type)
})
commit('setMedia', media)

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

@ -31,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) => {

View file

@ -191,7 +191,7 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json())
}
const updateProfileImages = ({ credentials, avatar = null, avatarName = null, banner = null, background = null }) => {
const updateProfileImages = ({ credentials, avatar = null, avatarName = null, banner = null, background = null, ...extra }) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
@ -202,6 +202,10 @@ const updateProfileImages = ({ credentials, avatar = null, avatarName = null, ba
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
for (const ekey in extra)
form.append(ekey, extra[ekey])
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
@ -426,7 +430,7 @@ const fetchLists = ({ credentials }) => {
.then((data) => data.json())
}
const createList = ({ title, credentials }) => {
const createList = ({ title, exclusive, credentials }) => {
const url = MASTODON_LISTS_URL
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
@ -434,7 +438,7 @@ const createList = ({ title, credentials }) => {
return fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify({ title })
body: JSON.stringify({ title, exclusive })
}).then((data) => data.json())
}
@ -444,7 +448,7 @@ const getList = ({ id, credentials }) => {
.then((data) => data.json())
}
const updateList = ({ id, title, credentials }) => {
const updateList = ({ id, title, exclusive, credentials }) => {
const url = MASTODON_LIST_URL(id)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
@ -452,8 +456,8 @@ const updateList = ({ id, title, credentials }) => {
return fetch(url, {
method: 'PUT',
headers: headers,
body: JSON.stringify({ title })
})
body: JSON.stringify({ title, exclusive })
}).then((data) => data.json())
}
const getListAccounts = ({ id, credentials }) => {

View file

@ -1,6 +1,17 @@
export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
export const showDesktopNotification = async (rootState, desktopNotificationOpts) => {
if (!('Notification' in window && window.Notification.permission === 'granted')) return
if (rootState.statuses.notifications.desktopNotificationSilence) { return }
// we in mobile?
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
return registration.showNotification(desktopNotificationOpts.title, desktopNotificationOpts);
} catch (err) {
console.error("Service Worker failed me:", err);
}
}
// or maybe not?
return new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
}

View file

@ -83,9 +83,11 @@ export const parseUser = (data) => {
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
output.avatar_description = data.avatar_description
// Same, utilize header_static?
output.cover_photo = data.header
output.header_description = data.header_description
output.friends_count = data.following_count
@ -104,6 +106,8 @@ export const parseUser = (data) => {
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.pleroma.background_image_description = data.pleroma.background_image_description
output.favicon = data.pleroma.favicon
output.pleroma.unread_conversation_count = data.pleroma.unread_conversation_count
@ -243,8 +247,10 @@ export const parseAttachment = (data) => {
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta
output.id = data.id
output.type = data.type
} else {
output.mimetype = data.mimetype
output.type = 'unknown'
// output.meta = ??? missing
}
@ -306,6 +312,8 @@ export const parseStatus = (data) => {
if (data.akkoma) {
const { akkoma } = data
output.akkoma = akkoma
if (akkoma && akkoma.source) {
output.media_type = akkoma.source.mediaType
}

View file

@ -1,7 +1,7 @@
// TODO this func might as well take the entire file and use its mimetype
// or the entire service could be just mimetype service that only operates
// on mimetypes and not files. Currently the naming is confusing.
const fileType = mimetype => {
const fileType = mediaAttachment => {
// *oma extension; full MIME type of the file if known
const mimetype = mediaAttachment.mimetype
if (mimetype.match(/flash/)) {
return 'flash'
}
@ -22,15 +22,16 @@ const fileType = mimetype => {
return 'audio'
}
return 'unknown'
}
// if inconclusive, try generic type from vanilla Mastodon
if (mediaAttachment.type === 'gifv') {
return 'video'
}
const fileMatchesSomeType = (types, file) =>
types.some(type => fileType(file.mimetype) === type)
return (mediaAttachment.type || 'unknown')
}
const fileTypeService = {
fileType,
fileMatchesSomeType
}
export default fileTypeService

View file

@ -22,7 +22,7 @@ export const getAttrs = tag => {
.replace(new RegExp('^' + getTagName(tag)), '')
.replace(/\/?$/, '')
.trim()
const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
const attrs = Array.from(innertag.matchAll(/([a-z]+[a-z0-9-]*)(?:=((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')))?/gi))
.map(([trash, key, value]) => [key, value])
.map(([k, v]) => {
if (!v) return [k, true]

View file

@ -258,7 +258,7 @@ describe('RichContent', () => {
const html = p('Ebin :DDDD :spurdo:')
const expected = p(
'Ebin :DDDD ',
'<anonymous-stub src="about:blank" alt=":spurdo:" class="emoji img" title=":spurdo:"></anonymous-stub>'
'<anonymous-stub src="about:blank" title=":spurdo:" alt=":spurdo:" class="emoji img"></anonymous-stub>'
)
const wrapper = shallowMount(RichContent, {

View file

@ -5,7 +5,7 @@ describe('The lists module', () => {
describe('mutations', () => {
it('updates array of all lists', () => {
const state = cloneDeep(defaultState)
const list = { id: '1', title: 'testList' }
const list = { id: '1', title: 'testList', exclusive: false }
mutations.setLists(state, [list])
expect(state.allLists).to.have.length(1)
@ -14,37 +14,51 @@ describe('The lists module', () => {
it('adds a new list with a title, updating the title for existing lists', () => {
const state = cloneDeep(defaultState)
const list = { id: '1', title: 'testList' }
const modList = { id: '1', title: 'anotherTestTitle' }
mutations.setList(state, list)
expect(state.allListsObject[list.id]).to.eql({ title: list.title })
expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(list)
// new list
const listInit = { id: '1', title: 'testList', exclusive: false }
const listResultObj = { title: 'testList', exclusive: false, accountIds: [] }
const listResultItem = { id: '1', ...listResultObj }
mutations.setList(state, modList)
expect(state.allListsObject[modList.id]).to.eql({ title: modList.title })
mutations.setList(state, listInit)
expect(state.allListsObject[listInit.id]).to.eql(listResultObj)
expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(modList)
expect(state.allLists[0]).to.eql(listResultItem)
// update
const modParams = { id: '1', title: 'anotherTestTitle', exclusive: true }
const modResultObj = { title: 'anotherTestTitle', exclusive: true, accountIds: [] }
const modResultItem = { id: '1', ...modResultObj }
mutations.setList(state, modParams)
expect(state.allListsObject[modParams.id]).to.eql(modResultObj)
expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(modResultItem)
})
it('adds a new list with an array of IDs, updating the IDs for existing lists', () => {
const state = cloneDeep(defaultState)
// new list
const list = { id: '1', accountIds: ['1', '2', '3'] }
const modList = { id: '1', accountIds: ['3', '4', '5'] }
mutations.setListAccounts(state, list)
expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds })
expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds, title: "", exclusive: false })
mutations.setListAccounts(state, modList)
expect(state.allListsObject[modList.id]).to.eql({ accountIds: modList.accountIds })
// update, preserving title etc
const title = "FunList"
const exclusive = true
mutations.setList(state, { id: list.id, title, exclusive })
const modParams = { id: list.id, accountIds: ['3', '4', '5'] }
mutations.setListAccounts(state, modParams)
expect(state.allListsObject[modParams.id]).to.eql({ accountIds: modParams.accountIds, title, exclusive })
})
it('deletes a list', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allLists: [{ id: '1', title: 'testList', exclusive: false }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
1: { title: 'testList', accountIds: ['1', '2', '3'], exclusive: false }
}
}
const id = '1'
@ -58,9 +72,9 @@ describe('The lists module', () => {
describe('getters', () => {
it('returns list title', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allLists: [{ id: '1', title: 'testList', exclusive: false }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
1: { title: 'testList', accountIds: ['1', '2', '3'], exclusive: false }
}
}
const id = '1'
@ -70,9 +84,9 @@ describe('The lists module', () => {
it('returns list accounts', () => {
const state = {
allLists: [{ id: '1', title: 'testList' }],
allLists: [{ id: '1', title: 'testList', exclusive: false }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] }
1: { title: 'testList', accountIds: ['1', '2', '3'], exclusive: false }
}
}
const id = '1'

View file

@ -1,19 +0,0 @@
import fileType from 'src/services/file_type/file_type.service.js'
describe('fileType service', () => {
describe('fileMatchesSomeType', () => {
it('should be true when file type is one of the listed', () => {
const file = { mimetype: 'audio/mpeg' }
const types = ['video', 'audio']
expect(fileType.fileMatchesSomeType(types, file)).to.eql(true)
})
it('should be false when files type is not included in type list', () => {
const file = { mimetype: 'audio/mpeg' }
const types = ['image', 'video']
expect(fileType.fileMatchesSomeType(types, file)).to.eql(false)
})
})
})