Compare commits

...

18 commits

15 changed files with 349 additions and 259 deletions

3
.gitignore vendored
View file

@ -9,4 +9,5 @@ selenium-debug.log
config/local.json config/local.json
config/local.*.json config/local.*.json
docs/site/ docs/site/
.vscode/ .vscode/
.yarn/

View file

@ -1,3 +1,20 @@
A soft fork of Akkoma-FE fixing a few minor but longstanding annoyances.
Hopefully most of these will be merged upstream when I learn how to do PRs.
## Comprehensive List of Fixes and Changes
### Added
- Support for 'Fedi Links' web+ap addresses on user profiles and posts
- Approve and deny follow request buttons to the user profile page
### Fixed
- Styling adjustments for the user profile to avoid some ellipsing in the notifications panel
- Settings backup when the word filters contain non-ASCII characters
- The hover color of the checkmark icon on follow request notifications
# Akkoma-FE # Akkoma-FE
![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet) ![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)

View file

@ -0,0 +1,13 @@
const facActivityPub = {
prefix: "fac",
iconName: "activity-pub",
icon: [
448,
512,
[],
"",
"M 253.31024,102.2775 -10,254.04683 v 60.95152 L 200.28249,193.7048 v 243.19662 l 53.02775,29.86625 z m 52.41823,0 v 60.95153 L 516.62048,284.52259 305.72847,406.42564 v 60.34203 L 569.03871,314.99835 v -60.95152 z m 0,121.90308 V 345.47412 L 411.17449,284.52259 Z M 147.86424,284.52259 42.418248,345.47412 147.86424,406.42564 Z"
]
}
export default facActivityPub

View file

