next => action => {
if (action.type && !action.skipAlert) {
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
if (action.type.match(isFail)) {
- if (action.error.response) {
- const { data, status, statusText } = action.error.response;
-
- let message = statusText;
- let title = `${status}`;
-
- if (data.error) {
- message = data.error;
- }
-
- dispatch(showAlert(title, message));
- } else {
- console.error(action.error);
- dispatch(showAlert(messages.unexpectedTitle, messages.unexpectedMessage));
- }
+ dispatch(showAlertForError(action.error));
}
}
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 5eadebb81..8524ddb8e 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -4,6 +4,7 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
+ COMPOSE_DIRECT,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@@ -31,11 +32,11 @@ import {
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
+import { REDRAFT } from '../actions/statuses';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import uuid from '../uuid';
import { me } from '../initial_state';
-
-const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
+import { unescapeHTML } from '../utils/html';
const initialState = ImmutableMap({
mounted: 0,
@@ -45,6 +46,7 @@ const initialState = ImmutableMap({
privacy: null,
text: '',
focusDate: null,
+ caretPosition: null,
preselectDate: null,
in_reply_to: null,
is_composing: false,
@@ -92,7 +94,6 @@ function appendMedia(state, media) {
map.update('media_attachments', list => list.push(media));
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
- map.set('focusDate', new Date());
map.set('idempotencyKey', uuid());
if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
@@ -120,6 +121,7 @@ const insertSuggestion = (state, position, token, completion) => {
map.set('suggestion_token', null);
map.update('suggestions', ImmutableList(), list => list.clear());
map.set('focusDate', new Date());
+ map.set('caretPosition', position + completion.length + 1);
map.set('idempotencyKey', uuid());
});
};
@@ -136,14 +138,14 @@ const updateSuggestionTags = (state, token) => {
});
};
-const insertEmoji = (state, position, emojiData) => {
+const insertEmoji = (state, position, emojiData, needsSpace) => {
const oldText = state.get('text');
- const needsSpace = emojiData.custom && position > 0 && !allowedAroundShortCode.includes(oldText[position - 1]);
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
return state.merge({
text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
focusDate: new Date(),
+ caretPosition: position + emoji.length + 1,
idempotencyKey: uuid(),
});
};
@@ -170,6 +172,18 @@ const hydrate = (state, hydratedState) => {
return state;
};
+const domParser = new DOMParser();
+
+const expandMentions = status => {
+ const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
+
+ status.get('mentions').forEach(mention => {
+ fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
+ });
+
+ return fragment.innerHTML;
+};
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@@ -218,6 +232,7 @@ export default function compose(state = initialState, action) {
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
+ map.set('caretPosition', null);
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
@@ -258,10 +273,20 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
- return state
- .update('text', text => `${text}@${action.account.get('acct')} `)
- .set('focusDate', new Date())
- .set('idempotencyKey', uuid());
+ return state.withMutations(map => {
+ map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('idempotencyKey', uuid());
+ });
+ case COMPOSE_DIRECT:
+ return state.withMutations(map => {
+ map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+ map.set('privacy', 'direct');
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('idempotencyKey', uuid());
+ });
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:
@@ -279,7 +304,7 @@ export default function compose(state = initialState, action) {
return state;
}
case COMPOSE_EMOJI_INSERT:
- return insertEmoji(state, action.position, action.emoji);
+ return insertEmoji(state, action.position, action.emoji, action.needsSpace);
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state
.set('is_submitting', false)
@@ -290,6 +315,24 @@ export default function compose(state = initialState, action) {
return item;
}));
+ case REDRAFT:
+ return state.withMutations(map => {
+ map.set('text', unescapeHTML(expandMentions(action.status)));
+ map.set('in_reply_to', action.status.get('in_reply_to_id'));
+ map.set('privacy', action.status.get('visibility'));
+ map.set('media_attachments', action.status.get('media_attachments'));
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.status.get('spoiler_text'));
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+ });
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index fe8308d0c..4c2d6cc8a 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -3,71 +3,103 @@ import {
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
-import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from '../compare_id';
const initialState = ImmutableMap({
- ancestors: ImmutableMap(),
- descendants: ImmutableMap(),
+ inReplyTos: ImmutableMap(),
+ replies: ImmutableMap(),
});
-const normalizeContext = (state, id, ancestors, descendants) => {
- const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id));
- const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
+ state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+ state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+ function addReply({ id, in_reply_to_id }) {
+ if (in_reply_to_id && !inReplyTos.has(id)) {
- return state.withMutations(map => {
- map.setIn(['ancestors', id], ancestorsIds);
- map.setIn(['descendants', id], descendantsIds);
- });
+ replies.update(in_reply_to_id, ImmutableList(), siblings => {
+ const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
+ return siblings.insert(index + 1, id);
+ });
+
+ inReplyTos.set(id, in_reply_to_id);
+ }
+ }
+
+ // We know in_reply_to_id of statuses but `id` itself.
+ // So we assume that the status of the id replies to last ancestors.
+
+ ancestors.forEach(addReply);
+
+ if (ancestors[0]) {
+ addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
+ }
+
+ descendants.forEach(addReply);
+ }));
+ }));
+});
+
+const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
+ state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+ state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+ ids.forEach(id => {
+ const inReplyToIdOfId = inReplyTos.get(id);
+ const repliesOfId = replies.get(id);
+ const siblings = replies.get(inReplyToIdOfId);
+
+ if (siblings) {
+ replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
+ }
+
+
+ if (repliesOfId) {
+ repliesOfId.forEach(reply => inReplyTos.delete(reply));
+ }
+
+ inReplyTos.delete(id);
+ replies.delete(id);
+ });
+ }));
+ }));
+});
+
+const filterContexts = (state, relationship, statuses) => {
+ const ownedStatusIds = statuses
+ .filter(status => status.get('account') === relationship.id)
+ .map(status => status.get('id'));
+
+ return deleteFromContexts(state, ownedStatusIds);
};
-const deleteFromContexts = (state, id) => {
- state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
- state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
- });
+const updateContext = (state, status) => {
+ if (status.in_reply_to_id) {
+ return state.withMutations(mutable => {
+ const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
- state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
- state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
- });
+ mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
- state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+ if (!replies.includes(status.id)) {
+ mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
+ }
+ });
+ }
return state;
};
-const filterContexts = (state, relationship) => {
- return state.map(
- statuses => statuses.filter(
- status => status.get('account') !== relationship.id));
-};
-
-const updateContext = (state, status, references) => {
- return state.update('descendants', map => {
- references.forEach(parentId => {
- map = map.update(parentId, ImmutableList(), list => {
- if (list.includes(status.id)) {
- return list;
- }
-
- return list.push(status.id);
- });
- });
-
- return map;
- });
-};
-
-export default function contexts(state = initialState, action) {
+export default function replies(state = initialState, action) {
switch(action.type) {
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
- return filterContexts(state, action.relationship);
+ return filterContexts(state, action.relationship, action.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE:
- return deleteFromContexts(state, action.id);
- case TIMELINE_CONTEXT_UPDATE:
- return updateContext(state, action.status, action.references);
+ return deleteFromContexts(state, [action.id]);
+ case TIMELINE_UPDATE:
+ return updateContext(state, action.status);
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
index 307bcc7dc..d2c801ade 100644
--- a/app/javascript/mastodon/reducers/custom_emojis.js
+++ b/app/javascript/mastodon/reducers/custom_emojis.js
@@ -1,16 +1,15 @@
-import { List as ImmutableList } from 'immutable';
-import { STORE_HYDRATE } from '../actions/store';
+import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable';
+import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { buildCustomEmojis } from '../features/emoji/emoji';
-const initialState = ImmutableList();
+const initialState = ImmutableList([]);
export default function custom_emojis(state = initialState, action) {
- switch(action.type) {
- case STORE_HYDRATE:
- emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
- return action.state.get('custom_emojis');
- default:
- return state;
+ if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) {
+ state = ConvertToImmutable(action.custom_emojis);
+ emojiSearch('', { custom: buildCustomEmojis(state) });
}
+
+ return state;
};
diff --git a/app/javascript/mastodon/reducers/domain_lists.js b/app/javascript/mastodon/reducers/domain_lists.js
new file mode 100644
index 000000000..eff97fbd6
--- /dev/null
+++ b/app/javascript/mastodon/reducers/domain_lists.js
@@ -0,0 +1,25 @@
+import {
+ DOMAIN_BLOCKS_FETCH_SUCCESS,
+ DOMAIN_BLOCKS_EXPAND_SUCCESS,
+ DOMAIN_UNBLOCK_SUCCESS,
+} from '../actions/domain_blocks';
+import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
+
+const initialState = ImmutableMap({
+ blocks: ImmutableMap({
+ items: ImmutableOrderedSet(),
+ }),
+});
+
+export default function domainLists(state = initialState, action) {
+ switch(action.type) {
+ case DOMAIN_BLOCKS_FETCH_SUCCESS:
+ return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next);
+ case DOMAIN_BLOCKS_EXPAND_SUCCESS:
+ return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next);
+ case DOMAIN_UNBLOCK_SUCCESS:
+ return state.updateIn(['blocks', 'items'], set => set.delete(action.domain));
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index b84b2d18a..3d9a6a132 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -6,6 +6,7 @@ import alerts from './alerts';
import { loadingBarReducer } from 'react-redux-loading-bar';
import modal from './modal';
import user_lists from './user_lists';
+import domain_lists from './domain_lists';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import statuses from './statuses';
@@ -34,6 +35,7 @@ const reducers = {
loadingBar: loadingBarReducer,
modal,
user_lists,
+ domain_lists,
status_lists,
accounts,
accounts_counters,
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index f023984b8..84d4fc698 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -12,6 +12,7 @@ import {
} from '../actions/accounts';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from '../compare_id';
const initialState = ImmutableMap({
items: ImmutableList(),
@@ -44,13 +45,6 @@ const normalizeNotification = (state, notification) => {
});
};
-const newer = (m, n) => {
- const mId = m.get('id');
- const nId = n.get('id');
-
- return mId.length === nId.length ? mId > nId : mId.length > nId.length;
-};
-
const expandNormalizedNotifications = (state, notifications, next) => {
let items = ImmutableList();
@@ -62,11 +56,11 @@ const expandNormalizedNotifications = (state, notifications, next) => {
if (!items.isEmpty()) {
mutable.update('items', list => {
const lastIndex = 1 + list.findLastIndex(
- item => item !== null && (newer(item, items.last()) || item.get('id') === items.last().get('id'))
+ item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
);
const firstIndex = 1 + list.take(lastIndex).findLastIndex(
- item => item !== null && newer(item, items.first())
+ item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
);
return list.take(firstIndex).concat(items, list.skip(lastIndex));
@@ -82,7 +76,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {
};
const filterNotifications = (state, relationship) => {
- return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+ return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
};
const updateTop = (state, top) => {
@@ -94,7 +88,7 @@ const updateTop = (state, top) => {
};
const deleteByStatus = (state, statusId) => {
- return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+ return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
};
export default function notifications(state = initialState, action) {
@@ -111,7 +105,7 @@ export default function notifications(state = initialState, action) {
return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
- return filterNotifications(state, action.relationship);
+ return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index c7b04a668..d1caabc1c 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -23,6 +23,14 @@ const normalizeRelationships = (state, relationships) => {
return state;
};
+const setDomainBlocking = (state, accounts, blocking) => {
+ return state.withMutations(map => {
+ accounts.forEach(id => {
+ map.setIn([id, 'domain_blocking'], blocking);
+ });
+ });
+};
+
const initialState = ImmutableMap();
export default function relationships(state = initialState, action) {
@@ -37,9 +45,9 @@ export default function relationships(state = initialState, action) {
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
case DOMAIN_BLOCK_SUCCESS:
- return state.setIn([action.accountId, 'domain_blocking'], true);
+ return setDomainBlocking(state, action.accounts, true);
case DOMAIN_UNBLOCK_SUCCESS:
- return state.setIn([action.accountId, 'domain_blocking'], false);
+ return setDomainBlocking(state, action.accounts, false);
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 08d90e4e8..4758defb1 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -4,8 +4,12 @@ import {
SEARCH_FETCH_SUCCESS,
SEARCH_SHOW,
} from '../actions/search';
-import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+ COMPOSE_MENTION,
+ COMPOSE_REPLY,
+ COMPOSE_DIRECT,
+} from '../actions/compose';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
value: '',
@@ -29,12 +33,13 @@ export default function search(state = initialState, action) {
return state.set('hidden', false);
case COMPOSE_REPLY:
case COMPOSE_MENTION:
+ case COMPOSE_DIRECT:
return state.set('hidden', true);
case SEARCH_FETCH_SUCCESS:
return state.set('results', ImmutableMap({
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
- hashtags: ImmutableList(action.results.hashtags),
+ hashtags: fromJS(action.results.hashtags),
})).set('submitted', true);
default:
return state;
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 390b2a13a..0a034d739 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -1,5 +1,5 @@
import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
-import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns';
import { STORE_HYDRATE } from '../actions/store';
import { EMOJI_USE } from '../actions/emojis';
import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
@@ -58,6 +58,16 @@ const initialState = ImmutableMap({
body: '',
}),
}),
+
+ direct: ImmutableMap({
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
+ trends: ImmutableMap({
+ show: true,
+ }),
});
const defaultColumns = fromJS([
@@ -83,6 +93,17 @@ const moveColumn = (state, uuid, direction) => {
.set('saved', false);
};
+const changeColumnParams = (state, uuid, params) => {
+ const columns = state.get('columns');
+ const index = columns.findIndex(item => item.get('uuid') === uuid);
+
+ const newColumns = columns.update(index, column => column.update('params', () => fromJS(params)));
+
+ return state
+ .set('columns', newColumns)
+ .set('saved', false);
+};
+
const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
@@ -105,6 +126,8 @@ export default function settings(state = initialState, action) {
.set('saved', false);
case COLUMN_MOVE:
return moveColumn(state, action.uuid, action.direction);
+ case COLUMN_PARAMS_CHANGE:
+ return changeColumnParams(state, action.uuid, action.params);
case EMOJI_USE:
return updateFrequentEmojis(state, action.emoji);
case SETTING_SAVE:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index f795e7e08..916a091eb 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -13,6 +13,7 @@ import {
ACCOUNT_UNFOLLOW_SUCCESS,
} from '../actions/accounts';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import compareId from '../compare_id';
const initialState = ImmutableMap();
@@ -32,8 +33,13 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) =>
if (!statuses.isEmpty()) {
mMap.update('items', ImmutableList(), oldIds => {
const newIds = statuses.map(status => status.get('id'));
- const lastIndex = oldIds.findLastIndex(id => id !== null && id >= newIds.last()) + 1;
- const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && id > newIds.first());
+
+ if (timeline.indexOf(':pinned') !== -1) {
+ return newIds;
+ }
+
+ const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+ const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
if (firstIndex < 0) {
return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
@@ -133,7 +139,7 @@ export default function timelines(state = initialState, action) {
initialTimeline,
map => map.update(
'items',
- items => items.first() ? items : items.unshift(null)
+ items => items.first() ? items.unshift(null) : items
)
);
default:
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
index 8b65f27a3..2435da117 100644
--- a/app/javascript/mastodon/service_worker/entry.js
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -1,17 +1,25 @@
+import { freeStorage, storageFreeable } from '../storage/modifier';
import './web_push_notifications';
-function openCache() {
+function openSystemCache() {
+ return caches.open('mastodon-system');
+}
+
+function openWebCache() {
return caches.open('mastodon-web');
}
function fetchRoot() {
- return fetch('/', { credentials: 'include' });
+ return fetch('/', { credentials: 'include', redirect: 'manual' });
}
+const firefox = navigator.userAgent.match(/Firefox\/(\d+)/);
+const invalidOnlyIfCached = firefox && firefox[1] < 60;
+
// Cause a new version of a registered Service Worker to replace an existing one
// that is already installed, and replace the currently active worker on open pages.
self.addEventListener('install', function(event) {
- event.waitUntil(Promise.all([openCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root)));
+ event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root)));
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
@@ -21,28 +29,51 @@ self.addEventListener('fetch', function(event) {
if (url.pathname.startsWith('/web/')) {
const asyncResponse = fetchRoot();
- const asyncCache = openCache();
+ const asyncCache = openWebCache();
- event.respondWith(asyncResponse.then(async response => {
- if (response.ok) {
- const cache = await asyncCache;
- await cache.put('/', response);
- return response.clone();
- }
-
- throw null;
- }).catch(() => caches.match('/')));
+ event.respondWith(asyncResponse.then(
+ response => asyncCache.then(cache => cache.put('/', response.clone()))
+ .then(() => response),
+ () => asyncCache.then(cache => cache.match('/'))));
} else if (url.pathname === '/auth/sign_out') {
const asyncResponse = fetch(event.request);
- const asyncCache = openCache();
+ const asyncCache = openWebCache();
- event.respondWith(asyncResponse.then(async response => {
+ event.respondWith(asyncResponse.then(response => {
if (response.ok || response.type === 'opaqueredirect') {
- const cache = await asyncCache;
- await cache.delete('/');
+ return Promise.all([
+ asyncCache.then(cache => cache.delete('/')),
+ indexedDB.deleteDatabase('mastodon'),
+ ]).then(() => response);
}
return response;
}));
+ } else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
+ event.respondWith(openSystemCache().then(cache => {
+ return cache.match(event.request.url).then(cached => {
+ if (cached === undefined) {
+ const asyncResponse = invalidOnlyIfCached && event.request.cache === 'only-if-cached' ?
+ fetch(event.request, { cache: 'no-cache' }) : fetch(event.request);
+
+ return asyncResponse.then(response => {
+ if (response.ok) {
+ const put = cache.put(event.request.url, response.clone());
+
+ put.catch(() => freeStorage());
+
+ return put.then(() => {
+ freeStorage();
+ return response;
+ });
+ }
+
+ return response;
+ });
+ }
+
+ return cached;
+ });
+ }));
}
});
diff --git a/app/javascript/mastodon/service_worker/web_push_locales.js b/app/javascript/mastodon/service_worker/web_push_locales.js
new file mode 100644
index 000000000..ce96ae297
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/web_push_locales.js
@@ -0,0 +1,30 @@
+/* @preval */
+
+const fs = require('fs');
+const path = require('path');
+
+const filtered = {};
+const filenames = fs.readdirSync(path.resolve(__dirname, '../locales'));
+
+filenames.forEach(filename => {
+ if (!filename.match(/\.json$/) || filename.match(/defaultMessages|whitelist/)) return;
+
+ const content = fs.readFileSync(path.resolve(__dirname, `../locales/${filename}`), 'utf-8');
+ const full = JSON.parse(content);
+ const locale = filename.split('.')[0];
+
+ filtered[locale] = {
+ 'notification.favourite': full['notification.favourite'] || '',
+ 'notification.follow': full['notification.follow'] || '',
+ 'notification.mention': full['notification.mention'] || '',
+ 'notification.reblog': full['notification.reblog'] || '',
+
+ 'status.show_more': full['status.show_more'] || '',
+ 'status.reblog': full['status.reblog'] || '',
+ 'status.favourite': full['status.favourite'] || '',
+
+ 'notifications.group': full['notifications.group'] || '',
+ };
+});
+
+module.exports = JSON.parse(JSON.stringify(filtered));
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index f63cff335..3318bbadc 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -1,36 +1,33 @@
+import IntlMessageFormat from 'intl-messageformat';
+import locales from './web_push_locales';
+import { unescape } from 'lodash';
+
const MAX_NOTIFICATIONS = 5;
const GROUP_TAG = 'tag';
-// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
-const formatGroupTitle = (message, count) => message.replace('%{count}', count);
-
const notify = options =>
self.registration.getNotifications().then(notifications => {
- if (notifications.length === MAX_NOTIFICATIONS) {
- // Reached the maximum number of notifications, proceed with grouping
+ if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
const group = {
- title: formatGroupTitle(options.data.message, notifications.length + 1),
- body: notifications
- .sort((n1, n2) => n1.timestamp < n2.timestamp)
- .map(notification => notification.title).join('\n'),
+ title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }),
+ body: notifications.sort((n1, n2) => n1.timestamp < n2.timestamp).map(notification => notification.title).join('\n'),
badge: '/badge.png',
icon: '/android-chrome-192x192.png',
tag: GROUP_TAG,
data: {
url: (new URL('/web/notifications', self.location)).href,
count: notifications.length + 1,
- message: options.data.message,
+ preferred_locale: options.data.preferred_locale,
},
};
notifications.forEach(notification => notification.close());
return self.registration.showNotification(group.title, group);
- } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
- // Already grouped, proceed with appending the notification to the group
+ } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { // Already grouped, proceed with appending the notification to the group
const group = cloneNotification(notifications[0]);
- group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+ group.title = formatMessage('notifications.group', options.data.preferred_locale, { count: group.data.count + 1 });
group.body = `${options.title}\n${group.body}`;
group.data = { ...group.data, count: group.data.count + 1 };
@@ -40,57 +37,102 @@ const notify = options =>
return self.registration.showNotification(options.title, options);
});
-const handlePush = (event) => {
- const options = event.data.json();
+const fetchFromApi = (path, method, accessToken) => {
+ const url = (new URL(path, self.location)).href;
- options.body = options.data.nsfw || options.data.content;
- options.dir = options.data.dir;
- options.image = options.image || undefined; // Null results in a network request (404)
- options.timestamp = options.timestamp && new Date(options.timestamp);
+ return fetch(url, {
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
- const expandAction = options.data.actions.find(action => action.todo === 'expand');
-
- if (expandAction) {
- options.actions = [expandAction];
- options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
- options.data.hiddenImage = options.image;
- options.image = undefined;
- } else {
- options.actions = options.data.actions;
- }
-
- event.waitUntil(notify(options));
+ method: method,
+ credentials: 'include',
+ }).then(res => {
+ if (res.ok) {
+ return res;
+ } else {
+ throw new Error(res.status);
+ }
+ }).then(res => res.json());
};
-const cloneNotification = (notification) => {
- const clone = { };
+const cloneNotification = notification => {
+ const clone = {};
+ let k;
- for(var k in notification) {
+ // Object.assign() does not work with notifications
+ for(k in notification) {
clone[k] = notification[k];
}
return clone;
};
-const expandNotification = (notification) => {
- const nextNotification = cloneNotification(notification);
+const formatMessage = (messageId, locale, values = {}) =>
+ (new IntlMessageFormat(locales[locale][messageId], locale)).format(values);
- nextNotification.body = notification.data.content;
- nextNotification.image = notification.data.hiddenImage;
- nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+const htmlToPlainText = html =>
+ unescape(html.replace(/ /g, '\n').replace(/<\/p>/g, '\n\n').replace(/<[^>]*>/g, ''));
- return self.registration.showNotification(nextNotification.title, nextNotification);
+const handlePush = (event) => {
+ const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
+
+ // Placeholder until more information can be loaded
+ event.waitUntil(
+ notify({
+ title,
+ body,
+ icon,
+ tag: notification_id,
+ timestamp: new Date(),
+ badge: '/badge.png',
+ data: { access_token, preferred_locale, url: '/web/notifications' },
+ }).then(() => fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token)).then(notification => {
+ const options = {};
+
+ options.title = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+ options.body = notification.status && htmlToPlainText(notification.status.content);
+ options.icon = notification.account.avatar_static;
+ options.timestamp = notification.created_at && new Date(notification.created_at);
+ options.tag = notification.id;
+ options.badge = '/badge.png';
+ options.image = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined;
+ options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/web/statuses/${notification.status.id}` : `/web/accounts/${notification.account.id}` };
+
+ if (notification.status && notification.status.sensitive) {
+ options.data.hiddenBody = htmlToPlainText(notification.status.content);
+ options.data.hiddenImage = notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url;
+
+ options.body = notification.status.spoiler_text;
+ options.image = undefined;
+ options.actions = [actionExpand(preferred_locale)];
+ } else if (notification.type === 'mention') {
+ options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)];
+ }
+
+ return notify(options);
+ })
+ );
};
-const makeRequest = (notification, action) =>
- fetch(action.action, {
- headers: {
- 'Authorization': `Bearer ${notification.data.access_token}`,
- 'Content-Type': 'application/json',
- },
- method: action.method,
- credentials: 'include',
- });
+const actionExpand = preferred_locale => ({
+ action: 'expand',
+ icon: '/web-push-icon_expand.png',
+ title: formatMessage('status.show_more', preferred_locale),
+});
+
+const actionReblog = preferred_locale => ({
+ action: 'reblog',
+ icon: '/web-push-icon_reblog.png',
+ title: formatMessage('status.reblog', preferred_locale),
+});
+
+const actionFavourite = preferred_locale => ({
+ action: 'favourite',
+ icon: '/web-push-icon_favourite.png',
+ title: formatMessage('status.favourite', preferred_locale),
+});
const findBestClient = clients => {
const focusedClient = clients.find(client => client.focused);
@@ -99,6 +141,24 @@ const findBestClient = clients => {
return focusedClient || visibleClient || clients[0];
};
+const expandNotification = notification => {
+ const newNotification = cloneNotification(notification);
+
+ newNotification.body = newNotification.data.hiddenBody;
+ newNotification.image = newNotification.data.hiddenImage;
+ newNotification.actions = [actionReblog(notification.data.preferred_locale), actionFavourite(notification.data.preferred_locale)];
+
+ return self.registration.showNotification(newNotification.title, newNotification);
+};
+
+const removeActionFromNotification = (notification, action) => {
+ const newNotification = cloneNotification(notification);
+
+ newNotification.actions = newNotification.actions.filter(item => item.action !== action);
+
+ return self.registration.showNotification(newNotification.title, newNotification);
+};
+
const openUrl = url =>
self.clients.matchAll({ type: 'window' }).then(clientList => {
if (clientList.length !== 0) {
@@ -124,27 +184,19 @@ const openUrl = url =>
return self.clients.openWindow(url);
});
-const removeActionFromNotification = (notification, action) => {
- const actions = notification.actions.filter(act => act.action !== action.action);
- const nextNotification = cloneNotification(notification);
-
- nextNotification.actions = actions;
-
- return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
const handleNotificationClick = (event) => {
const reactToNotificationClick = new Promise((resolve, reject) => {
if (event.action) {
- const action = event.notification.data.actions.find(({ action }) => action === event.action);
-
- if (action.todo === 'expand') {
+ if (event.action === 'expand') {
resolve(expandNotification(event.notification));
- } else if (action.todo === 'request') {
- resolve(makeRequest(event.notification, action)
- .then(() => removeActionFromNotification(event.notification, action)));
+ } else if (event.action === 'reblog') {
+ const { data } = event.notification;
+ resolve(fetchFromApi(`/api/v1/statuses/${data.id}/reblog`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'reblog')));
+ } else if (event.action === 'favourite') {
+ const { data } = event.notification;
+ resolve(fetchFromApi(`/api/v1/statuses/${data.id}/favourite`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'favourite')));
} else {
- reject(`Unknown action: ${action.todo}`);
+ reject(`Unknown action: ${event.action}`);
}
} else {
event.notification.close();
diff --git a/app/javascript/mastodon/db/async.js b/app/javascript/mastodon/storage/db.js
similarity index 78%
rename from app/javascript/mastodon/db/async.js
rename to app/javascript/mastodon/storage/db.js
index e08fc3f3d..377a792a7 100644
--- a/app/javascript/mastodon/db/async.js
+++ b/app/javascript/mastodon/storage/db.js
@@ -1,15 +1,14 @@
-import { me } from '../initial_state';
-
-export default new Promise((resolve, reject) => {
+export default () => new Promise((resolve, reject) => {
+ // ServiceWorker is required to synchronize the login state.
// Microsoft Edge 17 does not support getAll according to:
// Catalog of standard and vendor APIs across browsers - Microsoft Edge Development
// https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb
- if (!me || !('getAll' in IDBObjectStore.prototype)) {
+ if (!('caches' in self && 'getAll' in IDBObjectStore.prototype)) {
reject();
return;
}
- const request = indexedDB.open('mastodon:' + me);
+ const request = indexedDB.open('mastodon');
request.onerror = reject;
request.onsuccess = ({ target }) => resolve(target.result);
diff --git a/app/javascript/mastodon/storage/modifier.js b/app/javascript/mastodon/storage/modifier.js
new file mode 100644
index 000000000..9fadabef4
--- /dev/null
+++ b/app/javascript/mastodon/storage/modifier.js
@@ -0,0 +1,211 @@
+import openDB from './db';
+
+const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static'];
+const storageMargin = 8388608;
+const storeLimit = 1024;
+
+// navigator.storage is not present on:
+// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.100 Safari/537.36 Edge/16.16299
+// estimate method is not present on Chrome 57.0.2987.98 on Linux.
+export const storageFreeable = 'storage' in navigator && 'estimate' in navigator.storage;
+
+function openCache() {
+ // ServiceWorker and Cache API is not available on iOS 11
+ // https://webkit.org/status/#specification-service-workers
+ return self.caches ? caches.open('mastodon-system') : Promise.reject();
+}
+
+function printErrorIfAvailable(error) {
+ if (error) {
+ console.warn(error);
+ }
+}
+
+function put(name, objects, onupdate, oncreate) {
+ return openDB().then(db => (new Promise((resolve, reject) => {
+ const putTransaction = db.transaction(name, 'readwrite');
+ const putStore = putTransaction.objectStore(name);
+ const putIndex = putStore.index('id');
+
+ objects.forEach(object => {
+ putIndex.getKey(object.id).onsuccess = retrieval => {
+ function addObject() {
+ putStore.add(object);
+ }
+
+ function deleteObject() {
+ putStore.delete(retrieval.target.result).onsuccess = addObject;
+ }
+
+ if (retrieval.target.result) {
+ if (onupdate) {
+ onupdate(object, retrieval.target.result, putStore, deleteObject);
+ } else {
+ deleteObject();
+ }
+ } else {
+ if (oncreate) {
+ oncreate(object, addObject);
+ } else {
+ addObject();
+ }
+ }
+ };
+ });
+
+ putTransaction.oncomplete = () => {
+ const readTransaction = db.transaction(name, 'readonly');
+ const readStore = readTransaction.objectStore(name);
+ const count = readStore.count();
+
+ count.onsuccess = () => {
+ const excess = count.result - storeLimit;
+
+ if (excess > 0) {
+ const retrieval = readStore.getAll(null, excess);
+
+ retrieval.onsuccess = () => resolve(retrieval.result);
+ retrieval.onerror = reject;
+ } else {
+ resolve([]);
+ }
+ };
+
+ count.onerror = reject;
+ };
+
+ putTransaction.onerror = reject;
+ })).then(resolved => {
+ db.close();
+ return resolved;
+ }, error => {
+ db.close();
+ throw error;
+ }));
+}
+
+function evictAccountsByRecords(records) {
+ return openDB().then(db => {
+ const transaction = db.transaction(['accounts', 'statuses'], 'readwrite');
+ const accounts = transaction.objectStore('accounts');
+ const accountsIdIndex = accounts.index('id');
+ const accountsMovedIndex = accounts.index('moved');
+ const statuses = transaction.objectStore('statuses');
+ const statusesIndex = statuses.index('account');
+
+ function evict(toEvict) {
+ toEvict.forEach(record => {
+ openCache()
+ .then(cache => accountAssetKeys.forEach(key => cache.delete(records[key])))
+ .catch(printErrorIfAvailable);
+
+ accountsMovedIndex.getAll(record.id).onsuccess = ({ target }) => evict(target.result);
+
+ statusesIndex.getAll(record.id).onsuccess =
+ ({ target }) => evictStatusesByRecords(target.result);
+
+ accountsIdIndex.getKey(record.id).onsuccess =
+ ({ target }) => target.result && accounts.delete(target.result);
+ });
+ }
+
+ evict(records);
+
+ db.close();
+ }).catch(printErrorIfAvailable);
+}
+
+export function evictStatus(id) {
+ evictStatuses([id]);
+}
+
+export function evictStatuses(ids) {
+ return openDB().then(db => {
+ const transaction = db.transaction('statuses', 'readwrite');
+ const store = transaction.objectStore('statuses');
+ const idIndex = store.index('id');
+ const reblogIndex = store.index('reblog');
+
+ ids.forEach(id => {
+ reblogIndex.getAllKeys(id).onsuccess =
+ ({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey));
+
+ idIndex.getKey(id).onsuccess =
+ ({ target }) => target.result && store.delete(target.result);
+ });
+
+ db.close();
+ }).catch(printErrorIfAvailable);
+}
+
+function evictStatusesByRecords(records) {
+ return evictStatuses(records.map(({ id }) => id));
+}
+
+export function putAccounts(records, avatarStatic) {
+ const avatarKey = avatarStatic ? 'avatar_static' : 'avatar';
+ const newURLs = [];
+
+ put('accounts', records, (newRecord, oldKey, store, oncomplete) => {
+ store.get(oldKey).onsuccess = ({ target }) => {
+ accountAssetKeys.forEach(key => {
+ const newURL = newRecord[key];
+ const oldURL = target.result[key];
+
+ if (newURL !== oldURL) {
+ openCache()
+ .then(cache => cache.delete(oldURL))
+ .catch(printErrorIfAvailable);
+ }
+ });
+
+ const newURL = newRecord[avatarKey];
+ const oldURL = target.result[avatarKey];
+
+ if (newURL !== oldURL) {
+ newURLs.push(newURL);
+ }
+
+ oncomplete();
+ };
+ }, (newRecord, oncomplete) => {
+ newURLs.push(newRecord[avatarKey]);
+ oncomplete();
+ }).then(records => Promise.all([
+ evictAccountsByRecords(records),
+ openCache().then(cache => cache.addAll(newURLs)),
+ ])).then(freeStorage, error => {
+ freeStorage();
+ throw error;
+ }).catch(printErrorIfAvailable);
+}
+
+export function putStatuses(records) {
+ put('statuses', records)
+ .then(evictStatusesByRecords)
+ .catch(printErrorIfAvailable);
+}
+
+export function freeStorage() {
+ return storageFreeable && navigator.storage.estimate().then(({ quota, usage }) => {
+ if (usage + storageMargin < quota) {
+ return null;
+ }
+
+ return openDB().then(db => new Promise((resolve, reject) => {
+ const retrieval = db.transaction('accounts', 'readonly').objectStore('accounts').getAll(null, 1);
+
+ retrieval.onsuccess = () => {
+ if (retrieval.result.length > 0) {
+ resolve(evictAccountsByRecords(retrieval.result).then(freeStorage));
+ } else {
+ resolve(caches.delete('mastodon-system'));
+ }
+ };
+
+ retrieval.onerror = reject;
+
+ db.close();
+ }));
+ });
+}
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 6c67ba275..9928d0dd7 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -1,21 +1,24 @@
import WebSocketClient from 'websocket.js';
+const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
+
export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
return (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = getState().getIn(['meta', 'access_token']);
const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+
let polling = null;
const setupPolling = () => {
- polling = setInterval(() => {
- pollingRefresh(dispatch);
- }, 20000);
+ pollingRefresh(dispatch, () => {
+ polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
+ });
};
const clearPolling = () => {
if (polling) {
- clearInterval(polling);
+ clearTimeout(polling);
polling = null;
}
};
@@ -29,8 +32,9 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
disconnected () {
if (pollingRefresh) {
- setupPolling();
+ polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
}
+
onDisconnect();
},
@@ -51,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
if (subscription) {
subscription.close();
}
+
clearPolling();
};
diff --git a/app/javascript/mastodon/utils/__tests__/base64-test.js b/app/javascript/mastodon/utils/__tests__/base64-test.js
new file mode 100644
index 000000000..1b3260faa
--- /dev/null
+++ b/app/javascript/mastodon/utils/__tests__/base64-test.js
@@ -0,0 +1,10 @@
+import * as base64 from '../base64';
+
+describe('base64', () => {
+ describe('decode', () => {
+ it('returns a uint8 array', () => {
+ const arr = base64.decode('dGVzdA==');
+ expect(arr).toEqual(new Uint8Array([116, 101, 115, 116]));
+ });
+ });
+});
diff --git a/app/javascript/mastodon/utils/base64.js b/app/javascript/mastodon/utils/base64.js
new file mode 100644
index 000000000..8226e2c54
--- /dev/null
+++ b/app/javascript/mastodon/utils/base64.js
@@ -0,0 +1,10 @@
+export const decode = base64 => {
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+
+ return outputArray;
+};
diff --git a/app/javascript/mastodon/utils/html.js b/app/javascript/mastodon/utils/html.js
new file mode 100644
index 000000000..5159df9db
--- /dev/null
+++ b/app/javascript/mastodon/utils/html.js
@@ -0,0 +1,5 @@
+export const unescapeHTML = (html) => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = html.replace(/ /g, '\n').replace(/<\/p>
/g, '\n\n').replace(/<[^>]*>/g, '');
+ return wrapper.textContent;
+};
diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.js
new file mode 100644
index 000000000..fdd8269ae
--- /dev/null
+++ b/app/javascript/mastodon/utils/numbers.js
@@ -0,0 +1,10 @@
+import React, { Fragment } from 'react';
+import { FormattedNumber } from 'react-intl';
+
+export const shortNumberFormat = number => {
+ if (number < 1000) {
+ return ;
+ } else {
+ return K ;
+ }
+};
diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js
new file mode 100644
index 000000000..279a858ca
--- /dev/null
+++ b/app/javascript/mastodon/utils/resize_image.js
@@ -0,0 +1,116 @@
+import EXIF from 'exif-js';
+
+const MAX_IMAGE_DIMENSION = 1280;
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+ if (window.URL && URL.createObjectURL) {
+ try {
+ resolve(URL.createObjectURL(inputFile));
+ } catch (error) {
+ reject(error);
+ }
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onerror = (...args) => reject(...args);
+ reader.onload = ({ target }) => resolve(target.result);
+
+ reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+ getImageUrl(inputFile).then(url => {
+ const img = new Image();
+
+ img.onerror = (...args) => reject(...args);
+ img.onload = () => resolve(img);
+
+ img.src = url;
+ }).catch(reject);
+});
+
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+ if (type !== 'image/jpeg') {
+ resolve(1);
+ return;
+ }
+
+ EXIF.getData(img, () => {
+ const orientation = EXIF.getTag(img, 'Orientation');
+ resolve(orientation);
+ });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+ const canvas = document.createElement('canvas');
+
+ if (4 < orientation && orientation < 9) {
+ canvas.width = height;
+ canvas.height = width;
+ } else {
+ canvas.width = width;
+ canvas.height = height;
+ }
+
+ const context = canvas.getContext('2d');
+
+ switch (orientation) {
+ case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+ case 3: context.transform(-1, 0, 0, -1, width, height); break;
+ case 4: context.transform(1, 0, 0, -1, 0, height); break;
+ case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+ case 6: context.transform(0, 1, -1, 0, height, 0); break;
+ case 7: context.transform(0, -1, -1, 0, height, width); break;
+ case 8: context.transform(0, -1, 1, 0, 0, width); break;
+ }
+
+ context.drawImage(img, 0, 0, width, height);
+
+ canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+ const { width, height } = img;
+
+ let newWidth, newHeight;
+
+ if (width > height) {
+ newHeight = height * MAX_IMAGE_DIMENSION / width;
+ newWidth = MAX_IMAGE_DIMENSION;
+ } else if (height > width) {
+ newWidth = width * MAX_IMAGE_DIMENSION / height;
+ newHeight = MAX_IMAGE_DIMENSION;
+ } else {
+ newWidth = MAX_IMAGE_DIMENSION;
+ newHeight = MAX_IMAGE_DIMENSION;
+ }
+
+ getOrientation(img, type)
+ .then(orientation => processImage(img, {
+ width: newWidth,
+ height: newHeight,
+ orientation,
+ type,
+ }))
+ .then(resolve)
+ .catch(reject);
+});
+
+export default inputFile => new Promise((resolve, reject) => {
+ if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+ resolve(inputFile);
+ return;
+ }
+
+ loadImage(inputFile).then(img => {
+ if (img.width < MAX_IMAGE_DIMENSION && img.height < MAX_IMAGE_DIMENSION) {
+ resolve(inputFile);
+ return;
+ }
+
+ resizeImage(img, inputFile.type)
+ .then(resolve)
+ .catch(() => resolve(inputFile));
+ }).catch(reject);
+});
diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js
index 2bf1514a9..5dbcc03d3 100644
--- a/app/javascript/packs/admin.js
+++ b/app/javascript/packs/admin.js
@@ -24,6 +24,7 @@ delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector('#batch_checkbox_all');
if (checkAllElement) {
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+ checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
}
});
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 7096b9b4f..d5e5b7fe0 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -24,8 +24,6 @@ function main() {
const emojify = require('../mastodon/features/emoji/emoji').default;
const { getLocale } = require('../mastodon/locales');
const { localeData } = getLocale();
- const VideoContainer = require('../mastodon/containers/video_container').default;
- const CardContainer = require('../mastodon/containers/card_container').default;
const React = require('react');
const ReactDOM = require('react-dom');
@@ -70,24 +68,16 @@ function main() {
});
});
- [].forEach.call(document.querySelectorAll('[data-component="Video"]'), (content) => {
- const props = JSON.parse(content.getAttribute('data-props'));
- ReactDOM.render( , content);
- });
+ const reactComponents = document.querySelectorAll('[data-component]');
+ if (reactComponents.length > 0) {
+ import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
+ .then(({ default: MediaContainer }) => {
+ const content = document.createElement('div');
- [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
- const props = JSON.parse(content.getAttribute('data-props'));
- ReactDOM.render( , content);
- });
-
- const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
-
- if (mediaGalleries.length > 0) {
- const MediaGalleriesContainer = require('../mastodon/containers/media_galleries_container').default;
- const content = document.createElement('div');
-
- ReactDOM.render( , content);
- document.body.appendChild(content);
+ ReactDOM.render( , content);
+ document.body.appendChild(content);
+ })
+ .catch(error => console.error(error));
}
});
diff --git a/app/javascript/styles/contrast.scss b/app/javascript/styles/contrast.scss
new file mode 100644
index 000000000..5b43aecbe
--- /dev/null
+++ b/app/javascript/styles/contrast.scss
@@ -0,0 +1,3 @@
+@import 'contrast/variables';
+@import 'application';
+@import 'contrast/diff';
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
new file mode 100644
index 000000000..eee9ecc3e
--- /dev/null
+++ b/app/javascript/styles/contrast/diff.scss
@@ -0,0 +1,14 @@
+// components.scss
+.compose-form {
+ .compose-form__modifiers {
+ .compose-form__upload {
+ &-description {
+ input {
+ &::placeholder {
+ opacity: 1.0;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/javascript/styles/contrast/variables.scss b/app/javascript/styles/contrast/variables.scss
new file mode 100644
index 000000000..f6cadf029
--- /dev/null
+++ b/app/javascript/styles/contrast/variables.scss
@@ -0,0 +1,24 @@
+// Dependent colors
+$black: #000000;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #2b90d9;
+
+$ui-base-color: $classic-base-color !default;
+$ui-primary-color: $classic-primary-color !default;
+$ui-secondary-color: $classic-secondary-color !default;
+
+// Differences
+$ui-highlight-color: #2b5fd9;
+
+$darker-text-color: lighten($ui-primary-color, 20%) !default;
+$dark-text-color: lighten($ui-primary-color, 12%) !default;
+$secondary-text-color: lighten($ui-secondary-color, 6%) !default;
+$highlight-text-color: $classic-highlight-color !default;
+$action-button-color: #8d9ac2;
+
+$inverted-text-color: $black !default;
+$lighter-text-color: darken($ui-base-color,6%) !default;
+$light-text-color: darken($ui-primary-color, 40%) !default;
diff --git a/app/javascript/styles/mastodon-light.scss b/app/javascript/styles/mastodon-light.scss
new file mode 100644
index 000000000..756a12d86
--- /dev/null
+++ b/app/javascript/styles/mastodon-light.scss
@@ -0,0 +1,3 @@
+@import 'mastodon-light/variables';
+@import 'application';
+@import 'mastodon-light/diff';
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
new file mode 100644
index 000000000..fad7feb98
--- /dev/null
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -0,0 +1,265 @@
+// Notes!
+// Sass color functions, "darken" and "lighten" are automatically replaced.
+
+// Change the colors of button texts
+.button {
+ color: $white;
+
+ &.button-alternative-2 {
+ color: $white;
+ }
+}
+
+// Change default background colors of columns
+.column {
+ > .scrollable {
+ background: $white;
+ }
+}
+
+.drawer__inner {
+ background: $ui-base-color;
+}
+
+.drawer__inner__mastodon {
+ background: $ui-base-color url('data:image/svg+xml;utf8, ') no-repeat bottom / 100% auto;
+}
+
+.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button {
+ color: lighten($white, 7%);
+
+ &:active,
+ &:focus,
+ &:hover {
+ color: $white;
+ }
+}
+
+.compose-form .compose-form__modifiers .compose-form__upload-description input {
+ color: lighten($white, 7%);
+
+ &::placeholder {
+ color: lighten($white, 7%);
+ }
+}
+
+.compose-form .compose-form__buttons-wrapper {
+ background: darken($ui-base-color, 6%);
+}
+
+.emoji-mart-bar {
+ border-color: lighten($ui-base-color, 8%);
+
+ &:first-child {
+ background: $ui-base-color;
+ }
+}
+
+.emoji-mart-search input {
+ background: rgba($ui-base-color, 0.3);
+ border-color: $ui-base-color;
+}
+
+.focusable:focus {
+ background: $ui-base-color;
+}
+
+.status.status-direct {
+ background: lighten($ui-base-color, 4%);
+}
+
+.focusable:focus .status.status-direct {
+ background: lighten($ui-base-color, 8%);
+}
+
+.detailed-status,
+.detailed-status__action-bar {
+ background: darken($ui-base-color, 6%);
+}
+
+// Change the background color of status__content__spoiler-link
+.reply-indicator__content .status__content__spoiler-link,
+.status__content .status__content__spoiler-link {
+ background: $ui-base-lighter-color;
+
+ &:hover {
+ background: lighten($ui-base-lighter-color, 6%);
+ }
+}
+
+// Change the background colors of media and video spoiler
+
+.media-spoiler,
+.video-player__spoiler {
+ background: $ui-base-color;
+}
+
+.account-gallery__item a {
+ background-color: $ui-base-color;
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+ background: $ui-base-color;
+}
+
+.dropdown-menu__arrow {
+ &.left {
+ border-left-color: $ui-base-color;
+ }
+
+ &.top {
+ border-top-color: $ui-base-color;
+ }
+
+ &.bottom {
+ border-bottom-color: $ui-base-color;
+ }
+
+ &.right {
+ border-right-color: $ui-base-color;
+ }
+}
+
+.dropdown-menu__item {
+ a {
+ background: $ui-base-color;
+ color: $darker-text-color;
+ }
+}
+
+// Change the text colors on inverted background
+.privacy-dropdown__option.active .privacy-dropdown__option__content,
+.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content,
+.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
+.dropdown-menu__item a:active,
+.dropdown-menu__item a:focus,
+.dropdown-menu__item a:hover,
+.actions-modal ul li:not(:empty) a.active,
+.actions-modal ul li:not(:empty) a.active button,
+.actions-modal ul li:not(:empty) a:active,
+.actions-modal ul li:not(:empty) a:active button,
+.actions-modal ul li:not(:empty) a:focus,
+.actions-modal ul li:not(:empty) a:focus button,
+.actions-modal ul li:not(:empty) a:hover,
+.actions-modal ul li:not(:empty) a:hover button,
+.admin-wrapper .sidebar ul ul a.selected,
+.simple_form .block-button,
+.simple_form .button,
+.simple_form button {
+ color: $white;
+}
+
+// Change the background colors of modals
+.actions-modal,
+.boost-modal,
+.confirmation-modal,
+.mute-modal,
+.report-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal {
+ background: $ui-base-color;
+}
+
+.boost-modal__action-bar,
+.confirmation-modal__action-bar,
+.mute-modal__action-bar,
+.onboarding-modal__paginator,
+.error-modal__footer {
+ background: darken($ui-base-color, 6%);
+
+ .onboarding-modal__nav,
+ .error-modal__nav {
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: darken($ui-base-color, 12%);
+ }
+ }
+}
+
+.display-case__case {
+ background: $white;
+}
+
+.embed-modal .embed-modal__container .embed-modal__html {
+ background: $white;
+
+ &:focus {
+ background: darken($ui-base-color, 6%);
+ }
+}
+
+.react-toggle-track {
+ background: $ui-secondary-color;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background: darken($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+ background: lighten($ui-highlight-color, 10%);
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+ color: $primary-text-color;
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+ background: $account-background-color;
+ border-bottom-color: lighten($ui-base-color, 8%);
+}
+
+.activity-stream {
+ .entry {
+ background: $account-background-color;
+
+ .detailed-status.light,
+ .more.light,
+ .status.light {
+ border-bottom-color: lighten($ui-base-color, 8%);
+ }
+ }
+
+ .status.light {
+ .status__content {
+ color: $primary-text-color;
+ }
+
+ .display-name {
+ strong {
+ color: $primary-text-color;
+ }
+ }
+ }
+}
+
+.accounts-grid {
+ .account-grid-card {
+ .controls {
+ .icon-button {
+ color: $darker-text-color;
+ }
+ }
+
+ .name {
+ a {
+ color: $primary-text-color;
+ }
+ }
+
+ .username {
+ color: $darker-text-color;
+ }
+
+ .account__header__content {
+ color: $primary-text-color;
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
new file mode 100644
index 000000000..9f6d470b1
--- /dev/null
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -0,0 +1,41 @@
+// Dependent colors
+$black: #000000;
+$white: #ffffff;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #2b90d9;
+
+// Differences
+$success-green: #3c754d;
+
+$base-overlay-background: $white !default;
+$valid-value-color: $success-green !default;
+
+$ui-base-color: $classic-secondary-color !default;
+$ui-base-lighter-color: #b0c0cf;
+$ui-primary-color: #9bcbed;
+$ui-secondary-color: $classic-base-color !default;
+$ui-highlight-color: #2b5fd9;
+
+$primary-text-color: $black !default;
+$darker-text-color: $classic-base-color !default;
+$dark-text-color: #444b5d;
+$action-button-color: #606984;
+
+$inverted-text-color: $black !default;
+$lighter-text-color: $classic-base-color !default;
+$light-text-color: #444b5d;
+
+//Newly added colors
+$account-background-color: $white !default;
+
+//Invert darkened and lightened colors
+@function darken($color, $amount) {
+ @return hsl(hue($color), saturation($color), lightness($color) + $amount);
+}
+
+@function lighten($color, $amount) {
+ @return hsl(hue($color), saturation($color), lightness($color) - $amount);
+}
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index c484f074b..77728995d 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -169,7 +169,7 @@ $small-breakpoint: 960px;
background: $ui-base-color;
font-size: 12px;
font-weight: 500;
- color: $ui-primary-color;
+ color: $darker-text-color;
text-transform: uppercase;
position: relative;
z-index: 1;
@@ -186,10 +186,10 @@ $small-breakpoint: 960px;
font-size: 16px;
line-height: 30px;
margin-bottom: 12px;
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
}
}
@@ -202,11 +202,11 @@ $small-breakpoint: 960px;
text-align: center;
font-size: 12px;
line-height: 18px;
- color: $ui-primary-color;
+ color: $darker-text-color;
margin-bottom: 0;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
}
}
@@ -225,7 +225,7 @@ $small-breakpoint: 960px;
font-family: inherit;
font-size: inherit;
line-height: inherit;
- color: lighten($ui-primary-color, 10%);
+ color: lighten($darker-text-color, 10%);
}
h1 {
@@ -234,14 +234,14 @@ $small-breakpoint: 960px;
line-height: 30px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
small {
font-family: 'mastodon-font-sans-serif', sans-serif;
display: block;
font-size: 18px;
font-weight: 400;
- color: $ui-base-lighter-color;
+ color: lighten($darker-text-color, 10%);
}
}
@@ -251,7 +251,7 @@ $small-breakpoint: 960px;
line-height: 26px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
h3 {
@@ -260,7 +260,7 @@ $small-breakpoint: 960px;
line-height: 24px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
h4 {
@@ -269,7 +269,7 @@ $small-breakpoint: 960px;
line-height: 24px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
h5 {
@@ -278,7 +278,7 @@ $small-breakpoint: 960px;
line-height: 24px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
h6 {
@@ -287,7 +287,7 @@ $small-breakpoint: 960px;
line-height: 24px;
font-weight: 500;
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
ul,
@@ -322,6 +322,11 @@ $small-breakpoint: 960px;
border: 0;
border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
margin: 20px 0;
+
+ &.spacer {
+ height: 1px;
+ border: 0;
+ }
}
.container-alt {
@@ -349,10 +354,10 @@ $small-breakpoint: 960px;
font-weight: 400;
font-size: 16px;
line-height: 30px;
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
}
}
@@ -391,7 +396,7 @@ $small-breakpoint: 960px;
display: flex;
justify-content: center;
align-items: center;
- color: $ui-primary-color;
+ color: $darker-text-color;
text-decoration: none;
padding: 12px 16px;
line-height: 32px;
@@ -400,7 +405,7 @@ $small-breakpoint: 960px;
font-size: 14px;
&:hover {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
}
@@ -473,10 +478,10 @@ $small-breakpoint: 960px;
font-weight: 400;
font-size: 16px;
line-height: 30px;
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
}
}
@@ -512,7 +517,7 @@ $small-breakpoint: 960px;
span {
&:last-child {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
}
@@ -543,7 +548,7 @@ $small-breakpoint: 960px;
font-size: 14px;
line-height: 24px;
font-weight: 500;
- color: $ui-primary-color;
+ color: $darker-text-color;
padding-bottom: 5px;
margin-bottom: 15px;
border-bottom: 1px solid lighten($ui-base-color, 4%);
@@ -554,7 +559,7 @@ $small-breakpoint: 960px;
a,
span {
font-weight: 400;
- color: darken($ui-primary-color, 10%);
+ color: darken($darker-text-color, 10%);
}
a {
@@ -597,7 +602,7 @@ $small-breakpoint: 960px;
.username {
display: block;
- color: $ui-primary-color;
+ color: $darker-text-color;
}
}
}
@@ -681,6 +686,54 @@ $small-breakpoint: 960px;
margin-bottom: 0;
}
+ .account {
+ border-bottom: 0;
+ padding: 0;
+
+ &__display-name {
+ align-items: center;
+ display: flex;
+ margin-right: 5px;
+ }
+
+ div.account__display-name {
+ &:hover {
+ .display-name strong {
+ text-decoration: none;
+ }
+ }
+
+ .account__avatar {
+ cursor: default;
+ }
+ }
+
+ &__avatar-wrapper {
+ margin-left: 0;
+ flex: 0 0 auto;
+ }
+
+ &__avatar {
+ width: 44px;
+ height: 44px;
+ background-size: 44px 44px;
+ }
+
+ .display-name {
+ font-size: 15px;
+
+ &__account {
+ font-size: 14px;
+ }
+ }
+ }
+
+ @media screen and (max-width: $small-breakpoint) {
+ .contact {
+ margin-top: 30px;
+ }
+ }
+
@media screen and (max-width: $column-breakpoint) {
padding: 25px 20px;
}
@@ -722,7 +775,7 @@ $small-breakpoint: 960px;
}
p a {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
h1 {
@@ -731,10 +784,10 @@ $small-breakpoint: 960px;
margin-bottom: 0;
small {
- color: $ui-primary-color;
+ color: $darker-text-color;
span {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
}
}
@@ -843,7 +896,7 @@ $small-breakpoint: 960px;
}
a {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
text-decoration: none;
}
}
@@ -882,7 +935,7 @@ $small-breakpoint: 960px;
.fa {
display: block;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-size: 48px;
}
}
@@ -890,7 +943,7 @@ $small-breakpoint: 960px;
.text {
font-size: 16px;
line-height: 30px;
- color: $ui-primary-color;
+ color: $darker-text-color;
h6 {
font-size: inherit;
@@ -916,10 +969,10 @@ $small-breakpoint: 960px;
font-weight: 400;
font-size: 16px;
line-height: 30px;
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
}
}
@@ -927,7 +980,7 @@ $small-breakpoint: 960px;
.footer-links {
padding-bottom: 50px;
text-align: right;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
p {
font-size: 14px;
@@ -942,7 +995,7 @@ $small-breakpoint: 960px;
&__footer {
margin-top: 10px;
text-align: center;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
p {
font-size: 14px;
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index dd82ab375..3ccce383b 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -1,5 +1,5 @@
.card {
- background-color: lighten($ui-base-color, 4%);
+ background-color: $base-shadow-color;
background-size: cover;
background-position: center;
border-radius: 4px 4px 0 0;
@@ -75,10 +75,14 @@
small {
display: block;
font-size: 14px;
- color: $ui-highlight-color;
+ color: $highlight-text-color;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
+
+ .fa {
+ margin-left: 3px;
+ }
}
}
@@ -113,7 +117,7 @@
width: 33.3%;
box-sizing: border-box;
flex: 0 0 auto;
- color: $ui-primary-color;
+ color: $darker-text-color;
padding: 5px 10px 0;
margin-bottom: 10px;
border-right: 1px solid lighten($ui-base-color, 4%);
@@ -143,7 +147,7 @@
&.active {
&::after {
- border-bottom: 4px solid $ui-highlight-color;
+ border-bottom: 4px solid $highlight-text-color;
opacity: 1;
}
}
@@ -178,7 +182,7 @@
font-size: 14px;
line-height: 18px;
padding: 0 15px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
@media screen and (max-width: 480px) {
@@ -256,7 +260,7 @@
.current {
background: $simple-background-color;
border-radius: 100px;
- color: $ui-base-color;
+ color: $inverted-text-color;
cursor: default;
margin: 0 10px;
}
@@ -268,7 +272,7 @@
.older,
.newer {
text-transform: uppercase;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
.older {
@@ -293,7 +297,7 @@
.disabled {
cursor: default;
- color: lighten($ui-base-color, 10%);
+ color: lighten($inverted-text-color, 10%);
}
@media screen and (max-width: 700px) {
@@ -322,6 +326,15 @@
z-index: 2;
position: relative;
+ &.empty img {
+ position: absolute;
+ opacity: 0.2;
+ height: 200px;
+ left: 0;
+ bottom: 0;
+ pointer-events: none;
+ }
+
@media screen and (max-width: 740px) {
border-radius: 0;
box-shadow: none;
@@ -332,7 +345,7 @@
width: 335px;
background: $simple-background-color;
border-radius: 4px;
- color: $ui-base-color;
+ color: $inverted-text-color;
margin: 0 5px 10px;
position: relative;
@@ -344,7 +357,7 @@
overflow: hidden;
height: 100px;
border-radius: 4px 4px 0 0;
- background-color: lighten($ui-base-color, 4%);
+ background-color: lighten($inverted-text-color, 4%);
background-size: cover;
background-position: center;
position: relative;
@@ -392,7 +405,7 @@
a {
display: block;
- color: $ui-base-color;
+ color: $inverted-text-color;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
@@ -414,7 +427,7 @@
}
.username {
- color: lighten($ui-base-color, 34%);
+ color: $lighter-text-color;
font-size: 14px;
font-weight: 400;
}
@@ -422,7 +435,7 @@
.account__header__content {
padding: 10px 15px;
padding-top: 15px;
- color: lighten($ui-base-color, 26%);
+ color: $lighter-text-color;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
@@ -434,12 +447,12 @@
.nothing-here {
width: 100%;
display: block;
- color: $ui-primary-color;
+ color: $light-text-color;
font-size: 14px;
font-weight: 500;
text-align: center;
- padding: 60px 0;
- padding-top: 55px;
+ padding: 130px 0;
+ padding-top: 125px;
margin: 0 auto;
cursor: default;
}
@@ -493,7 +506,7 @@
span {
font-size: 14px;
- color: $ui-primary-color;
+ color: $light-text-color;
}
}
@@ -508,7 +521,7 @@
.account__header__content {
font-size: 14px;
- color: $ui-base-color;
+ color: $inverted-text-color;
}
}
@@ -522,18 +535,18 @@
display: inline-block;
padding: 15px;
text-decoration: none;
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-transform: uppercase;
font-weight: 500;
&:hover,
&:active,
&:focus {
- color: lighten($ui-highlight-color, 8%);
+ color: lighten($highlight-text-color, 8%);
}
&.active {
- color: $ui-base-color;
+ color: $inverted-text-color;
cursor: default;
}
}
@@ -563,3 +576,57 @@
border-color: rgba(lighten($error-red, 12%), 0.5);
}
}
+
+.account__header__fields {
+ padding: 0;
+ margin: 15px -15px -15px;
+ border: 0 none;
+ border-top: 1px solid lighten($ui-base-color, 4%);
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ font-size: 14px;
+ line-height: 20px;
+
+ dl {
+ display: flex;
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ }
+
+ dt,
+ dd {
+ box-sizing: border-box;
+ padding: 14px;
+ text-align: center;
+ max-height: 48px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ dt {
+ font-weight: 500;
+ width: 120px;
+ flex: 0 0 auto;
+ color: $secondary-text-color;
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ }
+
+ dd {
+ flex: 1 1 auto;
+ color: $darker-text-color;
+ }
+
+ a {
+ color: $highlight-text-color;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
+ }
+
+ dl:last-child {
+ border-bottom: 0;
+ }
+}
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index e6bd0c717..560b11ddf 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -33,7 +33,7 @@
a {
display: block;
padding: 15px;
- color: rgba($primary-text-color, 0.7);
+ color: $darker-text-color;
text-decoration: none;
transition: all 200ms linear;
border-radius: 4px 0 0 4px;
@@ -90,7 +90,7 @@
padding-left: 25px;
h2 {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-size: 24px;
line-height: 28px;
font-weight: 400;
@@ -98,7 +98,7 @@
}
h3 {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-size: 20px;
line-height: 28px;
font-weight: 400;
@@ -109,7 +109,7 @@
text-transform: uppercase;
font-size: 13px;
font-weight: 500;
- color: $ui-primary-color;
+ color: $darker-text-color;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -117,7 +117,7 @@
h6 {
font-size: 16px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
line-height: 28px;
font-weight: 400;
}
@@ -125,7 +125,7 @@
& > p {
font-size: 14px;
line-height: 18px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
margin-bottom: 20px;
strong {
@@ -141,17 +141,23 @@
}
hr {
- margin: 20px 0;
+ width: 100%;
+ height: 0;
border: 0;
- background: transparent;
- border-bottom: 1px solid $ui-base-color;
+ border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+ margin: 20px 0;
+
+ &.spacer {
+ height: 1px;
+ border: 0;
+ }
}
.muted-hint {
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
}
@@ -248,7 +254,7 @@
a {
display: inline-block;
- color: rgba($primary-text-color, 0.7);
+ color: $darker-text-color;
text-decoration: none;
text-transform: uppercase;
font-size: 12px;
@@ -261,7 +267,7 @@
}
&.selected {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
border-bottom: 2px solid $ui-highlight-color;
}
}
@@ -286,7 +292,7 @@
font-weight: 500;
font-size: 14px;
line-height: 18px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@@ -330,6 +336,11 @@
}
}
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
+ max-width: 100%;
+}
+
.batch-form-box {
display: flex;
flex-wrap: wrap;
@@ -355,19 +366,12 @@
}
}
-.batch-checkbox,
-.batch-checkbox-all {
- display: flex;
- align-items: center;
- margin-right: 5px;
-}
-
.back-link {
margin-bottom: 10px;
font-size: 14px;
a {
- color: $classic-highlight-color;
+ color: $highlight-text-color;
text-decoration: none;
&:hover {
@@ -381,7 +385,7 @@
}
.log-entry {
- margin-bottom: 8px;
+ margin-bottom: 20px;
line-height: 20px;
&__header {
@@ -390,7 +394,7 @@
align-items: center;
padding: 10px;
background: $ui-base-color;
- color: $ui-primary-color;
+ color: $darker-text-color;
border-radius: 4px 4px 0 0;
font-size: 14px;
position: relative;
@@ -417,14 +421,14 @@
}
&__timestamp {
- color: lighten($ui-base-color, 34%);
+ color: $dark-text-color;
}
&__extras {
background: lighten($ui-base-color, 6%);
border-radius: 0 0 4px 4px;
padding: 10px;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-family: 'mastodon-font-monospace', monospace;
font-size: 12px;
word-wrap: break-word;
@@ -434,7 +438,7 @@
&__icon {
font-size: 28px;
margin-right: 10px;
- color: lighten($ui-base-color, 34%);
+ color: $dark-text-color;
}
&__icon__overlay {
@@ -450,7 +454,7 @@
}
&.negative {
- background: $error-red;
+ background: lighten($error-red, 12%);
}
&.neutral {
@@ -461,17 +465,17 @@
a,
.username,
.target {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
text-decoration: none;
font-weight: 500;
}
.diff-old {
- color: $error-red;
+ color: lighten($error-red, 12%);
}
.diff-neutral {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
.diff-new {
@@ -479,6 +483,31 @@
}
}
+a.name-tag,
+.name-tag,
+a.inline-name-tag,
+.inline-name-tag {
+ text-decoration: none;
+ color: $secondary-text-color;
+
+ .username {
+ font-weight: 500;
+ }
+
+ &.suspended {
+ .username {
+ text-decoration: line-through;
+ color: lighten($error-red, 12%);
+ }
+
+ .avatar {
+ filter: grayscale(100%);
+ opacity: 0.8;
+ }
+ }
+}
+
+a.name-tag,
.name-tag {
display: flex;
align-items: center;
@@ -490,7 +519,46 @@
border-radius: 50%;
}
- .username {
- font-weight: 500;
+ &.suspended {
+ .avatar {
+ filter: grayscale(100%);
+ opacity: 0.8;
+ }
+ }
+}
+
+.speech-bubble {
+ margin-bottom: 20px;
+ border-left: 4px solid $ui-highlight-color;
+
+ &.positive {
+ border-left-color: $success-green;
+ }
+
+ &.negative {
+ border-left-color: lighten($error-red, 12%);
+ }
+
+ &__bubble {
+ padding: 16px;
+ padding-left: 14px;
+ font-size: 15px;
+ line-height: 20px;
+ border-radius: 4px 4px 4px 0;
+ position: relative;
+ font-weight: 500;
+
+ a {
+ color: $darker-text-color;
+ }
+ }
+
+ &__owner {
+ padding: 8px;
+ padding-left: 12px;
+ }
+
+ time {
+ color: $dark-text-color;
}
}
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index bec0d4d91..c52e069be 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -75,7 +75,7 @@ body {
&.error {
position: absolute;
text-align: center;
- color: $ui-primary-color;
+ color: $darker-text-color;
background: $ui-base-color;
width: 100%;
height: 100%;
diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss
index 31053decc..8e11cb596 100644
--- a/app/javascript/styles/mastodon/boost.scss
+++ b/app/javascript/styles/mastodon/boost.scss
@@ -6,13 +6,13 @@
}
button.icon-button i.fa-retweet {
- background-image: url("data:image/svg+xml;utf8, ");
+ background-image: url("data:image/svg+xml;utf8, ");
&:hover {
- background-image: url("data:image/svg+xml;utf8, ");
+ background-image: url("data:image/svg+xml;utf8, ");
}
}
button.icon-button.disabled i.fa-retweet {
- background-image: url("data:image/svg+xml;utf8, ");
+ background-image: url("data:image/svg+xml;utf8, ");
}
diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss
index 90d98cc8c..4980ab5f1 100644
--- a/app/javascript/styles/mastodon/compact_header.scss
+++ b/app/javascript/styles/mastodon/compact_header.scss
@@ -2,7 +2,7 @@
h1 {
font-size: 24px;
line-height: 28px;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-weight: 500;
margin-bottom: 20px;
padding: 0 10px;
@@ -20,7 +20,7 @@
small {
font-weight: 400;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
img {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 1fb1fa851..cb790ac05 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -4,7 +4,7 @@
}
.button {
- background-color: darken($ui-highlight-color, 3%);
+ background-color: $ui-highlight-color;
border: 10px none;
border-radius: 4px;
box-sizing: border-box;
@@ -31,7 +31,7 @@
&:active,
&:focus,
&:hover {
- background-color: lighten($ui-highlight-color, 7%);
+ background-color: lighten($ui-highlight-color, 10%);
transition: all 200ms ease-out;
}
@@ -40,6 +40,16 @@
cursor: default;
}
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
&.button-primary,
&.button-alternative,
&.button-secondary,
@@ -52,7 +62,7 @@
}
&.button-alternative {
- color: $ui-base-color;
+ color: $inverted-text-color;
background: $ui-primary-color;
&:active,
@@ -73,7 +83,7 @@
}
&.button-secondary {
- color: $ui-primary-color;
+ color: $darker-text-color;
background: transparent;
padding: 3px 15px;
border: 1px solid $ui-primary-color;
@@ -82,7 +92,7 @@
&:focus,
&:hover {
border-color: lighten($ui-primary-color, 4%);
- color: lighten($ui-primary-color, 4%);
+ color: lighten($darker-text-color, 4%);
}
}
@@ -98,26 +108,10 @@
position: relative;
}
-.column-icon {
- background: lighten($ui-base-color, 4%);
- color: $ui-primary-color;
- cursor: pointer;
- font-size: 16px;
- padding: 15px;
- position: absolute;
- right: 0;
- top: -48px;
- z-index: 3;
-
- &:hover {
- color: lighten($ui-primary-color, 7%);
- }
-}
-
.icon-button {
display: inline-block;
padding: 0;
- color: $ui-base-lighter-color;
+ color: $action-button-color;
border: none;
background: transparent;
cursor: pointer;
@@ -126,17 +120,17 @@
&:hover,
&:active,
&:focus {
- color: lighten($ui-base-color, 33%);
+ color: lighten($action-button-color, 7%);
transition: color 200ms ease-out;
}
&.disabled {
- color: lighten($ui-base-color, 13%);
+ color: darken($action-button-color, 13%);
cursor: default;
}
&.active {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
&::-moz-focus-inner {
@@ -150,23 +144,23 @@
}
&.inverted {
- color: lighten($ui-base-color, 33%);
+ color: $lighter-text-color;
&:hover,
&:active,
&:focus {
- color: $ui-base-lighter-color;
+ color: darken($lighter-text-color, 7%);
}
&.disabled {
- color: $ui-primary-color;
+ color: lighten($lighter-text-color, 7%);
}
&.active {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
&.disabled {
- color: lighten($ui-highlight-color, 13%);
+ color: lighten($highlight-text-color, 13%);
}
}
}
@@ -185,7 +179,7 @@
}
.text-icon-button {
- color: lighten($ui-base-color, 33%);
+ color: $lighter-text-color;
border: none;
background: transparent;
cursor: pointer;
@@ -199,17 +193,17 @@
&:hover,
&:active,
&:focus {
- color: $ui-base-lighter-color;
+ color: darken($lighter-text-color, 7%);
transition: color 200ms ease-out;
}
&.disabled {
- color: lighten($ui-base-color, 13%);
+ color: lighten($lighter-text-color, 20%);
cursor: default;
}
&.active {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
&::-moz-focus-inner {
@@ -228,25 +222,6 @@
transform-origin: 50% 0;
}
-.dropdown--active .icon-button {
- color: $ui-highlight-color;
-}
-
-.dropdown--active::after {
- @media screen and (min-width: 631px) {
- content: "";
- display: block;
- position: absolute;
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 0 4.5px 7.8px;
- border-color: transparent transparent $ui-secondary-color;
- bottom: 8px;
- right: 104px;
- }
-}
-
.invisible {
font-size: 0;
line-height: 0;
@@ -271,16 +246,12 @@
}
}
-.lightbox .icon-button {
- color: $ui-base-color;
-}
-
.compose-form {
padding: 10px;
.compose-form__warning {
- color: darken($ui-secondary-color, 65%);
- margin-bottom: 15px;
+ color: $inverted-text-color;
+ margin-bottom: 10px;
background: $ui-primary-color;
box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
padding: 8px 10px;
@@ -289,7 +260,7 @@
font-weight: 400;
strong {
- color: darken($ui-secondary-color, 65%);
+ color: $inverted-text-color;
font-weight: 500;
@each $lang in $cjk-langs {
@@ -300,7 +271,7 @@
}
a {
- color: darken($ui-primary-color, 33%);
+ color: $lighter-text-color;
font-weight: 500;
text-decoration: underline;
@@ -327,13 +298,26 @@
position: relative;
}
+ .spoiler-input {
+ height: 0;
+ transform-origin: bottom;
+ opacity: 0.0;
+ transition: all 0.4s ease;
+
+ &.spoiler-input--visible {
+ height: 47px;
+ opacity: 1.0;
+ transition: all 0.4s ease;
+ }
+ }
+
.autosuggest-textarea__textarea,
.spoiler-input__input {
display: block;
box-sizing: border-box;
width: 100%;
margin: 0;
- color: $ui-base-color;
+ color: $inverted-text-color;
background: $simple-background-color;
padding: 10px;
font-family: inherit;
@@ -378,7 +362,7 @@
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
background: $ui-secondary-color;
border-radius: 0 0 4px 4px;
- color: $ui-base-color;
+ color: $inverted-text-color;
font-size: 14px;
padding: 6px;
@@ -419,11 +403,11 @@
}
.autosuggest-account .display-name__account {
- color: lighten($ui-base-color, 36%);
+ color: $lighter-text-color;
}
.compose-form__modifiers {
- color: $ui-base-color;
+ color: $inverted-text-color;
font-family: inherit;
font-size: 14px;
background: $simple-background-color;
@@ -454,7 +438,7 @@
.icon-button {
flex: 0 1 auto;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-size: 14px;
font-weight: 500;
padding: 10px;
@@ -463,7 +447,7 @@
&:hover,
&:focus,
&:active {
- color: lighten($ui-secondary-color, 4%);
+ color: lighten($secondary-text-color, 7%);
}
}
@@ -486,7 +470,7 @@
input {
background: transparent;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
border: 0;
padding: 0;
margin: 0;
@@ -500,8 +484,8 @@
}
&::placeholder {
- opacity: 0.54;
- color: $ui-secondary-color;
+ opacity: 0.75;
+ color: $secondary-text-color;
}
}
@@ -563,7 +547,7 @@
font-family: 'mastodon-font-sans-serif', sans-serif;
font-size: 14px;
font-weight: 600;
- color: lighten($ui-base-color, 12%);
+ color: $lighter-text-color;
&.character-counter--over {
color: $warning-red;
@@ -585,7 +569,6 @@
}
.emojione {
- display: inline-block;
font-size: inherit;
vertical-align: middle;
object-fit: contain;
@@ -599,9 +582,8 @@
}
.reply-indicator {
- border-radius: 4px 4px 0 0;
- position: relative;
- bottom: -2px;
+ border-radius: 4px;
+ margin-bottom: 10px;
background: $ui-primary-color;
padding: 10px;
}
@@ -617,7 +599,7 @@
}
.reply-indicator__display-name {
- color: $ui-base-color;
+ color: $inverted-text-color;
display: block;
max-width: 100%;
line-height: 24px;
@@ -672,14 +654,14 @@
}
a {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
.fa {
- color: lighten($ui-base-color, 40%);
+ color: lighten($dark-text-color, 7%);
}
}
@@ -694,17 +676,27 @@
}
.fa {
- color: lighten($ui-base-color, 30%);
+ color: $dark-text-color;
}
}
.status__content__spoiler-link {
- background: lighten($ui-base-color, 30%);
+ background: $action-button-color;
&:hover {
- background: lighten($ui-base-color, 33%);
+ background: lighten($action-button-color, 7%);
text-decoration: none;
}
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
}
.status__content__text {
@@ -721,7 +713,7 @@
border-radius: 2px;
background: transparent;
border: 0;
- color: lighten($ui-base-color, 8%);
+ color: $inverted-text-color;
font-weight: 700;
font-size: 11px;
padding: 0 6px;
@@ -784,36 +776,32 @@
&.status-direct {
background: lighten($ui-base-color, 8%);
-
- .icon-button.disabled {
- color: lighten($ui-base-color, 16%);
- }
}
&.light {
.status__relative-time {
- color: $ui-primary-color;
+ color: $light-text-color;
}
.status__display-name {
- color: $ui-base-color;
+ color: $inverted-text-color;
}
.display-name {
strong {
- color: $ui-base-color;
+ color: $inverted-text-color;
}
span {
- color: $ui-primary-color;
+ color: $light-text-color;
}
}
.status__content {
- color: $ui-base-color;
+ color: $inverted-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
a.status__content__spoiler-link {
@@ -833,19 +821,19 @@
background: transparent;
.icon-button.disabled {
- color: lighten($ui-base-color, 13%);
+ color: lighten($action-button-color, 13%);
}
}
}
.status__relative-time {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
float: right;
font-size: 14px;
}
.status__display-name {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
}
.status__info .status__display-name {
@@ -896,14 +884,14 @@
.status__prepend {
margin-left: 68px;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
padding: 8px 0;
padding-bottom: 2px;
font-size: 14px;
position: relative;
.status__display-name strong {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
}
> span {
@@ -965,7 +953,7 @@
.detailed-status__meta {
margin-top: 15px;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
font-size: 14px;
line-height: 18px;
}
@@ -993,22 +981,55 @@
}
.reply-indicator__content {
- color: $ui-base-color;
+ color: $inverted-text-color;
font-size: 14px;
a {
- color: lighten($ui-base-color, 20%);
+ color: $lighter-text-color;
}
}
+.domain {
+ padding: 10px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ .domain__domain-name {
+ flex: 1 1 auto;
+ display: block;
+ color: $primary-text-color;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 500;
+ }
+}
+
+.domain__wrapper {
+ display: flex;
+}
+
+.domain_buttons {
+ height: 18px;
+ padding: 10px;
+ white-space: nowrap;
+}
+
.account {
padding: 10px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
+ &.compact {
+ padding: 0;
+ border-bottom: 0;
+
+ .account__avatar-wrapper {
+ margin-left: 0;
+ }
+ }
+
.account__display-name {
flex: 1 1 auto;
display: block;
- color: $ui-primary-color;
+ color: $darker-text-color;
overflow: hidden;
text-decoration: none;
font-size: 14px;
@@ -1028,7 +1049,6 @@
.account__avatar {
@include avatar-radius();
position: relative;
- cursor: pointer;
&-inline {
display: inline-block;
@@ -1037,6 +1057,10 @@
}
}
+a .account__avatar {
+ cursor: pointer;
+}
+
.account__avatar-overlay {
@include avatar-size(48px);
@@ -1078,7 +1102,7 @@
}
.account__header__username {
- color: $ui-primary-color;
+ color: $secondary-text-color;
}
}
@@ -1088,7 +1112,7 @@
}
.account__header__content {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
.account__header__display-name {
@@ -1103,7 +1127,7 @@
}
.account__header__username {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
font-size: 14px;
font-weight: 400;
display: block;
@@ -1116,7 +1140,7 @@
.account__disclaimer {
padding: 10px;
border-top: 1px solid lighten($ui-base-color, 8%);
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
strong {
font-weight: 500;
@@ -1142,7 +1166,7 @@
}
.account__header__content {
- color: $ui-primary-color;
+ color: $darker-text-color;
font-size: 14px;
font-weight: 400;
overflow: hidden;
@@ -1219,7 +1243,7 @@
display: block;
text-transform: uppercase;
font-size: 11px;
- color: $ui-primary-color;
+ color: $darker-text-color;
}
strong {
@@ -1234,10 +1258,6 @@
}
}
}
-
- abbr {
- color: $ui-base-lighter-color;
- }
}
.account__header__avatar {
@@ -1289,7 +1309,7 @@
.status__display-name,
.reply-indicator__display-name,
.detailed-status__display-name,
-.account__display-name {
+a.account__display-name {
&:hover strong {
text-decoration: underline;
}
@@ -1307,7 +1327,7 @@
}
.detailed-status__display-name {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
display: block;
line-height: 24px;
margin-bottom: 15px;
@@ -1342,11 +1362,11 @@
.muted {
.status__content p,
.status__content a {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
}
.status__display-name strong {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
}
.status__avatar {
@@ -1355,26 +1375,25 @@
a.status__content__spoiler-link {
background: $ui-base-lighter-color;
- color: lighten($ui-base-color, 4%);
+ color: $inverted-text-color;
&:hover {
- background: lighten($ui-base-color, 29%);
+ background: lighten($ui-base-lighter-color, 7%);
text-decoration: none;
}
}
}
.notification__message {
- margin-left: 68px;
- padding: 8px 0;
- padding-bottom: 0;
+ margin: 0 10px 0 68px;
+ padding: 8px 0 0;
cursor: default;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-size: 15px;
position: relative;
.fa {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
> span {
@@ -1477,10 +1496,10 @@
display: flex;
flex-shrink: 0;
cursor: default;
- color: $ui-primary-color;
+ color: $darker-text-color;
strong {
- color: $primary-text-color;
+ color: $secondary-text-color;
}
a {
@@ -1585,7 +1604,7 @@
box-sizing: border-box;
text-decoration: none;
background: $ui-secondary-color;
- color: $ui-base-color;
+ color: $inverted-text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1594,7 +1613,7 @@
&:hover,
&:active {
background: $ui-highlight-color;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
outline: 0;
}
}
@@ -1636,7 +1655,7 @@
box-sizing: border-box;
text-decoration: none;
background: $ui-secondary-color;
- color: $ui-base-color;
+ color: $inverted-text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1647,7 +1666,7 @@
&:hover {
background: $ui-highlight-color;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
}
}
}
@@ -1656,24 +1675,6 @@
vertical-align: middle;
}
-.static-content {
- padding: 10px;
- padding-top: 20px;
- color: $ui-base-lighter-color;
-
- h1 {
- font-size: 16px;
- font-weight: 500;
- margin-bottom: 40px;
- text-align: center;
- }
-
- p {
- font-size: 13px;
- margin-bottom: 20px;
- }
-}
-
.columns-area {
display: flex;
flex: 1 1 auto;
@@ -1746,7 +1747,7 @@
display: block;
flex: 1 1 auto;
padding: 15px 5px 13px;
- color: $ui-primary-color;
+ color: $darker-text-color;
text-decoration: none;
text-align: center;
font-size: 16px;
@@ -1765,6 +1766,8 @@
margin-bottom: 0;
}
+ .getting-started__wrapper,
+ .getting-started__trends,
.search {
margin-bottom: 10px;
}
@@ -1911,8 +1914,8 @@
}
&.active {
- border-bottom: 2px solid $ui-highlight-color;
- color: $ui-highlight-color;
+ border-bottom: 2px solid $highlight-text-color;
+ color: $highlight-text-color;
}
&:hover,
@@ -1967,7 +1970,7 @@
.column-back-button {
background: lighten($ui-base-color, 4%);
- color: $ui-highlight-color;
+ color: $highlight-text-color;
cursor: pointer;
flex: 0 0 auto;
font-size: 16px;
@@ -1976,6 +1979,7 @@
padding: 15px;
margin: 0;
z-index: 3;
+ outline: 0;
&:hover {
text-decoration: underline;
@@ -1986,7 +1990,7 @@
background: lighten($ui-base-color, 4%);
border: 0;
font-family: inherit;
- color: $ui-highlight-color;
+ color: $highlight-text-color;
cursor: pointer;
white-space: nowrap;
font-size: 16px;
@@ -2158,7 +2162,7 @@
.column-subheading {
background: $ui-base-color;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
padding: 8px 20px;
font-size: 12px;
font-weight: 500;
@@ -2167,25 +2171,82 @@
}
.getting-started__wrapper,
-.getting_started {
+.getting-started,
+.flex-spacer {
background: $ui-base-color;
}
.getting-started__wrapper {
- position: relative;
- overflow-y: auto;
+ flex: 0 0 auto;
+}
+
+.flex-spacer {
+ flex: 1 1 auto;
}
.getting-started {
- background: $ui-base-color;
- flex: 1 0 auto;
+ color: $dark-text-color;
p {
- color: $ui-secondary-color;
+ color: $dark-text-color;
+ font-size: 13px;
+ margin-bottom: 20px;
+
+ a {
+ color: $dark-text-color;
+ text-decoration: underline;
+ }
}
a {
- color: $ui-base-lighter-color;
+ text-decoration: none;
+ color: $darker-text-color;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
+ }
+
+ &__footer {
+ flex: 0 0 auto;
+ padding: 10px;
+ padding-top: 20px;
+
+ ul {
+ margin-bottom: 10px;
+ }
+
+ ul li {
+ display: inline;
+ }
+ }
+
+ &__trends {
+ background: $ui-base-color;
+ flex: 0 1 auto;
+
+ @media screen and (max-height: 810px) {
+ .trends__item:nth-child(3) {
+ display: none;
+ }
+ }
+
+ @media screen and (max-height: 720px) {
+ .trends__item:nth-child(2) {
+ display: none;
+ }
+ }
+
+ @media screen and (max-height: 670px) {
+ display: none;
+ }
+ }
+
+ &__scrollable {
+ max-height: 100%;
+ overflow-y: auto;
}
}
@@ -2211,7 +2272,7 @@
}
.setting-text {
- color: $ui-primary-color;
+ color: $darker-text-color;
background: transparent;
border: none;
border-bottom: 2px solid $ui-primary-color;
@@ -2225,23 +2286,12 @@
&:focus,
&:active {
color: $primary-text-color;
- border-bottom-color: $ui-highlight-color;
+ border-bottom-color: $highlight-text-color;
}
@media screen and (max-width: 600px) {
font-size: 16px;
}
-
- &.light {
- color: $ui-base-color;
- border-bottom: 2px solid lighten($ui-base-color, 27%);
-
- &:focus,
- &:active {
- color: $ui-base-color;
- border-bottom-color: $ui-highlight-color;
- }
- }
}
.no-reduce-motion button.icon-button i.fa-retweet {
@@ -2264,12 +2314,12 @@
}
.reduce-motion button.icon-button i.fa-retweet {
- color: $ui-base-lighter-color;
+ color: $action-button-color;
transition: color 100ms ease-in;
}
.reduce-motion button.icon-button.active i.fa-retweet {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
.status-card {
@@ -2277,7 +2327,7 @@
font-size: 14px;
border: 1px solid lighten($ui-base-color, 8%);
border-radius: 4px;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
margin-top: 14px;
text-decoration: none;
overflow: hidden;
@@ -2357,7 +2407,7 @@ a.status-card {
display: block;
font-weight: 500;
margin-bottom: 5px;
- color: $ui-primary-color;
+ color: $darker-text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2371,7 +2421,7 @@ a.status-card {
}
.status-card__description {
- color: $ui-primary-color;
+ color: $darker-text-color;
}
.status-card__host {
@@ -2415,7 +2465,7 @@ a.status-card {
.load-more {
display: block;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
background-color: transparent;
border: 0;
font-size: inherit;
@@ -2423,19 +2473,25 @@ a.status-card {
line-height: inherit;
margin: 0;
padding: 15px;
+ box-sizing: border-box;
width: 100%;
clear: both;
+ text-decoration: none;
&:hover {
background: lighten($ui-base-color, 2%);
}
}
+.load-gap {
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
.regeneration-indicator {
text-align: center;
font-size: 16px;
font-weight: 500;
- color: lighten($ui-base-color, 16%);
+ color: $dark-text-color;
background: $ui-base-color;
cursor: default;
display: flex;
@@ -2475,7 +2531,7 @@ a.status-card {
strong {
display: block;
margin-bottom: 10px;
- color: lighten($ui-base-color, 34%);
+ color: $dark-text-color;
}
span {
@@ -2533,15 +2589,15 @@ a.status-card {
}
& > .column-header__back-button {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
&.active {
- box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);
+ box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);
.column-header__icon {
- color: $ui-highlight-color;
- text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);
+ color: $highlight-text-color;
+ text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);
}
}
@@ -2563,13 +2619,13 @@ a.status-card {
.column-header__button {
background: lighten($ui-base-color, 4%);
border: 0;
- color: $ui-primary-color;
+ color: $darker-text-color;
cursor: pointer;
font-size: 16px;
padding: 0 15px;
&:hover {
- color: lighten($ui-primary-color, 7%);
+ color: lighten($darker-text-color, 7%);
}
&.active {
@@ -2587,7 +2643,7 @@ a.status-card {
max-height: 70vh;
overflow: hidden;
overflow-y: auto;
- color: $ui-primary-color;
+ color: $darker-text-color;
transition: max-height 150ms ease-in-out, opacity 300ms linear;
opacity: 1;
@@ -2616,7 +2672,7 @@ a.status-card {
.column-header__setting-btn {
&:hover {
- color: lighten($ui-primary-color, 4%);
+ color: $darker-text-color;
text-decoration: underline;
}
}
@@ -2650,7 +2706,7 @@ a.status-card {
}
.loading-indicator {
- color: lighten($ui-base-color, 26%);
+ color: $dark-text-color;
font-size: 12px;
font-weight: 400;
text-transform: uppercase;
@@ -2735,7 +2791,7 @@ a.status-card {
.media-spoiler {
background: $base-overlay-background;
- color: $ui-primary-color;
+ color: $darker-text-color;
border: 0;
padding: 0;
width: 100%;
@@ -2747,7 +2803,7 @@ a.status-card {
&:active,
&:focus {
padding: 0;
- color: lighten($ui-primary-color, 8%);
+ color: lighten($darker-text-color, 8%);
}
}
@@ -2800,7 +2856,7 @@ a.status-card {
}
.column-settings__section {
- color: $ui-primary-color;
+ color: $darker-text-color;
cursor: default;
display: block;
font-weight: 500;
@@ -2858,7 +2914,7 @@ a.status-card {
.setting-toggle__label,
.setting-meta__label {
- color: $ui-primary-color;
+ color: $darker-text-color;
display: inline-block;
margin-bottom: 14px;
margin-left: 8px;
@@ -2866,13 +2922,12 @@ a.status-card {
}
.setting-meta__label {
- color: $ui-primary-color;
float: right;
}
.empty-column-indicator,
.error-column {
- color: lighten($ui-base-color, 20%);
+ color: $dark-text-color;
background: $ui-base-color;
text-align: center;
padding: 20px;
@@ -2889,7 +2944,7 @@ a.status-card {
}
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: none;
&:hover {
@@ -3074,7 +3129,7 @@ a.status-card {
display: flex;
align-items: center;
justify-content: center;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-size: 18px;
font-weight: 500;
border: 2px dashed $ui-base-lighter-color;
@@ -3083,7 +3138,7 @@ a.status-card {
.upload-progress {
padding: 10px;
- color: $ui-base-lighter-color;
+ color: $lighter-text-color;
overflow: hidden;
display: flex;
@@ -3172,7 +3227,7 @@ a.status-card {
}
.privacy-dropdown__option {
- color: $ui-base-color;
+ color: $inverted-text-color;
padding: 10px;
cursor: pointer;
display: flex;
@@ -3181,6 +3236,7 @@ a.status-card {
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
+ outline: 0;
.privacy-dropdown__option__content {
color: $primary-text-color;
@@ -3205,12 +3261,12 @@ a.status-card {
.privacy-dropdown__option__content {
flex: 1 1 auto;
- color: darken($ui-primary-color, 24%);
+ color: $lighter-text-color;
strong {
font-weight: 500;
display: block;
- color: $ui-base-color;
+ color: $inverted-text-color;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@@ -3259,7 +3315,7 @@ a.status-card {
padding-right: 30px;
font-family: inherit;
background: $ui-base-color;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-size: 14px;
margin: 0;
@@ -3283,6 +3339,15 @@ a.status-card {
}
.search__icon {
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus {
+ outline: 0 !important;
+ }
+
.fa {
position: absolute;
top: 10px;
@@ -3294,7 +3359,7 @@ a.status-card {
font-size: 18px;
width: 18px;
height: 18px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
cursor: default;
pointer-events: none;
@@ -3316,6 +3381,7 @@ a.status-card {
.fa-times-circle {
top: 11px;
transform: rotate(0deg);
+ color: $action-button-color;
cursor: pointer;
&.active {
@@ -3323,48 +3389,41 @@ a.status-card {
}
&:hover {
- color: $primary-text-color;
+ color: lighten($action-button-color, 7%);
}
}
}
.search-results__header {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
background: lighten($ui-base-color, 2%);
- border-bottom: 1px solid darken($ui-base-color, 4%);
- padding: 15px 10px;
- font-size: 14px;
+ padding: 15px;
font-weight: 500;
+ font-size: 16px;
+ cursor: default;
+
+ .fa {
+ display: inline-block;
+ margin-right: 5px;
+ }
}
.search-results__section {
- margin-bottom: 20px;
+ margin-bottom: 5px;
h5 {
- position: relative;
+ background: darken($ui-base-color, 4%);
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ cursor: default;
+ display: flex;
+ padding: 15px;
+ font-weight: 500;
+ font-size: 16px;
+ color: $dark-text-color;
- &::before {
- content: "";
- display: block;
- position: absolute;
- left: 0;
- right: 0;
- top: 50%;
- width: 100%;
- height: 0;
- border-top: 1px solid lighten($ui-base-color, 8%);
- }
-
- span {
+ .fa {
display: inline-block;
- background: $ui-base-color;
- color: $ui-primary-color;
- font-size: 14px;
- font-weight: 500;
- padding: 10px;
- position: relative;
- z-index: 1;
- cursor: default;
+ margin-right: 5px;
}
}
@@ -3377,13 +3436,13 @@ a.status-card {
.search-results__hashtag {
display: block;
padding: 10px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
text-decoration: none;
&:hover,
&:active,
&:focus {
- color: lighten($ui-secondary-color, 4%);
+ color: lighten($secondary-text-color, 4%);
text-decoration: underline;
}
}
@@ -3436,6 +3495,19 @@ a.status-card {
width: 100%;
height: 100%;
position: relative;
+
+ .extended-video-player {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ video {
+ max-width: $media-modal-media-max-width;
+ max-height: $media-modal-media-max-height;
+ }
+ }
}
.media-modal__closer {
@@ -3508,7 +3580,7 @@ a.status-card {
}
.media-modal__button {
- background-color: $white;
+ background-color: $primary-text-color;
height: 12px;
width: 12px;
border-radius: 6px;
@@ -3519,7 +3591,7 @@ a.status-card {
}
.media-modal__button--active {
- background-color: $ui-highlight-color;
+ background-color: $highlight-text-color;
}
.media-modal__close {
@@ -3533,7 +3605,7 @@ a.status-card {
.error-modal,
.embed-modal {
background: $ui-secondary-color;
- color: $ui-base-color;
+ color: $inverted-text-color;
border-radius: 8px;
overflow: hidden;
display: flex;
@@ -3621,7 +3693,7 @@ a.status-card {
.onboarding-modal__nav,
.error-modal__nav {
- color: darken($ui-secondary-color, 34%);
+ color: $lighter-text-color;
border: 0;
font-size: 14px;
font-weight: 500;
@@ -3635,18 +3707,18 @@ a.status-card {
&:hover,
&:focus,
&:active {
- color: darken($ui-secondary-color, 38%);
+ color: darken($lighter-text-color, 4%);
background-color: darken($ui-secondary-color, 16%);
}
&.onboarding-modal__done,
&.onboarding-modal__next {
- color: $ui-base-color;
+ color: $inverted-text-color;
&:hover,
&:focus,
&:active {
- color: darken($ui-base-color, 4%);
+ color: lighten($inverted-text-color, 4%);
}
}
}
@@ -3698,17 +3770,17 @@ a.status-card {
h1 {
font-size: 18px;
font-weight: 500;
- color: $ui-base-color;
+ color: $inverted-text-color;
margin-bottom: 20px;
}
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
&:hover,
&:focus,
&:active {
- color: lighten($ui-highlight-color, 4%);
+ color: lighten($highlight-text-color, 4%);
}
}
@@ -3718,7 +3790,7 @@ a.status-card {
p {
font-size: 16px;
- color: lighten($ui-base-color, 8%);
+ color: $lighter-text-color;
margin-top: 10px;
margin-bottom: 10px;
@@ -3729,7 +3801,7 @@ a.status-card {
strong {
font-weight: 500;
background: $ui-base-color;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
border-radius: 4px;
font-size: 14px;
padding: 3px 6px;
@@ -3781,7 +3853,7 @@ a.status-card {
&__label {
font-weight: 500;
- color: $ui-base-color;
+ color: $inverted-text-color;
margin-bottom: 5px;
text-transform: uppercase;
font-size: 12px;
@@ -3789,7 +3861,7 @@ a.status-card {
&__case {
background: $ui-base-color;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-weight: 500;
padding: 10px;
border-radius: 4px;
@@ -3806,7 +3878,7 @@ a.status-card {
.figure {
background: darken($ui-base-color, 8%);
- color: $ui-secondary-color;
+ color: $secondary-text-color;
margin-bottom: 20px;
border-radius: 4px;
padding: 10px;
@@ -3895,7 +3967,7 @@ a.status-card {
.actions-modal,
.mute-modal {
background: lighten($ui-secondary-color, 8%);
- color: $ui-base-color;
+ color: $inverted-text-color;
border-radius: 8px;
overflow: hidden;
max-width: 90vw;
@@ -3916,6 +3988,10 @@ a.status-card {
top: 10px;
width: 48px;
}
+
+ .status__content__spoiler-link {
+ color: lighten($secondary-text-color, 8%);
+ }
}
.actions-modal {
@@ -3953,7 +4029,7 @@ a.status-card {
& > div {
flex: 1 1 auto;
text-align: right;
- color: lighten($ui-base-color, 33%);
+ color: $lighter-text-color;
padding-right: 10px;
}
@@ -4015,10 +4091,14 @@ a.status-card {
.report-modal__statuses {
flex: 1 1 auto;
min-height: 20vh;
- max-height: 40vh;
+ max-height: 80vh;
overflow-y: auto;
overflow-x: hidden;
+ .status__content a {
+ color: $highlight-text-color;
+ }
+
@media screen and (max-width: 480px) {
max-height: 10vh;
}
@@ -4040,7 +4120,7 @@ a.status-card {
box-sizing: border-box;
width: 100%;
margin: 0;
- color: $ui-base-color;
+ color: $inverted-text-color;
background: $white;
padding: 10px;
font-family: inherit;
@@ -4062,7 +4142,7 @@ a.status-card {
margin-bottom: 24px;
&__label {
- color: $ui-base-color;
+ color: $inverted-text-color;
font-size: 14px;
}
}
@@ -4101,7 +4181,7 @@ a.status-card {
li:not(:empty) {
a {
- color: $ui-base-color;
+ color: $inverted-text-color;
display: flex;
padding: 12px 16px;
font-size: 15px;
@@ -4137,14 +4217,14 @@ a.status-card {
.confirmation-modal__cancel-button,
.mute-modal__cancel-button {
background-color: transparent;
- color: darken($ui-secondary-color, 34%);
+ color: $lighter-text-color;
font-size: 14px;
font-weight: 500;
&:hover,
&:focus,
&:active {
- color: darken($ui-secondary-color, 38%);
+ color: darken($lighter-text-color, 4%);
}
}
}
@@ -4177,7 +4257,7 @@ a.status-card {
}
.loading-bar {
- background-color: $ui-highlight-color;
+ background-color: $highlight-text-color;
height: 3px;
position: absolute;
top: 0;
@@ -4225,7 +4305,7 @@ a.status-card {
&__icon {
flex: 0 0 auto;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
padding: 8px 18px;
cursor: default;
border-right: 1px solid lighten($ui-base-color, 8%);
@@ -4255,7 +4335,7 @@ a.status-card {
a {
text-decoration: none;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
font-weight: 500;
&:hover {
@@ -4274,7 +4354,7 @@ a.status-card {
}
.fa {
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
}
}
}
@@ -4310,7 +4390,7 @@ a.status-card {
cursor: zoom-in;
display: block;
text-decoration: none;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
line-height: 0;
&,
@@ -4410,9 +4490,13 @@ a.status-card {
max-width: 100%;
border-radius: 4px;
+ &:focus {
+ outline: 0;
+ }
+
video {
- height: 100%;
- width: 100%;
+ max-width: 100vw;
+ max-height: 80vh;
z-index: 1;
}
@@ -4424,6 +4508,8 @@ a.status-card {
video {
max-width: 100% !important;
max-height: 100% !important;
+ width: 100% !important;
+ height: 100% !important;
}
}
@@ -4469,8 +4555,8 @@ a.status-card {
height: 100%;
z-index: 4;
border: 0;
- background: $base-shadow-color;
- color: $ui-primary-color;
+ background: $base-overlay-background;
+ color: $darker-text-color;
transition: none;
pointer-events: none;
@@ -4481,7 +4567,7 @@ a.status-card {
&:hover,
&:active,
&:focus {
- color: lighten($ui-primary-color, 8%);
+ color: lighten($darker-text-color, 7%);
}
}
@@ -4678,7 +4764,7 @@ a.status-card {
background-size: cover;
background-position: center;
position: absolute;
- color: $ui-primary-color;
+ color: $darker-text-color;
text-decoration: none;
border-radius: 4px;
@@ -4686,7 +4772,7 @@ a.status-card {
&:active,
&:focus {
outline: 0;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
&::before {
content: "";
@@ -4708,6 +4794,8 @@ a.status-card {
}
}
+.community-timeline__section-headline,
+.public-timeline__section-headline,
.account__section-headline {
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -4717,7 +4805,7 @@ a.status-card {
a {
display: block;
flex: 1 1 auto;
- color: $ui-primary-color;
+ color: $darker-text-color;
padding: 15px 0;
font-size: 14px;
font-weight: 500;
@@ -4726,7 +4814,7 @@ a.status-card {
position: relative;
&.active {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
&::before,
&::after {
@@ -4761,12 +4849,12 @@ a.status-card {
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
- color: $ui-primary-color;
+ color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
- color: $ui-primary-color;
+ color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
@@ -4782,7 +4870,7 @@ a.status-card {
em {
font-weight: 500;
- color: $ui-base-color;
+ color: $inverted-text-color;
}
}
@@ -4798,11 +4886,11 @@ noscript {
div {
font-size: 14px;
margin: 30px auto;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
max-width: 400px;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
&:hover {
@@ -4900,7 +4988,6 @@ noscript {
}
.embed-modal__html {
- color: $ui-secondary-color;
outline: 0;
box-sizing: border-box;
display: block;
@@ -4909,7 +4996,7 @@ noscript {
padding: 10px;
font-family: 'mastodon-font-monospace', monospace;
background: $ui-base-color;
- color: $ui-primary-color;
+ color: $primary-text-color;
font-size: 14px;
margin: 0;
margin-bottom: 15px;
@@ -4952,7 +5039,7 @@ noscript {
&__message {
position: relative;
margin-left: 58px;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
padding: 8px 0;
padding-top: 0;
padding-bottom: 4px;
@@ -5135,3 +5222,125 @@ noscript {
background: lighten($ui-highlight-color, 7%);
}
}
+
+.account__header .roles {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ padding: 0 15px;
+}
+
+.account__header .account__header__fields {
+ font-size: 14px;
+ line-height: 20px;
+ overflow: hidden;
+ margin: 20px -10px -20px;
+ border-bottom: 0;
+
+ dl {
+ border-top: 1px solid lighten($ui-base-color, 8%);
+ display: flex;
+ }
+
+ dt,
+ dd {
+ box-sizing: border-box;
+ padding: 14px 20px;
+ text-align: center;
+ max-height: 48px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ dt {
+ color: $darker-text-color;
+ background: darken($ui-base-color, 4%);
+ width: 120px;
+ flex: 0 0 auto;
+ font-weight: 500;
+ }
+
+ dd {
+ flex: 1 1 auto;
+ color: $primary-text-color;
+ background: $ui-base-color;
+ }
+}
+
+.trends {
+ &__header {
+ color: $dark-text-color;
+ background: lighten($ui-base-color, 2%);
+ border-bottom: 1px solid darken($ui-base-color, 4%);
+ font-weight: 500;
+ padding: 15px;
+ font-size: 16px;
+ cursor: default;
+
+ .fa {
+ display: inline-block;
+ margin-right: 5px;
+ }
+ }
+
+ &__item {
+ display: flex;
+ align-items: center;
+ padding: 15px;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &__name {
+ flex: 1 1 auto;
+ color: $dark-text-color;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ strong {
+ font-weight: 500;
+ }
+
+ a {
+ color: $darker-text-color;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 500;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:hover,
+ &:focus,
+ &:active {
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ &__current {
+ flex: 0 0 auto;
+ width: 100px;
+ font-size: 24px;
+ line-height: 36px;
+ font-weight: 500;
+ text-align: center;
+ color: $secondary-text-color;
+ }
+
+ &__sparkline {
+ flex: 0 0 auto;
+ width: 50px;
+
+ path {
+ stroke: lighten($highlight-text-color, 6%) !important;
+ }
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index e761f58eb..ac648c868 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -60,7 +60,7 @@
}
}
-.media-gallery-standalone__body {
+.media-standalone__body {
overflow: hidden;
}
@@ -100,7 +100,7 @@
.name {
flex: 1 1 auto;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
width: calc(100% - 88px);
.username {
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
index 4161cc045..e49084b5f 100644
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -7,7 +7,7 @@
font-size: 13px;
display: inline-block;
- color: $ui-base-color;
+ color: $inverted-text-color;
.emoji-mart-emoji {
padding: 6px;
@@ -36,7 +36,7 @@
display: flex;
justify-content: space-between;
padding: 0 6px;
- color: $ui-primary-color;
+ color: $lighter-text-color;
line-height: 0;
}
@@ -50,29 +50,29 @@
cursor: pointer;
&:hover {
- color: darken($ui-primary-color, 4%);
+ color: darken($lighter-text-color, 4%);
}
}
.emoji-mart-anchor-selected {
- color: darken($ui-highlight-color, 3%);
+ color: $highlight-text-color;
&:hover {
- color: darken($ui-highlight-color, 3%);
+ color: darken($highlight-text-color, 4%);
}
.emoji-mart-anchor-bar {
- bottom: 0;
+ bottom: -1px;
}
}
.emoji-mart-anchor-bar {
position: absolute;
- bottom: -3px;
+ bottom: -5px;
left: 0;
width: 100%;
- height: 3px;
- background-color: darken($ui-highlight-color, 3%);
+ height: 4px;
+ background-color: $highlight-text-color;
}
.emoji-mart-anchors {
@@ -115,7 +115,7 @@
display: block;
width: 100%;
background: rgba($ui-secondary-color, 0.3);
- color: $ui-primary-color;
+ color: $inverted-text-color;
border: 1px solid $ui-secondary-color;
border-radius: 4px;
@@ -184,7 +184,7 @@
font-size: 14px;
text-align: center;
padding-top: 70px;
- color: $ui-primary-color;
+ color: $light-text-color;
.emoji-mart-category-label {
display: none;
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index 2d953b34e..81eb1ce2d 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -1,10 +1,11 @@
.footer {
text-align: center;
margin-top: 30px;
+ padding-bottom: 60px;
font-size: 12px;
- color: darken($ui-secondary-color, 25%);
+ color: $darker-text-color;
- .domain {
+ .footer__domain {
font-weight: 500;
a {
@@ -26,5 +27,13 @@
text-decoration: none;
}
}
+
+ img {
+ margin: 0 4px;
+ position: relative;
+ bottom: -1px;
+ height: 18px;
+ vertical-align: top;
+ }
}
}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index d74c5a4fd..de16784a8 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -15,16 +15,28 @@ code {
overflow: hidden;
}
+ .row {
+ display: flex;
+ margin: 0 -5px;
+
+ .input {
+ box-sizing: border-box;
+ flex: 1 1 auto;
+ width: 50%;
+ padding: 0 5px;
+ }
+ }
+
span.hint {
display: block;
- color: $ui-primary-color;
+ color: $darker-text-color;
font-size: 12px;
margin-top: 4px;
}
p.hint {
margin-bottom: 15px;
- color: $ui-primary-color;
+ color: $darker-text-color;
&.subtle-hint {
text-align: center;
@@ -32,10 +44,10 @@ code {
line-height: 18px;
margin-top: 15px;
margin-bottom: 0;
- color: $ui-primary-color;
+ color: $darker-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
}
}
@@ -75,6 +87,10 @@ code {
align-items: flex-start;
}
+ &.file .label_input {
+ flex-wrap: nowrap;
+ }
+
&.select .label_input {
align-items: initial;
}
@@ -232,7 +248,7 @@ code {
}
&:focus:invalid {
- border-bottom-color: $error-value-color;
+ border-bottom-color: lighten($error-red, 12%);
}
&:required:valid {
@@ -241,26 +257,26 @@ code {
&:active,
&:focus {
- border-bottom-color: $ui-highlight-color;
+ border-bottom-color: $highlight-text-color;
background: rgba($base-overlay-background, 0.1);
}
}
.input.field_with_errors {
label {
- color: $error-value-color;
+ color: lighten($error-red, 12%);
}
input[type=text],
input[type=email],
input[type=password] {
- border-bottom-color: $error-value-color;
+ border-bottom-color: $valid-value-color;
}
.error {
display: block;
font-weight: 500;
- color: $error-value-color;
+ color: lighten($error-red, 12%);
margin-top: 4px;
}
}
@@ -344,7 +360,7 @@ code {
padding: 7px 4px;
padding-bottom: 9px;
font-size: 16px;
- color: $ui-base-lighter-color;
+ color: $dark-text-color;
font-family: inherit;
pointer-events: none;
cursor: default;
@@ -354,7 +370,7 @@ code {
.flash-message {
background: lighten($ui-base-color, 8%);
- color: $ui-primary-color;
+ color: $darker-text-color;
border-radius: 4px;
padding: 15px 10px;
margin-bottom: 30px;
@@ -366,7 +382,6 @@ code {
}
.oauth-code {
- color: $ui-secondary-color;
outline: 0;
box-sizing: border-box;
display: block;
@@ -375,7 +390,7 @@ code {
padding: 10px;
font-family: 'mastodon-font-monospace', monospace;
background: $ui-base-color;
- color: $ui-primary-color;
+ color: $primary-text-color;
font-size: 14px;
margin: 0;
@@ -414,7 +429,7 @@ code {
text-align: center;
a {
- color: $ui-primary-color;
+ color: $darker-text-color;
text-decoration: none;
&:hover {
@@ -427,7 +442,7 @@ code {
.follow-prompt {
margin-bottom: 30px;
text-align: center;
- color: $ui-primary-color;
+ color: $darker-text-color;
h2 {
font-size: 16px;
@@ -435,7 +450,7 @@ code {
}
strong {
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-weight: 500;
@each $lang in $cjk-langs {
@@ -472,7 +487,7 @@ code {
.qr-alternative {
margin-bottom: 20px;
- color: $ui-secondary-color;
+ color: $secondary-text-color;
flex: 150px;
samp {
@@ -557,7 +572,7 @@ code {
.post-follow-actions {
text-align: center;
- color: $ui-primary-color;
+ color: $darker-text-color;
div {
margin-bottom: 4px;
@@ -570,7 +585,7 @@ code {
h4 {
font-size: 16px;
- color: $ui-base-lighter-color;
+ color: $primary-text-color;
text-align: center;
margin-bottom: 20px;
border: 0;
diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss
index ffa1e149d..86614b89b 100644
--- a/app/javascript/styles/mastodon/landing_strip.scss
+++ b/app/javascript/styles/mastodon/landing_strip.scss
@@ -1,7 +1,7 @@
.landing-strip,
.memoriam-strip {
background: rgba(darken($ui-base-color, 7%), 0.8);
- color: $ui-primary-color;
+ color: $darker-text-color;
font-weight: 400;
padding: 14px;
border-radius: 4px;
@@ -45,7 +45,7 @@
padding: 14px;
border-radius: 4px;
background: rgba(darken($ui-base-color, 7%), 0.8);
- color: $ui-secondary-color;
+ color: $secondary-text-color;
font-weight: 400;
margin-bottom: 20px;
@@ -88,7 +88,7 @@
.fa {
margin-right: 5px;
- color: $ui-primary-color;
+ color: $darker-text-color;
}
}
@@ -103,7 +103,7 @@
text-decoration: none;
span {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
font-weight: 400;
}
}
diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss
index cc5ba9d7c..ff3b2c022 100644
--- a/app/javascript/styles/mastodon/reset.scss
+++ b/app/javascript/styles/mastodon/reset.scss
@@ -54,8 +54,8 @@ table {
}
::-webkit-scrollbar {
- width: 8px;
- height: 8px;
+ width: 12px;
+ height: 12px;
}
::-webkit-scrollbar-thumb {
diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss
index 442b143a0..281cbaf83 100644
--- a/app/javascript/styles/mastodon/stream_entries.scss
+++ b/app/javascript/styles/mastodon/stream_entries.scss
@@ -6,7 +6,8 @@
background: $simple-background-color;
.detailed-status.light,
- .status.light {
+ .status.light,
+ .more.light {
border-bottom: 1px solid $ui-secondary-color;
animation: none;
}
@@ -83,7 +84,7 @@
font-size: 14px;
.status__relative-time {
- color: $ui-primary-color;
+ color: $lighter-text-color;
}
}
}
@@ -92,7 +93,7 @@
display: block;
max-width: 100%;
padding-right: 25px;
- color: $ui-base-color;
+ color: $inverted-text-color;
}
.status__avatar {
@@ -122,7 +123,7 @@
strong {
font-weight: 500;
- color: $ui-base-color;
+ color: $inverted-text-color;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@@ -133,15 +134,15 @@
span {
font-size: 14px;
- color: $ui-primary-color;
+ color: $light-text-color;
}
}
.status__content {
- color: $ui-base-color;
+ color: $inverted-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
a.status__content__spoiler-link {
@@ -179,7 +180,7 @@
strong {
font-weight: 500;
- color: $ui-base-color;
+ color: $inverted-text-color;
@each $lang in $cjk-langs {
&:lang(#{$lang}) {
@@ -190,7 +191,7 @@
span {
font-size: 14px;
- color: $ui-primary-color;
+ color: $light-text-color;
}
}
}
@@ -206,10 +207,10 @@
}
.status__content {
- color: $ui-base-color;
+ color: $inverted-text-color;
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
}
a.status__content__spoiler-link {
@@ -224,7 +225,7 @@
.detailed-status__meta {
margin-top: 15px;
- color: $ui-primary-color;
+ color: $light-text-color;
font-size: 14px;
line-height: 18px;
@@ -242,7 +243,7 @@
.status-card {
border-color: lighten($ui-secondary-color, 4%);
- color: darken($ui-primary-color, 4%);
+ color: $lighter-text-color;
&:hover {
background: lighten($ui-secondary-color, 4%);
@@ -251,7 +252,7 @@
.status-card__title,
.status-card__description {
- color: $ui-base-color;
+ color: $inverted-text-color;
}
.status-card__image {
@@ -261,7 +262,7 @@
.media-spoiler {
background: $ui-base-color;
- color: $ui-primary-color;
+ color: $darker-text-color;
}
.pre-header {
@@ -269,7 +270,7 @@
padding-left: (48px + 14px * 2);
padding-bottom: 0;
margin-bottom: -4px;
- color: $ui-primary-color;
+ color: $light-text-color;
font-size: 14px;
position: relative;
@@ -279,7 +280,7 @@
}
.status__display-name.muted strong {
- color: $ui-primary-color;
+ color: $light-text-color;
}
}
@@ -290,6 +291,17 @@
text-decoration: underline;
}
}
+
+ .more {
+ color: $darker-text-color;
+ display: block;
+ padding: 14px;
+ text-align: center;
+
+ &:not(:hover) {
+ text-decoration: none;
+ }
+ }
}
.embed {
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index 92870e679..e54c55947 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -1,3 +1,9 @@
+@keyframes Swag {
+ 0% { background-position: 0% 0%; }
+ 50% { background-position: 100% 0%; }
+ 100% { background-position: 200% 0%; }
+}
+
.table {
width: 100%;
max-width: 100%;
@@ -11,6 +17,7 @@
vertical-align: top;
border-top: 1px solid $ui-base-color;
text-align: left;
+ background: darken($ui-base-color, 4%);
}
& > thead > tr > th {
@@ -30,7 +37,7 @@
}
a {
- color: $ui-highlight-color;
+ color: $highlight-text-color;
text-decoration: underline;
&:hover {
@@ -48,9 +55,38 @@
}
}
- &.inline-table > tbody > tr:nth-child(odd) > td,
- &.inline-table > tbody > tr:nth-child(odd) > th {
- background: transparent;
+ &.inline-table {
+ & > tbody > tr:nth-child(odd) {
+ & > td,
+ & > th {
+ background: transparent;
+ }
+ }
+
+ & > tbody > tr:first-child {
+ & > td,
+ & > th {
+ border-top: 0;
+ }
+ }
+ }
+
+ &.batch-table {
+ & > thead > tr > th {
+ background: $ui-base-color;
+ border-top: 1px solid darken($ui-base-color, 8%);
+ border-bottom: 1px solid darken($ui-base-color, 8%);
+
+ &:first-child {
+ border-radius: 4px 0 0;
+ border-left: 1px solid darken($ui-base-color, 8%);
+ }
+
+ &:last-child {
+ border-radius: 0 4px 0 0;
+ border-right: 1px solid darken($ui-base-color, 8%);
+ }
+ }
}
}
@@ -63,12 +99,19 @@ samp {
font-family: 'mastodon-font-monospace', monospace;
}
+button.table-action-link {
+ background: transparent;
+ border: 0;
+ font: inherit;
+}
+
+button.table-action-link,
a.table-action-link {
text-decoration: none;
display: inline-block;
margin-right: 5px;
padding: 0 10px;
- color: rgba($primary-text-color, 0.7);
+ color: $darker-text-color;
font-weight: 500;
&:hover {
@@ -79,4 +122,83 @@ a.table-action-link {
font-weight: 400;
margin-right: 5px;
}
+
+ &:first-child {
+ padding-left: 0;
+ }
+}
+
+.batch-table {
+ &__toolbar,
+ &__row {
+ display: flex;
+
+ &__select {
+ box-sizing: border-box;
+ padding: 8px 16px;
+ cursor: pointer;
+ min-height: 100%;
+
+ input {
+ margin-top: 8px;
+ }
+ }
+
+ &__actions,
+ &__content {
+ padding: 8px 0;
+ padding-right: 16px;
+ flex: 1 1 auto;
+ }
+ }
+
+ &__toolbar {
+ border: 1px solid darken($ui-base-color, 8%);
+ background: $ui-base-color;
+ border-radius: 4px 0 0;
+ height: 47px;
+ align-items: center;
+
+ &__actions {
+ text-align: right;
+ padding-right: 16px - 5px;
+ }
+ }
+
+ &__row {
+ border: 1px solid darken($ui-base-color, 8%);
+ border-top: 0;
+ background: darken($ui-base-color, 4%);
+
+ &:hover {
+ background: darken($ui-base-color, 2%);
+ }
+
+ &:nth-child(even) {
+ background: $ui-base-color;
+
+ &:hover {
+ background: lighten($ui-base-color, 2%);
+ }
+ }
+
+ &__content {
+ padding-top: 12px;
+ padding-bottom: 16px;
+ }
+ }
+
+ .status__content {
+ padding-top: 0;
+
+ strong {
+ font-weight: 700;
+ background: linear-gradient(to right, orange , yellow, green, cyan, blue, violet,orange , yellow, green, cyan, blue, violet);
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: Swag 2s linear 0s infinite;
+ }
+ }
}
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index e456c27ee..40aeb4afc 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -1,10 +1,10 @@
// Commonly used web colors
$black: #000000; // Black
$white: #ffffff; // White
-$success-green: #79bd9a; // Padua
-$error-red: #df405a; // Cerise
-$warning-red: #ff5050; // Sunset Orange
-$gold-star: #ca8f04; // Dark Goldenrod
+$success-green: #79bd9a !default; // Padua
+$error-red: #df405a !default; // Cerise
+$warning-red: #ff5050 !default; // Sunset Orange
+$gold-star: #ca8f04 !default; // Dark Goldenrod
// Values from the classic Mastodon UI
$classic-base-color: #282c37; // Midnight Express
@@ -17,7 +17,6 @@ $base-shadow-color: $black !default;
$base-overlay-background: $black !default;
$base-border-color: $white !default;
$simple-background-color: $white !default;
-$primary-text-color: $white !default;
$valid-value-color: $success-green !default;
$error-value-color: $error-red !default;
@@ -26,7 +25,19 @@ $ui-base-color: $classic-base-color !default; // Darkest
$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
$ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest
-$ui-highlight-color: $classic-highlight-color !default; // Vibrant
+$ui-highlight-color: $classic-highlight-color !default;
+
+// Variables for texts
+$primary-text-color: $white !default;
+$darker-text-color: $ui-primary-color !default;
+$dark-text-color: $ui-base-lighter-color !default;
+$secondary-text-color: $ui-secondary-color !default;
+$highlight-text-color: $ui-highlight-color !default;
+$action-button-color: $ui-base-lighter-color !default;
+// For texts on inverted backgrounds
+$inverted-text-color: $ui-base-color !default;
+$lighter-text-color: $ui-base-lighter-color !default;
+$light-text-color: $ui-primary-color !default;
// Language codes that uses CJK fonts
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 9b00f0f52..03476920b 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -78,8 +78,10 @@ class ActivityPub::Activity
notify_about_reblog(status) if reblog_of_local_account?(status)
notify_about_mentions(status)
- # Only continue if the status is supposed to have
- # arrived in real-time
+ # Only continue if the status is supposed to have arrived in real-time.
+ # Note that if @options[:override_timestamps] isn't set, the status
+ # may have a lower snowflake id than other existing statuses, potentially
+ # "hiding" it from paginated API calls
return unless @options[:override_timestamps] || status.within_realtime_window?
distribute_to_followers(status)
@@ -116,4 +118,13 @@ class ActivityPub::Activity
def delete_later!(uri)
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
end
+
+ def fetch_remote_original_status
+ if object_uri.start_with?('http')
+ return if ActivityPub::TagManager.instance.local_uri?(object_uri)
+ ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
+ elsif @object['url'].present?
+ ::FetchRemoteStatusService.new.call(@object['url'])
+ end
+ end
end
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index ea94d2f98..688ab00b3 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -4,9 +4,10 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
def perform
return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
- status = status_from_uri(object_uri)
+ status = status_from_uri(object_uri)
+ status ||= fetch_remote_original_status
- return unless status.account_id == @account.id && !@account.pinned?(status)
+ return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
StatusPin.create!(account: @account, status: status)
end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index c8a358195..1147a4481 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -15,7 +15,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
account: @account,
reblog: original_status,
uri: @json['id'],
- created_at: @options[:override_timestamps] ? nil : @json['published'],
+ created_at: @json['published'],
+ override_timestamps: @options[:override_timestamps],
visibility: original_status.visibility
)
@@ -25,16 +26,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
private
- def fetch_remote_original_status
- if object_uri.start_with?('http')
- return if ActivityPub::TagManager.instance.local_uri?(object_uri)
-
- ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true)
- elsif @object['url'].present?
- ::FetchRemoteStatusService.new.call(@object['url'])
- end
- end
-
def announceable?(status)
status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
index f630d5db2..26da8bdf5 100644
--- a/app/lib/activitypub/activity/block.rb
+++ b/app/lib/activitypub/activity/block.rb
@@ -7,6 +7,6 @@ class ActivityPub::Activity::Block < ActivityPub::Activity
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
- @account.block!(target_account)
+ @account.block!(target_account, uri: @json['id'])
end
end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 676e885c0..00479fd9a 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -11,6 +11,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
if lock.acquired?
@status = find_existing_status
process_status if @status.nil?
+ else
+ raise Mastodon::RaceConditionError
end
end
@@ -47,7 +49,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
text: text_from_content || '',
language: detected_language,
spoiler_text: @object['summary'] || '',
- created_at: @options[:override_timestamps] ? nil : @object['published'],
+ created_at: @object['published'],
+ override_timestamps: @options[:override_timestamps],
reply: @object['inReplyTo'].present?,
sensitive: @object['sensitive'] || false,
visibility: visibility_from_audience,
@@ -61,12 +64,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if @object['tag'].nil?
as_array(@object['tag']).each do |tag|
- case tag['type']
- when 'Hashtag'
+ if equals_or_includes?(tag['type'], 'Hashtag')
process_hashtag tag, status
- when 'Mention'
+ elsif equals_or_includes?(tag['type'], 'Mention')
process_mention tag, status
- when 'Emoji'
+ elsif equals_or_includes?(tag['type'], 'Emoji')
process_emoji tag, status
end
end
@@ -76,9 +78,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if tag['name'].blank?
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
- hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+ hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
+
+ return if status.tags.include?(hashtag)
status.tags << hashtag
+ TrendingTags.record_use!(hashtag, status.account, status.created_at) if status.public_visibility?
+ rescue ActiveRecord::RecordInvalid
+ nil
end
def process_mention(tag, status)
@@ -113,13 +120,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachments = []
as_array(@object['attachment']).each do |attachment|
- next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
+ next if attachment['url'].blank?
href = Addressable::URI.parse(attachment['url']).normalize.to_s
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
media_attachments << media_attachment
- next if skip_download?
+ next if unsupported_media_type?(attachment['mediaType']) || skip_download?
media_attachment.file_remote_url = href
media_attachment.save
@@ -233,11 +240,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def supported_object_type?
- SUPPORTED_TYPES.include?(@object['type'])
+ equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
def converted_object_type?
- CONVERTED_TYPES.include?(@object['type'])
+ equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end
def skip_download?
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 5fa60a81c..3474d55d9 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -17,21 +17,25 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
def delete_note
- status = Status.find_by(uri: object_uri, account: @account)
- status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
+ @status = Status.find_by(uri: object_uri, account: @account)
+ @status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
delete_later!(object_uri)
- return if status.nil?
+ return if @status.nil?
- forward_for_reblogs(status)
- delete_now!(status)
+ if @status.public_visibility? || @status.unlisted_visibility?
+ forward_for_reply
+ forward_for_reblogs
+ end
+
+ delete_now!
end
- def forward_for_reblogs(status)
+ def forward_for_reblogs
return if @json['signature'].blank?
- rebloggers_ids = status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)
+ rebloggers_ids = @status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)
inboxes = Account.where(id: ::Follow.where(target_account_id: rebloggers_ids).select(:account_id)).inboxes - [@account.preferred_inbox_url]
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
@@ -39,8 +43,22 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
end
- def delete_now!(status)
- RemoveStatusService.new.call(status)
+ def replied_to_status
+ return @replied_to_status if defined?(@replied_to_status)
+ @replied_to_status = @status.thread
+ end
+
+ def reply_to_local?
+ !replied_to_status.nil? && replied_to_status.account.local?
+ end
+
+ def forward_for_reply
+ return unless @json['signature'].present? && reply_to_local?
+ ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
+ end
+
+ def delete_now!
+ RemoveStatusService.new.call(@status)
end
def payload
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
index 8adbbb9c3..826dcf18e 100644
--- a/app/lib/activitypub/activity/follow.rb
+++ b/app/lib/activitypub/activity/follow.rb
@@ -6,13 +6,18 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
+ if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain)
+ reject_follow_request!(target_account)
+ return
+ end
+
# Fast-forward repeat follow requests
if @account.following?(target_account)
AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
return
end
- follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+ follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id'])
if target_account.locked?
NotifyService.new.call(target_account, follow_request)
@@ -21,4 +26,9 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
end
end
+
+ def reject_follow_request!(target_account)
+ json = Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(FollowRequest.new(account: @account, target_account: target_account, uri: @json['id']), serializer: ActivityPub::RejectFollowSerializer, adapter: ActivityPub::Adapter).as_json).sign!(target_account))
+ ActivityPub::DeliveryWorker.perform_async(json, target_account.id, @account.inbox_url)
+ end
end
diff --git a/app/lib/activitypub/activity/remove.rb b/app/lib/activitypub/activity/remove.rb
index 62a1e3196..f523ead9f 100644
--- a/app/lib/activitypub/activity/remove.rb
+++ b/app/lib/activitypub/activity/remove.rb
@@ -6,7 +6,7 @@ class ActivityPub::Activity::Remove < ActivityPub::Activity
status = status_from_uri(object_uri)
- return unless status.account_id == @account.id
+ return unless !status.nil? && status.account_id == @account.id
pin = StatusPin.find_by(account: @account, status: status)
pin&.destroy!
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 0134b4015..aa5907f03 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -1,11 +1,10 @@
# frozen_string_literal: true
class ActivityPub::Activity::Update < ActivityPub::Activity
+ SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+
def perform
- case @object['type']
- when 'Person'
- update_account
- end
+ update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
private
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index f19b04ae6..e880499f1 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -19,6 +19,9 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
'Emoji' => 'toot:Emoji',
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
'featured' => 'toot:featured',
+ 'schema' => 'http://schema.org#',
+ 'PropertyValue' => 'schema:PropertyValue',
+ 'value' => 'schema:value',
},
],
}.freeze
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index fa2a8f7d3..95d1cf9f3 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -38,6 +38,10 @@ class ActivityPub::TagManager
end
end
+ def generate_uri_for(_target)
+ URI.join(root_url, 'payloads', SecureRandom.uuid)
+ end
+
def activity_uri_for(target)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
@@ -82,6 +86,8 @@ class ActivityPub::TagManager
end
def local_uri?(uri)
+ return false if uri.nil?
+
uri = Addressable::URI.parse(uri)
host = uri.normalized_host
host = "#{host}:#{uri.port}" if uri.port
@@ -95,6 +101,8 @@ class ActivityPub::TagManager
end
def uri_to_resource(uri, klass)
+ return if uri.nil?
+
if local_uri?(uri)
case klass.name
when 'Account'
diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb
new file mode 100644
index 000000000..2aa37389c
--- /dev/null
+++ b/app/lib/entity_cache.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'singleton'
+
+class EntityCache
+ include Singleton
+
+ MAX_EXPIRATION = 7.days.freeze
+
+ def mention(username, domain)
+ Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
+ end
+
+ def emoji(shortcodes, domain)
+ shortcodes = [shortcodes] unless shortcodes.is_a?(Array)
+ cached = Rails.cache.read_multi(*shortcodes.map { |shortcode| to_key(:emoji, shortcode, domain) })
+ uncached_ids = []
+
+ shortcodes.each do |shortcode|
+ uncached_ids << shortcode unless cached.key?(to_key(:emoji, shortcode, domain))
+ end
+
+ unless uncached_ids.empty?
+ uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).map { |item| [item.shortcode, item] }.to_h
+ uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
+ end
+
+ shortcodes.map { |shortcode| cached[to_key(:emoji, shortcode, domain)] || uncached[shortcode] }.compact
+ end
+
+ def to_key(type, *ids)
+ "#{type}:#{ids.compact.map(&:downcase).join(':')}"
+ end
+end
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index e88e98eae..01346bfe5 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -6,6 +6,7 @@ module Mastodon
class ValidationError < Error; end
class HostValidationError < ValidationError; end
class LengthValidationError < ValidationError; end
+ class DimensionsValidationError < ValidationError; end
class RaceConditionError < Error; end
class UnexpectedResponseError < Error
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index dd78e543f..c18c07b33 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -145,19 +145,20 @@ class FeedManager
redis.exists("subscribed:#{timeline_id}")
end
+ def blocks_or_mutes?(receiver_id, account_ids, context)
+ Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
+ (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
+ end
+
def filter_from_home?(status, receiver_id)
return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
- check_for_mutes = [status.account_id]
- check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
-
- return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
-
check_for_blocks = status.mentions.pluck(:account_id)
+ check_for_blocks.concat([status.account_id])
check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
- return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
+ return true if blocks_or_mutes?(receiver_id, check_for_blocks, :home)
if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
@@ -177,11 +178,13 @@ class FeedManager
def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
- check_for_blocks = [status.account_id]
- check_for_blocks.concat(status.mentions.pluck(:account_id))
+ # This filter is called from NotifyService, but already after the sender of
+ # the notification has been checked for mute/block. Therefore, it's not
+ # necessary to check the author of the toot for mute/block again
+ check_for_blocks = status.mentions.pluck(:account_id)
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
- should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
+ should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
should_filter
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 1df4ff8d4..e1ab05cc0 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -51,9 +51,10 @@ class Formatter
strip_tags(text)
end
- def simplified_format(account)
- return reformat(account.note).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
- linkify(account.note)
+ def simplified_format(account, **options)
+ html = account.local? ? linkify(account.note) : reformat(account.note)
+ html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+ html.html_safe # rubocop:disable Rails/OutputSafety
end
def sanitize(html, config)
@@ -66,6 +67,19 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
+ def format_display_name(account, **options)
+ html = encode(account.display_name.presence || account.username)
+ html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+ html.html_safe # rubocop:disable Rails/OutputSafety
+ end
+
+ def format_field(account, str, **options)
+ return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
+ html = encode_and_link_urls(str, me: true)
+ html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
+ html.html_safe # rubocop:disable Rails/OutputSafety
+ end
+
def linkify(text)
html = encode_and_link_urls(text)
html = simple_format(html, {}, sanitize: false)
@@ -80,12 +94,17 @@ class Formatter
HTMLEntities.new.encode(html)
end
- def encode_and_link_urls(html, accounts = nil)
+ def encode_and_link_urls(html, accounts = nil, options = {})
entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false)
+ if accounts.is_a?(Hash)
+ options = accounts
+ accounts = nil
+ end
+
rewrite(html.dup, entities) do |entity|
if entity[:url]
- link_to_url(entity)
+ link_to_url(entity, options)
elsif entity[:hashtag]
link_to_hashtag(entity)
elsif entity[:screen_name]
@@ -172,10 +191,12 @@ class Formatter
result.flatten.join
end
- def link_to_url(entity)
+ def link_to_url(entity, options = {})
url = Addressable::URI.parse(entity[:url])
html_attrs = { target: '_blank', rel: 'nofollow noopener' }
+ html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
+
Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
encode(entity[:url])
@@ -194,7 +215,7 @@ class Formatter
username, domain = acct.split('@')
domain = nil if TagManager.instance.local_domain?(domain)
- account = Account.find_remote(username, domain)
+ account = EntityCache.instance.mention(username, domain)
account ? mention_html(account) : "@#{acct}"
end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index 6235127b2..d3a303a0c 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -15,6 +15,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
@status = find_status(id)
return [@status, false] unless @status.nil?
@status = process_status
+ else
+ raise Mastodon::RaceConditionError
end
end
@@ -39,13 +41,15 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
reblog: cached_reblog,
text: content,
spoiler_text: content_warning,
- created_at: @options[:override_timestamps] ? nil : published,
+ created_at: published,
+ override_timestamps: @options[:override_timestamps],
reply: thread?,
language: content_language,
visibility: visibility_scope,
conversation: find_or_create_conversation,
thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil,
- media_attachment_ids: media_attachments.map(&:id)
+ media_attachment_ids: media_attachments.map(&:id),
+ sensitive: sensitive?
)
save_mentions(status)
@@ -61,7 +65,14 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
- DistributionWorker.perform_async(status.id) if @options[:override_timestamps] || status.within_realtime_window?
+
+ # Only continue if the status is supposed to have arrived in real-time.
+ # Note that if @options[:override_timestamps] isn't set, the status
+ # may have a lower snowflake id than other existing statuses, potentially
+ # "hiding" it from paginated API calls
+ return status unless @options[:override_timestamps] || status.within_realtime_window?
+
+ DistributionWorker.perform_async(status.id)
status
end
@@ -97,6 +108,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
private
+ def sensitive?
+ # OStatus-specific convention (not standard)
+ @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).any? { |category| category['term'] == 'nsfw' }
+ end
+
def find_or_create_conversation
uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
return if uri.nil?
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 46d0a8b37..5c6ff4f9b 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -26,6 +26,9 @@ class OStatus::AtomSerializer
append_element(author, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(account))
append_element(author, 'link', nil, rel: :avatar, type: account.avatar_content_type, 'media:width': 120, 'media:height': 120, href: full_asset_url(account.avatar.url(:original))) if account.avatar?
append_element(author, 'link', nil, rel: :header, type: account.header_content_type, 'media:width': 700, 'media:height': 335, href: full_asset_url(account.header.url(:original))) if account.header?
+ account.emojis.each do |emoji|
+ append_element(author, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode)
+ end
append_element(author, 'poco:preferredUsername', account.username)
append_element(author, 'poco:displayName', account.display_name) if account.display_name?
append_element(author, 'poco:note', account.local? ? account.note : strip_tags(account.note)) if account.note?
@@ -351,7 +354,7 @@ class OStatus::AtomSerializer
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
- status.mentions.order(:id).each do |mentioned|
+ status.mentions.sort_by(&:id).each do |mentioned|
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
end
@@ -361,12 +364,11 @@ class OStatus::AtomSerializer
append_element(entry, 'category', nil, term: tag.name)
end
- append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive?
-
status.media_attachments.each do |media|
append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
end
+ append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive? && status.media_attachments.any?
append_element(entry, 'mastodon:scope', status.visibility)
status.emojis.each do |emoji|
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
deleted file mode 100644
index 3bec7211b..000000000
--- a/app/lib/provider_discovery.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-class ProviderDiscovery < OEmbed::ProviderDiscovery
- class << self
- def get(url, **options)
- provider = discover_provider(url, options)
-
- options.delete(:html)
-
- provider.get(url, options)
- end
-
- def discover_provider(url, **options)
- format = options[:format]
-
- html = if options[:html]
- Nokogiri::HTML(options[:html])
- else
- Request.new(:get, url).perform do |res|
- raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
- Nokogiri::HTML(res.body_with_limit)
- end
- end
-
- if format.nil? || format == :json
- provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
- format ||= :json if provider_endpoint
- end
-
- if format.nil? || format == :xml
- provider_endpoint ||= html.at_xpath('//link[@type="text/xml+oembed"]')&.attribute('href')&.value
- format ||= :xml if provider_endpoint
- end
-
- raise OEmbed::NotFound, url if provider_endpoint.nil?
- begin
- provider_endpoint = Addressable::URI.parse(provider_endpoint)
- provider_endpoint.query = nil
- provider_endpoint = provider_endpoint.to_s
- rescue Addressable::URI::InvalidURIError
- raise OEmbed::NotFound, url
- end
-
- OEmbed::Provider.new(provider_endpoint, format)
- end
- end
-end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index dca93a6e9..397614fac 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -9,11 +9,15 @@ class Request
include RoutingHelper
def initialize(verb, url, **options)
+ raise ArgumentError if url.blank?
+
@verb = verb
@url = Addressable::URI.parse(url).normalize
- @options = options.merge(socket_class: Socket)
+ @options = options.merge(use_proxy? ? Rails.configuration.x.http_client_proxy : { socket_class: Socket })
@headers = {}
+ raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
+
set_common_headers!
set_digest! if options.key?(:body)
end
@@ -53,10 +57,11 @@ class Request
private
def set_common_headers!
- @headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
- @headers['User-Agent'] = user_agent
- @headers['Host'] = @url.host
- @headers['Date'] = Time.now.utc.httpdate
+ @headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
+ @headers['User-Agent'] = Mastodon::Version.user_agent
+ @headers['Host'] = @url.host
+ @headers['Date'] = Time.now.utc.httpdate
+ @headers['Accept-Encoding'] = 'gzip' if @verb != :head
end
def set_digest!
@@ -78,10 +83,6 @@ class Request
@headers.keys.join(' ').downcase
end
- def user_agent
- @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
- end
-
def key_id
case @key_id_format
when :acct
@@ -96,7 +97,15 @@ class Request
end
def http_client
- @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
+ @http_client ||= HTTP.use(:auto_inflate).timeout(:per_operation, timeout).follow(max_hops: 2)
+ end
+
+ def use_proxy?
+ Rails.configuration.x.http_client_proxy.present?
+ end
+
+ def block_hidden_service?
+ !Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match(@url.host)
end
module ClientLimit
@@ -129,6 +138,7 @@ class Request
class Socket < TCPSocket
class << self
def open(host, *args)
+ return super host, *args if thru_hidden_service? host
outer_e = nil
Addrinfo.foreach(host, nil, nil, :SOCK_STREAM) do |address|
begin
@@ -142,6 +152,10 @@ class Request
end
alias new open
+
+ def thru_hidden_service?(host)
+ Rails.configuration.x.hidden_service_via_transparent_proxy && /\.(onion|i2p)$/.match(host)
+ end
end
end
diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb
new file mode 100644
index 000000000..63ddba2e8
--- /dev/null
+++ b/app/lib/rss_builder.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+class RSSBuilder
+ class ItemBuilder
+ def initialize
+ @item = Ox::Element.new('item')
+ end
+
+ def title(str)
+ @item << (Ox::Element.new('title') << str)
+
+ self
+ end
+
+ def link(str)
+ @item << Ox::Element.new('guid').tap do |guid|
+ guid['isPermalink'] = 'true'
+ guid << str
+ end
+
+ @item << (Ox::Element.new('link') << str)
+
+ self
+ end
+
+ def pub_date(date)
+ @item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822))
+
+ self
+ end
+
+ def description(str)
+ @item << (Ox::Element.new('description') << str)
+
+ self
+ end
+
+ def enclosure(url, type, size)
+ @item << Ox::Element.new('enclosure').tap do |enclosure|
+ enclosure['url'] = url
+ enclosure['length'] = size
+ enclosure['type'] = type
+ end
+
+ self
+ end
+
+ def to_element
+ @item
+ end
+ end
+
+ def initialize
+ @document = Ox::Document.new(version: '1.0')
+ @channel = Ox::Element.new('channel')
+
+ @document << (rss << @channel)
+ end
+
+ def title(str)
+ @channel << (Ox::Element.new('title') << str)
+
+ self
+ end
+
+ def link(str)
+ @channel << (Ox::Element.new('link') << str)
+
+ self
+ end
+
+ def image(str)
+ @channel << Ox::Element.new('image').tap do |image|
+ image << (Ox::Element.new('url') << str)
+ image << (Ox::Element.new('title') << '')
+ image << (Ox::Element.new('link') << '')
+ end
+
+ @channel << (Ox::Element.new('webfeeds:icon') << str)
+
+ self
+ end
+
+ def cover(str)
+ @channel << Ox::Element.new('webfeeds:cover').tap do |cover|
+ cover['image'] = str
+ end
+
+ self
+ end
+
+ def logo(str)
+ @channel << (Ox::Element.new('webfeeds:logo') << str)
+
+ self
+ end
+
+ def accent_color(str)
+ @channel << (Ox::Element.new('webfeeds:accentColor') << str)
+
+ self
+ end
+
+ def description(str)
+ @channel << (Ox::Element.new('description') << str)
+
+ self
+ end
+
+ def item
+ @channel << ItemBuilder.new.tap do |item|
+ yield item
+ end.to_element
+
+ self
+ end
+
+ def to_xml
+ ('' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8')
+ end
+
+ private
+
+ def rss
+ Ox::Element.new('rss').tap do |rss|
+ rss['version'] = '2.0'
+ rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0'
+ end
+ end
+end
diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb
index 41d4381e5..b6c80b801 100644
--- a/app/lib/status_filter.rb
+++ b/app/lib/status_filter.rb
@@ -3,9 +3,10 @@
class StatusFilter
attr_reader :status, :account
- def initialize(status, account)
- @status = status
- @account = account
+ def initialize(status, account, preloaded_relations = {})
+ @status = status
+ @account = account
+ @preloaded_relations = preloaded_relations
end
def filtered?
@@ -24,15 +25,15 @@ class StatusFilter
end
def blocking_account?
- account.blocking? status.account_id
+ @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id)
end
def blocking_domain?
- account.domain_blocking? status.account_domain
+ @preloaded_relations[:domain_blocking_by_domain] ? @preloaded_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain)
end
def muting_account?
- account.muting? status.account_id
+ @preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id)
end
def silenced_account?
@@ -44,7 +45,7 @@ class StatusFilter
end
def account_following_status_account?
- account&.following? status.account_id
+ @preloaded_relations[:following] ? @preloaded_relations[:following][status.account_id] : account&.following?(status.account_id)
end
def blocked_by_policy?
@@ -52,6 +53,6 @@ class StatusFilter
end
def policy_allows_show?
- StatusPolicy.new(account, status).show?
+ StatusPolicy.new(account, status, @preloaded_relations).show?
end
end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 4d6f19467..a82f8974b 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -28,6 +28,7 @@ class UserSettingsDecorator
user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui')
user.settings['noindex'] = noindex_preference if change?('setting_noindex')
user.settings['theme'] = theme_preference if change?('setting_theme')
+ user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network')
end
def merged_notification_emails
@@ -78,12 +79,16 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_noindex'
end
+ def hide_network_preference
+ boolean_cast_setting 'setting_hide_network'
+ end
+
def theme_preference
settings['setting_theme']
end
def boolean_cast_setting(key)
- settings[key] == '1'
+ ActiveModel::Type::Boolean.new.cast(settings[key])
end
def coerced_settings(key)
@@ -91,7 +96,7 @@ class UserSettingsDecorator
end
def coerce_values(params_hash)
- params_hash.transform_values { |x| x == '1' }
+ params_hash.transform_values { |x| ActiveModel::Type::Boolean.new.cast(x) }
end
def change?(key)
diff --git a/app/models/account.rb b/app/models/account.rb
index 25e7d7436..72e850aa7 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -3,7 +3,7 @@
#
# Table name: accounts
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# username :string default(""), not null
# domain :string
# secret :string default(""), not null
@@ -42,8 +42,10 @@
# followers_url :string default(""), not null
# protocol :integer default("ostatus"), not null
# memorial :boolean default(FALSE), not null
-# moved_to_account_id :integer
+# moved_to_account_id :bigint(8)
# featured_collection_url :string
+# fields :jsonb
+# actor_type :string
#
class Account < ApplicationRecord
@@ -73,6 +75,7 @@ class Account < ApplicationRecord
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
+ validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
# Timelines
has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -95,6 +98,8 @@ class Account < ApplicationRecord
has_many :reports
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
+ has_many :report_notes, dependent: :destroy
+
# Moderation notes
has_many :account_moderation_notes, dependent: :destroy
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy
@@ -111,9 +116,10 @@ class Account < ApplicationRecord
scope :without_followers, -> { where(followers_count: 0) }
scope :with_followers, -> { where('followers_count > 0') }
scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
- scope :partitioned, -> { order('row_number() over (partition by domain)') }
+ scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
scope :silenced, -> { where(silenced: true) }
scope :suspended, -> { where(suspended: true) }
+ scope :without_suspended, -> { where(suspended: false) }
scope :recent, -> { reorder(id: :desc) }
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
@@ -122,6 +128,7 @@ class Account < ApplicationRecord
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
delegate :email,
+ :unconfirmed_email,
:current_sign_in_ip,
:current_sign_in_at,
:confirmed?,
@@ -129,6 +136,7 @@ class Account < ApplicationRecord
:moderator?,
:staff?,
:locale,
+ :hides_network?,
to: :user,
prefix: true,
allow_nil: true
@@ -143,6 +151,16 @@ class Account < ApplicationRecord
moved_to_account_id.present?
end
+ def bot?
+ %w(Application Service).include? actor_type
+ end
+
+ alias bot bot?
+
+ def bot=(val)
+ self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
+ end
+
def acct
local? ? username : "#{username}@#{domain}"
end
@@ -186,6 +204,32 @@ class Account < ApplicationRecord
@keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
end
+ def fields
+ (self[:fields] || []).map { |f| Field.new(self, f) }
+ end
+
+ def fields_attributes=(attributes)
+ fields = []
+
+ if attributes.is_a?(Hash)
+ attributes.each_value do |attr|
+ next if attr[:name].blank?
+ fields << attr
+ end
+ end
+
+ self[:fields] = fields
+ end
+
+ def build_fields
+ return if fields.size >= 4
+
+ raw_fields = self[:fields] || []
+ add_fields = 4 - raw_fields.size
+ add_fields.times { raw_fields << { name: '', value: '' } }
+ self.fields = raw_fields
+ end
+
def magic_key
modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component|
result = []
@@ -235,17 +279,32 @@ class Account < ApplicationRecord
shared_inbox_url.presence || inbox_url
end
+ class Field < ActiveModelSerializers::Model
+ attributes :name, :value, :account, :errors
+
+ def initialize(account, attr)
+ @account = account
+ @name = attr['name'].strip[0, 255]
+ @value = attr['value'].strip[0, 255]
+ @errors = {}
+ end
+
+ def to_h
+ { name: @name, value: @value }
+ end
+ end
+
class << self
def readonly_attributes
super - %w(statuses_count following_count followers_count)
end
def domains
- reorder(nil).pluck('distinct accounts.domain')
+ reorder(nil).pluck(Arel.sql('distinct accounts.domain'))
end
def inboxes
- urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+ urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)"))
DeliveryFailureTracker.filter(urls)
end
@@ -350,6 +409,10 @@ class Account < ApplicationRecord
end
end
+ def emojis
+ @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
+ end
+
before_create :generate_keys
before_validation :normalize_domain
before_validation :prepare_contents, if: :local?
@@ -362,9 +425,9 @@ class Account < ApplicationRecord
end
def generate_keys
- return unless local?
+ return unless local? && !Rails.env.test?
- keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
+ keypair = OpenSSL::PKey::RSA.new(2048)
self.private_key = keypair.to_pem
self.public_key = keypair.public_key.to_pem
end
@@ -374,4 +437,8 @@ class Account < ApplicationRecord
self.domain = TagManager.instance.normalize_domain(domain)
end
+
+ def emojifiable_text
+ [note, display_name, fields.map(&:value)].join(' ')
+ end
end
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index bc00b4f32..e352000c3 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -3,11 +3,11 @@
#
# Table name: account_domain_blocks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# domain :string
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer
+# account_id :bigint(8)
#
class AccountDomainBlock < ApplicationRecord
diff --git a/app/models/account_moderation_note.rb b/app/models/account_moderation_note.rb
index 3ac9b1ac1..22e312bb2 100644
--- a/app/models/account_moderation_note.rb
+++ b/app/models/account_moderation_note.rb
@@ -3,10 +3,10 @@
#
# Table name: account_moderation_notes
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# content :text not null
-# account_id :integer not null
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# target_account_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index c437c8ee8..1d1db1b7a 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -3,11 +3,11 @@
#
# Table name: admin_action_logs
#
-# id :integer not null, primary key
-# account_id :integer
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
# action :string default(""), not null
# target_type :string
-# target_id :integer
+# target_id :bigint(8)
# recorded_changes :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -35,6 +35,11 @@ class Admin::ActionLog < ApplicationRecord
self.recorded_changes = target.attributes
when :update, :promote, :demote
self.recorded_changes = target.previous_changes
+ when :change_email
+ self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
+ email: [target.email, nil],
+ unconfirmed_email: [nil, target.unconfirmed_email]
+ )
end
end
end
diff --git a/app/models/backup.rb b/app/models/backup.rb
index 5a7e6a14d..c2651313b 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -3,8 +3,8 @@
#
# Table name: backups
#
-# id :integer not null, primary key
-# user_id :integer
+# id :bigint(8) not null, primary key
+# user_id :bigint(8)
# dump_file_name :string
# dump_content_type :string
# dump_file_size :integer
diff --git a/app/models/block.rb b/app/models/block.rb
index d6ecabd3b..bf3e07600 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -3,11 +3,12 @@
#
# Table name: blocks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# target_account_id :bigint(8) not null
+# uri :string
#
class Block < ApplicationRecord
@@ -19,7 +20,12 @@ class Block < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id }
+ def local?
+ false # Force uri_for to use uri attribute
+ end
+
after_commit :remove_blocking_cache
+ before_validation :set_uri, only: :create
private
@@ -27,4 +33,8 @@ class Block < ApplicationRecord
Rails.cache.delete("exclude_account_ids_for:#{account_id}")
Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
end
+
+ def set_uri
+ self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+ end
end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index fdf35a4e3..ef59f5d15 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -20,6 +20,10 @@ module AccountInteractions
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
+ def blocked_by_map(target_account_ids, account_id)
+ follow_mapping(Block.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
+ end
+
def muting_map(target_account_ids, account_id)
Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
mapping[mute.target_account_id] = {
@@ -38,8 +42,12 @@ module AccountInteractions
def domain_blocking_map(target_account_ids, account_id)
accounts_map = Account.where(id: target_account_ids).select('id, domain').map { |a| [a.id, a.domain] }.to_h
- blocked_domains = AccountDomainBlock.where(account_id: account_id, domain: accounts_map.values).pluck(:domain)
- accounts_map.map { |id, domain| [id, blocked_domains.include?(domain)] }.to_h
+ blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
+ accounts_map.map { |id, domain| [id, blocked_domains[domain]] }.to_h
+ end
+
+ def domain_blocking_map_by_domain(target_domains, account_id)
+ follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
end
private
@@ -74,16 +82,19 @@ module AccountInteractions
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
end
- def follow!(other_account, reblogs: nil)
+ def follow!(other_account, reblogs: nil, uri: nil)
reblogs = true if reblogs.nil?
- rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account)
- rel.update!(show_reblogs: reblogs)
+ rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri)
+ .find_or_create_by!(target_account: other_account)
+
+ rel.update!(show_reblogs: reblogs)
rel
end
- def block!(other_account)
- block_relationships.find_or_create_by!(target_account: other_account)
+ def block!(other_account, uri: nil)
+ block_relationships.create_with(uri: uri)
+ .find_or_create_by!(target_account: other_account)
end
def mute!(other_account, notifications: nil)
@@ -93,6 +104,7 @@ module AccountInteractions
if mute.hide_notifications? != notifications
mute.update!(hide_notifications: notifications)
end
+ mute
end
def mute_conversation!(conversation)
@@ -171,4 +183,15 @@ module AccountInteractions
def pinned?(status)
status_pins.where(status: status).exists?
end
+
+ def followers_for_local_distribution
+ followers.local
+ .joins(:user)
+ .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
+ end
+
+ def lists_for_local_distribution
+ lists.joins(account: :user)
+ .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
+ end
end
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 90ce88463..6f8489b89 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -1,10 +1,15 @@
# frozen_string_literal: true
+require 'mime/types'
+
module Attachmentable
extend ActiveSupport::Concern
+ MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
+
included do
before_post_process :set_file_extensions
+ before_post_process :check_image_dimensions
end
private
@@ -12,10 +17,31 @@ module Attachmentable
def set_file_extensions
self.class.attachment_definitions.each_key do |attachment_name|
attachment = send(attachment_name)
+
next if attachment.blank?
- extension = Paperclip::Interpolations.content_type_extension(attachment, :original)
- basename = Paperclip::Interpolations.basename(attachment, :original)
- attachment.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.')
+
+ attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
end
end
+
+ def check_image_dimensions
+ self.class.attachment_definitions.each_key do |attachment_name|
+ attachment = send(attachment_name)
+
+ next if attachment.blank? || !attachment.content_type.match?(/image.*/) || attachment.queued_for_write[:original].blank?
+
+ width, height = FastImage.size(attachment.queued_for_write[:original].path)
+
+ raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height >= MAX_MATRIX_LIMIT)
+ end
+ end
+
+ def appropriate_extension(attachment)
+ mime_type = MIME::Types[attachment.content_type]
+
+ extensions_for_mime_type = mime_type.empty? ? [] : mime_type.first.extensions
+ original_extension = Paperclip::Interpolations.extension(attachment, :original)
+
+ extensions_for_mime_type.include?(original_extension) ? original_extension : extensions_for_mime_type.first
+ end
end
diff --git a/app/models/concerns/cacheable.rb b/app/models/concerns/cacheable.rb
index 51451d260..d7524cdfd 100644
--- a/app/models/concerns/cacheable.rb
+++ b/app/models/concerns/cacheable.rb
@@ -3,14 +3,19 @@
module Cacheable
extend ActiveSupport::Concern
- class_methods do
+ module ClassMethods
+ @cache_associated = []
+
def cache_associated(*associations)
@cache_associated = associations
end
- end
- included do
- scope :with_includes, -> { includes(@cache_associated) }
- scope :cache_ids, -> { select(:id, :updated_at) }
+ def with_includes
+ includes(@cache_associated)
+ end
+
+ def cache_ids
+ select(:id, :updated_at)
+ end
end
end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 3b8c507c3..c17f19a60 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -24,23 +24,29 @@ module Remotable
Request.new(:get, url).perform do |response|
next if response.code != 200
- matches = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
- filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+ content_type = parse_content_type(response.headers.get('content-type').last)
+ extname = detect_extname_from_content_type(content_type)
+
+ if extname.nil?
+ disposition = response.headers.get('content-disposition').last
+ matches = disposition&.match(/filename="([^"]*)"/)
+ filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+ extname = filename.nil? ? '' : File.extname(filename)
+ end
+
basename = SecureRandom.hex(8)
- extname = if filename.nil?
- ''
- else
- File.extname(filename)
- end
send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
send("#{attachment_name}_file_name=", basename + extname)
self[attribute_name] = url if has_attribute?(attribute_name)
end
- rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e
+ rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
nil
+ rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
+ Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
+ nil
end
end
@@ -54,4 +60,26 @@ module Remotable
end
end
end
+
+ private
+
+ def detect_extname_from_content_type(content_type)
+ return if content_type.nil?
+
+ type = MIME::Types[content_type].first
+
+ return if type.nil?
+
+ extname = type.extensions.first
+
+ return if extname.nil?
+
+ ".#{extname}"
+ end
+
+ def parse_content_type(content_type)
+ return if content_type.nil?
+
+ content_type.split(/\s*;\s*/).first
+ end
end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 65f8e112e..fa441469c 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -3,28 +3,31 @@
module StatusThreadingConcern
extend ActiveSupport::Concern
- def ancestors(account = nil)
- find_statuses_from_tree_path(ancestor_ids, account)
+ def ancestors(limit, account = nil)
+ find_statuses_from_tree_path(ancestor_ids(limit), account)
end
- def descendants(account = nil)
- find_statuses_from_tree_path(descendant_ids, account)
+ def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
+ find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account)
end
private
- def ancestor_ids
- Rails.cache.fetch("ancestors:#{id}") do
- ancestors_without_self.pluck(:id)
+ def ancestor_ids(limit)
+ key = "ancestors:#{id}"
+ ancestors = Rails.cache.fetch(key)
+
+ if ancestors.nil? || ancestors[:limit] < limit
+ ids = ancestor_statuses(limit).pluck(:id).reverse!
+ Rails.cache.write key, limit: limit, ids: ids
+ ids
+ else
+ ancestors[:ids].last(limit)
end
end
- def ancestors_without_self
- ancestor_statuses - [self]
- end
-
- def ancestor_statuses
- Status.find_by_sql([<<-SQL.squish, id: id])
+ def ancestor_statuses(limit)
+ Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit])
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
AS (
SELECT id, in_reply_to_id, ARRAY[id]
@@ -38,52 +41,70 @@ module StatusThreadingConcern
)
SELECT id
FROM search_tree
- ORDER BY path DESC
+ ORDER BY path
+ LIMIT :limit
SQL
end
- def descendant_ids
- descendants_without_self.pluck(:id)
+ def descendant_ids(limit, max_child_id, since_child_id, depth)
+ descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id)
end
- def descendants_without_self
- descendant_statuses - [self]
- end
+ def descendant_statuses(limit, max_child_id, since_child_id, depth)
+ # use limit + 1 and depth + 1 because 'self' is included
+ depth += 1 if depth.present?
+ limit += 1 if limit.present?
- def descendant_statuses
- Status.find_by_sql([<<-SQL.squish, id: id])
+ descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
WITH RECURSIVE search_tree(id, path)
AS (
SELECT id, ARRAY[id]
FROM statuses
- WHERE id = :id
+ WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
UNION ALL
SELECT statuses.id, path || statuses.id
FROM search_tree
JOIN statuses ON statuses.in_reply_to_id = search_tree.id
- WHERE NOT statuses.id = ANY(path)
+ WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
)
SELECT id
FROM search_tree
ORDER BY path
+ LIMIT :limit
SQL
+
+ descendants_with_self - [self]
end
def find_statuses_from_tree_path(ids, account)
- statuses = statuses_with_accounts(ids).to_a
+ statuses = statuses_with_accounts(ids).to_a
+ account_ids = statuses.map(&:account_id).uniq
+ domains = statuses.map(&:account_domain).compact.uniq
+ relations = relations_map_for_account(account, account_ids, domains)
- # FIXME: n+1 bonanza
- statuses.reject! { |status| filter_from_context?(status, account) }
+ statuses.reject! { |status| filter_from_context?(status, account, relations) }
# Order ancestors/descendants by tree path
statuses.sort_by! { |status| ids.index(status.id) }
end
+ def relations_map_for_account(account, account_ids, domains)
+ return {} if account.nil?
+
+ {
+ blocking: Account.blocking_map(account_ids, account.id),
+ blocked_by: Account.blocked_by_map(account_ids, account.id),
+ muting: Account.muting_map(account_ids, account.id),
+ following: Account.following_map(account_ids, account.id),
+ domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
+ }
+ end
+
def statuses_with_accounts(ids)
Status.where(id: ids).includes(:account)
end
- def filter_from_context?(status, account)
- StatusFilter.new(status, account).filtered?
+ def filter_from_context?(status, account, relations)
+ StatusFilter.new(status, account, relations).filtered?
end
end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 08c1ce945..4dfaea889 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -3,7 +3,7 @@
#
# Table name: conversations
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 272eb81af..52c1a33e0 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -3,9 +3,9 @@
#
# Table name: conversation_mutes
#
-# id :integer not null, primary key
-# conversation_id :integer not null
-# account_id :integer not null
+# id :bigint(8) not null, primary key
+# conversation_id :bigint(8) not null
+# account_id :bigint(8) not null
#
class ConversationMute < ApplicationRecord
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 476178e86..b99ed01f0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -3,7 +3,7 @@
#
# Table name: custom_emojis
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# shortcode :string default(""), not null
# domain :string
# image_file_name :string
@@ -40,6 +40,10 @@ class CustomEmoji < ApplicationRecord
remotable_attachment :image, LIMIT
+ include Attachmentable
+
+ after_commit :remove_entity_cache
+
def local?
domain.nil?
end
@@ -56,7 +60,17 @@ class CustomEmoji < ApplicationRecord
return [] if shortcodes.empty?
- where(shortcode: shortcodes, domain: domain, disabled: false)
+ EntityCache.instance.emoji(shortcodes, domain)
+ end
+
+ def search(shortcode)
+ where('"custom_emojis"."shortcode" ILIKE ?', "%#{shortcode}%")
end
end
+
+ private
+
+ def remove_entity_cache
+ Rails.cache.delete(EntityCache.instance.to_key(:emoji, shortcode, domain))
+ end
end
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
index 2c09ed65c..c4bc310bb 100644
--- a/app/models/custom_emoji_filter.rb
+++ b/app/models/custom_emoji_filter.rb
@@ -28,7 +28,7 @@ class CustomEmojiFilter
when 'by_domain'
CustomEmoji.where(domain: value)
when 'shortcode'
- CustomEmoji.where(shortcode: value)
+ CustomEmoji.search(value)
else
raise "Unknown filter: #{key}"
end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index aea8919af..93658793b 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -3,7 +3,7 @@
#
# Table name: domain_blocks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index a104810d1..10490375b 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -3,7 +3,7 @@
#
# Table name: email_domain_blocks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index fa1884b86..0fce82f6f 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -3,11 +3,11 @@
#
# Table name: favourites
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# status_id :integer not null
+# account_id :bigint(8) not null
+# status_id :bigint(8) not null
#
class Favourite < ApplicationRecord
@@ -16,7 +16,7 @@ class Favourite < ApplicationRecord
update_index('statuses#status', :status) if Chewy.enabled?
belongs_to :account, inverse_of: :favourites
- belongs_to :status, inverse_of: :favourites, counter_cache: true
+ belongs_to :status, inverse_of: :favourites
has_one :notification, as: :activity, dependent: :destroy
@@ -25,4 +25,27 @@ class Favourite < ApplicationRecord
before_validation do
self.status = status.reblog if status&.reblog?
end
+
+ after_create :increment_cache_counters
+ after_destroy :decrement_cache_counters
+
+ private
+
+ def increment_cache_counters
+ if association(:status).loaded?
+ status.update_attribute(:favourites_count, status.favourites_count + 1)
+ else
+ Status.where(id: status_id).update_all('favourites_count = COALESCE(favourites_count, 0) + 1')
+ end
+ end
+
+ def decrement_cache_counters
+ return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
+
+ if association(:status).loaded?
+ status.update_attribute(:favourites_count, [status.favourites_count - 1, 0].max)
+ else
+ Status.where(id: status_id).update_all('favourites_count = GREATEST(COALESCE(favourites_count, 0) - 1, 0)')
+ end
+ end
end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 8e6fe537a..eaf8445f3 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,12 +3,13 @@
#
# Table name: follows
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# target_account_id :bigint(8) not null
# show_reblogs :boolean default(TRUE), not null
+# uri :string
#
class Follow < ApplicationRecord
@@ -26,4 +27,16 @@ class Follow < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id }
scope :recent, -> { reorder(id: :desc) }
+
+ def local?
+ false # Force uri_for to use uri attribute
+ end
+
+ before_validation :set_uri, only: :create
+
+ private
+
+ def set_uri
+ self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+ end
end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index cde26ceed..9c4875564 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -3,12 +3,13 @@
#
# Table name: follow_requests
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# target_account_id :bigint(8) not null
# show_reblogs :boolean default(TRUE), not null
+# uri :string
#
class FollowRequest < ApplicationRecord
@@ -23,11 +24,22 @@ class FollowRequest < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id }
def authorize!
- account.follow!(target_account, reblogs: show_reblogs)
+ account.follow!(target_account, reblogs: show_reblogs, uri: uri)
MergeWorker.perform_async(target_account.id, account.id)
-
destroy!
end
alias reject! destroy!
+
+ def local?
+ false # Force uri_for to use uri attribute
+ end
+
+ before_validation :set_uri, only: :create
+
+ private
+
+ def set_uri
+ self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
+ end
end
diff --git a/app/models/import.rb b/app/models/import.rb
index fdb4c6b80..55e970b0d 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -3,7 +3,7 @@
#
# Table name: imports
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# type :integer not null
# approved :boolean default(FALSE), not null
# created_at :datetime not null
@@ -12,7 +12,7 @@
# data_content_type :string
# data_file_size :integer
# data_updated_at :datetime
-# account_id :integer not null
+# account_id :bigint(8) not null
#
class Import < ApplicationRecord
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 4ba5432d2..2250e588e 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -3,8 +3,8 @@
#
# Table name: invites
#
-# id :integer not null, primary key
-# user_id :integer not null
+# id :bigint(8) not null, primary key
+# user_id :bigint(8) not null
# code :string default(""), not null
# expires_at :datetime
# max_uses :integer
diff --git a/app/models/list.rb b/app/models/list.rb
index a2ec7e84a..c9c94fca1 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -3,8 +3,8 @@
#
# Table name: lists
#
-# id :integer not null, primary key
-# account_id :integer not null
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
# title :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
index da46cf032..87b498224 100644
--- a/app/models/list_account.rb
+++ b/app/models/list_account.rb
@@ -3,10 +3,10 @@
#
# Table name: list_accounts
#
-# id :integer not null, primary key
-# list_id :integer not null
-# account_id :integer not null
-# follow_id :integer not null
+# id :bigint(8) not null, primary key
+# list_id :bigint(8) not null
+# account_id :bigint(8) not null
+# follow_id :bigint(8) not null
#
class ListAccount < ApplicationRecord
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index ac2aa7ed2..f9a8f322e 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -3,8 +3,8 @@
#
# Table name: media_attachments
#
-# id :integer not null, primary key
-# status_id :integer
+# id :bigint(8) not null, primary key
+# status_id :bigint(8)
# file_file_name :string
# file_content_type :string
# file_file_size :integer
@@ -15,12 +15,10 @@
# shortcode :string
# type :integer default("image"), not null
# file_meta :json
-# account_id :integer
+# account_id :bigint(8)
# description :text
#
-require 'mime/types'
-
class MediaAttachment < ApplicationRecord
self.inheritance_column = nil
@@ -70,6 +68,8 @@ class MediaAttachment < ApplicationRecord
validates_attachment_size :file, less_than: LIMIT
remotable_attachment :file, LIMIT
+ include Attachmentable
+
validates :account, presence: true
validates :description, length: { maximum: 420 }, if: :local?
@@ -130,8 +130,9 @@ class MediaAttachment < ApplicationRecord
'pix_fmt' => 'yuv420p',
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
'vsync' => 'cfr',
- 'b:v' => '1300K',
- 'maxrate' => '500K',
+ 'c:v' => 'h264',
+ 'b:v' => '500K',
+ 'maxrate' => '1300K',
'bufsize' => '1300K',
'crf' => 18,
},
@@ -175,9 +176,6 @@ class MediaAttachment < ApplicationRecord
def set_type_and_extension
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
- extension = appropriate_extension
- basename = Paperclip::Interpolations.basename(file, :original)
- file.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.')
end
def set_meta
@@ -222,13 +220,4 @@ class MediaAttachment < ApplicationRecord
bitrate: movie.bitrate,
}
end
-
- def appropriate_extension
- mime_type = MIME::Types[file.content_type]
-
- extensions_for_mime_type = mime_type.empty? ? [] : mime_type.first.extensions
- original_extension = Paperclip::Interpolations.extension(file, :original)
-
- extensions_for_mime_type.include?(original_extension) ? original_extension : extensions_for_mime_type.first
- end
end
diff --git a/app/models/mention.rb b/app/models/mention.rb
index f864bf8e1..8ab886b18 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -3,11 +3,11 @@
#
# Table name: mentions
#
-# id :integer not null, primary key
-# status_id :integer
+# id :bigint(8) not null, primary key
+# status_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer
+# account_id :bigint(8)
#
class Mention < ApplicationRecord
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 8efa27ac0..0e00c2278 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,11 +3,11 @@
#
# Table name: mutes
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# target_account_id :bigint(8) not null
# hide_notifications :boolean default(TRUE), not null
#
diff --git a/app/models/notification.rb b/app/models/notification.rb
index be9964087..4f6ec8e8e 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -3,13 +3,13 @@
#
# Table name: notifications
#
-# id :integer not null, primary key
-# activity_id :integer not null
+# id :bigint(8) not null, primary key
+# activity_id :bigint(8) not null
# activity_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# from_account_id :integer not null
+# account_id :bigint(8) not null
+# from_account_id :bigint(8) not null
#
class Notification < ApplicationRecord
@@ -81,8 +81,6 @@ class Notification < ApplicationRecord
end
end
- private
-
def activity_types_from_types(types)
types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact
end
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 0c82f06ce..a792b352b 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -3,7 +3,7 @@
#
# Table name: preview_cards
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# url :string default(""), not null
# title :string default(""), not null
# description :string default(""), not null
@@ -34,7 +34,7 @@ class PreviewCard < ApplicationRecord
has_and_belongs_to_many :statuses
- has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
+ has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
include Attachmentable
@@ -52,6 +52,23 @@ class PreviewCard < ApplicationRecord
save!
end
+ class << self
+ private
+
+ def image_styles(f)
+ styles = {
+ original: {
+ geometry: '400x400>',
+ file_geometry_parser: FastGeometryParser,
+ convert_options: '-coalesce -strip',
+ },
+ }
+
+ styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif'
+ styles
+ end
+ end
+
private
def extract_dimensions
diff --git a/app/models/remote_profile.rb b/app/models/remote_profile.rb
index 613911c57..742d2b56f 100644
--- a/app/models/remote_profile.rb
+++ b/app/models/remote_profile.rb
@@ -41,6 +41,10 @@ class RemoteProfile
@header ||= link_href_from_xml(author, 'header')
end
+ def emojis
+ @emojis ||= author.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS)
+ end
+
def locked?
scope == 'private'
end
diff --git a/app/models/report.rb b/app/models/report.rb
index dd123fc15..efe385b2d 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -3,21 +3,25 @@
#
# Table name: reports
#
-# id :integer not null, primary key
-# status_ids :integer default([]), not null, is an Array
+# id :bigint(8) not null, primary key
+# status_ids :bigint(8) default([]), not null, is an Array
# comment :text default(""), not null
# action_taken :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# action_taken_by_account_id :integer
-# target_account_id :integer not null
+# account_id :bigint(8) not null
+# action_taken_by_account_id :bigint(8)
+# target_account_id :bigint(8) not null
+# assigned_account_id :bigint(8)
#
class Report < ApplicationRecord
belongs_to :account
belongs_to :target_account, class_name: 'Account'
belongs_to :action_taken_by_account, class_name: 'Account', optional: true
+ belongs_to :assigned_account, class_name: 'Account', optional: true
+
+ has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) }
@@ -35,4 +39,50 @@ class Report < ApplicationRecord
def media_attachments
MediaAttachment.where(status_id: status_ids)
end
+
+ def assign_to_self!(current_account)
+ update!(assigned_account_id: current_account.id)
+ end
+
+ def unassign!
+ update!(assigned_account_id: nil)
+ end
+
+ def resolve!(acting_account)
+ update!(action_taken: true, action_taken_by_account_id: acting_account.id)
+ end
+
+ def unresolve!
+ update!(action_taken: false, action_taken_by_account_id: nil)
+ end
+
+ def unresolved?
+ !action_taken?
+ end
+
+ def history
+ time_range = created_at..updated_at
+
+ sql = [
+ Admin::ActionLog.where(
+ target_type: 'Report',
+ target_id: id,
+ created_at: time_range
+ ).unscope(:order),
+
+ Admin::ActionLog.where(
+ target_type: 'Account',
+ target_id: target_account_id,
+ created_at: time_range
+ ).unscope(:order),
+
+ Admin::ActionLog.where(
+ target_type: 'Status',
+ target_id: status_ids,
+ created_at: time_range
+ ).unscope(:order),
+ ].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
+
+ Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
+ end
end
diff --git a/app/models/report_note.rb b/app/models/report_note.rb
new file mode 100644
index 000000000..54b416577
--- /dev/null
+++ b/app/models/report_note.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: report_notes
+#
+# id :bigint(8) not null, primary key
+# content :text not null
+# report_id :bigint(8) not null
+# account_id :bigint(8) not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class ReportNote < ApplicationRecord
+ belongs_to :account
+ belongs_to :report, inverse_of: :notes, touch: true
+
+ scope :latest, -> { reorder('created_at ASC') }
+
+ validates :content, presence: true, length: { maximum: 500 }
+end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index d364f03df..34d25c83d 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -3,15 +3,15 @@
#
# Table name: session_activations
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# session_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_agent :string default(""), not null
# ip :inet
-# access_token_id :integer
-# user_id :integer not null
-# web_push_subscription_id :integer
+# access_token_id :bigint(8)
+# user_id :bigint(8) not null
+# web_push_subscription_id :bigint(8)
#
class SessionActivation < ApplicationRecord
diff --git a/app/models/setting.rb b/app/models/setting.rb
index df93590ce..033d09fd5 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -3,13 +3,13 @@
#
# Table name: settings
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# var :string not null
# value :text
# thing_type :string
# created_at :datetime
# updated_at :datetime
-# thing_id :integer
+# thing_id :bigint(8)
#
class Setting < RailsSettings::Base
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 641128adf..14d683767 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -3,7 +3,7 @@
#
# Table name: site_uploads
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# var :string default(""), not null
# file_file_name :string
# file_content_type :string
diff --git a/app/models/status.rb b/app/models/status.rb
index 60fa7a22e..7fa069083 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -3,13 +3,13 @@
#
# Table name: statuses
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# uri :string
# text :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
-# in_reply_to_id :integer
-# reblog_of_id :integer
+# in_reply_to_id :bigint(8)
+# reblog_of_id :bigint(8)
# url :string
# sensitive :boolean default(FALSE), not null
# visibility :integer default("public"), not null
@@ -18,11 +18,11 @@
# favourites_count :integer default(0), not null
# reblogs_count :integer default(0), not null
# language :string
-# conversation_id :integer
+# conversation_id :bigint(8)
# local :boolean
-# account_id :integer not null
-# application_id :integer
-# in_reply_to_account_id :integer
+# account_id :bigint(8) not null
+# application_id :bigint(8)
+# in_reply_to_account_id :bigint(8)
#
class Status < ApplicationRecord
@@ -31,24 +31,28 @@ class Status < ApplicationRecord
include Cacheable
include StatusThreadingConcern
+ # If `override_timestamps` is set at creation time, Snowflake ID creation
+ # will be based on current time instead of `created_at`
+ attr_accessor :override_timestamps
+
update_index('statuses#status', :proper) if Chewy.enabled?
enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
- belongs_to :account, inverse_of: :statuses, counter_cache: true
+ belongs_to :account, inverse_of: :statuses
belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
belongs_to :conversation, optional: true
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
- belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count, optional: true
+ belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy
- has_many :media_attachments, dependent: :destroy
+ has_many :media_attachments, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@@ -59,6 +63,7 @@ class Status < ApplicationRecord
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }
validates_with StatusLengthValidator
+ validates_with DisallowedHashtagsValidator
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
default_scope { recent }
@@ -159,9 +164,20 @@ class Status < ApplicationRecord
end
def emojis
- CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
+ @emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
end
+ def mark_for_mass_destruction!
+ @marked_for_mass_destruction = true
+ end
+
+ def marked_for_mass_destruction?
+ @marked_for_mass_destruction
+ end
+
+ after_create :increment_counter_caches
+ after_destroy :decrement_counter_caches
+
after_create_commit :store_uri, if: :local?
after_create_commit :update_statistics, if: :local?
@@ -171,7 +187,6 @@ class Status < ApplicationRecord
before_validation :set_reblog
before_validation :set_visibility
before_validation :set_conversation
- before_validation :set_sensitivity
before_validation :set_local
class << self
@@ -183,6 +198,47 @@ class Status < ApplicationRecord
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
end
+ def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
+ # direct timeline is mix of direct message from_me and to_me.
+ # 2 querys are executed with pagination.
+ # constant expression using arel_table is required for partial index
+
+ # _from_me part does not require any timeline filters
+ query_from_me = where(account_id: account.id)
+ .where(Status.arel_table[:visibility].eq(3))
+ .limit(limit)
+ .order('statuses.id DESC')
+
+ # _to_me part requires mute and block filter.
+ # FIXME: may we check mutes.hide_notifications?
+ query_to_me = Status
+ .joins(:mentions)
+ .merge(Mention.where(account_id: account.id))
+ .where(Status.arel_table[:visibility].eq(3))
+ .limit(limit)
+ .order('mentions.status_id DESC')
+ .not_excluded_by_account(account)
+
+ if max_id.present?
+ query_from_me = query_from_me.where('statuses.id < ?', max_id)
+ query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
+ end
+
+ if since_id.present?
+ query_from_me = query_from_me.where('statuses.id > ?', since_id)
+ query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
+ end
+
+ if cache_ids
+ # returns array of cache_ids object that have id and updated_at
+ (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+ else
+ # returns ActiveRecord.Relation
+ items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+ Status.where(id: items.map(&:id))
+ end
+ end
+
def as_public_timeline(account = nil, local_only = false)
query = timeline_scope(local_only).without_replies
@@ -249,7 +305,11 @@ class Status < ApplicationRecord
# non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
visibility.push(:private) if account.following?(target_account)
- where(visibility: visibility).or(where(id: account.mentions.select(:status_id)))
+ scope = left_outer_joins(:reblog)
+
+ scope.where(visibility: visibility)
+ .or(scope.where(id: account.mentions.select(:status_id)))
+ .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
end
end
@@ -311,10 +371,6 @@ class Status < ApplicationRecord
self.sensitive = false if sensitive.nil?
end
- def set_sensitivity
- self.sensitive = sensitive || spoiler_text.present?
- end
-
def set_conversation
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
@@ -322,7 +378,7 @@ class Status < ApplicationRecord
self.in_reply_to_account_id = carried_over_reply_to_account_id
self.conversation_id = thread.conversation_id if conversation_id.nil?
elsif conversation_id.nil?
- create_conversation
+ self.conversation = Conversation.new
end
end
@@ -342,4 +398,40 @@ class Status < ApplicationRecord
return unless public_visibility? || unlisted_visibility?
ActivityTracker.increment('activity:statuses:local')
end
+
+ def increment_counter_caches
+ return if direct_visibility?
+
+ if association(:account).loaded?
+ account.update_attribute(:statuses_count, account.statuses_count + 1)
+ else
+ Account.where(id: account_id).update_all('statuses_count = COALESCE(statuses_count, 0) + 1')
+ end
+
+ return unless reblog?
+
+ if association(:reblog).loaded?
+ reblog.update_attribute(:reblogs_count, reblog.reblogs_count + 1)
+ else
+ Status.where(id: reblog_of_id).update_all('reblogs_count = COALESCE(reblogs_count, 0) + 1')
+ end
+ end
+
+ def decrement_counter_caches
+ return if direct_visibility? || marked_for_mass_destruction?
+
+ if association(:account).loaded?
+ account.update_attribute(:statuses_count, [account.statuses_count - 1, 0].max)
+ else
+ Account.where(id: account_id).update_all('statuses_count = GREATEST(COALESCE(statuses_count, 0) - 1, 0)')
+ end
+
+ return unless reblog?
+
+ if association(:reblog).loaded?
+ reblog.update_attribute(:reblogs_count, [reblog.reblogs_count - 1, 0].max)
+ else
+ Status.where(id: reblog_of_id).update_all('reblogs_count = GREATEST(COALESCE(reblogs_count, 0) - 1, 0)')
+ end
+ end
end
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
index d3a98d8bd..afc76bded 100644
--- a/app/models/status_pin.rb
+++ b/app/models/status_pin.rb
@@ -3,9 +3,9 @@
#
# Table name: status_pins
#
-# id :integer not null, primary key
-# account_id :integer not null
-# status_id :integer not null
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
+# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index 2ae034d93..a2f273281 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -3,13 +3,13 @@
#
# Table name: stream_entries
#
-# id :integer not null, primary key
-# activity_id :integer
+# id :bigint(8) not null, primary key
+# activity_id :bigint(8)
# activity_type :string
# created_at :datetime not null
# updated_at :datetime not null
# hidden :boolean default(FALSE), not null
-# account_id :integer
+# account_id :bigint(8)
#
class StreamEntry < ApplicationRecord
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index ea1173160..79b81828d 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -3,7 +3,7 @@
#
# Table name: subscriptions
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# callback_url :string default(""), not null
# secret :string
# expires_at :datetime
@@ -12,7 +12,7 @@
# updated_at :datetime not null
# last_successful_delivery_at :datetime
# domain :string
-# account_id :integer not null
+# account_id :bigint(8) not null
#
class Subscription < ApplicationRecord
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 9fa9405d7..4f31f796e 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,7 +3,7 @@
#
# Table name: tags
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# name :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -21,6 +21,22 @@ class Tag < ApplicationRecord
name
end
+ def history
+ days = []
+
+ 7.times do |i|
+ day = i.days.ago.beginning_of_day.to_i
+
+ days << {
+ day: day.to_s,
+ uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
+ accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
+ }
+ end
+
+ days
+ end
+
class << self
def search_for(term, limit = 5)
pattern = sanitize_sql_like(term.strip) + '%'
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
new file mode 100644
index 000000000..c3641d7fd
--- /dev/null
+++ b/app/models/trending_tags.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class TrendingTags
+ EXPIRE_HISTORY_AFTER = 7.days.seconds
+
+ class << self
+ def record_use!(tag, account, at_time = Time.now.utc)
+ return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
+
+ increment_historical_use!(tag.id, at_time)
+ increment_unique_use!(tag.id, account.id, at_time)
+ end
+
+ private
+
+ def increment_historical_use!(tag_id, at_time)
+ key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
+ redis.incrby(key, 1)
+ redis.expire(key, EXPIRE_HISTORY_AFTER)
+ end
+
+ def increment_unique_use!(tag_id, account_id, at_time)
+ key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
+ redis.pfadd(key, account_id)
+ redis.expire(key, EXPIRE_HISTORY_AFTER)
+ end
+
+ def disallowed_hashtags
+ return @disallowed_hashtags if defined?(@disallowed_hashtags)
+
+ @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
+ @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
+ @disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
+ end
+
+ def redis
+ Redis.current
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 2d5f145fa..0becfa7e9 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,7 +3,7 @@
#
# Table name: users
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# email :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -30,10 +30,10 @@
# last_emailed_at :datetime
# otp_backup_codes :string is an Array
# filtered_languages :string default([]), not null, is an Array
-# account_id :integer not null
+# account_id :bigint(8) not null
# disabled :boolean default(FALSE), not null
# moderator :boolean default(FALSE), not null
-# invite_id :integer
+# invite_id :bigint(8)
# remember_token :string
#
@@ -41,7 +41,7 @@ class User < ApplicationRecord
include Settings::Extend
include Omniauthable
- ACTIVE_DURATION = 14.days
+ ACTIVE_DURATION = 7.days
devise :two_factor_authenticatable,
otp_secret_encryption_key: Rails.configuration.x.otp_secret
@@ -65,6 +65,7 @@ class User < ApplicationRecord
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed?
+ validates_with EmailMxValidator, if: :email_changed?
scope :recent, -> { order(id: :desc) }
scope :admins, -> { where(admin: true) }
@@ -86,7 +87,7 @@ class User < ApplicationRecord
has_many :session_activations, dependent: :destroy
delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
- :reduce_motion, :system_font_ui, :noindex, :theme, :display_sensitive_media,
+ :reduce_motion, :system_font_ui, :noindex, :theme, :display_sensitive_media, :hide_network,
to: :settings, prefix: :setting, allow_nil: false
attr_accessor :invite_code
@@ -219,6 +220,10 @@ class User < ApplicationRecord
settings.notification_emails['digest']
end
+ def hides_network?
+ @hides_network ||= settings.hide_network
+ end
+
def token_for_app(a)
return nil if a.nil? || a.owner != self
Doorkeeper::AccessToken
@@ -245,7 +250,7 @@ class User < ApplicationRecord
end
def web_push_subscription(session)
- session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload
+ session.web_push_subscription.nil? ? nil : session.web_push_subscription
end
def invite_code=(code)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index 5aee92d27..d19b20c48 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -3,46 +3,65 @@
#
# Table name: web_push_subscriptions
#
-# id :integer not null, primary key
-# endpoint :string not null
-# key_p256dh :string not null
-# key_auth :string not null
-# data :json
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :bigint(8) not null, primary key
+# endpoint :string not null
+# key_p256dh :string not null
+# key_auth :string not null
+# data :json
+# created_at :datetime not null
+# updated_at :datetime not null
+# access_token_id :bigint(8)
+# user_id :bigint(8)
#
-require 'webpush'
-
class Web::PushSubscription < ApplicationRecord
- has_one :session_activation
+ belongs_to :user, optional: true
+ belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', optional: true
+
+ has_one :session_activation, foreign_key: 'web_push_subscription_id', inverse_of: :web_push_subscription
def push(notification)
- I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
- push_payload(message_from(notification), 48.hours.seconds)
+ I18n.with_locale(associated_user&.locale || I18n.default_locale) do
+ push_payload(payload_for_notification(notification), 48.hours.seconds)
end
end
def pushable?(notification)
- data&.key?('alerts') && data['alerts'][notification.type.to_s]
+ data&.key?('alerts') && ActiveModel::Type::Boolean.new.cast(data['alerts'][notification.type.to_s])
end
- def as_payload
- payload = { id: id, endpoint: endpoint }
- payload[:alerts] = data['alerts'] if data&.key?('alerts')
- payload
+ def associated_user
+ return @associated_user if defined?(@associated_user)
+
+ @associated_user = if user_id.nil?
+ session_activation.user
+ else
+ user
+ end
end
- def access_token
- find_or_create_access_token.token
+ def associated_access_token
+ return @associated_access_token if defined?(@associated_access_token)
+
+ @associated_access_token = if access_token_id.nil?
+ find_or_create_access_token.token
+ else
+ access_token.token
+ end
+ end
+
+ class << self
+ def unsubscribe_for(application_id, resource_owner)
+ access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil)
+ .pluck(:id)
+
+ where(access_token_id: access_token_ids).delete_all
+ end
end
private
def push_payload(message, ttl = 5.minutes.seconds)
- # TODO: Make sure that the payload does not
- # exceed 4KB - Webpush::PayloadTooLarge
-
Webpush.payload_send(
message: Oj.dump(message),
endpoint: endpoint,
@@ -57,16 +76,20 @@ class Web::PushSubscription < ApplicationRecord
)
end
- def message_from(notification)
- serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
- serializable_resource.as_json
+ def payload_for_notification(notification)
+ ActiveModelSerializers::SerializableResource.new(
+ notification,
+ serializer: Web::NotificationSerializer,
+ scope: self,
+ scope_name: :current_push_subscription
+ ).as_json
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.find_by(superapp: true),
session_activation.user_id,
- Doorkeeper::OAuth::Scopes.from_string('read write follow'),
+ Doorkeeper::OAuth::Scopes.from_string('read write follow push'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
index 0a5129d17..99588d26c 100644
--- a/app/models/web/setting.rb
+++ b/app/models/web/setting.rb
@@ -3,11 +3,11 @@
#
# Table name: web_settings
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
# data :json
# created_at :datetime not null
# updated_at :datetime not null
-# user_id :integer not null
+# user_id :bigint(8) not null
#
class Web::Setting < ApplicationRecord
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 85e2c8419..efabe80d0 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -29,6 +29,10 @@ class AccountPolicy < ApplicationPolicy
admin?
end
+ def remove_avatar?
+ staff?
+ end
+
def subscribe?
admin?
end
diff --git a/app/policies/report_note_policy.rb b/app/policies/report_note_policy.rb
new file mode 100644
index 000000000..694bc096b
--- /dev/null
+++ b/app/policies/report_note_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ReportNotePolicy < ApplicationPolicy
+ def create?
+ staff?
+ end
+
+ def destroy?
+ admin? || owner?
+ end
+
+ private
+
+ def owner?
+ record.account_id == current_account&.id
+ end
+end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 0373fdf04..6addc8a8a 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -1,22 +1,32 @@
# frozen_string_literal: true
class StatusPolicy < ApplicationPolicy
+ def initialize(current_account, record, preloaded_relations = {})
+ super(current_account, record)
+
+ @preloaded_relations = preloaded_relations
+ end
+
def index?
staff?
end
def show?
if direct?
- owned? || record.mentions.where(account: current_account).exists?
+ owned? || mention_exists?
elsif private?
- owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists?
+ owned? || following_author? || mention_exists?
else
- current_account.nil? || !author.blocking?(current_account)
+ current_account.nil? || !author_blocking?
end
end
def reblog?
- !direct? && !private? && show?
+ !direct? && (!private? || owned?) && show? && !blocking_author?
+ end
+
+ def favourite?
+ show? && !blocking_author?
end
def destroy?
@@ -43,6 +53,34 @@ class StatusPolicy < ApplicationPolicy
record.private_visibility?
end
+ def mention_exists?
+ return false if current_account.nil?
+
+ if record.mentions.loaded?
+ record.mentions.any? { |mention| mention.account_id == current_account.id }
+ else
+ record.mentions.where(account: current_account).exists?
+ end
+ end
+
+ def blocking_author?
+ return false if current_account.nil?
+
+ @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][author.id] : current_account.blocking?(author)
+ end
+
+ def author_blocking?
+ return false if current_account.nil?
+
+ @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account)
+ end
+
+ def following_author?
+ return false if current_account.nil?
+
+ @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author)
+ end
+
def author
record.account
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index aae207d06..dabdf707a 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -5,6 +5,10 @@ class UserPolicy < ApplicationPolicy
staff? && !record.staff?
end
+ def change_email?
+ staff? && !record.staff?
+ end
+
def disable_2fa?
admin? && !record.staff?
end
diff --git a/app/presenters/activitypub/collection_presenter.rb b/app/presenters/activitypub/collection_presenter.rb
index 39657276f..ec84ab1a3 100644
--- a/app/presenters/activitypub/collection_presenter.rb
+++ b/app/presenters/activitypub/collection_presenter.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model
- attributes :id, :type, :size, :items, :part_of, :first, :next, :prev
+ attributes :id, :type, :size, :items, :part_of, :first, :last, :next, :prev
end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index afcd37771..41c9aa44e 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -10,6 +10,9 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
+ has_many :virtual_tags, key: :tag
+ has_many :virtual_attachments, key: :attachment
+
attribute :moved_to, if: :moved?
class EndpointsSerializer < ActiveModel::Serializer
@@ -34,7 +37,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
end
def type
- 'Person'
+ object.bot? ? 'Service' : 'Person'
end
def following
@@ -101,7 +104,30 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
object.locked
end
+ def virtual_tags
+ object.emojis
+ end
+
+ def virtual_attachments
+ object.fields
+ end
+
def moved_to
ActivityPub::TagManager.instance.uri_for(object.moved_to_account)
end
+
+ class CustomEmojiSerializer < ActivityPub::EmojiSerializer
+ end
+
+ class Account::FieldSerializer < ActiveModel::Serializer
+ attributes :type, :name, :value
+
+ def type
+ 'PropertyValue'
+ end
+
+ def value
+ Formatter.instance.format_field(object.account, object.value)
+ end
+ end
end
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index b3bd9f868..624ce2fce 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::BlockSerializer < ActiveModel::Serializer
attribute :virtual_object, key: :object
def id
- [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+ ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
end
def type
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index 1ae492945..e8960131b 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -7,12 +7,14 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
super
end
- attributes :id, :type, :total_items
+ attributes :id, :type
+ attribute :total_items, if: -> { object.size.present? }
attribute :next, if: -> { object.next.present? }
attribute :prev, if: -> { object.prev.present? }
attribute :part_of, if: -> { object.part_of.present? }
has_one :first, if: -> { object.first.present? }
+ has_one :last, if: -> { object.last.present? }
has_many :items, key: :items, if: -> { (!object.items.nil? || page?) && !ordered? }
has_many :items, key: :ordered_items, if: -> { (!object.items.nil? || page?) && ordered? }
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 86c9992fe..bb204ee8f 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -5,7 +5,7 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
attribute :virtual_object, key: :object
def id
- [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+ ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
end
def type
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 216cf5446..6c9fba2f5 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -2,13 +2,9 @@
class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
- :media_attachments, :settings, :push_subscription
+ :media_attachments, :settings
- has_many :custom_emojis, serializer: REST::CustomEmojiSerializer
-
- def custom_emojis
- CustomEmoji.local.where(disabled: false)
- end
+ has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
def meta
store = {
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 19b746520..6adcd7039 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -3,18 +3,29 @@
class REST::AccountSerializer < ActiveModel::Serializer
include RoutingHelper
- attributes :id, :username, :acct, :display_name, :locked, :created_at,
+ attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
:note, :url, :avatar, :avatar_static, :header, :header_static,
:followers_count, :following_count, :statuses_count
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
+ has_many :emojis, serializer: REST::CustomEmojiSerializer
+
+ class FieldSerializer < ActiveModel::Serializer
+ attributes :name, :value
+
+ def value
+ Formatter.instance.format_field(object.account, object.value)
+ end
+ end
+
+ has_many :fields
def id
object.id.to_s
end
def note
- Formatter.instance.simplified_format(object)
+ Formatter.instance.simplified_format(object, custom_emojify: true)
end
def url
diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb
index 870d8b71f..56857cba8 100644
--- a/app/serializers/rest/credential_account_serializer.rb
+++ b/app/serializers/rest/credential_account_serializer.rb
@@ -5,10 +5,12 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer
def source
user = object.user
+
{
privacy: user.setting_default_privacy,
sensitive: user.setting_default_sensitive,
note: object.note,
+ fields: object.fields.map(&:to_h),
}
end
end
diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb
new file mode 100644
index 000000000..74aa571a4
--- /dev/null
+++ b/app/serializers/rest/tag_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::TagSerializer < ActiveModel::Serializer
+ include RoutingHelper
+
+ attributes :name, :url, :history
+
+ def url
+ tag_url(object)
+ end
+end
diff --git a/app/serializers/rest/v2/search_serializer.rb b/app/serializers/rest/v2/search_serializer.rb
new file mode 100644
index 000000000..cdb6b3a53
--- /dev/null
+++ b/app/serializers/rest/v2/search_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::V2::SearchSerializer < ActiveModel::Serializer
+ has_many :accounts, serializer: REST::AccountSerializer
+ has_many :statuses, serializer: REST::StatusSerializer
+ has_many :hashtags, serializer: REST::TagSerializer
+end
diff --git a/app/serializers/rest/web_push_subscription_serializer.rb b/app/serializers/rest/web_push_subscription_serializer.rb
new file mode 100644
index 000000000..7fd952a56
--- /dev/null
+++ b/app/serializers/rest/web_push_subscription_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
+ attributes :id, :endpoint, :alerts, :server_key
+
+ def alerts
+ object.data&.dig('alerts') || {}
+ end
+
+ def server_key
+ Rails.configuration.x.vapid_public_key
+ end
+end
diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb
new file mode 100644
index 000000000..bde360a41
--- /dev/null
+++ b/app/serializers/rss/account_serializer.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class RSS::AccountSerializer
+ include ActionView::Helpers::NumberHelper
+ include StreamEntriesHelper
+ include RoutingHelper
+
+ def render(account, statuses)
+ builder = RSSBuilder.new
+
+ builder.title("#{display_name(account)} (@#{account.local_username_and_domain})")
+ .description(account_description(account))
+ .link(TagManager.instance.url_for(account))
+ .logo(full_asset_url(asset_pack_path('logo.svg')))
+ .accent_color('2b90d9')
+
+ builder.image(full_asset_url(account.avatar.url(:original))) if account.avatar?
+ builder.cover(full_asset_url(account.header.url(:original))) if account.header?
+
+ statuses.each do |status|
+ builder.item do |item|
+ item.title(status.title)
+ .link(TagManager.instance.url_for(status))
+ .pub_date(status.created_at)
+ .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str)
+
+ status.media_attachments.each do |media|
+ item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, length: media.file.size)
+ end
+ end
+ end
+
+ builder.to_xml
+ end
+
+ def self.render(account, statuses)
+ new.render(account, statuses)
+ end
+end
diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb
new file mode 100644
index 000000000..7680a8da5
--- /dev/null
+++ b/app/serializers/rss/tag_serializer.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class RSS::TagSerializer
+ include ActionView::Helpers::NumberHelper
+ include ActionView::Helpers::SanitizeHelper
+ include StreamEntriesHelper
+ include RoutingHelper
+
+ def render(tag, statuses)
+ builder = RSSBuilder.new
+
+ builder.title("##{tag.name}")
+ .description(strip_tags(I18n.t('about.about_hashtag_html', hashtag: tag.name)))
+ .link(tag_url(tag))
+ .logo(full_asset_url(asset_pack_path('logo.svg')))
+ .accent_color('2b90d9')
+
+ statuses.each do |status|
+ builder.item do |item|
+ item.title(status.title)
+ .link(TagManager.instance.url_for(status))
+ .pub_date(status.created_at)
+ .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str)
+
+ status.media_attachments.each do |media|
+ item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, length: media.file.size)
+ end
+ end
+ end
+
+ builder.to_xml
+ end
+
+ def self.render(tag, statuses)
+ new.render(tag, statuses)
+ end
+end
diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb
index e5524fe7a..43ba4d92a 100644
--- a/app/serializers/web/notification_serializer.rb
+++ b/app/serializers/web/notification_serializer.rb
@@ -2,168 +2,38 @@
class Web::NotificationSerializer < ActiveModel::Serializer
include RoutingHelper
- include StreamEntriesHelper
+ include ActionView::Helpers::TextHelper
+ include ActionView::Helpers::SanitizeHelper
- class DataSerializer < ActiveModel::Serializer
- include RoutingHelper
- include StreamEntriesHelper
- include ActionView::Helpers::SanitizeHelper
+ attributes :access_token, :preferred_locale, :notification_id,
+ :notification_type, :icon, :title, :body
- attributes :content, :nsfw, :url, :actions,
- :access_token, :message, :dir
-
- def content
- decoder.decode(strip_tags(body))
- end
-
- def dir
- rtl?(body) ? 'rtl' : 'ltr'
- end
-
- def nsfw
- return if object.target_status.nil?
- object.target_status.spoiler_text.presence
- end
-
- def url
- case object.type
- when :mention
- web_url("statuses/#{object.target_status.id}")
- when :follow
- web_url("accounts/#{object.from_account.id}")
- when :favourite
- web_url("statuses/#{object.target_status.id}")
- when :reblog
- web_url("statuses/#{object.target_status.id}")
- end
- end
-
- def actions
- return @actions if defined?(@actions)
-
- @actions = []
-
- if object.type == :mention
- @actions << expand_action if collapsed?
- @actions << favourite_action
- @actions << reblog_action if rebloggable?
- end
-
- @actions
- end
-
- def access_token
- return if actions.empty?
- current_push_subscription.access_token
- end
-
- def message
- I18n.t('push_notifications.group.title')
- end
-
- private
-
- def body
- case object.type
- when :mention
- object.target_status.text
- when :follow
- object.from_account.note
- when :favourite
- object.target_status.text
- when :reblog
- object.target_status.text
- end
- end
-
- def decoder
- @decoder ||= HTMLEntities.new
- end
-
- def expand_action
- {
- title: I18n.t('push_notifications.mention.action_expand'),
- icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true),
- todo: 'expand',
- action: 'expand',
- }
- end
-
- def favourite_action
- {
- title: I18n.t('push_notifications.mention.action_favourite'),
- icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
- todo: 'request',
- method: 'POST',
- action: "/api/v1/statuses/#{object.target_status.id}/favourite",
- }
- end
-
- def reblog_action
- {
- title: I18n.t('push_notifications.mention.action_boost'),
- icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true),
- todo: 'request',
- method: 'POST',
- action: "/api/v1/statuses/#{object.target_status.id}/reblog",
- }
- end
-
- def collapsed?
- !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?)
- end
-
- def rebloggable?
- !object.target_status.nil? && !object.target_status.hidden?
- end
+ def access_token
+ current_push_subscription.associated_access_token
end
- attributes :title, :image, :badge, :tag,
- :timestamp, :icon
-
- has_one :data, serializer: DataSerializer
-
- def title
- case object.type
- when :mention
- I18n.t('push_notifications.mention.title', name: name)
- when :follow
- I18n.t('push_notifications.follow.title', name: name)
- when :favourite
- I18n.t('push_notifications.favourite.title', name: name)
- when :reblog
- I18n.t('push_notifications.reblog.title', name: name)
- end
+ def preferred_locale
+ current_push_subscription.associated_user&.locale || I18n.default_locale
end
- def image
- return if object.target_status.nil? || object.target_status.media_attachments.empty?
- full_asset_url(object.target_status.media_attachments.first.file.url(:small))
- end
-
- def badge
- full_asset_url('badge.png', skip_pipeline: true)
- end
-
- def tag
+ def notification_id
object.id
end
- def timestamp
- object.created_at
+ def notification_type
+ object.type
end
def icon
- object.from_account.avatar_static_url
+ full_asset_url(object.from_account.avatar_static_url)
end
- def data
- object
+ def title
+ I18n.t("notification_mailer.#{object.type}.subject", name: object.from_account.display_name.presence || object.from_account.username)
end
- private
-
- def name
- display_name(object.from_account)
+ def body
+ str = truncate(strip_tags(object.target_status&.spoiler_text&.presence || object.target_status&.text || object.from_account.note), length: 140)
+ HTMLEntities.new.decode(str.to_str) # Do not encode entities, since this value will not be used in HTML
end
end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 3860a9cbd..7edbd9b47 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -65,9 +65,9 @@ class AccountSearchService < BaseService
def exact_match
@_exact_match ||= begin
if domain_is_local?
- search_from.find_local(query_username)
+ search_from.without_suspended.find_local(query_username)
else
- search_from.find_remote(query_username, query_domain)
+ search_from.without_suspended.find_remote(query_username, query_domain)
end
end
end
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
index 40714e980..6a137b520 100644
--- a/app/services/activitypub/fetch_featured_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -4,6 +4,8 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
include JsonLdHelper
def call(account)
+ return if account.featured_collection_url.blank?
+
@account = account
@json = fetch_resource(@account.featured_collection_url, true)
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index d6ba625a9..867e70876 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -3,6 +3,8 @@
class ActivityPub::FetchRemoteAccountService < BaseService
include JsonLdHelper
+ SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+
# Should be called when uri has already been checked for locality
# Does a WebFinger roundtrip on each call
def call(uri, id: true, prefetched_body: nil)
@@ -54,6 +56,6 @@ class ActivityPub::FetchRemoteAccountService < BaseService
end
def expected_type?
- @json['type'] == 'Person'
+ equals_or_includes_any?(@json['type'], SUPPORTED_TYPES)
end
end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
index ce1048fee..505baccd4 100644
--- a/app/services/activitypub/fetch_remote_key_service.rb
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -43,7 +43,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService
end
def person?
- @json['type'] == 'Person'
+ equals_or_includes_any?(@json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
end
def public_key?
@@ -55,6 +55,6 @@ class ActivityPub::FetchRemoteKeyService < BaseService
end
def confirmed_owner?
- @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
+ equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id']
end
end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 503c175d8..2b447abb3 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -4,9 +4,9 @@ class ActivityPub::FetchRemoteStatusService < BaseService
include JsonLdHelper
# Should be called when uri has already been checked for locality
- def call(uri, id: true, prefetched_body: nil)
+ def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil)
@json = if prefetched_body.nil?
- fetch_resource(uri, id)
+ fetch_resource(uri, id, on_behalf_of)
else
body_to_json(prefetched_body)
end
@@ -34,6 +34,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
end
def trustworthy_attribution?(uri, attributed_to)
+ return false if uri.nil? || attributed_to.nil?
Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
end
@@ -42,7 +43,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
end
def expected_type?
- (ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES).include? @json['type']
+ equals_or_includes_any?(@json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
end
def needs_update(actor)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 7d8dc1369..453253db4 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -22,9 +22,14 @@ class ActivityPub::ProcessAccountService < BaseService
create_account if @account.nil?
update_account
+ process_tags
+ else
+ raise Mastodon::RaceConditionError
end
end
+ return if @account.nil?
+
after_protocol_change! if protocol_changed?
after_key_change! if key_changed?
check_featured_collection! if @account.featured_collection_url.present?
@@ -41,7 +46,6 @@ class ActivityPub::ProcessAccountService < BaseService
@account.protocol = :activitypub
@account.username = @username
@account.domain = @domain
- @account.uri = @uri
@account.suspended = true if auto_suspend?
@account.silenced = true if auto_silence?
@account.private_key = nil
@@ -64,9 +68,12 @@ class ActivityPub::ProcessAccountService < BaseService
@account.followers_url = @json['followers'] || ''
@account.featured_collection_url = @json['featured'] || ''
@account.url = url || @uri
+ @account.uri = @uri
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@account.locked = @json['manuallyApprovesFollowers'] || false
+ @account.fields = property_values || {}
+ @account.actor_type = actor_type
end
def set_fetchable_attributes!
@@ -91,6 +98,14 @@ class ActivityPub::ProcessAccountService < BaseService
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
end
+ def actor_type
+ if @json['type'].is_a?(Array)
+ @json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
+ else
+ @json['type']
+ end
+ end
+
def image_url(key)
value = first_of_value(@json[key])
@@ -123,6 +138,11 @@ class ActivityPub::ProcessAccountService < BaseService
end
end
+ def property_values
+ return unless @json['attachment'].is_a?(Array)
+ @json['attachment'].select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') }
+ end
+
def mismatching_origin?(url)
needle = Addressable::URI.parse(url).host
haystack = Addressable::URI.parse(@uri).host
@@ -187,4 +207,29 @@ class ActivityPub::ProcessAccountService < BaseService
def lock_options
{ redis: Redis.current, key: "process_account:#{@uri}" }
end
+
+ def process_tags
+ return if @json['tag'].blank?
+
+ as_array(@json['tag']).each do |tag|
+ process_emoji tag if equals_or_includes?(tag['type'], 'Emoji')
+ end
+ end
+
+ def process_emoji(tag)
+ return if skip_download?
+ return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
+
+ shortcode = tag['name'].delete(':')
+ image_url = tag['icon']['url']
+ uri = tag['id']
+ updated = tag['updated']
+ emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
+
+ return unless emoji.nil? || emoji.updated_at >= updated
+
+ emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
+ emoji.image_remote_url = image_url
+ emoji.save
+ end
end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index eb93329e9..79cdca297 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -45,5 +45,8 @@ class ActivityPub::ProcessCollectionService < BaseService
def verify_account!
@account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+ rescue JSON::LD::JsonLdError => e
+ Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"
+ nil
end
end
diff --git a/app/services/after_block_domain_from_account_service.rb b/app/services/after_block_domain_from_account_service.rb
new file mode 100644
index 000000000..0f1a8505d
--- /dev/null
+++ b/app/services/after_block_domain_from_account_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class AfterBlockDomainFromAccountService < BaseService
+ # This service does not create an AccountDomainBlock record,
+ # it's meant to be called after such a record has been created
+ # synchronously, to "clean up"
+ def call(account, domain)
+ @account = account
+ @domain = domain
+
+ reject_existing_followers!
+ reject_pending_follow_requests!
+ end
+
+ private
+
+ def reject_existing_followers!
+ @account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).find_each do |follow|
+ reject_follow!(follow)
+ end
+ end
+
+ def reject_pending_follow_requests!
+ FollowRequest.where(target_account: @account).where(account: Account.where(domain: @domain)).includes(:account).find_each do |follow_request|
+ reject_follow!(follow_request)
+ end
+ end
+
+ def reject_follow!(follow)
+ follow.destroy
+
+ return unless follow.account.activitypub?
+
+ json = Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+ follow,
+ serializer: ActivityPub::RejectFollowSerializer,
+ adapter: ActivityPub::Adapter
+ ).as_json).sign!(@account))
+
+ ActivityPub::DeliveryWorker.perform_async(json, @account.id, follow.account.inbox_url)
+ end
+end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index e2763c2b9..ebb4034aa 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -21,7 +21,10 @@ class BatchedRemoveStatusService < BaseService
@activity_xml = {}
# Ensure that rendered XML reflects destroyed state
- statuses.each(&:destroy)
+ statuses.each do |status|
+ status.mark_for_mass_destruction!
+ status.destroy
+ end
# Batch by source account
statuses.group_by(&:account_id).each_value do |account_statuses|
@@ -36,6 +39,7 @@ class BatchedRemoveStatusService < BaseService
# Cannot be batched
statuses.each do |status|
unpush_from_public_timelines(status)
+ unpush_from_direct_timelines(status) if status.direct_visibility?
batch_salmon_slaps(status) if status.local?
end
@@ -52,7 +56,7 @@ class BatchedRemoveStatusService < BaseService
end
def unpush_from_home_timelines(account, statuses)
- recipients = account.followers.local.to_a
+ recipients = account.followers_for_local_distribution.to_a
recipients << account if account.local?
@@ -64,7 +68,7 @@ class BatchedRemoveStatusService < BaseService
end
def unpush_from_list_timelines(account, statuses)
- account.lists.select(:id, :account_id).each do |list|
+ account.lists_for_local_distribution.select(:id, :account_id).each do |list|
statuses.each do |status|
FeedManager.instance.unpush_from_list(list, status)
end
@@ -80,6 +84,11 @@ class BatchedRemoveStatusService < BaseService
redis.publish('timeline:public', payload)
redis.publish('timeline:public:local', payload) if status.local?
+ if status.media_attachments.any?
+ redis.publish('timeline:public:media', payload)
+ redis.publish('timeline:public:local:media', payload) if status.local?
+ end
+
@tags[status.id].each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag}", payload)
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
@@ -87,6 +96,16 @@ class BatchedRemoveStatusService < BaseService
end
end
+ def unpush_from_direct_timelines(status)
+ payload = @json_payloads[status.id]
+ redis.pipelined do
+ @mentions[status.id].each do |mention|
+ redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local?
+ end
+ redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local?
+ end
+ end
+
def batch_salmon_slaps(status)
return if @mentions[status.id].empty?
diff --git a/app/services/block_domain_from_account_service.rb b/app/services/block_domain_from_account_service.rb
deleted file mode 100644
index cae7abcbd..000000000
--- a/app/services/block_domain_from_account_service.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-class BlockDomainFromAccountService < BaseService
- def call(account, domain)
- account.block_domain!(domain)
- account.passive_relationships.where(account: Account.where(domain: domain)).delete_all
- end
-end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index bbaf3094b..5efd3edb2 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require 'sidekiq-bulk'
-
class FanOutOnWriteService < BaseService
# Push a status into home and mentions feeds
# @param [Status] status
@@ -10,8 +8,11 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local?
+ render_anonymous_payload(status)
+
if status.direct_visibility?
deliver_to_mentioned_followers(status)
+ deliver_to_direct_timelines(status)
else
deliver_to_followers(status)
deliver_to_lists(status)
@@ -19,12 +20,12 @@ class FanOutOnWriteService < BaseService
return if status.account.silenced? || !status.public_visibility? || status.reblog?
- render_anonymous_payload(status)
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id
deliver_to_public(status)
+ deliver_to_media(status) if status.media_attachments.any?
end
private
@@ -37,7 +38,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_followers(status)
Rails.logger.debug "Delivering status #{status.id} to followers"
- status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |followers|
+ status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
FeedInsertWorker.push_bulk(followers) do |follower|
[status.id, follower.id, :home]
end
@@ -47,7 +48,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_lists(status)
Rails.logger.debug "Delivering status #{status.id} to lists"
- status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).reorder(nil).find_in_batches do |lists|
+ status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists|
FeedInsertWorker.push_bulk(lists) do |list|
[status.id, list.id, :list]
end
@@ -84,4 +85,20 @@ class FanOutOnWriteService < BaseService
Redis.current.publish('timeline:public', @payload)
Redis.current.publish('timeline:public:local', @payload) if status.local?
end
+
+ def deliver_to_media(status)
+ Rails.logger.debug "Delivering status #{status.id} to media timeline"
+
+ Redis.current.publish('timeline:public:media', @payload)
+ Redis.current.publish('timeline:public:local:media', @payload) if status.local?
+ end
+
+ def deliver_to_direct_timelines(status)
+ Rails.logger.debug "Delivering status #{status.id} to direct timelines"
+
+ status.mentions.includes(:account).each do |mention|
+ Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
+ end
+ Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
+ end
end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 44df3ed13..bc2d1547a 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -8,7 +8,7 @@ class FavouriteService < BaseService
# @param [Status] status
# @return [Favourite]
def call(account, status)
- authorize_with account, status, :show?
+ authorize_with account, status, :favourite?
favourite = Favourite.find_by(account: account, status: status)
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 62dea8298..550e75f33 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -42,9 +42,9 @@ class FetchAtomService < BaseService
elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type)
body = response.body_with_limit
json = body_to_json(body)
- if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present?
+ if supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && json['inbox'].present?
[json['id'], { prefetched_body: body, id: true }, :activitypub]
- elsif supported_context?(json) && json['type'] == 'Note'
+ elsif supported_context?(json) && expected_type?(json)
[json['id'], { prefetched_body: body, id: true }, :activitypub]
else
@unsupported_activity = true
@@ -61,6 +61,10 @@ class FetchAtomService < BaseService
end
end
+ def expected_type?(json)
+ equals_or_includes_any?(json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
+ end
+
def process_html(response)
page = Nokogiri::HTML(response.body_with_limit)
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index d5920a417..560a81768 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -23,11 +23,13 @@ class FetchLinkCardService < BaseService
if lock.acquired?
@card = PreviewCard.find_by(url: @url)
process_url if @card.nil? || @card.updated_at <= 2.weeks.ago
+ else
+ raise Mastodon::RaceConditionError
end
end
attach_card if @card&.persisted?
- rescue HTTP::Error, Addressable::URI::InvalidURIError => e
+ rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::LengthValidationError => e
Rails.logger.debug "Error fetching link #{@url}: #{e}"
nil
end
@@ -38,7 +40,7 @@ class FetchLinkCardService < BaseService
@card ||= PreviewCard.new(url: @url)
failed = Request.new(:head, @url).perform do |res|
- res.code != 405 && (res.code != 200 || res.mime_type != 'text/html')
+ res.code != 405 && res.code != 501 && (res.code != 200 || res.mime_type != 'text/html')
end
return if failed
@@ -85,42 +87,40 @@ class FetchLinkCardService < BaseService
end
def attempt_oembed
- embed = OEmbed::Providers.get(@url, html: @html)
+ embed = FetchOEmbedService.new.call(@url, html: @html)
- return false unless embed.respond_to?(:type)
+ return false if embed.nil?
- @card.type = embed.type
- @card.title = embed.respond_to?(:title) ? embed.title : ''
- @card.author_name = embed.respond_to?(:author_name) ? embed.author_name : ''
- @card.author_url = embed.respond_to?(:author_url) ? embed.author_url : ''
- @card.provider_name = embed.respond_to?(:provider_name) ? embed.provider_name : ''
- @card.provider_url = embed.respond_to?(:provider_url) ? embed.provider_url : ''
+ @card.type = embed[:type]
+ @card.title = embed[:title] || ''
+ @card.author_name = embed[:author_name] || ''
+ @card.author_url = embed[:author_url] || ''
+ @card.provider_name = embed[:provider_name] || ''
+ @card.provider_url = embed[:provider_url] || ''
@card.width = 0
@card.height = 0
case @card.type
when 'link'
- @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
+ @card.image_remote_url = embed[:thumbnail_url] if embed[:thumbnail_url].present?
when 'photo'
- return false unless embed.respond_to?(:url)
+ return false if embed[:url].blank?
- @card.embed_url = embed.url
- @card.image_remote_url = embed.url
- @card.width = embed.width.presence || 0
- @card.height = embed.height.presence || 0
+ @card.embed_url = embed[:url]
+ @card.image_remote_url = embed[:url]
+ @card.width = embed[:width].presence || 0
+ @card.height = embed[:height].presence || 0
when 'video'
- @card.width = embed.width.presence || 0
- @card.height = embed.height.presence || 0
- @card.html = Formatter.instance.sanitize(embed.html, Sanitize::Config::MASTODON_OEMBED)
- @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
+ @card.width = embed[:width].presence || 0
+ @card.height = embed[:height].presence || 0
+ @card.html = Formatter.instance.sanitize(embed[:html], Sanitize::Config::MASTODON_OEMBED)
+ @card.image_remote_url = embed[:thumbnail_url] if embed[:thumbnail_url].present?
when 'rich'
# Most providers rely on '
end
+
+ context 'with custom_emojify option' do
+ let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) }
+
+ before { remote_account.note = text }
+
+ subject { Formatter.instance.simplified_format(remote_account, custom_emojify: true) }
+
+ context 'with emoji at the start' do
+ let(:text) { '
:coolcat: Beep boop ' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/
Beep :coolcat: boop
' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/Beep :coolcat::coolcat:' }
+
+ it 'does not touch the shortcodes' do
+ is_expected.to match(/:coolcat::coolcat:<\/p>/)
+ end
+ end
+
+ context 'with emoji at the end' do
+ let(:text) { '
Beep boop :coolcat:
' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/ 'false',
+ 'setting_boost_modal' => 'true',
+ }
+
+ settings.update(values)
+ expect(user.settings['delete_modal']).to eq false
+ expect(user.settings['boost_modal']).to eq true
+ end
end
end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index a8b24d0e2..cce659a8a 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -94,14 +94,14 @@ RSpec.describe Account, type: :model do
describe '#save_with_optional_media!' do
before do
- stub_request(:get, 'https://remote/valid_avatar').to_return(request_fixture('avatar.txt'))
- stub_request(:get, 'https://remote/invalid_avatar').to_return(request_fixture('feed.txt'))
+ stub_request(:get, 'https://remote.test/valid_avatar').to_return(request_fixture('avatar.txt'))
+ stub_request(:get, 'https://remote.test/invalid_avatar').to_return(request_fixture('feed.txt'))
end
let(:account) do
Fabricate(:account,
- avatar_remote_url: 'https://remote/valid_avatar',
- header_remote_url: 'https://remote/valid_avatar')
+ avatar_remote_url: 'https://remote.test/valid_avatar',
+ header_remote_url: 'https://remote.test/valid_avatar')
end
let!(:expectation) { account.dup }
@@ -121,7 +121,7 @@ RSpec.describe Account, type: :model do
context 'with invalid properties' do
before do
- account.avatar_remote_url = 'https://remote/invalid_avatar'
+ account.avatar_remote_url = 'https://remote.test/invalid_avatar'
account.save_with_optional_media!
end
@@ -525,6 +525,37 @@ RSpec.describe Account, type: :model do
end
end
+ describe '#statuses_count' do
+ subject { Fabricate(:account) }
+
+ it 'counts statuses' do
+ Fabricate(:status, account: subject)
+ Fabricate(:status, account: subject)
+ expect(subject.statuses_count).to eq 2
+ end
+
+ it 'does not count direct statuses' do
+ Fabricate(:status, account: subject, visibility: :direct)
+ expect(subject.statuses_count).to eq 0
+ end
+
+ it 'is decremented when status is removed' do
+ status = Fabricate(:status, account: subject)
+ expect(subject.statuses_count).to eq 1
+ status.destroy
+ expect(subject.statuses_count).to eq 0
+ end
+
+ it 'is decremented when status is removed when account is not preloaded' do
+ status = Fabricate(:status, account: subject)
+ expect(subject.reload.statuses_count).to eq 1
+ clean_status = Status.find(status.id)
+ expect(clean_status.association(:account).loaded?).to be false
+ clean_status.destroy
+ expect(subject.reload.statuses_count).to eq 0
+ end
+ end
+
describe '.following_map' do
it 'returns an hash' do
expect(Account.following_map([], 1)).to be_a Hash
@@ -815,7 +846,8 @@ RSpec.describe Account, type: :model do
end
context 'when is local' do
- it 'generates keys' do
+ # Test disabled because test environment omits autogenerating keys for performance
+ xit 'generates keys' do
account = Account.create!(domain: nil, username: Faker::Internet.user_name(nil, ['_']))
expect(account.keypair.private?).to eq true
end
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index d08bdc8b9..8df52b770 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -108,13 +108,15 @@ describe AccountInteractions do
end
describe '#mute!' do
+ subject { account.mute!(target_account, notifications: arg_notifications) }
+
context 'Mute does not exist yet' do
context 'arg :notifications is nil' do
let(:arg_notifications) { nil }
- it 'creates Mute, and returns nil' do
+ it 'creates Mute, and returns Mute' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
+ expect(subject).to be_kind_of Mute
end.to change { account.mute_relationships.count }.by 1
end
end
@@ -122,9 +124,9 @@ describe AccountInteractions do
context 'arg :notifications is false' do
let(:arg_notifications) { false }
- it 'creates Mute, and returns nil' do
+ it 'creates Mute, and returns Mute' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
+ expect(subject).to be_kind_of Mute
end.to change { account.mute_relationships.count }.by 1
end
end
@@ -132,9 +134,9 @@ describe AccountInteractions do
context 'arg :notifications is true' do
let(:arg_notifications) { true }
- it 'creates Mute, and returns nil' do
+ it 'creates Mute, and returns Mute' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
+ expect(subject).to be_kind_of Mute
end.to change { account.mute_relationships.count }.by 1
end
end
@@ -158,36 +160,30 @@ describe AccountInteractions do
context 'arg :notifications is nil' do
let(:arg_notifications) { nil }
- it 'returns nil without updating mute.hide_notifications' do
+ it 'returns Mute without updating mute.hide_notifications' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be true
- end
+ expect(subject).to be_kind_of Mute
+ end.not_to change { mute.reload.hide_notifications? }.from(true)
end
end
context 'arg :notifications is false' do
let(:arg_notifications) { false }
- it 'returns true, and updates mute.hide_notifications false' do
+ it 'returns Mute, and updates mute.hide_notifications false' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be true
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be false
- end
+ expect(subject).to be_kind_of Mute
+ end.to change { mute.reload.hide_notifications? }.from(true).to(false)
end
end
context 'arg :notifications is true' do
let(:arg_notifications) { true }
- it 'returns nil without updating mute.hide_notifications' do
+ it 'returns Mute without updating mute.hide_notifications' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be true
- end
+ expect(subject).to be_kind_of Mute
+ end.not_to change { mute.reload.hide_notifications? }.from(true)
end
end
end
@@ -198,36 +194,30 @@ describe AccountInteractions do
context 'arg :notifications is nil' do
let(:arg_notifications) { nil }
- it 'returns true, and updates mute.hide_notifications true' do
+ it 'returns Mute, and updates mute.hide_notifications true' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be true
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be true
- end
+ expect(subject).to be_kind_of Mute
+ end.to change { mute.reload.hide_notifications? }.from(false).to(true)
end
end
context 'arg :notifications is false' do
let(:arg_notifications) { false }
- it 'returns nil without updating mute.hide_notifications' do
+ it 'returns Mute without updating mute.hide_notifications' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be nil
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be false
- end
+ expect(subject).to be_kind_of Mute
+ end.not_to change { mute.reload.hide_notifications? }.from(false)
end
end
context 'arg :notifications is true' do
let(:arg_notifications) { true }
- it 'returns true, and updates mute.hide_notifications true' do
+ it 'returns Mute, and updates mute.hide_notifications true' do
expect do
- expect(account.mute!(target_account, notifications: arg_notifications)).to be true
- mute = account.mute_relationships.find_by(target_account: target_account)
- expect(mute.hide_notifications?).to be true
- end
+ expect(subject).to be_kind_of Mute
+ end.to change { mute.reload.hide_notifications? }.from(false).to(true)
end
end
end
diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status_threading_concern_spec.rb
index 62f5f6e31..e5736a307 100644
--- a/spec/models/concerns/status_threading_concern_spec.rb
+++ b/spec/models/concerns/status_threading_concern_spec.rb
@@ -14,34 +14,34 @@ describe StatusThreadingConcern do
let!(:viewer) { Fabricate(:account, username: 'viewer') }
it 'returns conversation history' do
- expect(reply3.ancestors).to include(status, reply1, reply2)
+ expect(reply3.ancestors(4)).to include(status, reply1, reply2)
end
it 'does not return conversation history user is not allowed to see' do
reply1.update(visibility: :private)
status.update(visibility: :direct)
- expect(reply3.ancestors(viewer)).to_not include(reply1, status)
+ expect(reply3.ancestors(4, viewer)).to_not include(reply1, status)
end
it 'does not return conversation history from blocked users' do
viewer.block!(jeff)
- expect(reply3.ancestors(viewer)).to_not include(reply1)
+ expect(reply3.ancestors(4, viewer)).to_not include(reply1)
end
it 'does not return conversation history from muted users' do
viewer.mute!(jeff)
- expect(reply3.ancestors(viewer)).to_not include(reply1)
+ expect(reply3.ancestors(4, viewer)).to_not include(reply1)
end
it 'does not return conversation history from silenced and not followed users' do
jeff.update(silenced: true)
- expect(reply3.ancestors(viewer)).to_not include(reply1)
+ expect(reply3.ancestors(4, viewer)).to_not include(reply1)
end
it 'does not return conversation history from blocked domains' do
viewer.block_domain!('example.com')
- expect(reply3.ancestors(viewer)).to_not include(reply2)
+ expect(reply3.ancestors(4, viewer)).to_not include(reply2)
end
it 'ignores deleted records' do
@@ -49,10 +49,32 @@ describe StatusThreadingConcern do
second_status = Fabricate(:status, thread: first_status, account: alice)
# Create cache and delete cached record
- second_status.ancestors
+ second_status.ancestors(4)
first_status.destroy
- expect(second_status.ancestors).to eq([])
+ expect(second_status.ancestors(4)).to eq([])
+ end
+
+ it 'can return more records than previously requested' do
+ first_status = Fabricate(:status, account: bob)
+ second_status = Fabricate(:status, thread: first_status, account: alice)
+ third_status = Fabricate(:status, thread: second_status, account: alice)
+
+ # Create cache
+ second_status.ancestors(1)
+
+ expect(third_status.ancestors(2)).to eq([first_status, second_status])
+ end
+
+ it 'can return fewer records than previously requested' do
+ first_status = Fabricate(:status, account: bob)
+ second_status = Fabricate(:status, thread: first_status, account: alice)
+ third_status = Fabricate(:status, thread: second_status, account: alice)
+
+ # Create cache
+ second_status.ancestors(2)
+
+ expect(third_status.ancestors(1)).to eq([second_status])
end
end
@@ -67,34 +89,34 @@ describe StatusThreadingConcern do
let!(:viewer) { Fabricate(:account, username: 'viewer') }
it 'returns replies' do
- expect(status.descendants).to include(reply1, reply2, reply3)
+ expect(status.descendants(4)).to include(reply1, reply2, reply3)
end
it 'does not return replies user is not allowed to see' do
reply1.update(visibility: :private)
reply3.update(visibility: :direct)
- expect(status.descendants(viewer)).to_not include(reply1, reply3)
+ expect(status.descendants(4, viewer)).to_not include(reply1, reply3)
end
it 'does not return replies from blocked users' do
viewer.block!(jeff)
- expect(status.descendants(viewer)).to_not include(reply3)
+ expect(status.descendants(4, viewer)).to_not include(reply3)
end
it 'does not return replies from muted users' do
viewer.mute!(jeff)
- expect(status.descendants(viewer)).to_not include(reply3)
+ expect(status.descendants(4, viewer)).to_not include(reply3)
end
it 'does not return replies from silenced and not followed users' do
jeff.update(silenced: true)
- expect(status.descendants(viewer)).to_not include(reply3)
+ expect(status.descendants(4, viewer)).to_not include(reply3)
end
it 'does not return replies from blocked domains' do
viewer.block_domain!('example.com')
- expect(status.descendants(viewer)).to_not include(reply2)
+ expect(status.descendants(4, viewer)).to_not include(reply2)
end
end
end
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index bb150b837..87367df50 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -1,6 +1,30 @@
require 'rails_helper'
RSpec.describe CustomEmoji, type: :model do
+ describe '#search' do
+ let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: shortcode) }
+
+ subject { described_class.search(search_term) }
+
+ context 'shortcode is exact' do
+ let(:shortcode) { 'blobpats' }
+ let(:search_term) { 'blobpats' }
+
+ it 'finds emoji' do
+ is_expected.to include(custom_emoji)
+ end
+ end
+
+ context 'shortcode is partial' do
+ let(:shortcode) { 'blobpats' }
+ let(:search_term) { 'blob' }
+
+ it 'finds emoji' do
+ is_expected.to include(custom_emoji)
+ end
+ end
+ end
+
describe '#local?' do
let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) }
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 59893a14f..2cf28b263 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe FollowRequest, type: :model do
let(:target_account) { Fabricate(:account) }
it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
- expect(account).to receive(:follow!).with(target_account, reblogs: true)
+ expect(account).to receive(:follow!).with(target_account, reblogs: true, uri: follow_request.uri)
expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id)
expect(follow_request).to receive(:destroy!)
follow_request.authorize!
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
index d40ebf6dc..a0cd0800d 100644
--- a/spec/models/report_spec.rb
+++ b/spec/models/report_spec.rb
@@ -22,6 +22,101 @@ describe Report do
end
end
+ describe 'assign_to_self!' do
+ subject { report.assigned_account_id }
+
+ let(:report) { Fabricate(:report, assigned_account_id: original_account) }
+ let(:original_account) { Fabricate(:account) }
+ let(:current_account) { Fabricate(:account) }
+
+ before do
+ report.assign_to_self!(current_account)
+ end
+
+ it 'assigns to a given account' do
+ is_expected.to eq current_account.id
+ end
+ end
+
+ describe 'unassign!' do
+ subject { report.assigned_account_id }
+
+ let(:report) { Fabricate(:report, assigned_account_id: account.id) }
+ let(:account) { Fabricate(:account) }
+
+ before do
+ report.unassign!
+ end
+
+ it 'unassigns' do
+ is_expected.to be_nil
+ end
+ end
+
+ describe 'resolve!' do
+ subject(:report) { Fabricate(:report, action_taken: false, action_taken_by_account_id: nil) }
+
+ let(:acting_account) { Fabricate(:account) }
+
+ before do
+ report.resolve!(acting_account)
+ end
+
+ it 'records action taken' do
+ expect(report).to have_attributes(action_taken: true, action_taken_by_account_id: acting_account.id)
+ end
+ end
+
+ describe 'unresolve!' do
+ subject(:report) { Fabricate(:report, action_taken: true, action_taken_by_account_id: acting_account.id) }
+
+ let(:acting_account) { Fabricate(:account) }
+
+ before do
+ report.unresolve!
+ end
+
+ it 'unresolves' do
+ expect(report).to have_attributes(action_taken: false, action_taken_by_account_id: nil)
+ end
+ end
+
+ describe 'unresolved?' do
+ subject { report.unresolved? }
+
+ let(:report) { Fabricate(:report, action_taken: action_taken) }
+
+ context 'if action is taken' do
+ let(:action_taken) { true }
+
+ it { is_expected.to be false }
+ end
+
+ context 'if action not is taken' do
+ let(:action_taken) { false }
+
+ it { is_expected.to be true }
+ end
+ end
+
+ describe 'history' do
+ subject(:action_logs) { report.history }
+
+ let(:report) { Fabricate(:report, target_account_id: target_account.id, status_ids: [status.id], created_at: 3.days.ago, updated_at: 1.day.ago) }
+ let(:target_account) { Fabricate(:account) }
+ let(:status) { Fabricate(:status) }
+
+ before do
+ Fabricate('Admin::ActionLog', target_type: 'Report', account_id: target_account.id, target_id: report.id, created_at: 2.days.ago)
+ Fabricate('Admin::ActionLog', target_type: 'Account', account_id: target_account.id, target_id: report.target_account_id, created_at: 2.days.ago)
+ Fabricate('Admin::ActionLog', target_type: 'Status', account_id: target_account.id, target_id: status.id, created_at: 2.days.ago)
+ end
+
+ it 'returns right logs' do
+ expect(action_logs.count).to eq 3
+ end
+ end
+
describe 'validatiions' do
it 'has a valid fabricator' do
report = Fabricate(:report)
diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb
index 6f54f80f9..6f0b2feb8 100644
--- a/spec/models/status_pin_spec.rb
+++ b/spec/models/status_pin_spec.rb
@@ -37,5 +37,36 @@ RSpec.describe StatusPin, type: :model do
expect(StatusPin.new(account: account, status: status).save).to be false
end
+
+ max_pins = 5
+ it 'does not allow pins above the max' do
+ account = Fabricate(:account)
+ status = []
+
+ (max_pins + 1).times do |i|
+ status[i] = Fabricate(:status, account: account)
+ end
+
+ max_pins.times do |i|
+ expect(StatusPin.new(account: account, status: status[i]).save).to be true
+ end
+
+ expect(StatusPin.new(account: account, status: status[max_pins]).save).to be false
+ end
+
+ it 'allows pins above the max for remote accounts' do
+ account = Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/')
+ status = []
+
+ (max_pins + 1).times do |i|
+ status[i] = Fabricate(:status, account: account)
+ end
+
+ max_pins.times do |i|
+ expect(StatusPin.new(account: account, status: status[i]).save).to be true
+ end
+
+ expect(StatusPin.new(account: account, status: status[max_pins]).save).to be true
+ end
end
end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 4b5c20871..5113b652f 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do
describe '#target' do
it 'returns nil if the status is self-contained' do
- expect(subject.target).to be_nil
+ expect(subject.target).to be_nil
end
it 'returns nil if the status is a reply' do
@@ -175,6 +175,13 @@ RSpec.describe Status, type: :model do
expect(subject.reblogs_count).to eq 2
end
+
+ it 'is decremented when reblog is removed' do
+ reblog = Fabricate(:status, account: bob, reblog: subject)
+ expect(subject.reblogs_count).to eq 1
+ reblog.destroy
+ expect(subject.reblogs_count).to eq 0
+ end
end
describe '#favourites_count' do
@@ -184,6 +191,13 @@ RSpec.describe Status, type: :model do
expect(subject.favourites_count).to eq 2
end
+
+ it 'is decremented when favourite is removed' do
+ favourite = Fabricate(:favourite, account: bob, status: subject)
+ expect(subject.favourites_count).to eq 1
+ favourite.destroy
+ expect(subject.favourites_count).to eq 0
+ end
end
describe '#proper' do
@@ -304,6 +318,56 @@ RSpec.describe Status, type: :model do
end
end
+ describe '.as_direct_timeline' do
+ let(:account) { Fabricate(:account) }
+ let(:followed) { Fabricate(:account) }
+ let(:not_followed) { Fabricate(:account) }
+
+ before do
+ Fabricate(:follow, account: account, target_account: followed)
+
+ @self_public_status = Fabricate(:status, account: account, visibility: :public)
+ @self_direct_status = Fabricate(:status, account: account, visibility: :direct)
+ @followed_public_status = Fabricate(:status, account: followed, visibility: :public)
+ @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct)
+ @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct)
+
+ @results = Status.as_direct_timeline(account)
+ end
+
+ it 'does not include public statuses from self' do
+ expect(@results).to_not include(@self_public_status)
+ end
+
+ it 'includes direct statuses from self' do
+ expect(@results).to include(@self_direct_status)
+ end
+
+ it 'does not include public statuses from followed' do
+ expect(@results).to_not include(@followed_public_status)
+ end
+
+ it 'does not include direct statuses not mentioning recipient from followed' do
+ expect(@results).to_not include(@followed_direct_status)
+ end
+
+ it 'does not include direct statuses not mentioning recipient from non-followed' do
+ expect(@results).to_not include(@not_followed_direct_status)
+ end
+
+ it 'includes direct statuses mentioning recipient from followed' do
+ Fabricate(:mention, account: account, status: @followed_direct_status)
+ results2 = Status.as_direct_timeline(account)
+ expect(results2).to include(@followed_direct_status)
+ end
+
+ it 'includes direct statuses mentioning recipient from non-followed' do
+ Fabricate(:mention, account: account, status: @not_followed_direct_status)
+ results2 = Status.as_direct_timeline(account)
+ expect(results2).to include(@not_followed_direct_status)
+ end
+ end
+
describe '.as_public_timeline' do
it 'only includes statuses with public visibility' do
public_status = Fabricate(:status, visibility: :public)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 8171c939a..cc8d88cc8 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe User, type: :model do
describe 'inactive' do
it 'returns a relation of inactive users' do
specified = Fabricate(:user, current_sign_in_at: 15.days.ago)
- Fabricate(:user, current_sign_in_at: 13.days.ago)
+ Fabricate(:user, current_sign_in_at: 6.days.ago)
expect(User.inactive).to match_array([specified])
end
@@ -324,4 +324,218 @@ RSpec.describe User, type: :model do
expect(admin.role?('moderator')).to be true
end
end
+
+ describe '#disable!' do
+ subject(:user) { Fabricate(:user, disabled: false, current_sign_in_at: current_sign_in_at, last_sign_in_at: nil) }
+ let(:current_sign_in_at) { Time.zone.now }
+
+ before do
+ user.disable!
+ end
+
+ it 'disables user' do
+ expect(user).to have_attributes(disabled: true, current_sign_in_at: nil, last_sign_in_at: current_sign_in_at)
+ end
+ end
+
+ describe '#disable!' do
+ subject(:user) { Fabricate(:user, disabled: false, current_sign_in_at: current_sign_in_at, last_sign_in_at: nil) }
+ let(:current_sign_in_at) { Time.zone.now }
+
+ before do
+ user.disable!
+ end
+
+ it 'disables user' do
+ expect(user).to have_attributes(disabled: true, current_sign_in_at: nil, last_sign_in_at: current_sign_in_at)
+ end
+ end
+
+ describe '#enable!' do
+ subject(:user) { Fabricate(:user, disabled: true) }
+
+ before do
+ user.enable!
+ end
+
+ it 'enables user' do
+ expect(user).to have_attributes(disabled: false)
+ end
+ end
+
+ describe '#confirm!' do
+ subject(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
+
+ before do
+ ActionMailer::Base.deliveries.clear
+ user.confirm!
+ end
+
+ after { ActionMailer::Base.deliveries.clear }
+
+ context 'when user is new' do
+ let(:confirmed_at) { nil }
+
+ it 'confirms user' do
+ expect(user.confirmed_at).to be_present
+ end
+
+ it 'delivers mails' do
+ expect(ActionMailer::Base.deliveries.count).to eq 2
+ end
+ end
+
+ context 'when user is not new' do
+ let(:confirmed_at) { Time.zone.now }
+
+ it 'confirms user' do
+ expect(user.confirmed_at).to be_present
+ end
+
+ it 'does not deliver mail' do
+ expect(ActionMailer::Base.deliveries.count).to eq 0
+ end
+ end
+ end
+
+ describe '#promote!' do
+ subject(:user) { Fabricate(:user, admin: is_admin, moderator: is_moderator) }
+
+ before do
+ user.promote!
+ end
+
+ context 'when user is an admin' do
+ let(:is_admin) { true }
+
+ context 'when user is a moderator' do
+ let(:is_moderator) { true }
+
+ it 'changes moderator filed false' do
+ expect(user).to be_admin
+ expect(user).not_to be_moderator
+ end
+ end
+
+ context 'when user is not a moderator' do
+ let(:is_moderator) { false }
+
+ it 'does not change status' do
+ expect(user).to be_admin
+ expect(user).not_to be_moderator
+ end
+ end
+ end
+
+ context 'when user is not admin' do
+ let(:is_admin) { false }
+
+ context 'when user is a moderator' do
+ let(:is_moderator) { true }
+
+ it 'changes user into an admin' do
+ expect(user).to be_admin
+ expect(user).not_to be_moderator
+ end
+ end
+
+ context 'when user is not a moderator' do
+ let(:is_moderator) { false }
+
+ it 'changes user into a moderator' do
+ expect(user).not_to be_admin
+ expect(user).to be_moderator
+ end
+ end
+ end
+ end
+
+ describe '#demote!' do
+ subject(:user) { Fabricate(:user, admin: admin, moderator: moderator) }
+
+ before do
+ user.demote!
+ end
+
+ context 'when user is an admin' do
+ let(:admin) { true }
+
+ context 'when user is a moderator' do
+ let(:moderator) { true }
+
+ it 'changes user into a moderator' do
+ expect(user).not_to be_admin
+ expect(user).to be_moderator
+ end
+ end
+
+ context 'when user is not a moderator' do
+ let(:moderator) { false }
+
+ it 'changes user into a moderator' do
+ expect(user).not_to be_admin
+ expect(user).to be_moderator
+ end
+ end
+ end
+
+ context 'when user is not an admin' do
+ let(:admin) { false }
+
+ context 'when user is a moderator' do
+ let(:moderator) { true }
+
+ it 'changes user into a plain user' do
+ expect(user).not_to be_admin
+ expect(user).not_to be_moderator
+ end
+ end
+
+ context 'when user is not a moderator' do
+ let(:moderator) { false }
+
+ it 'does not change any fields' do
+ expect(user).not_to be_admin
+ expect(user).not_to be_moderator
+ end
+ end
+ end
+ end
+
+ describe '#active_for_authentication?' do
+ subject { user.active_for_authentication? }
+ let(:user) { Fabricate(:user, disabled: disabled, confirmed_at: confirmed_at) }
+
+ context 'when user is disabled' do
+ let(:disabled) { true }
+
+ context 'when user is confirmed' do
+ let(:confirmed_at) { Time.zone.now }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not confirmed' do
+ let(:confirmed_at) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ context 'when user is not disabled' do
+ let(:disabled) { false }
+
+ context 'when user is confirmed' do
+ let(:confirmed_at) { Time.zone.now }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is not confirmed' do
+ let(:confirmed_at) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+ end
end
diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb
index 574da55ac..c6665611c 100644
--- a/spec/models/web/push_subscription_spec.rb
+++ b/spec/models/web/push_subscription_spec.rb
@@ -2,20 +2,8 @@ require 'rails_helper'
RSpec.describe Web::PushSubscription, type: :model do
let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
- let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload }
- let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload }
let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
- describe '#as_payload' do
- it 'only returns id and endpoint' do
- expect(payload_no_alerts.keys).to eq [:id, :endpoint]
- end
-
- it 'returns alerts if set' do
- expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts]
- end
- end
-
describe '#pushable?' do
it 'obeys alert settings' do
expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index dc1f32e08..c575128e4 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -50,6 +50,14 @@ RSpec.configure do |config|
Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}"
end
+ config.before :each, type: :controller do
+ stub_jsonld_contexts!
+ end
+
+ config.before :each, type: :service do
+ stub_jsonld_contexts!
+ end
+
config.after :each do
Rails.cache.clear
@@ -69,3 +77,9 @@ end
def attachment_fixture(name)
File.open(File.join(Rails.root, 'spec', 'fixtures', 'files', name))
end
+
+def stub_jsonld_contexts!
+ stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt'))
+ stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt'))
+ stub_request(:get, 'https://w3id.org/security/v1').to_return(request_fixture('json-ld.security.txt'))
+end
diff --git a/spec/requests/host_meta_request_spec.rb b/spec/requests/host_meta_request_spec.rb
index 0c51b5f48..beb33a859 100644
--- a/spec/requests/host_meta_request_spec.rb
+++ b/spec/requests/host_meta_request_spec.rb
@@ -5,7 +5,7 @@ describe "The host_meta route" do
it "returns an xml response" do
get host_meta_url
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq "application/xrd+xml"
end
end
diff --git a/spec/requests/webfinger_request_spec.rb b/spec/requests/webfinger_request_spec.rb
index a17d6cc22..7f9e1162e 100644
--- a/spec/requests/webfinger_request_spec.rb
+++ b/spec/requests/webfinger_request_spec.rb
@@ -7,7 +7,7 @@ describe 'The webfinger route' do
it 'returns a json response' do
get webfinger_url(resource: alice.to_webfinger_s)
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/jrd+json'
end
end
@@ -16,7 +16,7 @@ describe 'The webfinger route' do
it 'returns an xml response for xml format' do
get webfinger_url(resource: alice.to_webfinger_s, format: :xml)
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/xrd+xml'
end
@@ -24,7 +24,7 @@ describe 'The webfinger route' do
headers = { 'HTTP_ACCEPT' => 'application/xrd+xml' }
get webfinger_url(resource: alice.to_webfinger_s), headers: headers
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/xrd+xml'
end
end
@@ -33,7 +33,7 @@ describe 'The webfinger route' do
it 'returns a json response for json format' do
get webfinger_url(resource: alice.to_webfinger_s, format: :json)
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/jrd+json'
end
@@ -41,7 +41,7 @@ describe 'The webfinger route' do
headers = { 'HTTP_ACCEPT' => 'application/jrd+json' }
get webfinger_url(resource: alice.to_webfinger_s), headers: headers
- expect(response).to have_http_status(:success)
+ expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/jrd+json'
end
end
diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb
index 9bb27edad..c6cbdcce1 100644
--- a/spec/services/account_search_service_spec.rb
+++ b/spec/services/account_search_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe AccountSearchService do
+describe AccountSearchService, type: :service do
describe '.call' do
describe 'with a query to ignore' do
it 'returns empty array for missing query' do
@@ -137,5 +137,24 @@ describe AccountSearchService do
expect(service).not_to have_received(:call)
end
end
+
+ describe 'should not include suspended accounts' do
+ it 'returns the fuzzy match first, and does not return suspended exacts' do
+ partial = Fabricate(:account, username: 'exactness')
+ exact = Fabricate(:account, username: 'exact', suspended: true)
+
+ results = subject.call('exact', 10)
+ expect(results.size).to eq 1
+ expect(results).to eq [partial]
+ end
+
+ it "does not return suspended remote accounts" do
+ remote = Fabricate(:account, username: 'a', domain: 'remote', display_name: 'e', suspended: true)
+
+ results = subject.call('a@example.com', 2)
+ expect(results.size).to eq 0
+ expect(results).to eq []
+ end
+ end
end
end
diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb
index c50d3fb97..dba55c034 100644
--- a/spec/services/activitypub/fetch_remote_account_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ActivityPub::FetchRemoteAccountService do
+RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
subject { ActivityPub::FetchRemoteAccountService.new }
let!(:actor) do
diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb
index a533e8413..549eb80fa 100644
--- a/spec/services/activitypub/fetch_remote_status_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ActivityPub::FetchRemoteStatusService do
+RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do
include ActionView::Helpers::TextHelper
let(:sender) { Fabricate(:account) }
diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb
index 84a74c231..d3318b2ed 100644
--- a/spec/services/activitypub/process_account_service_spec.rb
+++ b/spec/services/activitypub/process_account_service_spec.rb
@@ -1,5 +1,31 @@
require 'rails_helper'
-RSpec.describe ActivityPub::ProcessAccountService do
- pending
+RSpec.describe ActivityPub::ProcessAccountService, type: :service do
+ subject { described_class.new }
+
+ context 'property values' do
+ let(:payload) do
+ {
+ id: 'https://foo.test',
+ type: 'Actor',
+ inbox: 'https://foo.test/inbox',
+ attachment: [
+ { type: 'PropertyValue', name: 'Pronouns', value: 'They/them' },
+ { type: 'PropertyValue', name: 'Occupation', value: 'Unit test' },
+ ],
+ }.with_indifferent_access
+ end
+
+ it 'parses out of attachment' do
+ account = subject.call('alice', 'example.com', payload)
+ expect(account.fields).to be_a Array
+ expect(account.fields.size).to eq 2
+ expect(account.fields[0]).to be_a Account::Field
+ expect(account.fields[0].name).to eq 'Pronouns'
+ expect(account.fields[0].value).to eq 'They/them'
+ expect(account.fields[1]).to be_a Account::Field
+ expect(account.fields[1].name).to eq 'Occupation'
+ expect(account.fields[1].value).to eq 'Unit test'
+ end
+ end
end
diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb
index 3cea970cf..e46f0ae45 100644
--- a/spec/services/activitypub/process_collection_service_spec.rb
+++ b/spec/services/activitypub/process_collection_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ActivityPub::ProcessCollectionService do
+RSpec.describe ActivityPub::ProcessCollectionService, type: :service do
let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account') }
let(:payload) do
diff --git a/spec/services/after_block_domain_from_account_service_spec.rb b/spec/services/after_block_domain_from_account_service_spec.rb
new file mode 100644
index 000000000..006e3f4d2
--- /dev/null
+++ b/spec/services/after_block_domain_from_account_service_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+RSpec.describe AfterBlockDomainFromAccountService, type: :service do
+ let!(:wolf) { Fabricate(:account, username: 'wolf', domain: 'evil.org', inbox_url: 'https://evil.org/inbox', protocol: :activitypub) }
+ let!(:alice) { Fabricate(:account, username: 'alice') }
+
+ subject { AfterBlockDomainFromAccountService.new }
+
+ before do
+ stub_jsonld_contexts!
+ allow(ActivityPub::DeliveryWorker).to receive(:perform_async)
+ end
+
+ it 'purge followers from blocked domain' do
+ wolf.follow!(alice)
+ subject.call(alice, 'evil.org')
+ expect(wolf.following?(alice)).to be false
+ end
+
+ it 'sends Reject->Follow to followers from blocked domain' do
+ wolf.follow!(alice)
+ subject.call(alice, 'evil.org')
+ expect(ActivityPub::DeliveryWorker).to have_received(:perform_async).once
+ end
+end
diff --git a/spec/services/after_block_service_spec.rb b/spec/services/after_block_service_spec.rb
index 1b115c938..f63b2045a 100644
--- a/spec/services/after_block_service_spec.rb
+++ b/spec/services/after_block_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe AfterBlockService do
+RSpec.describe AfterBlockService, type: :service do
subject do
-> { described_class.new.call(account, target_account) }
end
diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb
index 6ea4d83da..562ef0041 100644
--- a/spec/services/authorize_follow_service_spec.rb
+++ b/spec/services/authorize_follow_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe AuthorizeFollowService do
+RSpec.describe AuthorizeFollowService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { AuthorizeFollowService.new }
diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb
index 437da2a9d..23c122e59 100644
--- a/spec/services/batched_remove_status_service_spec.rb
+++ b/spec/services/batched_remove_status_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe BatchedRemoveStatusService do
+RSpec.describe BatchedRemoveStatusService, type: :service do
subject { BatchedRemoveStatusService.new }
let!(:alice) { Fabricate(:account) }
diff --git a/spec/services/block_domain_from_account_service_spec.rb b/spec/services/block_domain_from_account_service_spec.rb
deleted file mode 100644
index e7ee34372..000000000
--- a/spec/services/block_domain_from_account_service_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe BlockDomainFromAccountService do
- let!(:wolf) { Fabricate(:account, username: 'wolf', domain: 'evil.org') }
- let!(:alice) { Fabricate(:account, username: 'alice') }
-
- subject { BlockDomainFromAccountService.new }
-
- it 'creates domain block' do
- subject.call(alice, 'evil.org')
- expect(alice.domain_blocking?('evil.org')).to be true
- end
-
- it 'purge followers from blocked domain' do
- wolf.follow!(alice)
- subject.call(alice, 'evil.org')
- expect(wolf.following?(alice)).to be false
- end
-end
diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb
index 5c2cfc8c7..7ef9e2770 100644
--- a/spec/services/block_domain_service_spec.rb
+++ b/spec/services/block_domain_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe BlockDomainService do
+RSpec.describe BlockDomainService, type: :service do
let(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') }
let(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') }
let(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') }
diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb
index c69ff7804..6584bb90e 100644
--- a/spec/services/block_service_spec.rb
+++ b/spec/services/block_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe BlockService do
+RSpec.describe BlockService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { BlockService.new }
diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb
index 5189b1de8..a765de791 100644
--- a/spec/services/bootstrap_timeline_service_spec.rb
+++ b/spec/services/bootstrap_timeline_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe BootstrapTimelineService do
+RSpec.describe BootstrapTimelineService, type: :service do
subject { described_class.new }
describe '#call' do
diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb
index 764318e34..b7fc7f7ed 100644
--- a/spec/services/fan_out_on_write_service_spec.rb
+++ b/spec/services/fan_out_on_write_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FanOutOnWriteService do
+RSpec.describe FanOutOnWriteService, type: :service do
let(:author) { Fabricate(:account, username: 'tom') }
let(:status) { Fabricate(:status, text: 'Hello @alice #test', account: author) }
let(:alice) { Fabricate(:user, account: Fabricate(:account, username: 'alice')).account }
diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb
index 5bf2c74a9..0a20ccf6e 100644
--- a/spec/services/favourite_service_spec.rb
+++ b/spec/services/favourite_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FavouriteService do
+RSpec.describe FavouriteService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { FavouriteService.new }
diff --git a/spec/services/fetch_atom_service_spec.rb b/spec/services/fetch_atom_service_spec.rb
index 2bd127e92..bb233c12d 100644
--- a/spec/services/fetch_atom_service_spec.rb
+++ b/spec/services/fetch_atom_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FetchAtomService do
+RSpec.describe FetchAtomService, type: :service do
describe '#call' do
let(:url) { 'http://example.com' }
subject { FetchAtomService.new.call(url) }
diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb
index edacc4425..88c5339db 100644
--- a/spec/services/fetch_link_card_service_spec.rb
+++ b/spec/services/fetch_link_card_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FetchLinkCardService do
+RSpec.describe FetchLinkCardService, type: :service do
subject { FetchLinkCardService.new }
before do
diff --git a/spec/lib/provider_discovery_spec.rb b/spec/services/fetch_oembed_service_spec.rb
similarity index 53%
rename from spec/lib/provider_discovery_spec.rb
rename to spec/services/fetch_oembed_service_spec.rb
index 12e2616c9..706eb3f2a 100644
--- a/spec/lib/provider_discovery_spec.rb
+++ b/spec/services/fetch_oembed_service_spec.rb
@@ -2,12 +2,19 @@
require 'rails_helper'
-describe ProviderDiscovery do
+describe FetchOEmbedService, type: :service do
+ subject { described_class.new }
+
+ before do
+ stub_request(:get, "https://host.test/provider.json").to_return(status: 404)
+ stub_request(:get, "https://host.test/provider.xml").to_return(status: 404)
+ end
+
describe 'discover_provider' do
context 'when status code is 200 and MIME type is text/html' do
context 'Both of JSON and XML provider are discoverable' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_json_xml.html')
@@ -15,21 +22,21 @@ describe ProviderDiscovery do
end
it 'returns new OEmbed::Provider for JSON provider if :format option is set to :json' do
- provider = ProviderDiscovery.discover_provider('https://host/oembed.html', format: :json)
- expect(provider.endpoint).to eq 'https://host/provider.json'
- expect(provider.format).to eq :json
+ subject.call('https://host.test/oembed.html', format: :json)
+ expect(subject.endpoint_url).to eq 'https://host.test/provider.json'
+ expect(subject.format).to eq :json
end
it 'returns new OEmbed::Provider for XML provider if :format option is set to :xml' do
- provider = ProviderDiscovery.discover_provider('https://host/oembed.html', format: :xml)
- expect(provider.endpoint).to eq 'https://host/provider.xml'
- expect(provider.format).to eq :xml
+ subject.call('https://host.test/oembed.html', format: :xml)
+ expect(subject.endpoint_url).to eq 'https://host.test/provider.xml'
+ expect(subject.format).to eq :xml
end
end
context 'JSON provider is discoverable while XML provider is not' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_json.html')
@@ -37,15 +44,15 @@ describe ProviderDiscovery do
end
it 'returns new OEmbed::Provider for JSON provider' do
- provider = ProviderDiscovery.discover_provider('https://host/oembed.html')
- expect(provider.endpoint).to eq 'https://host/provider.json'
- expect(provider.format).to eq :json
+ subject.call('https://host.test/oembed.html')
+ expect(subject.endpoint_url).to eq 'https://host.test/provider.json'
+ expect(subject.format).to eq :json
end
end
context 'XML provider is discoverable while JSON provider is not' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_xml.html')
@@ -53,65 +60,65 @@ describe ProviderDiscovery do
end
it 'returns new OEmbed::Provider for XML provider' do
- provider = ProviderDiscovery.discover_provider('https://host/oembed.html')
- expect(provider.endpoint).to eq 'https://host/provider.xml'
- expect(provider.format).to eq :xml
+ subject.call('https://host.test/oembed.html')
+ expect(subject.endpoint_url).to eq 'https://host.test/provider.xml'
+ expect(subject.format).to eq :xml
end
end
context 'Invalid XML provider is discoverable while JSON provider is not' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_invalid_xml.html')
)
end
- it 'raises OEmbed::NotFound' do
- expect { ProviderDiscovery.discover_provider('https://host/oembed.html') }.to raise_error OEmbed::NotFound
+ it 'returns nil' do
+ expect(subject.call('https://host.test/oembed.html')).to be_nil
end
end
context 'Neither of JSON and XML provider is discoverable' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_undiscoverable.html')
)
end
- it 'raises OEmbed::NotFound' do
- expect { ProviderDiscovery.discover_provider('https://host/oembed.html') }.to raise_error OEmbed::NotFound
+ it 'returns nil' do
+ expect(subject.call('https://host.test/oembed.html')).to be_nil
end
end
end
context 'when status code is not 200' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 400,
headers: { 'Content-Type': 'text/html' },
body: request_fixture('oembed_xml.html')
)
end
- it 'raises OEmbed::NotFound' do
- expect { ProviderDiscovery.discover_provider('https://host/oembed.html') }.to raise_error OEmbed::NotFound
+ it 'returns nil' do
+ expect(subject.call('https://host.test/oembed.html')).to be_nil
end
end
context 'when MIME type is not text/html' do
before do
- stub_request(:get, 'https://host/oembed.html').to_return(
+ stub_request(:get, 'https://host.test/oembed.html').to_return(
status: 200,
body: request_fixture('oembed_xml.html')
)
end
- it 'raises OEmbed::NotFound' do
- expect { ProviderDiscovery.discover_provider('https://host/oembed.html') }.to raise_error OEmbed::NotFound
+ it 'returns nil' do
+ expect(subject.call('https://host.test/oembed.html')).to be_nil
end
end
end
diff --git a/spec/services/fetch_remote_account_service_spec.rb b/spec/services/fetch_remote_account_service_spec.rb
index 4388d4cf4..1c3abe8f3 100644
--- a/spec/services/fetch_remote_account_service_spec.rb
+++ b/spec/services/fetch_remote_account_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FetchRemoteAccountService do
+RSpec.describe FetchRemoteAccountService, type: :service do
let(:url) { 'https://example.com' }
let(:prefetched_body) { nil }
let(:protocol) { :ostatus }
diff --git a/spec/services/fetch_remote_status_service_spec.rb b/spec/services/fetch_remote_status_service_spec.rb
index fa5782b94..0df9c329a 100644
--- a/spec/services/fetch_remote_status_service_spec.rb
+++ b/spec/services/fetch_remote_status_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FetchRemoteStatusService do
+RSpec.describe FetchRemoteStatusService, type: :service do
let(:account) { Fabricate(:account) }
let(:prefetched_body) { nil }
let(:valid_domain) { Rails.configuration.x.local_domain }
diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb
index e59a2f1a6..3c4ec59be 100644
--- a/spec/services/follow_service_spec.rb
+++ b/spec/services/follow_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe FollowService do
+RSpec.describe FollowService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { FollowService.new }
diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb
index 2b3e3e152..4bb839b8d 100644
--- a/spec/services/mute_service_spec.rb
+++ b/spec/services/mute_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe MuteService do
+RSpec.describe MuteService, type: :service do
subject do
-> { described_class.new.call(account, target_account) }
end
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index abe557cf3..d34667943 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe NotifyService do
+RSpec.describe NotifyService, type: :service do
subject do
-> { described_class.new.call(recipient, activity) }
end
diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb
index 92fbc73cd..40fa8fbef 100644
--- a/spec/services/post_status_service_spec.rb
+++ b/spec/services/post_status_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe PostStatusService do
+RSpec.describe PostStatusService, type: :service do
subject { PostStatusService.new }
it 'creates a new status' do
diff --git a/spec/services/precompute_feed_service_spec.rb b/spec/services/precompute_feed_service_spec.rb
index 43340bffc..1f6b6ed88 100644
--- a/spec/services/precompute_feed_service_spec.rb
+++ b/spec/services/precompute_feed_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe PrecomputeFeedService do
+RSpec.describe PrecomputeFeedService, type: :service do
subject { PrecomputeFeedService.new }
describe 'call' do
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index aca675dc6..d8b065063 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ProcessFeedService do
+RSpec.describe ProcessFeedService, type: :service do
subject { ProcessFeedService.new }
describe 'processing a feed' do
diff --git a/spec/services/process_interaction_service_spec.rb b/spec/services/process_interaction_service_spec.rb
index 3ea7aec59..b858c19d0 100644
--- a/spec/services/process_interaction_service_spec.rb
+++ b/spec/services/process_interaction_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ProcessInteractionService do
+RSpec.describe ProcessInteractionService, type: :service do
let(:receiver) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account }
let(:sender) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
let(:remote_sender) { Fabricate(:account, username: 'carol', domain: 'localdomain.com', uri: 'https://webdomain.com/users/carol') }
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index 19a8678f0..963924fa9 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ProcessMentionsService do
+RSpec.describe ProcessMentionsService, type: :service do
let(:account) { Fabricate(:account, username: 'alice') }
let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") }
diff --git a/spec/services/pubsubhubbub/subscribe_service_spec.rb b/spec/services/pubsubhubbub/subscribe_service_spec.rb
index 82094117b..01c956230 100644
--- a/spec/services/pubsubhubbub/subscribe_service_spec.rb
+++ b/spec/services/pubsubhubbub/subscribe_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-describe Pubsubhubbub::SubscribeService do
+describe Pubsubhubbub::SubscribeService, type: :service do
describe '#call' do
subject { described_class.new }
let(:user_account) { Fabricate(:account) }
diff --git a/spec/services/pubsubhubbub/unsubscribe_service_spec.rb b/spec/services/pubsubhubbub/unsubscribe_service_spec.rb
index 59054ed99..7ed9fc5af 100644
--- a/spec/services/pubsubhubbub/unsubscribe_service_spec.rb
+++ b/spec/services/pubsubhubbub/unsubscribe_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-describe Pubsubhubbub::UnsubscribeService do
+describe Pubsubhubbub::UnsubscribeService, type: :service do
describe '#call' do
subject { described_class.new }
diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb
index 19d3bb6cb..2755da772 100644
--- a/spec/services/reblog_service_spec.rb
+++ b/spec/services/reblog_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ReblogService do
+RSpec.describe ReblogService, type: :service do
let(:alice) { Fabricate(:account, username: 'alice') }
context 'OStatus' do
diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb
index bf49dd2c9..e5ac37ed9 100644
--- a/spec/services/reject_follow_service_spec.rb
+++ b/spec/services/reject_follow_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe RejectFollowService do
+RSpec.describe RejectFollowService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { RejectFollowService.new }
diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb
index 5bb75b820..2134f51fd 100644
--- a/spec/services/remove_status_service_spec.rb
+++ b/spec/services/remove_status_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe RemoveStatusService do
+RSpec.describe RemoveStatusService, type: :service do
subject { RemoveStatusService.new }
let!(:alice) { Fabricate(:account) }
diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb
index 2f926ef00..2c392d376 100644
--- a/spec/services/report_service_spec.rb
+++ b/spec/services/report_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ReportService do
+RSpec.describe ReportService, type: :service do
subject { described_class.new }
let(:source_account) { Fabricate(:account) }
diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb
index 5f1b4467b..dd7561587 100644
--- a/spec/services/resolve_account_service_spec.rb
+++ b/spec/services/resolve_account_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe ResolveAccountService do
+RSpec.describe ResolveAccountService, type: :service do
subject { described_class.new }
before do
@@ -105,6 +105,21 @@ RSpec.describe ResolveAccountService do
expect(account.inbox_url).to eq 'https://ap.example.com/users/foo/inbox'
end
+ context 'with multiple types' do
+ before do
+ stub_request(:get, "https://ap.example.com/users/foo").to_return(request_fixture('activitypub-actor-individual.txt'))
+ end
+
+ it 'returns new remote account' do
+ account = subject.call('foo@ap.example.com')
+
+ expect(account.activitypub?).to eq true
+ expect(account.domain).to eq 'ap.example.com'
+ expect(account.inbox_url).to eq 'https://ap.example.com/users/foo/inbox'
+ expect(account.actor_type).to eq 'Person'
+ end
+ end
+
pending
end
diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb
index 1e9be4c07..7bb5d1940 100644
--- a/spec/services/resolve_url_service_spec.rb
+++ b/spec/services/resolve_url_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-describe ResolveURLService do
+describe ResolveURLService, type: :service do
subject { described_class.new }
describe '#call' do
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 957b60c7d..673de5233 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-describe SearchService do
+describe SearchService, type: :service do
subject { described_class.new }
describe '#call' do
diff --git a/spec/services/send_interaction_service_spec.rb b/spec/services/send_interaction_service_spec.rb
index ff08394b0..710d8184c 100644
--- a/spec/services/send_interaction_service_spec.rb
+++ b/spec/services/send_interaction_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe SendInteractionService do
+RSpec.describe SendInteractionService, type: :service do
subject { SendInteractionService.new }
it 'sends an XML envelope to the Salmon end point of remote user'
diff --git a/spec/services/subscribe_service_spec.rb b/spec/services/subscribe_service_spec.rb
index 835be5ec5..10bdb1ba8 100644
--- a/spec/services/subscribe_service_spec.rb
+++ b/spec/services/subscribe_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe SubscribeService do
+RSpec.describe SubscribeService, type: :service do
let(:account) { Fabricate(:account, username: 'bob', domain: 'example.com', hub_url: 'http://hub.example.com') }
subject { SubscribeService.new }
diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb
index 1cb647e8d..fd303a9d5 100644
--- a/spec/services/suspend_account_service_spec.rb
+++ b/spec/services/suspend_account_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe SuspendAccountService do
+RSpec.describe SuspendAccountService, type: :service do
describe '#call' do
subject do
-> { described_class.new.call(account) }
diff --git a/spec/services/unblock_domain_service_spec.rb b/spec/services/unblock_domain_service_spec.rb
index c32e5d655..8e8893d63 100644
--- a/spec/services/unblock_domain_service_spec.rb
+++ b/spec/services/unblock_domain_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-describe UnblockDomainService do
+describe UnblockDomainService, type: :service do
subject { described_class.new }
describe 'call' do
diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb
index ca7a6b77e..5835b912b 100644
--- a/spec/services/unblock_service_spec.rb
+++ b/spec/services/unblock_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe UnblockService do
+RSpec.describe UnblockService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { UnblockService.new }
diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb
index 021e76782..c5914c818 100644
--- a/spec/services/unfollow_service_spec.rb
+++ b/spec/services/unfollow_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe UnfollowService do
+RSpec.describe UnfollowService, type: :service do
let(:sender) { Fabricate(:account, username: 'alice') }
subject { UnfollowService.new }
diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb
index 5dc971fb1..8463eb283 100644
--- a/spec/services/unmute_service_spec.rb
+++ b/spec/services/unmute_service_spec.rb
@@ -1,5 +1,5 @@
require 'rails_helper'
-RSpec.describe UnmuteService do
+RSpec.describe UnmuteService, type: :service do
subject { UnmuteService.new }
end
diff --git a/spec/services/unsubscribe_service_spec.rb b/spec/services/unsubscribe_service_spec.rb
index 2a02f4c75..54d4b1b53 100644
--- a/spec/services/unsubscribe_service_spec.rb
+++ b/spec/services/unsubscribe_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe UnsubscribeService do
+RSpec.describe UnsubscribeService, type: :service do
let(:account) { Fabricate(:account, username: 'bob', domain: 'example.com', hub_url: 'http://hub.example.com') }
subject { UnsubscribeService.new }
diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb
index 64ec2dbbb..7ac3a809a 100644
--- a/spec/services/update_remote_profile_service_spec.rb
+++ b/spec/services/update_remote_profile_service_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-RSpec.describe UpdateRemoteProfileService do
+RSpec.describe UpdateRemoteProfileService, type: :service do
let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
subject { UpdateRemoteProfileService.new }
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index a0466dd4b..0cd1f91d0 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,11 +1,12 @@
-require 'simplecov'
-
GC.disable
-SimpleCov.start 'rails' do
- add_group 'Services', 'app/services'
- add_group 'Presenters', 'app/presenters'
- add_group 'Validators', 'app/validators'
+if ENV['DISABLE_SIMPLECOV'] != 'true'
+ require 'simplecov'
+ SimpleCov.start 'rails' do
+ add_group 'Services', 'app/services'
+ add_group 'Presenters', 'app/presenters'
+ add_group 'Validators', 'app/validators'
+ end
end
gc_counter = -1
diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb
new file mode 100644
index 000000000..b9d773bed
--- /dev/null
+++ b/spec/validators/unique_username_validator_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe UniqueUsernameValidator do
+ describe '#validate' do
+ it 'does not add errors if username is nil' do
+ account = double(username: nil, persisted?: false, errors: double(add: nil))
+ subject.validate(account)
+ expect(account.errors).to_not have_received(:add)
+ end
+
+ it 'does not add errors when existing one is subject itself' do
+ account = Fabricate(:account, username: 'abcdef')
+ expect(account).to be_valid
+ end
+
+ it 'adds an error when the username is already used with ignoring dots' do
+ pending 'allowing dots in username is still in development'
+ Fabricate(:account, username: 'abcd.ef')
+ account = double(username: 'ab.cdef', persisted?: false, errors: double(add: nil))
+ subject.validate(account)
+ expect(account.errors).to have_received(:add)
+ end
+
+ it 'adds an error when the username is already used with ignoring cases' do
+ Fabricate(:account, username: 'ABCdef')
+ account = double(username: 'abcDEF', persisted?: false, errors: double(add: nil))
+ subject.validate(account)
+ expect(account.errors).to have_received(:add)
+ end
+ end
+end
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index 03d6fb7ab..cbe5aa93b 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -19,6 +19,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
hero: nil,
user_count: 0,
status_count: 0,
+ contact_account: nil,
closed_registrations_message: 'yes')
assign(:instance_presenter, instance_presenter)
render
diff --git a/spec/views/stream_entries/show.html.haml_spec.rb b/spec/views/stream_entries/show.html.haml_spec.rb
index 59ea40990..560039ffa 100644
--- a/spec/views/stream_entries/show.html.haml_spec.rb
+++ b/spec/views/stream_entries/show.html.haml_spec.rb
@@ -24,6 +24,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
assign(:stream_entry, status.stream_entry)
assign(:account, alice)
assign(:type, status.stream_entry.activity_type.downcase)
+ assign(:descendant_threads, [])
render
@@ -48,8 +49,8 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
assign(:stream_entry, reply.stream_entry)
assign(:account, alice)
assign(:type, reply.stream_entry.activity_type.downcase)
- assign(:ancestors, reply.stream_entry.activity.ancestors(bob) )
- assign(:descendants, reply.stream_entry.activity.descendants(bob))
+ assign(:ancestors, reply.stream_entry.activity.ancestors(1, bob) )
+ assign(:descendant_threads, [{ statuses: reply.stream_entry.activity.descendants(1)}])
render
@@ -75,6 +76,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
assign(:stream_entry, status.stream_entry)
assign(:account, alice)
assign(:type, status.stream_entry.activity_type.downcase)
+ assign(:descendant_threads, [])
render
diff --git a/streaming/index.js b/streaming/index.js
index a42a91242..4eaf66865 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -241,7 +241,9 @@ const startWorker = (workerId) => {
const PUBLIC_STREAMS = [
'public',
+ 'public:media',
'public:local',
+ 'public:local:media',
'hashtag',
'hashtag:local',
];
@@ -329,52 +331,53 @@ const startWorker = (workerId) => {
// Only messages that may require filtering are statuses, since notifications
// are already personalized and deletes do not matter
- if (needsFiltering && event === 'update') {
- pgPool.connect((err, client, done) => {
- if (err) {
- log.error(err);
- return;
- }
-
- const unpackedPayload = payload;
- const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id));
- const accountDomain = unpackedPayload.account.acct.split('@')[1];
-
- if (Array.isArray(req.filteredLanguages) && req.filteredLanguages.indexOf(unpackedPayload.language) !== -1) {
- log.silly(req.requestId, `Message ${unpackedPayload.id} filtered by language (${unpackedPayload.language})`);
- done();
- return;
- }
-
- if (req.accountId) {
- const queries = [
- client.query(`SELECT 1 FROM blocks WHERE (account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})) OR (account_id = $2 AND target_account_id = $1) UNION SELECT 1 FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})`, [req.accountId, unpackedPayload.account.id].concat(targetAccountIds)),
- ];
-
- if (accountDomain) {
- queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]));
- }
-
- Promise.all(queries).then(values => {
- done();
-
- if (values[0].rows.length > 0 || (values.length > 1 && values[1].rows.length > 0)) {
- return;
- }
-
- transmit();
- }).catch(err => {
- done();
- log.error(err);
- });
- } else {
- done();
- transmit();
- }
- });
- } else {
+ if (!needsFiltering || event !== 'update') {
transmit();
+ return;
}
+
+ const unpackedPayload = payload;
+ const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id));
+ const accountDomain = unpackedPayload.account.acct.split('@')[1];
+
+ if (Array.isArray(req.filteredLanguages) && req.filteredLanguages.indexOf(unpackedPayload.language) !== -1) {
+ log.silly(req.requestId, `Message ${unpackedPayload.id} filtered by language (${unpackedPayload.language})`);
+ return;
+ }
+
+ // When the account is not logged in, it is not necessary to confirm the block or mute
+ if (!req.accountId) {
+ transmit();
+ return;
+ }
+
+ pgPool.connect((err, client, done) => {
+ if (err) {
+ log.error(err);
+ return;
+ }
+
+ const queries = [
+ client.query(`SELECT 1 FROM blocks WHERE (account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})) OR (account_id = $2 AND target_account_id = $1) UNION SELECT 1 FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 2)})`, [req.accountId, unpackedPayload.account.id].concat(targetAccountIds)),
+ ];
+
+ if (accountDomain) {
+ queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]));
+ }
+
+ Promise.all(queries).then(values => {
+ done();
+
+ if (values[0].rows.length > 0 || (values.length > 1 && values[1].rows.length > 0)) {
+ return;
+ }
+
+ transmit();
+ }).catch(err => {
+ done();
+ log.error(err);
+ });
+ });
};
subscribe(`${redisPrefix}${id}`, listener);
@@ -458,11 +461,21 @@ const startWorker = (workerId) => {
});
app.get('/api/v1/streaming/public', (req, res) => {
- streamFrom('timeline:public', req, streamToHttp(req, res), streamHttpEnd(req), true);
+ const onlyMedia = req.query.only_media === '1' || req.query.only_media === 'true';
+ const channel = onlyMedia ? 'timeline:public:media' : 'timeline:public';
+
+ streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
app.get('/api/v1/streaming/public/local', (req, res) => {
- streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true);
+ const onlyMedia = req.query.only_media === '1' || req.query.only_media === 'true';
+ const channel = onlyMedia ? 'timeline:public:local:media' : 'timeline:public:local';
+
+ streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req), true);
+ });
+
+ app.get('/api/v1/streaming/direct', (req, res) => {
+ streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
app.get('/api/v1/streaming/hashtag', (req, res) => {
@@ -516,6 +529,15 @@ const startWorker = (workerId) => {
case 'public:local':
streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
+ case 'public:media':
+ streamFrom('timeline:public:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+ break;
+ case 'public:local:media':
+ streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+ break;
+ case 'direct':
+ streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+ break;
case 'hashtag':
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
diff --git a/yarn.lock b/yarn.lock
index fbce624be..641835149 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10,6 +10,22 @@
esutils "^2.0.2"
js-tokens "^3.0.0"
+"@babel/code-frame@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0-beta.44.tgz#2a02643368de80916162be70865c97774f3adbd9"
+ dependencies:
+ "@babel/highlight" "7.0.0-beta.44"
+
+"@babel/generator@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.0.0-beta.44.tgz#c7e67b9b5284afcf69b309b50d7d37f3e5033d42"
+ dependencies:
+ "@babel/types" "7.0.0-beta.44"
+ jsesc "^2.5.1"
+ lodash "^4.2.0"
+ source-map "^0.5.0"
+ trim-right "^1.0.1"
+
"@babel/helper-function-name@7.0.0-beta.36":
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.36.tgz#366e3bc35147721b69009f803907c4d53212e88d"
@@ -18,12 +34,40 @@
"@babel/template" "7.0.0-beta.36"
"@babel/types" "7.0.0-beta.36"
+"@babel/helper-function-name@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.44.tgz#e18552aaae2231100a6e485e03854bc3532d44dd"
+ dependencies:
+ "@babel/helper-get-function-arity" "7.0.0-beta.44"
+ "@babel/template" "7.0.0-beta.44"
+ "@babel/types" "7.0.0-beta.44"
+
"@babel/helper-get-function-arity@7.0.0-beta.36":
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.36.tgz#f5383bac9a96b274828b10d98900e84ee43e32b8"
dependencies:
"@babel/types" "7.0.0-beta.36"
+"@babel/helper-get-function-arity@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0-beta.44.tgz#d03ca6dd2b9f7b0b1e6b32c56c72836140db3a15"
+ dependencies:
+ "@babel/types" "7.0.0-beta.44"
+
+"@babel/helper-split-export-declaration@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0-beta.44.tgz#c0b351735e0fbcb3822c8ad8db4e583b05ebd9dc"
+ dependencies:
+ "@babel/types" "7.0.0-beta.44"
+
+"@babel/highlight@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0-beta.44.tgz#18c94ce543916a80553edcdcf681890b200747d5"
+ dependencies:
+ chalk "^2.0.0"
+ esutils "^2.0.2"
+ js-tokens "^3.0.0"
+
"@babel/template@7.0.0-beta.36":
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.36.tgz#02e903de5d68bd7899bce3c5b5447e59529abb00"
@@ -33,6 +77,15 @@
babylon "7.0.0-beta.36"
lodash "^4.2.0"
+"@babel/template@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.44.tgz#f8832f4fdcee5d59bf515e595fc5106c529b394f"
+ dependencies:
+ "@babel/code-frame" "7.0.0-beta.44"
+ "@babel/types" "7.0.0-beta.44"
+ babylon "7.0.0-beta.44"
+ lodash "^4.2.0"
+
"@babel/traverse@7.0.0-beta.36":
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0-beta.36.tgz#1dc6f8750e89b6b979de5fe44aa993b1a2192261"
@@ -46,6 +99,21 @@
invariant "^2.2.0"
lodash "^4.2.0"
+"@babel/traverse@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0-beta.44.tgz#a970a2c45477ad18017e2e465a0606feee0d2966"
+ dependencies:
+ "@babel/code-frame" "7.0.0-beta.44"
+ "@babel/generator" "7.0.0-beta.44"
+ "@babel/helper-function-name" "7.0.0-beta.44"
+ "@babel/helper-split-export-declaration" "7.0.0-beta.44"
+ "@babel/types" "7.0.0-beta.44"
+ babylon "7.0.0-beta.44"
+ debug "^3.1.0"
+ globals "^11.1.0"
+ invariant "^2.2.0"
+ lodash "^4.2.0"
+
"@babel/types@7.0.0-beta.36":
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0-beta.36.tgz#64f2004353de42adb72f9ebb4665fc35b5499d23"
@@ -54,6 +122,14 @@
lodash "^4.2.0"
to-fast-properties "^2.0.0"
+"@babel/types@7.0.0-beta.44":
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0-beta.44.tgz#6b1b164591f77dec0a0342aca995f2d046b3a757"
+ dependencies:
+ esutils "^2.0.2"
+ lodash "^4.2.0"
+ to-fast-properties "^2.0.0"
+
"@types/node@*":
version "8.0.53"
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.53.tgz#396b35af826fa66aad472c8cb7b8d5e277f4e6d8"
@@ -103,9 +179,9 @@ acorn@^5.0.0, acorn@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7"
-acorn@^5.2.1:
- version "5.3.0"
- resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822"
+acorn@^5.5.0:
+ version "5.6.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.6.0.tgz#572bedb377a1c61b7a289e72b8c5cfeb7baaf0bf"
adjust-sourcemap-loader@^1.1.0:
version "1.1.0"
@@ -446,7 +522,7 @@ babel-core@^6.0.0, babel-core@^6.25.0, babel-core@^6.26.0:
slash "^1.0.0"
source-map "^0.5.6"
-babel-eslint@^8.0.1, babel-eslint@^8.2.1:
+babel-eslint@^8.0.1:
version "8.2.1"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.2.1.tgz#136888f3c109edc65376c23ebf494f36a3e03951"
dependencies:
@@ -457,6 +533,17 @@ babel-eslint@^8.0.1, babel-eslint@^8.2.1:
eslint-scope "~3.7.1"
eslint-visitor-keys "^1.0.0"
+babel-eslint@^8.2.3:
+ version "8.2.3"
+ resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.2.3.tgz#1a2e6681cc9bc4473c32899e59915e19cd6733cf"
+ dependencies:
+ "@babel/code-frame" "7.0.0-beta.44"
+ "@babel/traverse" "7.0.0-beta.44"
+ "@babel/types" "7.0.0-beta.44"
+ babylon "7.0.0-beta.44"
+ eslint-scope "~3.7.1"
+ eslint-visitor-keys "^1.0.0"
+
babel-generator@^6.18.0, babel-generator@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5"
@@ -1089,6 +1176,10 @@ babylon@7.0.0-beta.36:
version "7.0.0-beta.36"
resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.36.tgz#3a3683ba6a9a1e02b0aa507c8e63435e39305b9e"
+babylon@7.0.0-beta.44:
+ version "7.0.0-beta.44"
+ resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.44.tgz#89159e15e6e30c5096e22d738d8c0af8a0e8ca1d"
+
babylon@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
@@ -2121,7 +2212,7 @@ doctrine@1.5.0:
esutils "^2.0.2"
isarray "^1.0.0"
-doctrine@^2.0.0, doctrine@^2.0.2:
+doctrine@^2.0.2, doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
dependencies:
@@ -2213,8 +2304,8 @@ elliptic@^6.0.0:
minimalistic-crypto-utils "^1.0.0"
emoji-mart@Gargron/emoji-mart#build:
- version "2.1.4"
- resolved "https://codeload.github.com/Gargron/emoji-mart/tar.gz/a5e1afe5ebcf2841e611d20d261b029581cbe051"
+ version "2.6.2"
+ resolved "https://codeload.github.com/Gargron/emoji-mart/tar.gz/ff00dc470b5b2d9f145a6d6e977a54de5df2b4c9"
emoji-regex@^6.1.0:
version "6.5.1"
@@ -2421,9 +2512,9 @@ eslint-plugin-import@^2.8.0:
minimatch "^3.0.3"
read-pkg-up "^2.0.0"
-eslint-plugin-jsx-a11y@^5.1.1:
- version "5.1.1"
- resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-5.1.1.tgz#5c96bb5186ca14e94db1095ff59b3e2bd94069b1"
+eslint-plugin-jsx-a11y@^6.0.3:
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.0.3.tgz#54583d1ae442483162e040e13cc31865465100e5"
dependencies:
aria-query "^0.7.0"
array-includes "^3.0.3"
@@ -2431,15 +2522,19 @@ eslint-plugin-jsx-a11y@^5.1.1:
axobject-query "^0.1.0"
damerau-levenshtein "^1.0.0"
emoji-regex "^6.1.0"
- jsx-ast-utils "^1.4.0"
-
-eslint-plugin-react@^7.5.1:
- version "7.5.1"
- resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.5.1.tgz#52e56e8d80c810de158859ef07b880d2f56ee30b"
- dependencies:
- doctrine "^2.0.0"
- has "^1.0.1"
jsx-ast-utils "^2.0.0"
+
+eslint-plugin-promise@^3.8.0:
+ version "3.8.0"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-3.8.0.tgz#65ebf27a845e3c1e9d6f6a5622ddd3801694b621"
+
+eslint-plugin-react@^7.8.2:
+ version "7.8.2"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.8.2.tgz#e95c9c47fece55d2303d1a67c9d01b930b88a51d"
+ dependencies:
+ doctrine "^2.0.2"
+ has "^1.0.1"
+ jsx-ast-utils "^2.0.1"
prop-types "^15.6.0"
eslint-scope@^3.7.1, eslint-scope@~3.7.1:
@@ -2453,9 +2548,9 @@ eslint-visitor-keys@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
-eslint@^4.15.0:
- version "4.15.0"
- resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.15.0.tgz#89ab38c12713eec3d13afac14e4a89e75ef08145"
+eslint@^4.19.1:
+ version "4.19.1"
+ resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.19.1.tgz#32d1d653e1d90408854bfb296f076ec7e186a300"
dependencies:
ajv "^5.3.0"
babel-code-frame "^6.22.0"
@@ -2463,10 +2558,10 @@ eslint@^4.15.0:
concat-stream "^1.6.0"
cross-spawn "^5.1.0"
debug "^3.1.0"
- doctrine "^2.0.2"
+ doctrine "^2.1.0"
eslint-scope "^3.7.1"
eslint-visitor-keys "^1.0.0"
- espree "^3.5.2"
+ espree "^3.5.4"
esquery "^1.0.0"
esutils "^2.0.2"
file-entry-cache "^2.0.0"
@@ -2488,18 +2583,19 @@ eslint@^4.15.0:
path-is-inside "^1.0.2"
pluralize "^7.0.0"
progress "^2.0.0"
+ regexpp "^1.0.1"
require-uncached "^1.0.3"
semver "^5.3.0"
strip-ansi "^4.0.0"
strip-json-comments "~2.0.1"
- table "^4.0.1"
+ table "4.0.2"
text-table "~0.2.0"
-espree@^3.5.2:
- version "3.5.2"
- resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.2.tgz#756ada8b979e9dcfcdb30aad8d1a9304a905e1ca"
+espree@^3.5.4:
+ version "3.5.4"
+ resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
dependencies:
- acorn "^5.2.1"
+ acorn "^5.5.0"
acorn-jsx "^3.0.0"
esprima@^2.6.0:
@@ -2597,6 +2693,10 @@ execa@^0.7.0:
signal-exit "^3.0.0"
strip-eof "^1.0.0"
+exif-js@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/exif-js/-/exif-js-2.3.0.tgz#9d10819bf571f873813e7640241255ab9ce1a814"
+
expand-brackets@^0.1.4:
version "0.1.5"
resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@@ -3364,6 +3464,13 @@ import-local@^0.1.1:
pkg-dir "^2.0.0"
resolve-cwd "^2.0.0"
+imports-loader@^0.8.0:
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.8.0.tgz#030ea51b8ca05977c40a3abfd9b4088fe0be9a69"
+ dependencies:
+ loader-utils "^1.0.2"
+ source-map "^0.6.1"
+
imurmurhash@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
@@ -4068,6 +4175,10 @@ jsesc@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+jsesc@^2.5.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe"
+
jsesc@~0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
@@ -4133,11 +4244,7 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
-jsx-ast-utils@^1.4.0:
- version "1.4.1"
- resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1"
-
-jsx-ast-utils@^2.0.0:
+jsx-ast-utils@^2.0.0, jsx-ast-utils@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f"
dependencies:
@@ -4358,6 +4465,10 @@ lodash.restparam@^3.0.0:
version "3.6.1"
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
+lodash.sortby@^4.7.0:
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+
lodash.tail@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
@@ -5983,9 +6094,9 @@ rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
-react-dom@^16.2.0:
- version "16.2.0"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
+react-dom@^16.3.0:
+ version "16.3.2"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.3.2.tgz#cb90f107e09536d683d84ed5d4888e9640e0e4df"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
@@ -6109,6 +6220,12 @@ react-router@^4.2.0:
prop-types "^15.5.4"
warning "^3.0.0"
+react-sparklines@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"
+ dependencies:
+ prop-types "^15.5.10"
+
react-swipeable-views-core@^0.12.11:
version "0.12.11"
resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.12.11.tgz#3cf2b4daffbb36f9d69bd19bf5b2d5370b6b2c1b"
@@ -6169,9 +6286,9 @@ react-transition-group@^2.2.0:
prop-types "^15.5.8"
warning "^3.0.0"
-react@^16.2.0:
- version "16.2.0"
- resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
+react@^16.3.0:
+ version "16.3.2"
+ resolved "https://registry.yarnpkg.com/react/-/react-16.3.2.tgz#fdc8420398533a1e58872f59091b272ce2f91ea9"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
@@ -6323,6 +6440,10 @@ regex-parser@^2.2.1:
version "2.2.8"
resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.8.tgz#da4c0cda5a828559094168930f455f532b6ffbac"
+regexpp@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-1.1.0.tgz#0e3516dd0b7904f413d2d4193dce4618c3a689ab"
+
regexpu-core@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b"
@@ -6869,7 +6990,7 @@ source-map@^0.4.2, source-map@^0.4.4:
dependencies:
amdefine ">=0.0.4"
-source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.6:
+source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.6:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
@@ -7118,7 +7239,7 @@ symbol-tree@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
-table@^4.0.1:
+table@4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36"
dependencies:
@@ -7236,6 +7357,12 @@ tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3:
dependencies:
punycode "^1.4.1"
+tr46@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+ dependencies:
+ punycode "^2.1.0"
+
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@@ -7473,7 +7600,7 @@ webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
-webidl-conversions@^4.0.0:
+webidl-conversions@^4.0.0, webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@@ -7616,6 +7743,14 @@ whatwg-url@^4.3.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
+whatwg-url@^6.4.1:
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.4.1.tgz#fdb94b440fd4ad836202c16e9737d511f012fd67"
+ dependencies:
+ lodash.sortby "^4.7.0"
+ tr46 "^1.0.1"
+ webidl-conversions "^4.0.2"
+
whet.extend@~0.9.9:
version "0.9.9"
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"