Merge pull request #2858 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 28966fa0a6
This commit is contained in:
Claire 2024-09-25 21:15:51 +02:00 committed by GitHub
commit f610fdd6e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 409 additions and 651 deletions

View file

@ -100,16 +100,16 @@ GEM
attr_required (1.0.2) attr_required (1.0.2)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.977.0) aws-partitions (1.978.0)
aws-sdk-core (3.208.0) aws-sdk-core (3.209.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.93.0) aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-core (~> 3, >= 3.207.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.165.0) aws-sdk-s3 (1.166.0)
aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)

View file

@ -31,7 +31,7 @@ module WebAppControllerConcern
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
permalink_redirector = PermalinkRedirector.new(request.path) permalink_redirector = PermalinkRedirector.new(request.original_fullpath)
return if permalink_redirector.redirect_path.blank? return if permalink_redirector.redirect_path.blank?
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?

View file

@ -68,10 +68,15 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses)); dispatch(importFetchedStatuses(fetchedStatuses));
} }
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => async (_params, { getState }) =>
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }), apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -93,6 +98,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: params.gap.maxId, max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
@ -109,6 +115,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications', 'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotificationGroups({ return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones

View file

@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
export const apiFetchNotificationGroups = async (params?: { export const apiFetchNotificationGroups = async (params?: {
url?: string; url?: string;
grouped_types?: string[];
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;

View file

@ -315,11 +315,14 @@ class StatusActionBar extends ImmutablePureComponent {
} }
const filterButton = this.props.onFilter && ( const filterButton = this.props.onFilter && (
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
</div>
); );
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton <IconButton
className='status__action-bar-button' className='status__action-bar-button'
title={replyTitle} title={replyTitle}
@ -329,12 +332,20 @@ class StatusActionBar extends ImmutablePureComponent {
counter={showReplyCount ? status.get('replies_count') : undefined} counter={showReplyCount ? status.get('replies_count') : undefined}
obfuscateCount obfuscateCount
/> />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /> <IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
{filterButton} {filterButton}
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
@ -345,6 +356,7 @@ class StatusActionBar extends ImmutablePureComponent {
direction='right' direction='right'
ariaLabel={intl.formatMessage(messages.more)} ariaLabel={intl.formatMessage(messages.more)}
/> />
</div>
<div className='status__action-bar-spacer' /> <div className='status__action-bar-spacer' />
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>

View file

@ -196,7 +196,7 @@ class SwitchingColumnsArea extends PureComponent {
{redirect} {redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null} {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null} {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}

View file

@ -93,7 +93,7 @@
&:disabled, &:disabled,
&.disabled { &.disabled {
background-color: $ui-primary-color; background-color: $ui-primary-color;
cursor: default; cursor: not-allowed;
} }
&.copyable { &.copyable {
@ -299,6 +299,10 @@
} }
} }
&--with-counter {
padding-inline-end: 4px;
}
&__counter { &__counter {
display: block; display: block;
width: auto; width: auto;
@ -1516,6 +1520,15 @@ body > [data-popper-placement] {
} }
} }
&__action-bar__button-wrapper {
flex-basis: 0;
flex-grow: 1;
&:last-child {
flex-grow: 0;
}
}
&--first-in-thread { &--first-in-thread {
border-top: 1px solid var(--background-border-color); border-top: 1px solid var(--background-border-color);
} }

View file

@ -68,10 +68,15 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses)); dispatch(importFetchedStatuses(fetchedStatuses));
} }
const supportedGroupedNotificationTypes = ['favourite', 'reblog'];
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => async (_params, { getState }) =>
apiFetchNotificationGroups({ exclude_types: getExcludedTypes(getState()) }), apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
@ -93,6 +98,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: params.gap.maxId, max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
@ -109,6 +115,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications', 'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotificationGroups({ return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes,
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones

View file

@ -31,6 +31,7 @@ export const apiFetchNotifications = async (
export const apiFetchNotificationGroups = async (params?: { export const apiFetchNotificationGroups = async (params?: {
url?: string; url?: string;
grouped_types?: string[];
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;

View file

@ -375,11 +375,19 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} /> <IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> <IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} /> <IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /> <IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
<div className='status__action-bar__button-wrapper'>
<DropdownMenuContainer <DropdownMenuContainer
scrollKey={scrollKey} scrollKey={scrollKey}
status={status} status={status}
@ -390,6 +398,7 @@ class StatusActionBar extends ImmutablePureComponent {
title={intl.formatMessage(messages.more)} title={intl.formatMessage(messages.more)}
/> />
</div> </div>
</div>
); );
} }

View file

@ -186,7 +186,7 @@ class SwitchingColumnsArea extends PureComponent {
{redirect} {redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null} {singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null} {singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null} {!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}

View file

@ -164,7 +164,7 @@
"compose_form.publish": "Publier", "compose_form.publish": "Publier",
"compose_form.publish_form": "Publier", "compose_form.publish_form": "Publier",
"compose_form.reply": "Répondre", "compose_form.reply": "Répondre",
"compose_form.save_changes": "Mis à jour", "compose_form.save_changes": "Mettre à jour",
"compose_form.spoiler.marked": "Enlever l'avertissement de contenu", "compose_form.spoiler.marked": "Enlever l'avertissement de contenu",
"compose_form.spoiler.unmarked": "Ajouter un avertissement de contenu", "compose_form.spoiler.unmarked": "Ajouter un avertissement de contenu",
"compose_form.spoiler_placeholder": "Avertissement de contenu (optionnel)", "compose_form.spoiler_placeholder": "Avertissement de contenu (optionnel)",

View file

@ -164,7 +164,7 @@
"compose_form.publish": "Publier", "compose_form.publish": "Publier",
"compose_form.publish_form": "Nouvelle publication", "compose_form.publish_form": "Nouvelle publication",
"compose_form.reply": "Répondre", "compose_form.reply": "Répondre",
"compose_form.save_changes": "Mis à jour", "compose_form.save_changes": "Mettre à jour",
"compose_form.spoiler.marked": "Enlever lavertissement de contenu", "compose_form.spoiler.marked": "Enlever lavertissement de contenu",
"compose_form.spoiler.unmarked": "Ajouter un avertissement de contenu", "compose_form.spoiler.unmarked": "Ajouter un avertissement de contenu",
"compose_form.spoiler_placeholder": "Avertissement de contenu (optionnel)", "compose_form.spoiler_placeholder": "Avertissement de contenu (optionnel)",

View file

@ -76,7 +76,7 @@
"admin.dashboard.monthly_retention": "Tỉ lệ người dùng ở lại sau khi đăng ký", "admin.dashboard.monthly_retention": "Tỉ lệ người dùng ở lại sau khi đăng ký",
"admin.dashboard.retention.average": "Trung bình", "admin.dashboard.retention.average": "Trung bình",
"admin.dashboard.retention.cohort": "Tháng đăng ký", "admin.dashboard.retention.cohort": "Tháng đăng ký",
"admin.dashboard.retention.cohort_size": "Người mới", "admin.dashboard.retention.cohort_size": "Số người",
"admin.impact_report.instance_accounts": "Hồ sơ tài khoản này sẽ xóa", "admin.impact_report.instance_accounts": "Hồ sơ tài khoản này sẽ xóa",
"admin.impact_report.instance_followers": "Người theo dõi của thành viên máy chủ sẽ mất", "admin.impact_report.instance_followers": "Người theo dõi của thành viên máy chủ sẽ mất",
"admin.impact_report.instance_follows": "Người theo dõi người dùng của họ sẽ mất", "admin.impact_report.instance_follows": "Người theo dõi người dùng của họ sẽ mất",
@ -154,7 +154,7 @@
"compose_form.lock_disclaimer": "Tài khoản của bạn không {locked}. Bất cứ ai cũng có thể theo dõi và xem tút riêng tư của bạn.", "compose_form.lock_disclaimer": "Tài khoản của bạn không {locked}. Bất cứ ai cũng có thể theo dõi và xem tút riêng tư của bạn.",
"compose_form.lock_disclaimer.lock": "khóa", "compose_form.lock_disclaimer.lock": "khóa",
"compose_form.placeholder": "Bạn đang nghĩ gì?", "compose_form.placeholder": "Bạn đang nghĩ gì?",
"compose_form.poll.duration": "Hết hạn", "compose_form.poll.duration": "Hết hạn sau",
"compose_form.poll.multiple": "Chọn nhiều", "compose_form.poll.multiple": "Chọn nhiều",
"compose_form.poll.option_placeholder": "Lựa chọn {number}", "compose_form.poll.option_placeholder": "Lựa chọn {number}",
"compose_form.poll.single": "Chọn một", "compose_form.poll.single": "Chọn một",
@ -180,7 +180,7 @@
"confirmations.discard_edit_media.message": "Bạn chưa lưu thay đổi đối với phần mô tả hoặc bản xem trước của media, vẫn bỏ luôn?", "confirmations.discard_edit_media.message": "Bạn chưa lưu thay đổi đối với phần mô tả hoặc bản xem trước của media, vẫn bỏ luôn?",
"confirmations.edit.confirm": "Sửa", "confirmations.edit.confirm": "Sửa",
"confirmations.edit.message": "Nội dung tút cũ sẽ bị ghi đè, bạn có tiếp tục?", "confirmations.edit.message": "Nội dung tút cũ sẽ bị ghi đè, bạn có tiếp tục?",
"confirmations.edit.title": "Viết đè lên tút cũ", "confirmations.edit.title": "Ghi đè lên tút cũ",
"confirmations.logout.confirm": "Đăng xuất", "confirmations.logout.confirm": "Đăng xuất",
"confirmations.logout.message": "Bạn có chắc muốn thoát?", "confirmations.logout.message": "Bạn có chắc muốn thoát?",
"confirmations.logout.title": "Đăng xuất", "confirmations.logout.title": "Đăng xuất",
@ -190,11 +190,11 @@
"confirmations.redraft.title": "Xóa & viết lại", "confirmations.redraft.title": "Xóa & viết lại",
"confirmations.reply.confirm": "Trả lời", "confirmations.reply.confirm": "Trả lời",
"confirmations.reply.message": "Nội dung bạn đang soạn thảo sẽ bị ghi đè, bạn có tiếp tục?", "confirmations.reply.message": "Nội dung bạn đang soạn thảo sẽ bị ghi đè, bạn có tiếp tục?",
"confirmations.reply.title": "Viết đè lên tút cũ", "confirmations.reply.title": "Ghi đè lên tút cũ",
"confirmations.unfollow.confirm": "Bỏ theo dõi", "confirmations.unfollow.confirm": "Bỏ theo dõi",
"confirmations.unfollow.message": "Bạn có chắc muốn bỏ theo dõi {name}?", "confirmations.unfollow.message": "Bạn có chắc muốn bỏ theo dõi {name}?",
"confirmations.unfollow.title": "Bỏ theo dõi", "confirmations.unfollow.title": "Bỏ theo dõi",
"content_warning.hide": "Ẩn tút", "content_warning.hide": "Ẩn lại",
"content_warning.show": "Nhấn để xem", "content_warning.show": "Nhấn để xem",
"conversation.delete": "Xóa tin nhắn này", "conversation.delete": "Xóa tin nhắn này",
"conversation.mark_as_read": "Đánh dấu là đã đọc", "conversation.mark_as_read": "Đánh dấu là đã đọc",
@ -322,7 +322,7 @@
"follow_suggestions.hints.most_interactions": "Người này đang thu hút sự chú ý trên {domain}.", "follow_suggestions.hints.most_interactions": "Người này đang thu hút sự chú ý trên {domain}.",
"follow_suggestions.hints.similar_to_recently_followed": "Người này có nét giống những người mà bạn theo dõi gần đây.", "follow_suggestions.hints.similar_to_recently_followed": "Người này có nét giống những người mà bạn theo dõi gần đây.",
"follow_suggestions.personalized_suggestion": "Gợi ý cá nhân hóa", "follow_suggestions.personalized_suggestion": "Gợi ý cá nhân hóa",
"follow_suggestions.popular_suggestion": "Những người nổi tiếng", "follow_suggestions.popular_suggestion": "Người nổi tiếng",
"follow_suggestions.popular_suggestion_longer": "Nổi tiếng trên {domain}", "follow_suggestions.popular_suggestion_longer": "Nổi tiếng trên {domain}",
"follow_suggestions.similar_to_recently_followed_longer": "Tương tự những người mà bạn theo dõi gần đây", "follow_suggestions.similar_to_recently_followed_longer": "Tương tự những người mà bạn theo dõi gần đây",
"follow_suggestions.view_all": "Xem tất cả", "follow_suggestions.view_all": "Xem tất cả",
@ -480,7 +480,7 @@
"navigation_bar.domain_blocks": "Máy chủ đã ẩn", "navigation_bar.domain_blocks": "Máy chủ đã ẩn",
"navigation_bar.explore": "Xu hướng", "navigation_bar.explore": "Xu hướng",
"navigation_bar.favourites": "Tút thích", "navigation_bar.favourites": "Tút thích",
"navigation_bar.filters": "Bộ lọc từ ngữ", "navigation_bar.filters": "Từ khóa đã lọc",
"navigation_bar.follow_requests": "Yêu cầu theo dõi", "navigation_bar.follow_requests": "Yêu cầu theo dõi",
"navigation_bar.followed_tags": "Hashtag theo dõi", "navigation_bar.followed_tags": "Hashtag theo dõi",
"navigation_bar.follows_and_followers": "Quan hệ", "navigation_bar.follows_and_followers": "Quan hệ",
@ -555,7 +555,7 @@
"notification_requests.view": "Hiện thông báo", "notification_requests.view": "Hiện thông báo",
"notifications.clear": "Xóa hết thông báo", "notifications.clear": "Xóa hết thông báo",
"notifications.clear_confirmation": "Bạn có chắc muốn xóa vĩnh viễn tất cả thông báo của mình?", "notifications.clear_confirmation": "Bạn có chắc muốn xóa vĩnh viễn tất cả thông báo của mình?",
"notifications.clear_title": "Xóa hết thông báo?", "notifications.clear_title": "Xóa toàn bộ thông báo",
"notifications.column_settings.admin.report": "Báo cáo mới:", "notifications.column_settings.admin.report": "Báo cáo mới:",
"notifications.column_settings.admin.sign_up": "Người mới tham gia:", "notifications.column_settings.admin.sign_up": "Người mới tham gia:",
"notifications.column_settings.alert": "Báo trên máy tính", "notifications.column_settings.alert": "Báo trên máy tính",
@ -601,8 +601,8 @@
"notifications.policy.filter_not_followers_title": "Những người không theo dõi bạn", "notifications.policy.filter_not_followers_title": "Những người không theo dõi bạn",
"notifications.policy.filter_not_following_hint": "Cho tới khi bạn duyệt họ", "notifications.policy.filter_not_following_hint": "Cho tới khi bạn duyệt họ",
"notifications.policy.filter_not_following_title": "Những người bạn không theo dõi", "notifications.policy.filter_not_following_title": "Những người bạn không theo dõi",
"notifications.policy.filter_private_mentions_hint": "Được lọc trừ khi nó trả lời lượt nhắc từ bạn hoặc nếu bạn theo dõi người gửi", "notifications.policy.filter_private_mentions_hint": "Trừ khi nó trả lời lượt nhắc từ bạn hoặc nếu bạn theo dõi người gửi",
"notifications.policy.filter_private_mentions_title": "Lượt nhắc riêng tư không được yêu cầu", "notifications.policy.filter_private_mentions_title": "Lượt nhắn riêng không mong muốn",
"notifications.policy.title": "Quản lý thông báo từ…", "notifications.policy.title": "Quản lý thông báo từ…",
"notifications_permission_banner.enable": "Cho phép thông báo trên màn hình", "notifications_permission_banner.enable": "Cho phép thông báo trên màn hình",
"notifications_permission_banner.how_to_control": "Hãy bật thông báo trên màn hình để không bỏ lỡ những thông báo từ Mastodon. Một khi đã bật, bạn có thể lựa chọn từng loại thông báo khác nhau thông qua {icon} nút bên dưới.", "notifications_permission_banner.how_to_control": "Hãy bật thông báo trên màn hình để không bỏ lỡ những thông báo từ Mastodon. Một khi đã bật, bạn có thể lựa chọn từng loại thông báo khác nhau thông qua {icon} nút bên dưới.",
@ -713,7 +713,7 @@
"report.reasons.other": "Một lý do khác", "report.reasons.other": "Một lý do khác",
"report.reasons.other_description": "Vấn đề không nằm trong những mục trên", "report.reasons.other_description": "Vấn đề không nằm trong những mục trên",
"report.reasons.spam": "Đây là spam", "report.reasons.spam": "Đây là spam",
"report.reasons.spam_description": "Liên kết độc hại, tạo tương tác giả hoặc trả lời lặp đi lặp lại", "report.reasons.spam_description": "Liên kết độc hại, giả tương tác hoặc trả lời lặp đi lặp lại",
"report.reasons.violation": "Vi phạm nội quy máy chủ", "report.reasons.violation": "Vi phạm nội quy máy chủ",
"report.reasons.violation_description": "Bạn nhận thấy nó vi phạm nội quy máy chủ", "report.reasons.violation_description": "Bạn nhận thấy nó vi phạm nội quy máy chủ",
"report.rules.subtitle": "Chọn tất cả những gì phù hợp", "report.rules.subtitle": "Chọn tất cả những gì phù hợp",
@ -787,9 +787,9 @@
"status.edit": "Sửa", "status.edit": "Sửa",
"status.edited": "Sửa lần cuối {date}", "status.edited": "Sửa lần cuối {date}",
"status.edited_x_times": "Đã sửa {count, plural, other {{count} lần}}", "status.edited_x_times": "Đã sửa {count, plural, other {{count} lần}}",
"status.embed": "Lấy mã nhúng", "status.embed": "Nhúng",
"status.favourite": "Thích", "status.favourite": "Thích",
"status.favourites": "{count, plural, other {Thích}}", "status.favourites": "{count, plural, other {thích}}",
"status.filter": "Lọc tút này", "status.filter": "Lọc tút này",
"status.history.created": "{name} đăng {date}", "status.history.created": "{name} đăng {date}",
"status.history.edited": "{name} đã sửa {date}", "status.history.edited": "{name} đã sửa {date}",
@ -808,7 +808,7 @@
"status.reblog": "Đăng lại", "status.reblog": "Đăng lại",
"status.reblog_private": "Đăng lại (Riêng tư)", "status.reblog_private": "Đăng lại (Riêng tư)",
"status.reblogged_by": "{name} đăng lại", "status.reblogged_by": "{name} đăng lại",
"status.reblogs": "{count, plural, other {Đăng lại}}", "status.reblogs": "{count, plural, other {đăng lại}}",
"status.reblogs.empty": "Tút này chưa có ai đăng lại. Nếu có, nó sẽ hiển thị ở đây.", "status.reblogs.empty": "Tút này chưa có ai đăng lại. Nếu có, nó sẽ hiển thị ở đây.",
"status.redraft": "Xóa và viết lại", "status.redraft": "Xóa và viết lại",
"status.remove_bookmark": "Bỏ lưu", "status.remove_bookmark": "Bỏ lưu",

View file

@ -93,7 +93,7 @@
&:disabled, &:disabled,
&.disabled { &.disabled {
background-color: $ui-primary-color; background-color: $ui-primary-color;
cursor: default; cursor: not-allowed;
} }
&.copyable { &.copyable {
@ -299,6 +299,10 @@
} }
} }
&--with-counter {
padding-inline-end: 4px;
}
&__counter { &__counter {
display: block; display: block;
width: auto; width: auto;
@ -1465,6 +1469,15 @@ body > [data-popper-placement] {
} }
} }
&__action-bar__button-wrapper {
flex-basis: 0;
flex-grow: 1;
&:last-child {
flex-grow: 0;
}
}
&--first-in-thread { &--first-in-thread {
border-top: 1px solid var(--background-border-color); border-top: 1px solid var(--background-border-color);
} }

View file

@ -83,6 +83,6 @@ class PermalinkRedirector
end end
def path_segments def path_segments
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/') @path_segments ||= @path.split('?')[0].delete_prefix('/deck').delete_prefix('/').split('/')
end end
end end

View file

@ -13,12 +13,14 @@ class NotificationMailer < ApplicationMailer
before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request] before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
after_action :set_list_headers! after_action :set_list_headers!
before_deliver :verify_functional_user
default to: -> { email_address_with_name(@user.email, @me.username) } default to: -> { email_address_with_name(@user.email, @me.username) }
layout 'mailer' layout 'mailer'
def mention def mention
return unless @user.functional? && @status.present? return if @status.blank?
locale_for_account(@me) do locale_for_account(@me) do
mail subject: default_i18n_subject(name: @status.account.acct) mail subject: default_i18n_subject(name: @status.account.acct)
@ -26,15 +28,13 @@ class NotificationMailer < ApplicationMailer
end end
def follow def follow
return unless @user.functional?
locale_for_account(@me) do locale_for_account(@me) do
mail subject: default_i18n_subject(name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
end end
def favourite def favourite
return unless @user.functional? && @status.present? return if @status.blank?
locale_for_account(@me) do locale_for_account(@me) do
mail subject: default_i18n_subject(name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
@ -42,7 +42,7 @@ class NotificationMailer < ApplicationMailer
end end
def reblog def reblog
return unless @user.functional? && @status.present? return if @status.blank?
locale_for_account(@me) do locale_for_account(@me) do
mail subject: default_i18n_subject(name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
@ -50,8 +50,6 @@ class NotificationMailer < ApplicationMailer
end end
def follow_request def follow_request
return unless @user.functional?
locale_for_account(@me) do locale_for_account(@me) do
mail subject: default_i18n_subject(name: @account.acct) mail subject: default_i18n_subject(name: @account.acct)
end end
@ -75,6 +73,10 @@ class NotificationMailer < ApplicationMailer
@account = @notification.from_account @account = @notification.from_account
end end
def verify_functional_user
throw(:abort) unless @user.functional?
end
def set_list_headers! def set_list_headers!
headers( headers(
'List-ID' => "<#{@type}.#{@me.username}.#{Rails.configuration.x.local_domain}>", 'List-ID' => "<#{@type}.#{@me.username}.#{Rails.configuration.x.local_domain}>",

View file

@ -20,6 +20,7 @@ class Notification < ApplicationRecord
self.inheritance_column = nil self.inheritance_column = nil
include Paginable include Paginable
include Redisable
LEGACY_TYPE_CLASS_MAP = { LEGACY_TYPE_CLASS_MAP = {
'Mention' => :mention, 'Mention' => :mention,
@ -30,7 +31,9 @@ class Notification < ApplicationRecord
'Poll' => :poll, 'Poll' => :poll,
}.freeze }.freeze
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog).freeze # `set_group_key!` needs to be updated if this list changes
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog follow).freeze
MAXIMUM_GROUP_SPAN_HOURS = 12
# Please update app/javascript/api_types/notification.ts if you change this # Please update app/javascript/api_types/notification.ts if you change this
PROPERTIES = { PROPERTIES = {
@ -123,6 +126,30 @@ class Notification < ApplicationRecord
end end
end end
def set_group_key!
return if filtered? || Notification::GROUPABLE_NOTIFICATION_TYPES.exclude?(type)
type_prefix = case type
when :favourite, :reblog
[type, target_status&.id].join('-')
when :follow
type
else
raise NotImplementedError
end
redis_key = "notif-group/#{account.id}/#{type_prefix}"
hour_bucket = activity.created_at.utc.to_i / 1.hour.to_i
# Reuse previous group if it does not span too large an amount of time
previous_bucket = redis.get(redis_key).to_i
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
# We do not concern ourselves with race conditions since we use hour buckets
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
self.group_key = "#{type_prefix}-#{hour_bucket}"
end
class << self class << self
def browserable(types: [], exclude_types: [], from_account_id: nil, include_filtered: false) def browserable(types: [], exclude_types: [], from_account_id: nil, include_filtered: false)
requested_types = if types.empty? requested_types = if types.empty?

View file

@ -3,8 +3,6 @@
class NotifyService < BaseService class NotifyService < BaseService
include Redisable include Redisable
MAXIMUM_GROUP_SPAN_HOURS = 12
# TODO: the severed_relationships type probably warrants email notifications # TODO: the severed_relationships type probably warrants email notifications
NON_EMAIL_TYPES = %i( NON_EMAIL_TYPES = %i(
admin.report admin.report
@ -216,7 +214,7 @@ class NotifyService < BaseService
return if drop? return if drop?
@notification.filtered = filter? @notification.filtered = filter?
@notification.group_key = notification_group_key @notification.set_group_key!
@notification.save! @notification.save!
# It's possible the underlying activity has been deleted # It's possible the underlying activity has been deleted
@ -236,23 +234,6 @@ class NotifyService < BaseService
private private
def notification_group_key
return nil if @notification.filtered || Notification::GROUPABLE_NOTIFICATION_TYPES.exclude?(@notification.type)
type_prefix = "#{@notification.type}-#{@notification.target_status.id}"
redis_key = "notif-group/#{@recipient.id}/#{type_prefix}"
hour_bucket = @notification.activity.created_at.utc.to_i / 1.hour.to_i
# Reuse previous group if it does not span too large an amount of time
previous_bucket = redis.get(redis_key).to_i
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
# We do not concern ourselves with race conditions since we use hour buckets
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
"#{type_prefix}-#{hour_bucket}"
end
def drop? def drop?
DropCondition.new(@notification).drop? DropCondition.new(@notification).drop?
end end

View file

@ -15,6 +15,12 @@ eo:
user/invite_request: user/invite_request:
text: Kialo text: Kialo
errors: errors:
attributes:
domain:
invalid: ne estas valida domajna nomo
messages:
invalid_domain_on_line: "%{value} ne estas valida domajna nomo"
too_many_lines: superas la limon de %{limit} linioj
models: models:
account: account:
attributes: attributes:

View file

@ -48,10 +48,13 @@ eo:
subject: 'Mastodon: Instrukcioj por ŝanĝi pasvorton' subject: 'Mastodon: Instrukcioj por ŝanĝi pasvorton'
title: Pasvorto restarigita title: Pasvorto restarigita
two_factor_disabled: two_factor_disabled:
explanation: Ensalutu nun eblas uzante nur retadreson kaj pasvorton.
subject: 'Mastodon: dufaktora aŭtentigo malebligita' subject: 'Mastodon: dufaktora aŭtentigo malebligita'
subtitle: Dupaŝa aŭtentigo por via konto estas malŝaltita.
title: 2FA estas malŝaltita title: 2FA estas malŝaltita
two_factor_enabled: two_factor_enabled:
subject: 'Mastodon: Dufaktora aŭtentigo ebligita' subject: 'Mastodon: Dufaktora aŭtentigo ebligita'
subtitle: Dupaŝa aŭtentigo por via konto estas ŝaltita.
title: 2FA aktivigita title: 2FA aktivigita
two_factor_recovery_codes_changed: two_factor_recovery_codes_changed:
explanation: La antaŭaj reakiraj kodoj estis nuligitaj kaj novaj estis generitaj. explanation: La antaŭaj reakiraj kodoj estis nuligitaj kaj novaj estis generitaj.

View file

@ -60,7 +60,7 @@ es-AR:
error: error:
title: Ocurrió un error title: Ocurrió un error
new: new:
prompt_html: "%{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>" prompt_html: A %{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>
review_permissions: Revisar permisos review_permissions: Revisar permisos
title: Autorización requerida title: Autorización requerida
show: show:

View file

@ -60,7 +60,7 @@ es-MX:
error: error:
title: Ha ocurrido un error title: Ha ocurrido un error
new: new:
prompt_html: "%{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>" prompt_html: A %{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>
review_permissions: Revisar permisos review_permissions: Revisar permisos
title: Se requiere autorización title: Se requiere autorización
show: show:

View file

@ -60,7 +60,7 @@ es:
error: error:
title: Ha ocurrido un error title: Ha ocurrido un error
new: new:
prompt_html: "%{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>" prompt_html: A %{client_name} le gustaría obtener permiso para acceder a tu cuenta. <strong>Solo aprueba esta solicitud si reconoces y confías en esta fuente.</strong>
review_permissions: Revisar permisos review_permissions: Revisar permisos
title: Se requiere autorización title: Se requiere autorización
show: show:

View file

@ -60,6 +60,7 @@ hu:
error: error:
title: Hiba történt title: Hiba történt
new: new:
prompt_html: A(z) %{client_name} engedélyt kér hogy hozzáférjen a fiókodhoz. <strong>Csak akkor engedélyezd ezt a kérést, ha felismered és megbízol ebben a forrásban.</strong>
review_permissions: Jogosultságok áttekintése review_permissions: Jogosultságok áttekintése
title: Engedélyezés szükséges title: Engedélyezés szükséges
show: show:

View file

@ -71,7 +71,7 @@ vi:
confirmations: confirmations:
revoke: Bạn có chắc không? revoke: Bạn có chắc không?
index: index:
authorized_at: Cho phép %{date} authorized_at: Cho phép vào %{date}
description_html: Đây là những ứng dụng có thể truy cập tài khoản của bạn bằng API. Nếu có ứng dụng bạn không nhận ra ở đây hoặc ứng dụng hoạt động sai, bạn có thể thu hồi quyền truy cập của ứng dụng đó. description_html: Đây là những ứng dụng có thể truy cập tài khoản của bạn bằng API. Nếu có ứng dụng bạn không nhận ra ở đây hoặc ứng dụng hoạt động sai, bạn có thể thu hồi quyền truy cập của ứng dụng đó.
last_used_at: Dùng lần cuối %{date} last_used_at: Dùng lần cuối %{date}
never_used: Chưa dùng never_used: Chưa dùng
@ -151,7 +151,7 @@ vi:
scopes: scopes:
admin:read: đọc mọi dữ liệu trên máy chủ admin:read: đọc mọi dữ liệu trên máy chủ
admin:read:accounts: đọc thông tin nhạy cảm của tất cả các tài khoản admin:read:accounts: đọc thông tin nhạy cảm của tất cả các tài khoản
admin:read:canonical_email_blocks: đọc thông tin nhạy cảm của tất cả các khối email chuẩn admin:read:canonical_email_blocks: đọc thông tin nhạy cảm của tất cả khối email chuẩn
admin:read:domain_allows: đọc thông tin nhạy cảm của tất cả các tên miền cho phép admin:read:domain_allows: đọc thông tin nhạy cảm của tất cả các tên miền cho phép
admin:read:domain_blocks: đọc thông tin nhạy cảm của tất cả các tên miền chặn admin:read:domain_blocks: đọc thông tin nhạy cảm của tất cả các tên miền chặn
admin:read:email_domain_blocks: đọc thông tin nhạy cảm của tất cả các miền email chặn admin:read:email_domain_blocks: đọc thông tin nhạy cảm của tất cả các miền email chặn

View file

@ -42,7 +42,7 @@ vi:
autofollow: Những người đăng ký sẽ tự động theo dõi bạn autofollow: Những người đăng ký sẽ tự động theo dõi bạn
avatar: WEBP, PNG, GIF hoặc JPG, tối đa %{size}. Sẽ bị nén xuống %{dimensions}px avatar: WEBP, PNG, GIF hoặc JPG, tối đa %{size}. Sẽ bị nén xuống %{dimensions}px
bot: Tài khoản này tự động thực hiện các hành động và không được quản lý bởi người thật bot: Tài khoản này tự động thực hiện các hành động và không được quản lý bởi người thật
context: Chọn một hoặc nhiều nơi mà bộ lọc sẽ áp dụng context: Chọn những nơi mà bộ lọc sẽ áp dụng
current_password: Vì mục đích bảo mật, vui lòng nhập mật khẩu của tài khoản hiện tại current_password: Vì mục đích bảo mật, vui lòng nhập mật khẩu của tài khoản hiện tại
current_username: Để xác nhận, vui lòng nhập tên người dùng của tài khoản hiện tại current_username: Để xác nhận, vui lòng nhập tên người dùng của tài khoản hiện tại
digest: Chỉ gửi sau một thời gian dài không hoạt động hoặc khi bạn nhận được tin nhắn (trong thời gian vắng mặt) digest: Chỉ gửi sau một thời gian dài không hoạt động hoặc khi bạn nhận được tin nhắn (trong thời gian vắng mặt)
@ -51,7 +51,7 @@ vi:
inbox_url: Sao chép URL của máy chủ mà bạn muốn dùng inbox_url: Sao chép URL của máy chủ mà bạn muốn dùng
irreversible: Các tút đã lọc sẽ không thể phục hồi, kể cả sau khi xóa bộ lọc irreversible: Các tút đã lọc sẽ không thể phục hồi, kể cả sau khi xóa bộ lọc
locale: Ngôn ngữ của giao diện, email và thông báo đẩy locale: Ngôn ngữ của giao diện, email và thông báo đẩy
password: Dùng ít nhất 8 ký tự password: Tối thiểu 8 ký tự
phrase: Sẽ được hiện thị trong văn bản hoặc cảnh báo nội dung của một tút phrase: Sẽ được hiện thị trong văn bản hoặc cảnh báo nội dung của một tút
scopes: Ứng dụng sẽ được phép truy cập những API nào. Nếu bạn chọn quyền cấp cao nhất, không cần chọn quyền nhỏ. scopes: Ứng dụng sẽ được phép truy cập những API nào. Nếu bạn chọn quyền cấp cao nhất, không cần chọn quyền nhỏ.
setting_aggregate_reblogs: Nếu một tút đã được đăng lại thì những lượt đăng lại sau sẽ không hiện trên bảng tin nữa setting_aggregate_reblogs: Nếu một tút đã được đăng lại thì những lượt đăng lại sau sẽ không hiện trên bảng tin nữa
@ -74,8 +74,8 @@ vi:
filters: filters:
action: Chọn hành động sẽ thực hiện khi một tút khớp với bộ lọc action: Chọn hành động sẽ thực hiện khi một tút khớp với bộ lọc
actions: actions:
hide: Ẩn hoàn toàn nội dung đã lọc, như thể nó không tồn tại hide: Ẩn hoàn toàn, như thể nó không tồn tại
warn: Ẩn nội dung đã lọc đằng sau một cảnh báo đề cập đến tiêu đề của bộ lọc warn: Hiện cảnh báo và bộ lọc
form_admin_settings: form_admin_settings:
activity_api_enabled: Số lượng tút được đăng trong máy chủ, người dùng đang hoạt động và đăng ký mới hàng tuần activity_api_enabled: Số lượng tút được đăng trong máy chủ, người dùng đang hoạt động và đăng ký mới hàng tuần
app_icon: WEBP, PNG, GIF hoặc JPG. Dùng biểu tượng tùy chỉnh trên thiết bị di động. app_icon: WEBP, PNG, GIF hoặc JPG. Dùng biểu tượng tùy chỉnh trên thiết bị di động.
@ -226,7 +226,7 @@ vi:
setting_theme: Giao diện setting_theme: Giao diện
setting_trends: Hiển thị xu hướng trong ngày setting_trends: Hiển thị xu hướng trong ngày
setting_unfollow_modal: Hỏi trước khi bỏ theo dõi ai đó setting_unfollow_modal: Hỏi trước khi bỏ theo dõi ai đó
setting_use_blurhash: Phủ màu media nhạy cảm setting_use_blurhash: Làm mờ media nhạy cảm
setting_use_pending_items: Không tự động cập nhật bảng tin setting_use_pending_items: Không tự động cập nhật bảng tin
severity: Mức độ nghiêm trọng severity: Mức độ nghiêm trọng
sign_in_token_attempt: Mã an toàn sign_in_token_attempt: Mã an toàn
@ -305,7 +305,7 @@ vi:
label: Đã có phiên bản Mastodon mới label: Đã có phiên bản Mastodon mới
none: Không bao giờ thông báo (không đề xuất) none: Không bao giờ thông báo (không đề xuất)
patch: Thông báo bản cập sửa lỗi patch: Thông báo bản cập sửa lỗi
trending_tag: Phê duyệt nội dung nổi bật mới trending_tag: Phê duyệt xu hướng mới
rule: rule:
hint: Thông tin thêm hint: Thông tin thêm
text: Nội quy text: Nội quy

View file

@ -44,7 +44,7 @@ vi:
submit: Thay đổi email submit: Thay đổi email
title: Thay đổi email cho %{username} title: Thay đổi email cho %{username}
change_role: change_role:
changed_msg: Vai trò đã thay đổi thành công! changed_msg: Đã cập nhật vai trò!
edit_roles: Quản lý vai trò người dùng edit_roles: Quản lý vai trò người dùng
label: Đổi vai trò label: Đổi vai trò
no_role: Chưa có vai trò no_role: Chưa có vai trò
@ -55,7 +55,7 @@ vi:
custom: Tùy chỉnh custom: Tùy chỉnh
delete: Xóa dữ liệu delete: Xóa dữ liệu
deleted: Đã xóa deleted: Đã xóa
demote: Xóa vai trò demote: Hạ vai trò
destroyed_msg: Dữ liệu %{username} sẽ được lên lịch xóa ngay bây giờ destroyed_msg: Dữ liệu %{username} sẽ được lên lịch xóa ngay bây giờ
disable: Khóa disable: Khóa
disable_sign_in_token_auth: Tắt xác minh bằng email disable_sign_in_token_auth: Tắt xác minh bằng email
@ -108,7 +108,7 @@ vi:
previous_strikes: Lịch sử kiểm duyệt previous_strikes: Lịch sử kiểm duyệt
previous_strikes_description_html: previous_strikes_description_html:
other: Người này bị cảnh cáo <strong>%{count}</strong> lần. other: Người này bị cảnh cáo <strong>%{count}</strong> lần.
promote: Chỉ định vai trò promote: Nâng vai trò
protocol: Giao thức protocol: Giao thức
public: Công khai public: Công khai
push_subscription_expires: Đăng ký PuSH hết hạn push_subscription_expires: Đăng ký PuSH hết hạn
@ -153,8 +153,8 @@ vi:
suspension_irreversible: Toàn bộ dữ liệu của người này sẽ bị xóa hết. Bạn vẫn có thể ngừng vô hiệu hóa nhưng dữ liệu sẽ không thể phục hồi. suspension_irreversible: Toàn bộ dữ liệu của người này sẽ bị xóa hết. Bạn vẫn có thể ngừng vô hiệu hóa nhưng dữ liệu sẽ không thể phục hồi.
suspension_reversible_hint_html: Mọi dữ liệu của người này sẽ bị xóa sạch vào %{date}. Trước thời hạn này, dữ liệu vẫn có thể phục hồi. Nếu bạn muốn xóa dữ liệu của người này ngay lập tức, hãy tiếp tục. suspension_reversible_hint_html: Mọi dữ liệu của người này sẽ bị xóa sạch vào %{date}. Trước thời hạn này, dữ liệu vẫn có thể phục hồi. Nếu bạn muốn xóa dữ liệu của người này ngay lập tức, hãy tiếp tục.
title: Tài khoản title: Tài khoản
unblock_email: Mở khóa địa chỉ email unblock_email: Bỏ chặn địa chỉ email
unblocked_email_msg: Mở khóa thành công địa chỉ email của %{username} unblocked_email_msg: Đã bỏ chặn địa chỉ email của %{username}
unconfirmed_email: Email chưa được xác minh unconfirmed_email: Email chưa được xác minh
undo_sensitized: Đánh dấu bình thường undo_sensitized: Đánh dấu bình thường
undo_silenced: Bỏ hạn chế undo_silenced: Bỏ hạn chế
@ -170,42 +170,42 @@ vi:
action_logs: action_logs:
action_types: action_types:
approve_appeal: Chấp nhận kháng cáo approve_appeal: Chấp nhận kháng cáo
approve_user: Chấp nhận đăng ký approve_user: Duyệt đăng ký
assigned_to_self_report: Tự xử lý báo cáo assigned_to_self_report: Tự xử lý báo cáo
change_email_user: Đổi email người dùng change_email_user: Đổi email người dùng
change_role_user: Đổi vai trò change_role_user: Đổi vai trò
confirm_user: Xác minh confirm_user: Xác minh
create_account_warning: Cảnh cáo create_account_warning: Cảnh cáo
create_announcement: Tạo thông báo mới create_announcement: Tạo thông báo mới
create_canonical_email_block: Tạo chặn email create_canonical_email_block: Chặn địa chỉ email
create_custom_emoji: Tạo emoji create_custom_emoji: Tạo emoji
create_domain_allow: Cho phép máy chủ create_domain_allow: Cho phép máy chủ
create_domain_block: Chặn máy chủ create_domain_block: Chặn máy chủ
create_email_domain_block: Tạo chặn tên miền email create_email_domain_block: Tạo chặn tên miền email
create_ip_block: Tạo chặn IP mới create_ip_block: Chặn IP
create_unavailable_domain: Máy chủ không khả dụng create_unavailable_domain: Ngừng liên hợp
create_user_role: Tạo vai trò create_user_role: Tạo vai trò
demote_user: Xóa vai trò demote_user: Hạ vai trò
destroy_announcement: Xóa thông báo destroy_announcement: Xóa thông báo
destroy_canonical_email_block: Bỏ chặn email destroy_canonical_email_block: Bỏ chặn địa chỉ email
destroy_custom_emoji: Xóa emoji destroy_custom_emoji: Xóa emoji
destroy_domain_allow: Bỏ thanh trừng máy chủ destroy_domain_allow: Bỏ thanh trừng máy chủ
destroy_domain_block: Bỏ chặn máy chủ destroy_domain_block: Bỏ chặn máy chủ
destroy_email_domain_block: Bỏ chặn tên miền email destroy_email_domain_block: Bỏ chặn tên miền email
destroy_instance: Thanh trừng máy chủ destroy_instance: Thanh trừng máy chủ
destroy_ip_block: Xóa IP đã chặn destroy_ip_block: Bỏ chặn IP
destroy_status: Xóa tút destroy_status: Xóa tút
destroy_unavailable_domain: Xóa máy chủ không khả dụng destroy_unavailable_domain: Tái liên hợp
destroy_user_role: Xóa vai trò destroy_user_role: Xóa vai trò
disable_2fa_user: Vô hiệu hóa 2FA disable_2fa_user: Vô hiệu hóa 2FA
disable_custom_emoji: Vô hiệu hóa emoji disable_custom_emoji: Vô hiệu hóa emoji
disable_sign_in_token_auth_user: Tắt xác minh bằng email cho người dùng disable_sign_in_token_auth_user: Tắt xác minh bằng email cho người dùng
disable_user: Vô hiệu hóa đăng nhập disable_user: Vô hiệu hóa đăng nhập
enable_custom_emoji: Cho phép emoji enable_custom_emoji: Duyệt emoji
enable_sign_in_token_auth_user: Bật xác minh bằng email cho người dùng enable_sign_in_token_auth_user: Bật xác minh bằng email cho người dùng
enable_user: Bỏ vô hiệu hóa đăng nhập enable_user: Cho phép đăng nhập
memorialize_account: Đánh dấu tưởng niệm memorialize_account: Đánh dấu tưởng niệm
promote_user: Chỉ định vai trò promote_user: Nâng vai trò
reject_appeal: Từ chối kháng cáo reject_appeal: Từ chối kháng cáo
reject_user: Từ chối đăng ký reject_user: Từ chối đăng ký
remove_avatar_user: Xóa ảnh đại diện remove_avatar_user: Xóa ảnh đại diện
@ -213,11 +213,11 @@ vi:
resend_user: Gửi lại email xác minh resend_user: Gửi lại email xác minh
reset_password_user: Đặt lại mật khẩu reset_password_user: Đặt lại mật khẩu
resolve_report: Xử lý báo cáo resolve_report: Xử lý báo cáo
sensitive_account: Áp đặt nhạy cảm sensitive_account: Gán nhạy cảm
silence_account: Áp đặt ẩn silence_account: Gán ẩn
suspend_account: Áp đặt vô hiệu hóa suspend_account: Gán vô hiệu hóa
unassigned_report: Báo cáo chưa xử lý unassigned_report: Báo cáo chưa xử lý
unblock_email_account: Mở khóa địa chỉ email unblock_email_account: Bỏ chặn địa chỉ email
unsensitive_account: Bỏ nhạy cảm unsensitive_account: Bỏ nhạy cảm
unsilence_account: Bỏ ẩn unsilence_account: Bỏ ẩn
unsuspend_account: Bỏ vô hiệu hóa unsuspend_account: Bỏ vô hiệu hóa
@ -229,7 +229,7 @@ vi:
update_status: Cập nhật tút update_status: Cập nhật tút
update_user_role: Cập nhật vai trò update_user_role: Cập nhật vai trò
actions: actions:
approve_appeal_html: "%{name} đã chấp nhận kháng cáo của %{target}" approve_appeal_html: "%{name} đã duyệt kháng cáo của %{target}"
approve_user_html: "%{name} đã chấp nhận đăng ký từ %{target}" approve_user_html: "%{name} đã chấp nhận đăng ký từ %{target}"
assigned_to_self_report_html: "%{name} tự xử lý báo cáo %{target}" assigned_to_self_report_html: "%{name} tự xử lý báo cáo %{target}"
change_email_user_html: "%{name} đã thay đổi địa chỉ email của %{target}" change_email_user_html: "%{name} đã thay đổi địa chỉ email của %{target}"
@ -237,7 +237,7 @@ vi:
confirm_user_html: "%{name} đã xác minh địa chỉ email của %{target}" confirm_user_html: "%{name} đã xác minh địa chỉ email của %{target}"
create_account_warning_html: "%{name} đã cảnh cáo %{target}" create_account_warning_html: "%{name} đã cảnh cáo %{target}"
create_announcement_html: "%{name} tạo thông báo mới %{target}" create_announcement_html: "%{name} tạo thông báo mới %{target}"
create_canonical_email_block_html: "%{name} đã chặn email với hash %{target}" create_canonical_email_block_html: "%{name} đã chặn địa chỉ email với hash %{target}"
create_custom_emoji_html: "%{name} đã tải lên biểu tượng cảm xúc mới %{target}" create_custom_emoji_html: "%{name} đã tải lên biểu tượng cảm xúc mới %{target}"
create_domain_allow_html: "%{name} kích hoạt liên hợp với %{target}" create_domain_allow_html: "%{name} kích hoạt liên hợp với %{target}"
create_domain_block_html: "%{name} chặn máy chủ %{target}" create_domain_block_html: "%{name} chặn máy chủ %{target}"
@ -245,9 +245,9 @@ vi:
create_ip_block_html: "%{name} đã chặn IP %{target}" create_ip_block_html: "%{name} đã chặn IP %{target}"
create_unavailable_domain_html: "%{name} ngưng phân phối với máy chủ %{target}" create_unavailable_domain_html: "%{name} ngưng phân phối với máy chủ %{target}"
create_user_role_html: "%{name} đã tạo vai trò %{target}" create_user_role_html: "%{name} đã tạo vai trò %{target}"
demote_user_html: "%{name} đã xóa vai trò của %{target}" demote_user_html: "%{name} đã hạ vai trò của %{target}"
destroy_announcement_html: "%{name} xóa thông báo %{target}" destroy_announcement_html: "%{name} xóa thông báo %{target}"
destroy_canonical_email_block_html: "%{name} đã bỏ chặn email với hash %{target}" destroy_canonical_email_block_html: "%{name} đã bỏ chặn địa chỉ email với hash %{target}"
destroy_custom_emoji_html: "%{name} đã xóa emoji %{target}" destroy_custom_emoji_html: "%{name} đã xóa emoji %{target}"
destroy_domain_allow_html: "%{name} đã ngừng liên hợp với %{target}" destroy_domain_allow_html: "%{name} đã ngừng liên hợp với %{target}"
destroy_domain_block_html: "%{name} bỏ chặn máy chủ %{target}" destroy_domain_block_html: "%{name} bỏ chặn máy chủ %{target}"
@ -261,11 +261,11 @@ vi:
disable_custom_emoji_html: "%{name} đã ẩn emoji %{target}" disable_custom_emoji_html: "%{name} đã ẩn emoji %{target}"
disable_sign_in_token_auth_user_html: "%{name} đã tắt xác minh email của %{target}" disable_sign_in_token_auth_user_html: "%{name} đã tắt xác minh email của %{target}"
disable_user_html: "%{name} vô hiệu hóa đăng nhập %{target}" disable_user_html: "%{name} vô hiệu hóa đăng nhập %{target}"
enable_custom_emoji_html: "%{name} cho phép Emoji %{target}" enable_custom_emoji_html: "%{name} cho phép emoji %{target}"
enable_sign_in_token_auth_user_html: "%{name} đã bật xác minh email của %{target}" enable_sign_in_token_auth_user_html: "%{name} đã bật xác minh email của %{target}"
enable_user_html: "%{name} bỏ vô hiệu hóa đăng nhập %{target}" enable_user_html: "%{name} bỏ vô hiệu hóa đăng nhập %{target}"
memorialize_account_html: "%{name} đã biến tài khoản %{target} thành một trang tưởng niệm" memorialize_account_html: "%{name} đã biến tài khoản %{target} thành một trang tưởng niệm"
promote_user_html: "%{name} chỉ định vai trò cho %{target}" promote_user_html: "%{name} đã nâng vai trò của %{target}"
reject_appeal_html: "%{name} đã từ chối kháng cáo của %{target}" reject_appeal_html: "%{name} đã từ chối kháng cáo của %{target}"
reject_user_html: "%{name} đã từ chối đăng ký từ %{target}" reject_user_html: "%{name} đã từ chối đăng ký từ %{target}"
remove_avatar_user_html: "%{name} đã xóa ảnh đại diện của %{target}" remove_avatar_user_html: "%{name} đã xóa ảnh đại diện của %{target}"
@ -277,7 +277,7 @@ vi:
silence_account_html: "%{name} đã ẩn %{target}" silence_account_html: "%{name} đã ẩn %{target}"
suspend_account_html: "%{name} đã vô hiệu hóa %{target}" suspend_account_html: "%{name} đã vô hiệu hóa %{target}"
unassigned_report_html: "%{name} đã xử lý báo cáo %{target} chưa xử lí" unassigned_report_html: "%{name} đã xử lý báo cáo %{target} chưa xử lí"
unblock_email_account_html: "%{name} mở khóa địa chỉ email của %{target}" unblock_email_account_html: "%{name} bỏ chặn địa chỉ email của %{target}"
unsensitive_account_html: "%{name} đánh dấu nội dung của %{target} là bình thường" unsensitive_account_html: "%{name} đánh dấu nội dung của %{target} là bình thường"
unsilence_account_html: "%{name} đã bỏ ẩn %{target}" unsilence_account_html: "%{name} đã bỏ ẩn %{target}"
unsuspend_account_html: "%{name} đã bỏ vô hiệu hóa %{target}" unsuspend_account_html: "%{name} đã bỏ vô hiệu hóa %{target}"
@ -287,7 +287,7 @@ vi:
update_ip_block_html: "%{name} cập nhật chặn IP %{target}" update_ip_block_html: "%{name} cập nhật chặn IP %{target}"
update_report_html: "%{name} cập nhật báo cáo %{target}" update_report_html: "%{name} cập nhật báo cáo %{target}"
update_status_html: "%{name} cập nhật tút của %{target}" update_status_html: "%{name} cập nhật tút của %{target}"
update_user_role_html: "%{name} đã thay đổi vai trò %{target}" update_user_role_html: "%{name} đã cập nhật vai trò %{target}"
deleted_account: tài khoản đã xóa deleted_account: tài khoản đã xóa
empty: Không tìm thấy bản ghi. empty: Không tìm thấy bản ghi.
filter_by_action: Theo hành động filter_by_action: Theo hành động
@ -328,7 +328,7 @@ vi:
emoji: Emoji emoji: Emoji
enable: Cho phép enable: Cho phép
enabled: Đã cho phép enabled: Đã cho phép
enabled_msg: Đã cho phép thành công Emoji này enabled_msg: Đã cho phép emoji này xong
image_hint: PNG hoặc GIF tối đa %{size} image_hint: PNG hoặc GIF tối đa %{size}
list: Danh sách list: Danh sách
listed: Liệt kê listed: Liệt kê
@ -692,7 +692,7 @@ vi:
manage_announcements: Quản lý thông báo manage_announcements: Quản lý thông báo
manage_announcements_description: Cho phép quản lý thông báo trên máy chủ manage_announcements_description: Cho phép quản lý thông báo trên máy chủ
manage_appeals: Quản lý kháng cáo manage_appeals: Quản lý kháng cáo
manage_appeals_description: Cho phép xem xét kháng cáo đối với các hành động kiểm duyệt manage_appeals_description: Cho phép thành viên kháng cáo đối với các hành động kiểm duyệt
manage_blocks: Quản lý chặn manage_blocks: Quản lý chặn
manage_blocks_description: Cho phép người dùng tự chặn các nhà cung cấp email và địa chỉ IP manage_blocks_description: Cho phép người dùng tự chặn các nhà cung cấp email và địa chỉ IP
manage_custom_emojis: Quản lý emoji manage_custom_emojis: Quản lý emoji
@ -704,7 +704,7 @@ vi:
manage_reports: Quản lý báo cáo manage_reports: Quản lý báo cáo
manage_reports_description: Cho phép xem xét các báo cáo và thực hiện hành động kiểm duyệt đối với chúng manage_reports_description: Cho phép xem xét các báo cáo và thực hiện hành động kiểm duyệt đối với chúng
manage_roles: Quản lý vai trò manage_roles: Quản lý vai trò
manage_roles_description: Cho phép quản lý và chỉ định các vai trò nhỏ hơn họ manage_roles_description: Cho phép quản lý và nâng cấp các vai trò nhỏ hơn họ
manage_rules: Quản lý nội quy máy chủ manage_rules: Quản lý nội quy máy chủ
manage_rules_description: Cho phép thay đổi nội quy máy chủ manage_rules_description: Cho phép thay đổi nội quy máy chủ
manage_settings: Quản lý thiết lập manage_settings: Quản lý thiết lập
@ -798,7 +798,7 @@ vi:
patch: Bản vá - sửa lỗi và dễ dàng áp dụng các thay đổi patch: Bản vá - sửa lỗi và dễ dàng áp dụng các thay đổi
version: Phiên bản version: Phiên bản
statuses: statuses:
account: Tác giả account: Người đăng
application: Ứng dụng application: Ứng dụng
back_to_account: Quay lại trang tài khoản back_to_account: Quay lại trang tài khoản
back_to_report: Quay lại trang báo cáo back_to_report: Quay lại trang báo cáo
@ -817,7 +817,7 @@ vi:
open: Mở tút open: Mở tút
original_status: Tút gốc original_status: Tút gốc
reblogs: Lượt đăng lại reblogs: Lượt đăng lại
status_changed: Tút đã thay đổi status_changed: Tút đã sửa
title: Toàn bộ tút title: Toàn bộ tút
trending: Xu hướng trending: Xu hướng
visibility: Hiển thị visibility: Hiển thị
@ -896,7 +896,7 @@ vi:
title: Quản trị title: Quản trị
trends: trends:
allow: Cho phép allow: Cho phép
approved: Đã cho phép approved: Đã duyệt
confirm_allow: Bạn có chắc muốn cho phép những hashtag đã chọn? confirm_allow: Bạn có chắc muốn cho phép những hashtag đã chọn?
confirm_disallow: Bạn có chắc muốn cấm những hashtag đã chọn? confirm_disallow: Bạn có chắc muốn cấm những hashtag đã chọn?
disallow: Cấm disallow: Cấm
@ -915,17 +915,17 @@ vi:
no_publisher_selected: Không có nguồn đăng nào thay đổi vì không có nguồn đăng nào được chọn no_publisher_selected: Không có nguồn đăng nào thay đổi vì không có nguồn đăng nào được chọn
shared_by_over_week: shared_by_over_week:
other: "%{count} người chia sẻ tuần rồi" other: "%{count} người chia sẻ tuần rồi"
title: Tin tức nổi bật title: Xu hướng tin tức
usage_comparison: Chia sẻ %{today} lần hôm nay, so với %{yesterday} lần hôm qua usage_comparison: Chia sẻ %{today} lần hôm nay, so với %{yesterday} lần hôm qua
not_allowed_to_trend: Không được phép thành xu hướng not_allowed_to_trend: Không được phép thành xu hướng
only_allowed: Chỉ cho phép only_allowed: Đã cho phép
pending_review: Đang chờ pending_review: Đang chờ
preview_card_providers: preview_card_providers:
allowed: Tin tức từ nguồn này có thể lên xu hướng allowed: Tin tức từ nguồn này có thể lên xu hướng
description_html: Đây là những nguồn mà từ đó các liên kết thường được chia sẻ trên máy chủ của bạn. Các liên kết sẽ không thể lên xu hướng trừ khi bạn cho phép nguồn. Sự cho phép (hoặc cấm) của bạn áp dụng luôn cho các tên miền phụ. description_html: Đây là những nguồn mà từ đó các liên kết thường được chia sẻ trên máy chủ của bạn. Các liên kết sẽ không thể lên xu hướng trừ khi bạn cho phép nguồn. Sự cho phép (hoặc cấm) của bạn áp dụng luôn cho các tên miền phụ.
rejected: Tin tức từ nguồn này không thể lên xu hướng rejected: Tin tức từ nguồn này không thể lên xu hướng
title: Nguồn đăng title: Nguồn đăng
rejected: Đã cấm rejected: Từ chối
statuses: statuses:
allow: Cho phép tút allow: Cho phép tút
allow_account: Cho phép người đăng allow_account: Cho phép người đăng
@ -936,11 +936,11 @@ vi:
description_html: Đây là những tút đang được chia sẻ và yêu thích rất nhiều trên máy chủ của bạn. Nó có thể giúp người mới và người cũ tìm thấy nhiều người hơn để theo dõi. Không có tút nào được hiển thị công khai cho đến khi bạn cho phép người đăng và người cho phép đề xuất tài khoản của họ cho người khác. Bạn cũng có thể cho phép hoặc từ chối từng tút riêng. description_html: Đây là những tút đang được chia sẻ và yêu thích rất nhiều trên máy chủ của bạn. Nó có thể giúp người mới và người cũ tìm thấy nhiều người hơn để theo dõi. Không có tút nào được hiển thị công khai cho đến khi bạn cho phép người đăng và người cho phép đề xuất tài khoản của họ cho người khác. Bạn cũng có thể cho phép hoặc từ chối từng tút riêng.
disallow: Cấm tút disallow: Cấm tút
disallow_account: Cấm người đăng disallow_account: Cấm người đăng
no_status_selected: Không có tút xu hướng nào thay đổi vì không có tút nào được chọn no_status_selected: Bạn chưa chọn mục nào
not_discoverable: Tác giả đã chọn không tham gia mục khám phá not_discoverable: Người đăng đã chọn không tham gia mục khám phá
shared_by: shared_by:
other: Được thích và đăng lại %{friendly_count} lần other: Được thích và đăng lại %{friendly_count} lần
title: Tút xu hướng title: Xu hướng tút
tags: tags:
current_score: Chỉ số gần đây %{score} current_score: Chỉ số gần đây %{score}
dashboard: dashboard:
@ -956,9 +956,9 @@ vi:
not_trendable: Không cho lên xu hướng not_trendable: Không cho lên xu hướng
not_usable: Không được phép dùng not_usable: Không được phép dùng
peaked_on_and_decaying: Đỉnh điểm %{date}, giờ đang giảm peaked_on_and_decaying: Đỉnh điểm %{date}, giờ đang giảm
title: Hashtag nổi bật title: Xu hướng hashtag
trendable: Cho phép lên xu hướng trendable: Cho phép lên xu hướng
trending_rank: 'Nổi bật #%{rank}' trending_rank: 'Xu hướng #%{rank}'
usable: Có thể dùng usable: Có thể dùng
usage_comparison: Dùng %{today} lần hôm nay, so với %{yesterday} hôm qua usage_comparison: Dùng %{today} lần hôm nay, so với %{yesterday} hôm qua
used_by_over_week: used_by_over_week:
@ -1004,7 +1004,7 @@ vi:
silence: hạn chế tài khoản của họ silence: hạn chế tài khoản của họ
suspend: vô hiệu hóa tài khoản của họ suspend: vô hiệu hóa tài khoản của họ
body: "%{target} đã khiếu nại vì bị %{action_taken_by} %{type} vào %{date}. Họ cho biết:" body: "%{target} đã khiếu nại vì bị %{action_taken_by} %{type} vào %{date}. Họ cho biết:"
next_steps: Bạn có thể chấp nhận kháng cáo để hủy kiểm duyệt hoặc bỏ qua. next_steps: Bạn có thể duyệt kháng cáo để hủy kiểm duyệt hoặc bỏ qua.
subject: "%{username} đang khiếu nại quyết định kiểm duyệt trên %{instance}" subject: "%{username} đang khiếu nại quyết định kiểm duyệt trên %{instance}"
new_critical_software_updates: new_critical_software_updates:
body: Các phiên bản quan trọng mới của Mastodon đã được phát hành, bạn nên cập nhật càng sớm càng tốt! body: Các phiên bản quan trọng mới của Mastodon đã được phát hành, bạn nên cập nhật càng sớm càng tốt!
@ -1022,12 +1022,12 @@ vi:
new_trends: new_trends:
body: 'Các mục sau đây cần được xem xét trước khi chúng hiển thị công khai:' body: 'Các mục sau đây cần được xem xét trước khi chúng hiển thị công khai:'
new_trending_links: new_trending_links:
title: Tin tức nổi bật title: Xu hướng tin tức
new_trending_statuses: new_trending_statuses:
title: Tút nổi bật title: Xu hướng tút
new_trending_tags: new_trending_tags:
title: Hashtag nổi bật title: Xu hướng hashtag
subject: Nội dung nổi bật chờ duyệt trên %{instance} subject: Xu hướng chờ duyệt trên %{instance}
aliases: aliases:
add_new: Kết nối tài khoản add_new: Kết nối tài khoản
created_msg: Tạo thành công một tên hiển thị mới. Bây giờ bạn có thể bắt đầu di chuyển từ tài khoản cũ. created_msg: Tạo thành công một tên hiển thị mới. Bây giờ bạn có thể bắt đầu di chuyển từ tài khoản cũ.
@ -1147,7 +1147,7 @@ vi:
hint_html: Kiểm soát cách bạn được ghi nhận khi chia sẻ liên kết trên Mastodon. hint_html: Kiểm soát cách bạn được ghi nhận khi chia sẻ liên kết trên Mastodon.
more_from_html: Thêm từ %{name} more_from_html: Thêm từ %{name}
s_blog: "%{name}'s Blog" s_blog: "%{name}'s Blog"
title: Ghi nhận tác giả title: Ghi nhận người đăng
challenge: challenge:
confirm: Tiếp tục confirm: Tiếp tục
hint_html: "<strong>Mẹo:</strong> Chúng tôi sẽ không hỏi lại mật khẩu của bạn sau này." hint_html: "<strong>Mẹo:</strong> Chúng tôi sẽ không hỏi lại mật khẩu của bạn sau này."
@ -1201,7 +1201,7 @@ vi:
appealed_msg: Khiếu nại của bạn đã được gửi đi. Nếu nó được chấp nhận, bạn sẽ nhận được thông báo. appealed_msg: Khiếu nại của bạn đã được gửi đi. Nếu nó được chấp nhận, bạn sẽ nhận được thông báo.
appeals: appeals:
submit: Gửi khiếu nại submit: Gửi khiếu nại
approve_appeal: Chấp nhận kháng cáo approve_appeal: Duyệt kháng cáo
associated_report: Báo cáo đính kèm associated_report: Báo cáo đính kèm
created_at: Ngày created_at: Ngày
description_html: Đây là những cảnh cáo và áp đặt kiểm duyệt đối với bạn bởi đội ngũ %{instance}. description_html: Đây là những cảnh cáo và áp đặt kiểm duyệt đối với bạn bởi đội ngũ %{instance}.
@ -1280,7 +1280,7 @@ vi:
deprecated_api_multiple_keywords: Không thể thay đổi các tham số này từ ứng dụng này vì chúng áp dụng cho nhiều hơn một từ khóa bộ lọc. Sử dụng ứng dụng mới hơn hoặc giao diện web. deprecated_api_multiple_keywords: Không thể thay đổi các tham số này từ ứng dụng này vì chúng áp dụng cho nhiều hơn một từ khóa bộ lọc. Sử dụng ứng dụng mới hơn hoặc giao diện web.
invalid_context: Bối cảnh không hợp lệ hoặc không có invalid_context: Bối cảnh không hợp lệ hoặc không có
index: index:
contexts: Bộ lọc %{contexts} contexts: Lọc ở %{contexts}
delete: Xóa bỏ delete: Xóa bỏ
empty: Chưa có bộ lọc nào. empty: Chưa có bộ lọc nào.
expires_in: Hết hạn trong %{distance} expires_in: Hết hạn trong %{distance}
@ -1336,7 +1336,7 @@ vi:
merge: Hợp nhất merge: Hợp nhất
merge_long: Giữ hồ sơ hiện có và thêm hồ sơ mới merge_long: Giữ hồ sơ hiện có và thêm hồ sơ mới
overwrite: Ghi đè overwrite: Ghi đè
overwrite_long: Thay thế các bản ghi hiện tại bằng những cái mới overwrite_long: Thay thế các bản ghi hiện tại bằng các bản ghi mới
overwrite_preambles: overwrite_preambles:
blocking_html: Bạn sắp <strong>thay thế danh sách chặn</strong> với <strong>%{total_items} tài khoản</strong> từ <strong>%{filename}</strong>. blocking_html: Bạn sắp <strong>thay thế danh sách chặn</strong> với <strong>%{total_items} tài khoản</strong> từ <strong>%{filename}</strong>.
bookmarks_html: Bạn sắp <strong>thay thế lượt lưu</strong> với <strong>%{total_items} tút</strong> từ <strong>%{filename}</strong>. bookmarks_html: Bạn sắp <strong>thay thế lượt lưu</strong> với <strong>%{total_items} tút</strong> từ <strong>%{filename}</strong>.
@ -1414,7 +1414,7 @@ vi:
description_html: Nếu có lần đăng nhập đáng ngờ, hãy đổi ngay mật khẩu và bật xác minh 2 bước. description_html: Nếu có lần đăng nhập đáng ngờ, hãy đổi ngay mật khẩu và bật xác minh 2 bước.
empty: Không có lịch sử đăng nhập empty: Không có lịch sử đăng nhập
failed_sign_in_html: Đăng nhập thất bại bằng %{method} từ %{ip} (%{browser}) failed_sign_in_html: Đăng nhập thất bại bằng %{method} từ %{ip} (%{browser})
successful_sign_in_html: Đăng nhập thành công bằng %{method} từ %{ip} (%{browser}) successful_sign_in_html: Đăng nhập bằng %{method} từ %{ip} (%{browser})
title: Lịch sử đăng nhập title: Lịch sử đăng nhập
mail_subscriptions: mail_subscriptions:
unsubscribe: unsubscribe:
@ -1832,14 +1832,14 @@ vi:
spam: Spam spam: Spam
violation: Nội dung vi phạm quy tắc cộng đồng violation: Nội dung vi phạm quy tắc cộng đồng
explanation: explanation:
delete_statuses: Vài tút của bạn đã vi phạm nội quy máy chủ và tạm thời bị ẩn bởi kiểm duyệt viên của %{instance}. delete_statuses: Tút của bạn đã vi phạm nội quy máy chủ và tạm thời bị ẩn bởi kiểm duyệt viên của %{instance}.
disable: Bạn không còn có thể sử dụng tài khoản của mình, nhưng hồ sơ của bạn và dữ liệu khác vẫn còn nguyên. Bạn có thể yêu cầu sao lưu dữ liệu của mình, thay đổi cài đặt tài khoản hoặc xóa tài khoản của bạn. disable: Bạn không còn có thể sử dụng tài khoản của mình, nhưng hồ sơ của bạn và dữ liệu khác vẫn còn nguyên. Bạn có thể yêu cầu sao lưu dữ liệu của mình, thay đổi cài đặt tài khoản hoặc xóa tài khoản của bạn.
mark_statuses_as_sensitive: Vài tút của bạn đã bị kiểm duyệt viên %{instance} đánh dấu nhạy cảm. Mọi người cần nhấn vào media để xem nó. Bạn có thể tự đánh dấu tài khoản của bạn là nhạy cảm. mark_statuses_as_sensitive: Vài tút của bạn đã bị kiểm duyệt viên %{instance} đánh dấu nhạy cảm. Mọi người cần nhấn vào media để xem nó. Bạn có thể tự đánh dấu tài khoản của bạn là nhạy cảm.
sensitive: Từ giờ trở đi, tất cả các media của bạn bạn tải lên sẽ được đánh dấu là nhạy cảm và ẩn đằng sau cảnh báo nhấp chuột. sensitive: Từ giờ trở đi, tất cả các media của bạn bạn tải lên sẽ được đánh dấu là nhạy cảm và ẩn đằng sau cảnh báo nhấp chuột.
silence: Bạn vẫn có thể sử dụng tài khoản của mình, nhưng chỉ những người đang theo dõi bạn mới thấy bài đăng của bạn. Bạn cũng bị loại khỏi các tính năng khám phá khác. Tuy nhiên, những người khác vẫn có thể theo dõi bạn. silence: Bạn vẫn có thể sử dụng tài khoản của mình, nhưng chỉ những người đang theo dõi bạn mới thấy bài đăng của bạn. Bạn cũng bị loại khỏi các tính năng khám phá khác. Tuy nhiên, những người khác vẫn có thể theo dõi bạn.
suspend: Bạn không còn có thể sử dụng tài khoản của bạn, hồ sơ và các dữ liệu khác không còn có thể truy cập được. Trong vòng 30 ngày, bạn vẫn có thể đăng nhập để yêu cầu bản sao dữ liệu của mình cho đến khi dữ liệu bị xóa hoàn toàn, nhưng chúng tôi sẽ giữ lại một số dữ liệu cơ bản để ngăn bạn thoát khỏi việc vô hiệu hóa. suspend: Bạn không còn có thể sử dụng tài khoản của bạn, hồ sơ và các dữ liệu khác không còn có thể truy cập được. Trong vòng 30 ngày, bạn vẫn có thể đăng nhập để yêu cầu bản sao dữ liệu của mình cho đến khi dữ liệu bị xóa hoàn toàn, nhưng chúng tôi sẽ giữ lại một số dữ liệu cơ bản để ngăn bạn thoát khỏi việc vô hiệu hóa.
reason: 'Lý do:' reason: 'Lý do:'
statuses: 'Tút lưu ý:' statuses: 'Tút vi phạm:'
subject: subject:
delete_statuses: Những tút %{acct} của bạn đã bị xóa bỏ delete_statuses: Những tút %{acct} của bạn đã bị xóa bỏ
disable: Tài khoản %{acct} của bạn đã bị vô hiệu hóa disable: Tài khoản %{acct} của bạn đã bị vô hiệu hóa

View file

@ -301,21 +301,6 @@ namespace :api, format: false do
end end
end end
concern :grouped_notifications do
resources :notifications, param: :group_key, only: [:index, :show] do
collection do
post :clear
get :unread_count
end
member do
post :dismiss
end
resources :accounts, only: [:index], module: :notifications
end
end
namespace :v2 do namespace :v2 do
get '/search', to: 'search#index', as: :search get '/search', to: 'search#index', as: :search
@ -342,11 +327,18 @@ namespace :api, format: false do
resource :policy, only: [:show, :update] resource :policy, only: [:show, :update]
end end
concerns :grouped_notifications resources :notifications, param: :group_key, only: [:index, :show] do
collection do
post :clear
get :unread_count
end end
namespace :v2_alpha, module: 'v2' do member do
concerns :grouped_notifications post :dismiss
end
resources :accounts, only: [:index], module: :notifications
end
end end
namespace :web do namespace :web do

View file

@ -10,13 +10,6 @@ RSpec.describe Oauth::AuthorizationsController do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read' } get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read' }
end end
shared_examples 'stores location for user' do
it 'stores location for user' do
subject
expect(controller.stored_location_for(:user)).to eq "/oauth/authorize?client_id=#{app.uid}&redirect_uri=http%3A%2F%2Flocalhost%2F&response_type=code&scope=read"
end
end
context 'when signed in' do context 'when signed in' do
let!(:user) { Fabricate(:user) } let!(:user) { Fabricate(:user) }
@ -24,18 +17,17 @@ RSpec.describe Oauth::AuthorizationsController do
sign_in user, scope: :user sign_in user, scope: :user
end end
it 'returns http success' do it 'returns http success and private cache control headers' do
subject subject
expect(response).to have_http_status(200)
end
it 'returns private cache control headers' do expect(response)
subject .to have_http_status(200)
expect(response.headers['Cache-Control']).to include('private, no-store') expect(response.headers['Cache-Control'])
.to include('private, no-store')
expect(controller.stored_location_for(:user))
.to eq authorize_path_for(app)
end end
include_examples 'stores location for user'
context 'when app is already authorized' do context 'when app is already authorized' do
before do before do
Doorkeeper::AccessToken.find_or_create_for( Doorkeeper::AccessToken.find_or_create_for(
@ -52,10 +44,12 @@ RSpec.describe Oauth::AuthorizationsController do
expect(response).to redirect_to(/\A#{app.redirect_uri}/) expect(response).to redirect_to(/\A#{app.redirect_uri}/)
end end
it 'does not redirect to callback with force_login=true' do context 'with `force_login` param true' do
subject do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', force_login: 'true' } get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', force_login: 'true' }
end
expect(response).to have_http_status(:success) it { is_expected.to have_http_status(:success) }
end end
end end
end end
@ -63,10 +57,16 @@ RSpec.describe Oauth::AuthorizationsController do
context 'when not signed in' do context 'when not signed in' do
it 'redirects' do it 'redirects' do
subject subject
expect(response).to redirect_to '/auth/sign_in'
expect(response)
.to redirect_to '/auth/sign_in'
expect(controller.stored_location_for(:user))
.to eq authorize_path_for(app)
end
end end
include_examples 'stores location for user' def authorize_path_for(app)
"/oauth/authorize?client_id=#{app.uid}&redirect_uri=http%3A%2F%2Flocalhost%2F&response_type=code&scope=read"
end end
end end
end end

View file

@ -10,38 +10,31 @@ RSpec.describe Oauth::AuthorizedApplicationsController do
get :index get :index
end end
shared_examples 'stores location for user' do
it 'stores location for user' do
subject
expect(controller.stored_location_for(:user)).to eq '/oauth/authorized_applications'
end
end
context 'when signed in' do context 'when signed in' do
before do before do
sign_in Fabricate(:user), scope: :user sign_in Fabricate(:user), scope: :user
end end
it 'returns http success' do it 'returns http success with private cache control headers' do
subject subject
expect(response).to have_http_status(200) expect(response)
.to have_http_status(200)
expect(response.headers['Cache-Control'])
.to include('private, no-store')
expect(controller.stored_location_for(:user))
.to eq '/oauth/authorized_applications'
end end
it 'returns private cache control headers' do
subject
expect(response.headers['Cache-Control']).to include('private, no-store')
end
include_examples 'stores location for user'
end end
context 'when not signed in' do context 'when not signed in' do
it 'redirects' do it 'redirects' do
subject subject
expect(response).to redirect_to '/auth/sign_in'
end
include_examples 'stores location for user' expect(response)
.to redirect_to '/auth/sign_in'
expect(controller.stored_location_for(:user))
.to eq '/oauth/authorized_applications'
end
end end
end end
@ -55,23 +48,19 @@ RSpec.describe Oauth::AuthorizedApplicationsController do
before do before do
sign_in user, scope: :user sign_in user, scope: :user
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub) allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
end
it 'revokes access tokens for the application and removes subscriptions and sends kill payload to streaming' do
post :destroy, params: { id: application.id } post :destroy, params: { id: application.id }
end
it 'revokes access tokens for the application' do expect(Doorkeeper::AccessToken.where(application: application).first.revoked_at)
expect(Doorkeeper::AccessToken.where(application: application).first.revoked_at).to_not be_nil .to_not be_nil
end expect(Web::PushSubscription.where(user: user).count)
.to eq(0)
it 'removes subscriptions for the application\'s access tokens' do expect { web_push_subscription.reload }
expect(Web::PushSubscription.where(user: user).count).to eq 0 .to raise_error(ActiveRecord::RecordNotFound)
end expect(redis_pipeline_stub)
.to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}')
it 'removes the web_push_subscription' do
expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'sends a session kill payload to the streaming server' do
expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}')
end end
end end
end end

View file

@ -9,20 +9,15 @@ RSpec.describe Oauth::TokensController do
let!(:access_token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: application) } let!(:access_token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: application) }
let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) } let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
before do it 'revokes the token and removes subscriptions' do
post :revoke, params: { client_id: application.uid, token: access_token.token } post :revoke, params: { client_id: application.uid, token: access_token.token }
end
it 'revokes the token' do expect(access_token.reload.revoked_at)
expect(access_token.reload.revoked_at).to_not be_nil .to_not be_nil
end expect(Web::PushSubscription.where(access_token: access_token).count)
.to eq(0)
it 'removes web push subscription for token' do expect { web_push_subscription.reload }
expect(Web::PushSubscription.where(access_token: access_token).count).to eq 0 .to raise_error(ActiveRecord::RecordNotFound)
end
it 'removes the web_push_subscription' do
expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
end end

View file

@ -5,16 +5,10 @@ require 'rails_helper'
RSpec.describe Settings::FeaturedTagsController do RSpec.describe Settings::FeaturedTagsController do
render_views render_views
shared_examples 'authenticate user' do
it 'redirects to sign_in page' do
expect(subject).to redirect_to new_user_session_path
end
end
context 'when user is not signed in' do context 'when user is not signed in' do
subject { post :create } subject { post :create }
it_behaves_like 'authenticate user' it { is_expected.to redirect_to new_user_session_path }
end end
context 'when user is signed in' do context 'when user is signed in' do

View file

@ -5,17 +5,11 @@ require 'rails_helper'
RSpec.describe Settings::MigrationsController do RSpec.describe Settings::MigrationsController do
render_views render_views
shared_examples 'authenticate user' do
it 'redirects to sign_in page' do
expect(subject).to redirect_to new_user_session_path
end
end
describe 'GET #show' do describe 'GET #show' do
context 'when user is not sign in' do context 'when user is not sign in' do
subject { get :show } subject { get :show }
it_behaves_like 'authenticate user' it { is_expected.to redirect_to new_user_session_path }
end end
context 'when user is sign in' do context 'when user is sign in' do
@ -49,7 +43,7 @@ RSpec.describe Settings::MigrationsController do
context 'when user is not sign in' do context 'when user is not sign in' do
subject { post :create } subject { post :create }
it_behaves_like 'authenticate user' it { is_expected.to redirect_to new_user_session_path }
end end
context 'when user is signed in' do context 'when user is signed in' do

View file

@ -29,5 +29,20 @@ RSpec.describe PermalinkRedirector do
redirector = described_class.new('@alice/123') redirector = described_class.new('@alice/123')
expect(redirector.redirect_path).to eq 'https://example.com/status-123' expect(redirector.redirect_path).to eq 'https://example.com/status-123'
end end
it 'returns path for legacy status links with a query param' do
redirector = described_class.new('statuses/123?foo=bar')
expect(redirector.redirect_path).to eq 'https://example.com/status-123'
end
it 'returns path for pretty status links with a query param' do
redirector = described_class.new('@alice/123?foo=bar')
expect(redirector.redirect_path).to eq 'https://example.com/status-123'
end
it 'returns path for deck URLs with query params' do
redirector = described_class.new('/deck/directory?local=true')
expect(redirector.redirect_path).to eq '/directory?local=true'
end
end end
end end

View file

@ -7,16 +7,13 @@ RSpec.describe ScopeTransformer do
subject { described_class.new.apply(ScopeParser.new.parse(input)) } subject { described_class.new.apply(ScopeParser.new.parse(input)) }
shared_examples 'a scope' do |namespace, term, access| shared_examples 'a scope' do |namespace, term, access|
it 'parses the term' do it 'parses the attributes' do
expect(subject.term).to eq term expect(subject)
end .to have_attributes(
term: term,
it 'parses the namespace' do namespace: namespace,
expect(subject.namespace).to eq namespace access: access
end )
it 'parses the access' do
expect(subject.access).to eq access
end end
end end

View file

@ -3,6 +3,17 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe NotificationMailer do RSpec.describe NotificationMailer do
shared_examples 'delivery to non functional user' do
context 'when user is not functional' do
before { receiver.update(confirmed_at: nil) }
it 'does not deliver mail' do
emails = capture_emails { mail.deliver_now }
expect(emails).to be_empty
end
end
end
let(:receiver) { Fabricate(:user, account_attributes: { username: 'alice' }) } let(:receiver) { Fabricate(:user, account_attributes: { username: 'alice' }) }
let(:sender) { Fabricate(:account, username: 'bob') } let(:sender) { Fabricate(:account, username: 'bob') }
let(:foreign_status) { Fabricate(:status, account: sender, text: 'The body of the foreign status') } let(:foreign_status) { Fabricate(:status, account: sender, text: 'The body of the foreign status') }
@ -24,6 +35,8 @@ RSpec.describe NotificationMailer do
.and have_thread_headers .and have_thread_headers
.and have_standard_headers('mention').for(receiver) .and have_standard_headers('mention').for(receiver)
end end
include_examples 'delivery to non functional user'
end end
describe 'follow' do describe 'follow' do
@ -40,6 +53,8 @@ RSpec.describe NotificationMailer do
.and(have_body_text('bob is now following you')) .and(have_body_text('bob is now following you'))
.and have_standard_headers('follow').for(receiver) .and have_standard_headers('follow').for(receiver)
end end
include_examples 'delivery to non functional user'
end end
describe 'favourite' do describe 'favourite' do
@ -58,6 +73,8 @@ RSpec.describe NotificationMailer do
.and have_thread_headers .and have_thread_headers
.and have_standard_headers('favourite').for(receiver) .and have_standard_headers('favourite').for(receiver)
end end
include_examples 'delivery to non functional user'
end end
describe 'reblog' do describe 'reblog' do
@ -76,6 +93,8 @@ RSpec.describe NotificationMailer do
.and have_thread_headers .and have_thread_headers
.and have_standard_headers('reblog').for(receiver) .and have_standard_headers('reblog').for(receiver)
end end
include_examples 'delivery to non functional user'
end end
describe 'follow_request' do describe 'follow_request' do
@ -92,6 +111,8 @@ RSpec.describe NotificationMailer do
.and(have_body_text('bob has requested to follow you')) .and(have_body_text('bob has requested to follow you'))
.and have_standard_headers('follow_request').for(receiver) .and have_standard_headers('follow_request').for(receiver)
end end
include_examples 'delivery to non functional user'
end end
private private

View file

@ -23,8 +23,11 @@ RSpec.describe 'API V1 Accounts FollowerAccounts' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq 2 expect(response.parsed_body)
expect([response.parsed_body[0][:id], response.parsed_body[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) .to contain_exactly(
hash_including(id: alice.id.to_s),
hash_including(id: bob.id.to_s)
)
end end
it 'does not return blocked users', :aggregate_failures do it 'does not return blocked users', :aggregate_failures do
@ -34,8 +37,10 @@ RSpec.describe 'API V1 Accounts FollowerAccounts' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq 1 expect(response.parsed_body)
expect(response.parsed_body[0][:id]).to eq alice.id.to_s .to contain_exactly(
hash_including(id: alice.id.to_s)
)
end end
context 'when requesting user is blocked' do context 'when requesting user is blocked' do
@ -56,8 +61,11 @@ RSpec.describe 'API V1 Accounts FollowerAccounts' do
account.mute!(bob) account.mute!(bob)
get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers
expect(response.parsed_body.size).to eq 2 expect(response.parsed_body)
expect([response.parsed_body[0][:id], response.parsed_body[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) .to contain_exactly(
hash_including(id: alice.id.to_s),
hash_including(id: bob.id.to_s)
)
end end
end end
end end

View file

@ -23,8 +23,11 @@ RSpec.describe 'API V1 Accounts FollowingAccounts' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq 2 expect(response.parsed_body)
expect([response.parsed_body[0][:id], response.parsed_body[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) .to contain_exactly(
hash_including(id: alice.id.to_s),
hash_including(id: bob.id.to_s)
)
end end
it 'does not return blocked users', :aggregate_failures do it 'does not return blocked users', :aggregate_failures do
@ -34,8 +37,10 @@ RSpec.describe 'API V1 Accounts FollowingAccounts' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq 1 expect(response.parsed_body)
expect(response.parsed_body[0][:id]).to eq alice.id.to_s .to contain_exactly(
hash_including(id: alice.id.to_s)
)
end end
context 'when requesting user is blocked' do context 'when requesting user is blocked' do
@ -56,8 +61,11 @@ RSpec.describe 'API V1 Accounts FollowingAccounts' do
account.mute!(bob) account.mute!(bob)
get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers
expect(response.parsed_body.size).to eq 2 expect(response.parsed_body)
expect([response.parsed_body[0][:id], response.parsed_body[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) .to contain_exactly(
hash_including(id: alice.id.to_s),
hash_including(id: bob.id.to_s)
)
end end
end end
end end

View file

@ -84,8 +84,11 @@ RSpec.describe 'Directories API' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq(2) expect(response.parsed_body)
expect(response.parsed_body.pluck(:id)).to contain_exactly(eligible_remote_account.id.to_s, local_discoverable_account.id.to_s) .to contain_exactly(
hash_including(id: eligible_remote_account.id.to_s),
hash_including(id: local_discoverable_account.id.to_s)
)
end end
end end
@ -105,9 +108,11 @@ RSpec.describe 'Directories API' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq(1) expect(response.parsed_body)
expect(response.parsed_body.first[:id]).to include(local_account.id.to_s) .to contain_exactly(
expect(response.body).to_not include(remote_account.id.to_s) hash_including(id: local_account.id.to_s)
)
.and not_include(remote_account.id.to_s)
end end
end end
@ -121,9 +126,11 @@ RSpec.describe 'Directories API' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq(2) expect(response.parsed_body)
expect(response.parsed_body.first[:id]).to include(new_stat.account_id.to_s) .to contain_exactly(
expect(response.parsed_body.second[:id]).to include(old_stat.account_id.to_s) hash_including(id: new_stat.account_id.to_s),
hash_including(id: old_stat.account_id.to_s)
)
end end
end end
@ -138,9 +145,11 @@ RSpec.describe 'Directories API' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size).to eq(2) expect(response.parsed_body)
expect(response.parsed_body.first[:id]).to include(account_new.id.to_s) .to contain_exactly(
expect(response.parsed_body.second[:id]).to include(account_old.id.to_s) hash_including(id: account_new.id.to_s),
hash_including(id: account_old.id.to_s)
)
end end
end end
end end

View file

@ -55,10 +55,10 @@ RSpec.describe 'API Peers Search' do
.to have_http_status(200) .to have_http_status(200)
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size) expect(response.parsed_body)
.to eq(1) .to contain_exactly(
expect(response.parsed_body.first) eq(account.domain)
.to eq(account.domain) )
end end
end end
end end

View file

@ -36,8 +36,6 @@ RSpec.describe 'API V1 Statuses Favourited by Accounts' do
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size)
.to eq(2)
expect(response.parsed_body) expect(response.parsed_body)
.to contain_exactly( .to contain_exactly(
include(id: alice.id.to_s), include(id: alice.id.to_s),
@ -50,9 +48,10 @@ RSpec.describe 'API V1 Statuses Favourited by Accounts' do
subject subject
expect(response.parsed_body.size) expect(response.parsed_body)
.to eq 1 .to contain_exactly(
expect(response.parsed_body.first[:id]).to eq(alice.id.to_s) hash_including(id: alice.id.to_s)
)
end end
end end
end end

View file

@ -35,8 +35,6 @@ RSpec.describe 'API V1 Statuses Reblogged by Accounts' do
expect(response.content_type) expect(response.content_type)
.to start_with('application/json') .to start_with('application/json')
expect(response.parsed_body.size)
.to eq(2)
expect(response.parsed_body) expect(response.parsed_body)
.to contain_exactly( .to contain_exactly(
include(id: alice.id.to_s), include(id: alice.id.to_s),
@ -49,9 +47,10 @@ RSpec.describe 'API V1 Statuses Reblogged by Accounts' do
subject subject
expect(response.parsed_body.size) expect(response.parsed_body)
.to eq 1 .to contain_exactly(
expect(response.parsed_body.first[:id]).to eq(alice.id.to_s) hash_including(id: alice.id.to_s)
)
end end
end end
end end

View file

@ -1,345 +0,0 @@
# frozen_string_literal: true
# TODO: remove this before 4.3.0-rc1
require 'rails_helper'
RSpec.describe 'Notifications' do
let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'read:notifications write:notifications' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v2_alpha/notifications/unread_count', :inline_jobs do
subject do
get '/api/v2_alpha/notifications/unread_count', headers: headers, params: params
end
let(:params) { {} }
before do
first_status = PostStatusService.new.call(user.account, text: 'Test')
ReblogService.new.call(Fabricate(:account), first_status)
PostStatusService.new.call(Fabricate(:account), text: 'Hello @alice')
FavouriteService.new.call(Fabricate(:account), first_status)
FavouriteService.new.call(Fabricate(:account), first_status)
FollowService.new.call(Fabricate(:account), user.account)
end
it_behaves_like 'forbidden for wrong scope', 'write write:notifications'
context 'with no options' do
it 'returns expected notifications count' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq 4
end
end
context 'with grouped_types parameter' do
let(:params) { { grouped_types: %w(reblog) } }
it 'returns expected notifications count' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq 5
end
end
context 'with a read marker' do
before do
id = user.account.notifications.browserable.order(id: :desc).offset(2).first.id
user.markers.create!(timeline: 'notifications', last_read_id: id)
end
it 'returns expected notifications count' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq 2
end
end
context 'with exclude_types param' do
let(:params) { { exclude_types: %w(mention) } }
it 'returns expected notifications count' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq 3
end
end
context 'with a user-provided limit' do
let(:params) { { limit: 2 } }
it 'returns a capped value' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq 2
end
end
context 'when there are more notifications than the limit' do
before do
stub_const('Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT', 2)
end
it 'returns a capped value' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:count]).to eq Api::V2::NotificationsController::DEFAULT_NOTIFICATIONS_COUNT_LIMIT
end
end
end
describe 'GET /api/v2_alpha/notifications', :inline_jobs do
subject do
get '/api/v2_alpha/notifications', headers: headers, params: params
end
let(:bob) { Fabricate(:user) }
let(:tom) { Fabricate(:user) }
let(:params) { {} }
before do
first_status = PostStatusService.new.call(user.account, text: 'Test')
ReblogService.new.call(bob.account, first_status)
PostStatusService.new.call(bob.account, text: 'Hello @alice')
FavouriteService.new.call(bob.account, first_status)
FavouriteService.new.call(tom.account, first_status)
FollowService.new.call(bob.account, user.account)
end
it_behaves_like 'forbidden for wrong scope', 'write write:notifications'
context 'when there are no notifications' do
before do
user.account.notifications.destroy_all
end
it 'returns 0 notifications' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:notification_groups]).to eq []
end
end
context 'with no options' do
it 'returns expected notification types', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_json_types).to include('reblog', 'mention', 'favourite', 'follow')
end
end
context 'with grouped_types param' do
let(:params) { { grouped_types: %w(reblog) } }
it 'returns everything, but does not group favourites' do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:notification_groups]).to contain_exactly(
a_hash_including(
type: 'reblog',
sample_account_ids: [bob.account_id.to_s]
),
a_hash_including(
type: 'mention',
sample_account_ids: [bob.account_id.to_s]
),
a_hash_including(
type: 'favourite',
sample_account_ids: [bob.account_id.to_s]
),
a_hash_including(
type: 'favourite',
sample_account_ids: [tom.account_id.to_s]
),
a_hash_including(
type: 'follow',
sample_account_ids: [bob.account_id.to_s]
)
)
end
end
context 'with exclude_types param' do
let(:params) { { exclude_types: %w(mention) } }
it 'returns everything but excluded type', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body.size).to_not eq 0
expect(body_json_types.uniq).to_not include 'mention'
end
end
context 'with types param' do
let(:params) { { types: %w(mention) } }
it 'returns only requested type', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_json_types.uniq).to eq ['mention']
expect(response.parsed_body.dig(:notification_groups, 0, :page_min_id)).to_not be_nil
end
end
context 'with limit param' do
let(:params) { { limit: 3 } }
let(:notifications) { user.account.notifications.reorder(id: :desc) }
it 'returns the requested number of notifications paginated', :aggregate_failures do
subject
expect(response.parsed_body[:notification_groups].size)
.to eq(params[:limit])
expect(response)
.to include_pagination_headers(
prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id),
# TODO: one downside of the current approach is that we return the first ID matching the group,
# not the last that has been skipped, so pagination is very likely to give overlap
next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[3].id)
)
end
end
context 'with since_id param' do
let(:params) { { since_id: notifications[2].id } }
let(:notifications) { user.account.notifications.reorder(id: :desc) }
it 'returns the requested number of notifications paginated', :aggregate_failures do
subject
expect(response.parsed_body[:notification_groups].size)
.to eq(2)
expect(response)
.to include_pagination_headers(
prev: api_v2_notifications_url(limit: params[:limit], min_id: notifications.first.id),
# TODO: one downside of the current approach is that we return the first ID matching the group,
# not the last that has been skipped, so pagination is very likely to give overlap
next: api_v2_notifications_url(limit: params[:limit], max_id: notifications[1].id)
)
end
end
context 'when requesting stripped-down accounts' do
let(:params) { { expand_accounts: 'partial_avatars' } }
let(:recent_account) { Fabricate(:account) }
before do
FavouriteService.new.call(recent_account, user.account.statuses.first)
end
it 'returns an account in "partial_accounts", with the expected keys', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(response.parsed_body[:partial_accounts].size).to be > 0
expect(response.parsed_body[:partial_accounts][0].keys.map(&:to_sym)).to contain_exactly(:acct, :avatar, :avatar_static, :bot, :id, :locked, :url)
expect(response.parsed_body[:partial_accounts].pluck(:id)).to_not include(recent_account.id.to_s)
expect(response.parsed_body[:accounts].pluck(:id)).to include(recent_account.id.to_s)
end
end
context 'when passing an invalid value for "expand_accounts"' do
let(:params) { { expand_accounts: 'unknown_foobar' } }
it 'returns http bad request' do
subject
expect(response).to have_http_status(400)
end
end
def body_json_types
response.parsed_body[:notification_groups].pluck(:type)
end
end
describe 'GET /api/v2_alpha/notifications/:id' do
subject do
get "/api/v2_alpha/notifications/#{notification.group_key}", headers: headers
end
let(:notification) { Fabricate(:notification, account: user.account, group_key: 'foobar') }
it_behaves_like 'forbidden for wrong scope', 'write write:notifications'
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
context 'when notification belongs to someone else' do
let(:notification) { Fabricate(:notification, group_key: 'foobar') }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
describe 'POST /api/v2_alpha/notifications/:id/dismiss' do
subject do
post "/api/v2_alpha/notifications/#{notification.group_key}/dismiss", headers: headers
end
let!(:notification) { Fabricate(:notification, account: user.account, group_key: 'foobar') }
it_behaves_like 'forbidden for wrong scope', 'read read:notifications'
it 'destroys the notification' do
subject
expect(response).to have_http_status(200)
expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
context 'when notification belongs to someone else' do
let(:notification) { Fabricate(:notification) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
describe 'POST /api/v2_alpha/notifications/clear' do
subject do
post '/api/v2_alpha/notifications/clear', headers: headers
end
before do
Fabricate(:notification, account: user.account)
end
it_behaves_like 'forbidden for wrong scope', 'read read:notifications'
it 'clears notifications for the account' do
subject
expect(user.account.reload.notifications).to be_empty
expect(response).to have_http_status(200)
end
end
end