@ -1,5 +1,6 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue' import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import facActivityPub from '../../assets/icons/activity-pub'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faEllipsisH, faEllipsisH,
@ -9,7 +10,7 @@ import {
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
faHistory, faHistory,
faFilePen faFilePen,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faBookmark as faBookmarkReg, faBookmark as faBookmarkReg,
@ -25,6 +26,7 @@ library.add(
faThumbtack, faThumbtack,
faShareAlt, faShareAlt,
faExternalLinkAlt, faExternalLinkAlt,
facActivityPub,
faFlag, faFlag,
faHistory, faHistory,
faFilePen faFilePen
@ -95,6 +97,11 @@ const ExtraButtons = {
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
}, },
copyFediLink () {
navigator.clipboard.writeText(this.fediLinkURL)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () { bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id }) this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
@ -170,7 +177,7 @@ const ExtraButtons = {
canPin () { canPin () {
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted') return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
}, },
canMute () { loggedIn () {
return !!this.currentUser return !!this.currentUser
}, },
canTranslate () { canTranslate () {
@ -186,6 +193,16 @@ const ExtraButtons = {
return this.status.external_url return this.status.external_url
} }
}, },
showFediLinks () {
return this.$store.getters.mergedConfig.showFediLinks === true
},
fediLinkURL () {
try {
return this.status.external_uri.replace(/^https?/, 'web+ap')
} catch (e) {
return null
}
},
shouldConfirmDelete () { shouldConfirmDelete () {
return this.$store.getters.mergedConfig.modalOnDelete return this.$store.getters.mergedConfig.modalOnDelete
}, },

View file

@ -10,7 +10,7 @@
<template v-slot:content="{close}"> <template v-slot:content="{close}">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.thread_muted" v-if="loggedIn && !status.thread_muted"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click.prevent="muteConversation" @click.prevent="muteConversation"
> >
@ -20,7 +20,7 @@
/><span>{{ $t("status.mute_conversation") }}</span> /><span>{{ $t("status.mute_conversation") }}</span>
</button> </button>
<button <button
v-if="canMute && status.thread_muted" v-if="loggedIn && status.thread_muted"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unmuteConversation" @click.prevent="unmuteConversation"
> >
@ -52,7 +52,7 @@
/><span>{{ $t("status.unpin") }}</span> /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<button <button
v-if="!status.bookmarked" v-if="loggedIn && !status.bookmarked"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus" @click.prevent="bookmarkStatus"
@click="close" @click="close"
@ -63,7 +63,7 @@
/><span>{{ $t("status.bookmark") }}</span> /><span>{{ $t("status.bookmark") }}</span>
</button> </button>
<button <button
v-if="status.bookmarked" v-if="loggedIn && status.bookmarked"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus" @click.prevent="unbookmarkStatus"
@click="close" @click="close"
@ -127,6 +127,17 @@
icon="share-alt" icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span> /><span>{{ $t("status.copy_link") }}</span>
</button> </button>
<button
v-if="fediLinkURL && showFediLinks && loggedIn"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="copyFediLink"
@click="close"
>
<FAIcon
fixed-width
:icon="['fac', 'activity-pub']"
/><span>{{ $t("status.copy_fedi_link") }}</span>
</button>
<a <a
v-if="!status.is_local" v-if="!status.is_local"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@ -139,6 +150,18 @@
icon="external-link-alt" icon="external-link-alt"
/><span>{{ $t("status.external_source") }}</span> /><span>{{ $t("status.external_source") }}</span>
</a> </a>
<a
v-if="fediLinkURL && showFediLinks && !loggedIn"
class="button-default dropdown-item dropdown-item-icon"
title="Source"
:href="fediLinkURL"
target="_blank"
>
<FAIcon
fixed-width
:icon="['fac', 'activity-pub']"
/><span>{{ $t("status.open_fedi_link") }}</span>
</a>
<button <button
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@click.prevent="reportStatus" @click.prevent="reportStatus"

View file

@ -65,8 +65,8 @@
.follow-request-accept { .follow-request-accept {
&:hover { &:hover {
color: $fallback--text; color: $fallback--cGreen;
color: var(--text, $fallback--text); color: var(--cGreen, $fallback--text);
} }
} }

View file

@ -146,6 +146,14 @@
{{ $t('settings.show_wider_shortcuts') }} {{ $t('settings.show_wider_shortcuts') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting
path="showFediLinks"
expert="1"
>
{{ $t('settings.show_fedi_links') }}
</BooleanSetting>
</li>
<li> <li>
<BooleanSetting path="stopGifs"> <BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }} {{ $t('settings.stop_gifs') }}

View file

@ -8,6 +8,8 @@ import Select from '../select/select.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import ConfirmModal from '../confirm_modal/confirm_modal.vue' import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import facActivityPub from '../../assets/icons/activity-pub'
import { notificationsFromStore } from 'src/services/notification_utils/notification_utils.js'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -15,7 +17,7 @@ import {
faRss, faRss,
faSearchPlus, faSearchPlus,
faExternalLinkAlt, faExternalLinkAlt,
faEdit faEdit,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -23,7 +25,8 @@ library.add(
faBell, faBell,
faSearchPlus, faSearchPlus,
faExternalLinkAlt, faExternalLinkAlt,
faEdit faEdit,
facActivityPub
) )
export default { export default {
@ -34,7 +37,9 @@ export default {
return { return {
followRequestInProgress: false, followRequestInProgress: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter, betterShadow: this.$store.state.interface.browserSupport.cssFilter,
showingConfirmMute: false showingConfirmMute: false,
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false
} }
}, },
created () { created () {
@ -44,6 +49,16 @@ export default {
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
}, },
showFediLinks () {
return this.$store.getters.mergedConfig.showFediLinks === true
},
fediLinkURL () {
try {
return this.user._original.pleroma.ap_id.replace(/^https?/, 'web+ap')
} catch (e) {
return null
}
},
relationship () { relationship () {
return this.$store.getters.relationship(this.userId) return this.$store.getters.relationship(this.userId)
}, },
@ -117,6 +132,12 @@ export default {
shouldConfirmMute () { shouldConfirmMute () {
return this.mergedConfig.modalOnMute return this.mergedConfig.modalOnMute
}, },
shouldConfirmApprove () {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny () {
return this.mergedConfig.modalOnDenyFollow
},
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
components: { components: {
@ -193,6 +214,58 @@ export default {
}, },
mentionUser () { mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user }) this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
},
showApproveConfirmDialog () {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog () {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog () {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog () {
this.showingDenyConfirmDialog = false
},
approveUser () {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove () {
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('approveUser', this.user.id)
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: notification => {
notification.type = 'follow'
}
})
this.hideApproveConfirmDialog()
},
denyUser () {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny () {
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('denyUser', this.user.id)
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
})
this.hideDenyConfirmDialog()
},
findFollowRequestNotificationId () {
const notif = notificationsFromStore(this.$store).find(
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
)
return notif && notif.id
} }
} }
} }

View file

@ -97,14 +97,13 @@
.user-info { .user-info {
color: $fallback--lightText; color: $fallback--lightText;
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
padding: 0 26px; padding: 0 1.5em;
.container { .container {
min-width: 0; min-width: 0;
padding: 16px 0 6px; padding: 16px 0 6px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
max-height: 56px;
> * { > * {
min-width: 0; min-width: 0;
@ -165,7 +164,8 @@
} }
.user-summary { .user-summary {
display: block; display: flex;
flex-wrap: wrap;
margin-left: 0.6em; margin-left: 0.6em;
text-align: left; text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -184,6 +184,11 @@
} }
} }
.user-names {
flex-grow: 1;
max-width: 100%;
}
.user-name { .user-name {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
@ -228,6 +233,13 @@
} }
} }
.button-line {
display: flex;
min-width: 10em;
align-items: flex-start;
justify-content: flex-end;
}
.user-meta { .user-meta {
margin-bottom: .15em; margin-bottom: .15em;
display: flex; display: flex;
@ -242,6 +254,12 @@
text-align: left; text-align: left;
} }
.requested_by {
.btn {
margin-right: .25em;
}
}
.highlighter { .highlighter {
flex: 0 1 auto; flex: 0 1 auto;
display: flex; display: flex;

View file

@ -1,175 +1,108 @@
<template> <template>
<div <div class="user-card" :class="classes">
class="user-card" <div :class="{ 'hide-bio': hideBio }" :style="style" class="background-image" />
:class="classes"
>
<div
:class="{ 'hide-bio': hideBio }"
:style="style"
class="background-image"
/>
<div class="panel-heading -flexible-height"> <div class="panel-heading -flexible-height">
<div class="user-info"> <div class="user-info">
<div class="container"> <div class="container">
<a <a v-if="allowZoomingAvatar" class="user-info-avatar-link" @click="zoomAvatar">
v-if="allowZoomingAvatar" <UserAvatar :better-shadow="betterShadow" :user="user" />
class="user-info-avatar-link"
@click="zoomAvatar"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
<div class="user-info-avatar-link-overlay"> <div class="user-info-avatar-link-overlay">
<FAIcon <FAIcon class="fa-scale-110 fa-old-padding" icon="search-plus" />
class="fa-scale-110 fa-old-padding"
icon="search-plus"
/>
</div> </div>
</a> </a>
<router-link <router-link v-else :to="userProfileLink(user)">
v-else <UserAvatar :better-shadow="betterShadow" :user="user" />
:to="userProfileLink(user)"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
</router-link> </router-link>
<div class="user-summary"> <div class="user-summary">
<div class="top-line"> <div class="user-names">
<RichContent <div class="top-line">
:title="user.name" <RichContent :title="user.name" class="user-name" :html="user.name" :emoji="user.emoji" />
class="user-name" </div>
:html="user.name" <div class="bottom-line">
:emoji="user.emoji" <router-link class="user-screen-name" :title="user.screen_name_ui" :to="userProfileLink(user)">
/> @{{ user.screen_name_ui }}
<button </router-link>
v-if="!isOtherUser && user.is_local" <template v-if="!hideBio">
class="button-unstyled edit-profile-button" <span v-if="user.deactivated" class="alert user-role">
@click.stop="openProfileTab" {{ $t('user_card.deactivated') }}
> </span>
<FAIcon <span v-if="!!visibleRole" class="alert user-role">
fixed-width {{ $t(`general.role.${visibleRole}`) }}
class="icon" </span>
icon="edit" <span v-if="user.bot" class="alert user-role">
:title="$t('user_card.edit_profile')" {{ $t('user_card.bot') }}
/> </span>
</button> </template>
<a <span v-if="user.locked">
v-if="isOtherUser && !user.is_local" <FAIcon class="lock-icon" icon="lock" size="sm" />
:href="user.statusnet_profile_url" </span>
target="_blank" <span v-if="!mergedConfig.hideUserStats && !hideBio" class="dailyAvg">{{ dailyAvg }} {{
class="button-unstyled external-link-button" $t('user_card.per_day') }}</span>
> </div>
<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="bottom-line"> <div class="button-line">
<router-link <button v-if="!isOtherUser && user.is_local" class="button-unstyled edit-profile-button"
class="user-screen-name" @click.stop="openProfileTab">
:title="user.screen_name_ui" <FAIcon fixed-width class="icon" icon="edit" :title="$t('user_card.edit_profile')" />
:to="userProfileLink(user)" </button>
> <a v-if="isOtherUser && !user.is_local" :href="user.statusnet_profile_url" target="_blank"
@{{ user.screen_name_ui }} class="button-unstyled external-link-button" :title="$t('user_card.external_source')">
</router-link> <FAIcon class="icon" icon="external-link-alt" />
<template v-if="!hideBio"> </a>
<span <a v-if="fediLinkURL && showFediLinks && !hideBio" :href="fediLinkURL" target="_blank"
v-if="user.deactivated" class="button-unstyled external-link-button" :title="$t('user_card.open_fedi_link')">
class="alert user-role" <FAIcon class="icon" :icon="['fac', 'activity-pub']" />
> </a>
{{ $t('user_card.deactivated') }} <a v-if="isOtherUser" :href="user.statusnet_profile_url + '.rss'" target="_blank"
</span> class="button-unstyled external-link-button">
<span <FAIcon class="icon" icon="rss" />
v-if="!!visibleRole" </a>
class="alert user-role" <AccountActions v-if="isOtherUser && loggedIn" :user="user" :relationship="relationship" />
>
{{ $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"
/>
</span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="user-meta"> <div class="user-meta">
<div <div v-if="relationship.blocked_by && loggedIn && isOtherUser" class="blocking">
v-if="relationship.blocked_by && loggedIn && isOtherUser"
class="blocking"
>
{{ $t('user_card.blocks_you') }} {{ $t('user_card.blocks_you') }}
</div> </div>
<div <div v-if="relationship.followed_by && loggedIn && isOtherUser" class="following">
v-if="relationship.followed_by && loggedIn && isOtherUser"
class="following"
>
{{ $t('user_card.follows_you') }} {{ $t('user_card.follows_you') }}
</div> </div>
<div <div
v-if="relationship.requested_by && loggedIn && isOtherUser" v-if="relationship.requested_by && loggedIn && isOtherUser"
class="requested_by" class="requested_by"
style="white-space: nowrap;"
> >
{{ $t('user_card.requested_by') }} {{ $t('user_card.requested_by') }}
<button
class="btn button-default"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
>
<FAIcon
icon="check"
class="fa-scale-110 fa-old-padding follow-request-accept"
/>
</button>
<button
class="btn button-default"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
>
<FAIcon
icon="times"
class="fa-scale-110 fa-old-padding follow-request-reject"
/>
</button>
</div> </div>
<div <div v-if="isOtherUser && (loggedIn || !switcher)" class="highlighter">
v-if="isOtherUser && (loggedIn || !switcher)"
class="highlighter"
>
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to --> <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<input <input v-if="userHighlightType !== 'disabled'" :id="'userHighlightColorTx' + user.id"
v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor" class="userHighlightText" type="text">
:id="'userHighlightColorTx'+user.id" <input v-if="userHighlightType !== 'disabled'" :id="'userHighlightColor' + user.id" v-model="userHighlightColor"
v-model="userHighlightColor" class="userHighlightCl" type="color">
class="userHighlightText"
type="text"
>
<input
v-if="userHighlightType !== 'disabled'"
:id="'userHighlightColor'+user.id"
v-model="userHighlightColor"
class="userHighlightCl"
type="color"
>
{{ ' ' }} {{ ' ' }}
<Select <Select :id="'userHighlightSel' + user.id" v-model="userHighlightType" class="userHighlightSel">
:id="'userHighlightSel'+user.id"
v-model="userHighlightType"
class="userHighlightSel"
>
<option value="disabled"> <option value="disabled">
{{ $t('user_card.highlight.disabled') }} {{ $t('user_card.highlight.disabled') }}
</option> </option>
@ -185,144 +118,93 @@
</Select> </Select>
</div> </div>
</div> </div>
<div <div v-if="loggedIn && isOtherUser" class="user-interactions">
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div class="btn-group"> <div class="btn-group">
<FollowButton <FollowButton :relationship="relationship" :user="user" :disabled="relationship.blocked_by" />
:relationship="relationship"
:user="user"
:disabled="relationship.blocked_by"
/>
<template v-if="relationship.following"> <template v-if="relationship.following">
<ProgressButton <ProgressButton v-if="!relationship.subscribing" class="btn button-default" :click="subscribeUser"
v-if="!relationship.subscribing" :title="$t('user_card.subscribe')">
class="btn button-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<FAIcon icon="bell" /> <FAIcon icon="bell" />
</ProgressButton> </ProgressButton>
<ProgressButton <ProgressButton v-else class="btn button-default toggled" :click="unsubscribeUser"
v-else :title="$t('user_card.unsubscribe')">
class="btn button-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<FALayers> <FALayers>
<FAIcon <FAIcon icon="rss" transform="left-5 shrink-6 up-3 rotate-20" flip="horizontal" />
icon="rss" <FAIcon icon="rss" transform="right-5 shrink-6 up-3 rotate-20" />
transform="left-5 shrink-6 up-3 rotate-20"
flip="horizontal"
/>
<FAIcon
icon="rss"
transform="right-5 shrink-6 up-3 rotate-20"
/>
<FAIcon icon="bell" /> <FAIcon icon="bell" />
</FALayers> </FALayers>
</ProgressButton> </ProgressButton>
</template> </template>
</div> </div>
<div> <div>
<button <button v-if="relationship.muting" class="btn button-default btn-block toggled" :disabled="user.deactivated"
v-if="relationship.muting" @click="unmuteUser">
class="btn button-default btn-block toggled"
:disabled="user.deactivated"
@click="unmuteUser"
>
{{ $t('user_card.muted') }} {{ $t('user_card.muted') }}
</button> </button>
<button <button v-else class="btn button-default btn-block" :disabled="user.deactivated" @click="muteUser">
v-else
class="btn button-default btn-block"
:disabled="user.deactivated"
@click="muteUser"
>
{{ $t('user_card.mute') }} {{ $t('user_card.mute') }}
</button> </button>
</div> </div>
<div> <div>
<button <button class="btn button-default btn-block" :disabled="user.deactivated" @click="mentionUser">
class="btn button-default btn-block"
:disabled="user.deactivated"
@click="mentionUser"
>
{{ $t('user_card.mention') }} {{ $t('user_card.mention') }}
</button> </button>
</div> </div>
<ModerationTools <ModerationTools v-if="loggedIn.role === 'admin'" :user="user" />
v-if="loggedIn.role === 'admin'"
:user="user"
/>
</div> </div>
<div <div v-if="!loggedIn && user.is_local" class="user-interactions">
v-if="!loggedIn && user.is_local"
class="user-interactions"
>
<RemoteFollow :user="user" /> <RemoteFollow :user="user" />
</div> </div>
</div> </div>
</div> </div>
<div <div v-if="!hideBio" class="panel-body">
v-if="!hideBio" <div v-if="!mergedConfig.hideUserStats && switcher" class="user-counts">
class="panel-body" <div class="user-count" @click.prevent="setProfileView('statuses')">
>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5> <h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span> <span>{{ user.statuses_count }} <br></span>
</div> </div>
<div <div class="user-count" @click.prevent="setProfileView('friends')">
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5> <h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span> <span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div> </div>
<div <div class="user-count" @click.prevent="setProfileView('followers')">
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5> <h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span> <span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div> </div>
</div> </div>
<RichContent <RichContent v-if="!hideBio" class="user-card-bio" :html="user.description_html" :emoji="user.emoji"
v-if="!hideBio" :handle-links="true" />
class="user-card-bio"
:html="user.description_html"
:emoji="user.emoji"
:handle-links="true"
/>
</div> </div>
<teleport to="#modal"> <teleport to="#modal">
<confirm-modal <confirm-modal v-if="showingConfirmMute" :title="$t('user_card.mute_confirm_title')"
v-if="showingConfirmMute"
:title="$t('user_card.mute_confirm_title')"
:confirm-text="$t('user_card.mute_confirm_accept_button')" :confirm-text="$t('user_card.mute_confirm_accept_button')"
:cancel-text="$t('user_card.mute_confirm_cancel_button')" :cancel-text="$t('user_card.mute_confirm_cancel_button')" @accepted="doMuteUser" @cancelled="hideConfirmMute">
@accepted="doMuteUser" <i18n-t keypath="user_card.mute_confirm" tag="span">
@cancelled="hideConfirmMute"
>
<i18n-t
keypath="user_card.mute_confirm"
tag="span"
>
<template #user> <template #user>
<span <span v-text="user.screen_name_ui" />
v-text="user.screen_name_ui"
/>
</template> </template>
</i18n-t> </i18n-t>
</confirm-modal> </confirm-modal>
<confirm-modal
v-if="showingApproveConfirmDialog"
:title="$t('user_card.approve_confirm_title')"
:confirm-text="$t('user_card.approve_confirm_accept_button')"
:cancel-text="$t('user_card.approve_confirm_cancel_button')"
@accepted="doApprove"
@cancelled="hideApproveConfirmDialog"
>
{{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
</confirm-modal>
<confirm-modal
v-if="showingDenyConfirmDialog"
:title="$t('user_card.deny_confirm_title')"
:confirm-text="$t('user_card.deny_confirm_accept_button')"
:cancel-text="$t('user_card.deny_confirm_cancel_button')"
@accepted="doDeny"
@cancelled="hideDenyConfirmDialog"
>
{{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
</confirm-modal>
</teleport> </teleport>
</div> </div>
</template> </template>

View file

@ -750,6 +750,7 @@
"show_panel_nav_shortcuts": "Show timeline navigation shortcuts at the top of the panel", "show_panel_nav_shortcuts": "Show timeline navigation shortcuts at the top of the panel",
"show_scrollbars": "Show side column's scrollbars", "show_scrollbars": "Show side column's scrollbars",
"show_wider_shortcuts": "Show wider gap between top panel shortcuts", "show_wider_shortcuts": "Show wider gap between top panel shortcuts",
"show_fedi_links": "Show Fedi Links (web+ap URLs)",
"show_yous": "Show (You)s", "show_yous": "Show (You)s",
"stop_gifs": "Pause animated images until you hover on them", "stop_gifs": "Pause animated images until you hover on them",
"streaming": "Automatically show new posts when scrolled to the top", "streaming": "Automatically show new posts when scrolled to the top",
@ -961,6 +962,7 @@
"bookmark": "Bookmark", "bookmark": "Bookmark",
"collapse_attachments": "Collapse attachments", "collapse_attachments": "Collapse attachments",
"copy_link": "Copy link to post", "copy_link": "Copy link to post",
"copy_fedi_link": "Copy fedi link",
"delete": "Delete post", "delete": "Delete post",
"delete_confirm": "Do you really want to delete this post?", "delete_confirm": "Do you really want to delete this post?",
"delete_confirm_accept_button": "Yes, delete it", "delete_confirm_accept_button": "Yes, delete it",
@ -972,6 +974,7 @@
"edited_at": "Edited {time}", "edited_at": "Edited {time}",
"expand": "Expand", "expand": "Expand",
"external_source": "External source", "external_source": "External source",
"open_fedi_link": "Open fedi link",
"favorites": "Favorites", "favorites": "Favorites",
"hide_attachment": "Hide attachment", "hide_attachment": "Hide attachment",
"hide_content": "Hide content", "hide_content": "Hide content",
@ -1138,6 +1141,7 @@
"deny_confirm_title": "Deny follow request", "deny_confirm_title": "Deny follow request",
"domain_muted": "Unblock domain", "domain_muted": "Unblock domain",
"edit_profile": "Edit profile", "edit_profile": "Edit profile",
"external_source": "External source",
"favorites": "Favorites", "favorites": "Favorites",
"follow": "Follow", "follow": "Follow",
"follow_cancel": "Cancel request", "follow_cancel": "Cancel request",
@ -1171,6 +1175,7 @@
"mute_progress": "Muting…", "mute_progress": "Muting…",
"muted": "Muted", "muted": "Muted",
"note": "Private note", "note": "Private note",
"open_fedi_link": "Open fedi link",
"per_day": "per day", "per_day": "per day",
"remote_follow": "Remote follow", "remote_follow": "Remote follow",
"remove_follower": "Remove follower", "remove_follower": "Remove follower",

View file

@ -41,6 +41,7 @@ export const defaultState = {
showNavShortcuts: undefined, // instance default showNavShortcuts: undefined, // instance default
showPanelNavShortcuts: undefined, // instance default showPanelNavShortcuts: undefined, // instance default
showWiderShortcuts: undefined, // instance default showWiderShortcuts: undefined, // instance default
showFediLinks: true,
hideSiteFavicon: undefined, // instance default hideSiteFavicon: undefined, // instance default
hideSiteName: undefined, // instance default hideSiteName: undefined, // instance default
hideAttachments: false, hideAttachments: false,

View file

@ -54,6 +54,26 @@ const unblockUser = (store, id) => {
.then((relationship) => store.commit('updateUserRelationship', [relationship])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
} }
const approveUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.requested_by = false
predictedRelationship.followed_by = true
store.commit('updateUserRelationship', [predictedRelationship])
return store.rootState.api.backendInteractor.approveUser({ id })
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
const denyUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.requested_by = false
predictedRelationship.followed_by = false
store.commit('updateUserRelationship', [predictedRelationship])
return store.rootState.api.backendInteractor.denyUser({ id })
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
const removeUserFromFollowers = (store, id) => { const removeUserFromFollowers = (store, id) => {
return store.rootState.api.backendInteractor.removeUserFromFollowers({ id }) return store.rootState.api.backendInteractor.removeUserFromFollowers({ id })
.then((relationship) => store.commit('updateUserRelationship', [relationship])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
@ -344,6 +364,12 @@ const users = {
unblockUser (store, id) { unblockUser (store, id) {
return unblockUser(store, id) return unblockUser(store, id)
}, },
approveUser (store, id) {
return approveUser(store, id)
},
denyUser (store, id) {
return denyUser(store, id)
},
removeUserFromFollowers (store, id) { removeUserFromFollowers (store, id) {
return removeUserFromFollowers(store, id) return removeUserFromFollowers(store, id)
}, },

View file

@ -316,6 +316,7 @@ export const parseStatus = (data) => {
output.summary_raw_html = escape(data.spoiler_text) output.summary_raw_html = escape(data.spoiler_text)
output.external_url = data.url output.external_url = data.url
output.external_uri = data.uri
output.poll = data.poll output.poll = data.poll
if (output.poll) { if (output.poll) {
output.poll.options = (output.poll.options || []).map(field => ({ output.poll.options = (output.poll.options || []).map(field => ({

View file

@ -4,11 +4,14 @@ export const newExporter = ({
}) => ({ }) => ({
exportData () { exportData () {
const stringified = JSON.stringify(getExportedObject(), null, 2) // Pretty-print and indent with 2 spaces const stringified = JSON.stringify(getExportedObject(), null, 2) // Pretty-print and indent with 2 spaces
const bytes = new TextEncoder().encode(stringified)
const ascii = Array.from(bytes, (x) => String.fromCodePoint(x)).join("")
const data = window.btoa(ascii)
// Create an invisible link with a data url and simulate a click // Create an invisible link with a data url and simulate a click
const e = document.createElement('a') const e = document.createElement('a')
e.setAttribute('download', `${filename}.json`) e.setAttribute('download', `${filename}.json`)
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) e.setAttribute('href', 'data:application/json;base64,' + data)
e.style.display = 'none' e.style.display = 'none'
document.body.appendChild(e) document.body.appendChild(e)
@ -33,7 +36,9 @@ export const newImporter = ({
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
try { try {
const parsed = JSON.parse(target.result) const bytes = Uint8Array.from(target.result, (x) => x.codePointAt(0))
const data = new TextDecoder().decode(bytes)
const parsed = JSON.parse(data)
const validationResult = validator(parsed) const validationResult = validator(parsed)
if (validationResult === true) { if (validationResult === true) {
onImport(parsed) onImport(parsed)