Merge pull request #1398 from ThibG/glitch-soc/master
Merge upstream changes
This commit is contained in:
commit
aa1058acdd
8 changed files with 1146 additions and 416 deletions
|
@ -1,3 +1,5 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
import { connectStream } from 'flavours/glitch/util/stream';
|
import { connectStream } from 'flavours/glitch/util/stream';
|
||||||
import {
|
import {
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
|
@ -19,24 +21,59 @@ import { getLocale } from 'mastodon/locales';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
const { messages } = getLocale();
|
||||||
|
|
||||||
export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) {
|
/**
|
||||||
|
* @param {number} max
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
const randomUpTo = max =>
|
||||||
|
Math.floor(Math.random() * Math.floor(max));
|
||||||
|
|
||||||
return connectStream (path, pollingRefresh, (dispatch, getState) => {
|
/**
|
||||||
|
* @param {string} timelineId
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(Function, Function): void} [options.fallback]
|
||||||
|
* @param {function(object): boolean} [options.accept]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
|
||||||
|
connectStream(channelName, params, (dispatch, getState) => {
|
||||||
const locale = getState().getIn(['meta', 'locale']);
|
const locale = getState().getIn(['meta', 'locale']);
|
||||||
|
|
||||||
|
let pollingId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {function(Function, Function): void} fallback
|
||||||
|
*/
|
||||||
|
const useFallback = fallback => {
|
||||||
|
fallback(dispatch, () => {
|
||||||
|
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onConnect() {
|
onConnect() {
|
||||||
dispatch(connectTimeline(timelineId));
|
dispatch(connectTimeline(timelineId));
|
||||||
|
|
||||||
|
if (pollingId) {
|
||||||
|
clearTimeout(pollingId);
|
||||||
|
pollingId = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onDisconnect() {
|
onDisconnect() {
|
||||||
dispatch(disconnectTimeline(timelineId));
|
dispatch(disconnectTimeline(timelineId));
|
||||||
|
|
||||||
|
if (options.fallback) {
|
||||||
|
pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onReceive (data) {
|
onReceive (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
case 'update':
|
case 'update':
|
||||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), accept));
|
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
dispatch(deleteFromTimelines(data.payload));
|
dispatch(deleteFromTimelines(data.payload));
|
||||||
|
@ -63,17 +100,60 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Function} dispatch
|
||||||
|
* @param {function(): void} done
|
||||||
|
*/
|
||||||
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||||
dispatch(expandHomeTimeline({}, () =>
|
dispatch(expandHomeTimeline({}, () =>
|
||||||
dispatch(expandNotifications({}, () =>
|
dispatch(expandNotifications({}, () =>
|
||||||
dispatch(fetchAnnouncements(done))))));
|
dispatch(fetchAnnouncements(done))))));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
/**
|
||||||
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
* @return {function(): void}
|
||||||
export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`);
|
*/
|
||||||
export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
|
export const connectUserStream = () =>
|
||||||
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification });
|
||||||
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {boolean} [options.onlyMedia]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||||
|
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {boolean} [options.onlyMedia]
|
||||||
|
* @param {boolean} [options.onlyRemote]
|
||||||
|
* @param {boolean} [options.allowLocalOnly]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) =>
|
||||||
|
connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} columnId
|
||||||
|
* @param {string} tagName
|
||||||
|
* @param {boolean} onlyLocal
|
||||||
|
* @param {function(object): boolean} accept
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
|
||||||
|
connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectDirectStream = () =>
|
||||||
|
connectTimelineStream('direct', 'direct');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} listId
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectListStream = listId =>
|
||||||
|
connectTimelineStream(`list:${listId}`, 'list', { list: listId });
|
||||||
|
|
|
@ -1,87 +1,236 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
import WebSocketClient from '@gamestdio/websocket';
|
import WebSocketClient from '@gamestdio/websocket';
|
||||||
|
|
||||||
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
|
/**
|
||||||
|
* @type {WebSocketClient | undefined}
|
||||||
|
*/
|
||||||
|
let sharedConnection;
|
||||||
|
|
||||||
const knownEventTypes = [
|
/**
|
||||||
'update',
|
* @typedef Subscription
|
||||||
'delete',
|
* @property {string} channelName
|
||||||
'notification',
|
* @property {Object.<string, string>} params
|
||||||
'conversation',
|
* @property {function(): void} onConnect
|
||||||
'filters_changed',
|
* @property {function(StreamEvent): void} onReceive
|
||||||
];
|
* @property {function(): void} onDisconnect
|
||||||
|
*/
|
||||||
|
|
||||||
export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
|
/**
|
||||||
return (dispatch, getState) => {
|
* @typedef StreamEvent
|
||||||
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
|
* @property {string} event
|
||||||
const accessToken = getState().getIn(['meta', 'access_token']);
|
* @property {object} payload
|
||||||
const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
|
*/
|
||||||
|
|
||||||
let polling = null;
|
/**
|
||||||
|
* @type {Array.<Subscription>}
|
||||||
|
*/
|
||||||
|
const subscriptions = [];
|
||||||
|
|
||||||
const setupPolling = () => {
|
/**
|
||||||
pollingRefresh(dispatch, () => {
|
* @type {Object.<string, number>}
|
||||||
polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
|
*/
|
||||||
});
|
const subscriptionCounters = {};
|
||||||
};
|
|
||||||
|
|
||||||
const clearPolling = () => {
|
/**
|
||||||
if (polling) {
|
* @param {Subscription} subscription
|
||||||
clearTimeout(polling);
|
*/
|
||||||
polling = null;
|
const addSubscription = subscription => {
|
||||||
|
subscriptions.push(subscription);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const removeSubscription = subscription => {
|
||||||
|
const index = subscriptions.indexOf(subscription);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
subscriptions.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const subscribe = ({ channelName, params, onConnect }) => {
|
||||||
|
const key = channelNameWithInlineParams(channelName, params);
|
||||||
|
|
||||||
|
subscriptionCounters[key] = subscriptionCounters[key] || 0;
|
||||||
|
|
||||||
|
if (subscriptionCounters[key] === 0) {
|
||||||
|
sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params }));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCounters[key] += 1;
|
||||||
|
onConnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const unsubscribe = ({ channelName, params, onDisconnect }) => {
|
||||||
|
const key = channelNameWithInlineParams(channelName, params);
|
||||||
|
|
||||||
|
subscriptionCounters[key] = subscriptionCounters[key] || 1;
|
||||||
|
|
||||||
|
if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) {
|
||||||
|
sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params }));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCounters[key] -= 1;
|
||||||
|
onDisconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedCallbacks = {
|
||||||
|
connected () {
|
||||||
|
subscriptions.forEach(subscription => subscribe(subscription));
|
||||||
|
},
|
||||||
|
|
||||||
|
received (data) {
|
||||||
|
const { stream } = data;
|
||||||
|
|
||||||
|
subscriptions.filter(({ channelName, params }) => {
|
||||||
|
const streamChannelName = stream[0];
|
||||||
|
|
||||||
|
if (stream.length === 1) {
|
||||||
|
return channelName === streamChannelName;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
|
const streamIdentifier = stream[1];
|
||||||
|
|
||||||
|
if (['hashtag', 'hashtag:local'].includes(channelName)) {
|
||||||
|
return channelName === streamChannelName && params.tag === streamIdentifier;
|
||||||
|
} else if (channelName === 'list') {
|
||||||
|
return channelName === streamChannelName && params.list === streamIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}).forEach(subscription => {
|
||||||
|
subscription.onReceive(data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnected () {
|
||||||
|
subscriptions.forEach(({ onDisconnect }) => onDisconnect());
|
||||||
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
subscriptions.forEach(subscription => subscribe(subscription));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
const channelNameWithInlineParams = (channelName, params) => {
|
||||||
|
if (Object.keys(params).length === 0) {
|
||||||
|
return channelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
|
||||||
|
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
|
||||||
|
const accessToken = getState().getIn(['meta', 'access_token']);
|
||||||
|
const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState);
|
||||||
|
|
||||||
|
// If we cannot use a websockets connection, we must fall back
|
||||||
|
// to using individual connections for each channel
|
||||||
|
if (!streamingAPIBaseURL.startsWith('ws')) {
|
||||||
|
const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), {
|
||||||
connected () {
|
connected () {
|
||||||
if (pollingRefresh) {
|
|
||||||
clearPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnect();
|
onConnect();
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnected () {
|
|
||||||
if (pollingRefresh) {
|
|
||||||
polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
|
|
||||||
}
|
|
||||||
|
|
||||||
onDisconnect();
|
|
||||||
},
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
onReceive(data);
|
onReceive(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
reconnected () {
|
disconnected () {
|
||||||
if (pollingRefresh) {
|
onDisconnect();
|
||||||
clearPolling();
|
|
||||||
pollingRefresh(dispatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnect();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
onConnect();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const disconnect = () => {
|
return () => {
|
||||||
if (subscription) {
|
connection.close();
|
||||||
subscription.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPolling();
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return disconnect;
|
const subscription = {
|
||||||
|
channelName,
|
||||||
|
params,
|
||||||
|
onConnect,
|
||||||
|
onReceive,
|
||||||
|
onDisconnect,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
addSubscription(subscription);
|
||||||
|
|
||||||
export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
|
// If a connection is open, we can execute the subscription right now. Otherwise,
|
||||||
const params = stream.split('&');
|
// because we have already registered it, it will be executed on connect
|
||||||
stream = params.shift();
|
|
||||||
|
if (!sharedConnection) {
|
||||||
|
sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks));
|
||||||
|
} else if (sharedConnection.readyState === WebSocketClient.OPEN) {
|
||||||
|
subscribe(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeSubscription(subscription);
|
||||||
|
unsubscribe(subscription);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_EVENT_TYPES = [
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'notification',
|
||||||
|
'conversation',
|
||||||
|
'filters_changed',
|
||||||
|
'encrypted_message',
|
||||||
|
'announcement',
|
||||||
|
'announcement.delete',
|
||||||
|
'announcement.reaction',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MessageEvent} e
|
||||||
|
* @param {function(StreamEvent): void} received
|
||||||
|
*/
|
||||||
|
const handleEventSourceMessage = (e, received) => {
|
||||||
|
received({
|
||||||
|
event: e.type,
|
||||||
|
payload: e.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} streamingAPIBaseURL
|
||||||
|
* @param {string} accessToken
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks
|
||||||
|
* @return {WebSocketClient | EventSource}
|
||||||
|
*/
|
||||||
|
const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => {
|
||||||
|
const params = channelName.split('&');
|
||||||
|
|
||||||
|
channelName = params.shift();
|
||||||
|
|
||||||
if (streamingAPIBaseURL.startsWith('ws')) {
|
if (streamingAPIBaseURL.startsWith('ws')) {
|
||||||
params.unshift(`stream=${stream}`);
|
|
||||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
||||||
|
|
||||||
ws.onopen = connected;
|
ws.onopen = connected;
|
||||||
|
@ -92,11 +241,19 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
stream = stream.replace(/:/g, '/');
|
channelName = channelName.replace(/:/g, '/');
|
||||||
|
|
||||||
|
if (channelName.endsWith(':media')) {
|
||||||
|
channelName = channelName.replace('/media', '');
|
||||||
|
params.push('only_media=true');
|
||||||
|
}
|
||||||
|
|
||||||
params.push(`access_token=${accessToken}`);
|
params.push(`access_token=${accessToken}`);
|
||||||
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${stream}?${params.join('&')}`);
|
|
||||||
|
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
|
||||||
|
|
||||||
let firstConnect = true;
|
let firstConnect = true;
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
if (firstConnect) {
|
if (firstConnect) {
|
||||||
firstConnect = false;
|
firstConnect = false;
|
||||||
|
@ -105,15 +262,12 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co
|
||||||
reconnected();
|
reconnected();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (let type of knownEventTypes) {
|
|
||||||
es.addEventListener(type, (e) => {
|
KNOWN_EVENT_TYPES.forEach(type => {
|
||||||
received({
|
es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received));
|
||||||
event: e.type,
|
});
|
||||||
payload: e.data,
|
|
||||||
});
|
es.onerror = /** @type {function(): void} */ (disconnected);
|
||||||
});
|
|
||||||
}
|
|
||||||
es.onerror = disconnected;
|
|
||||||
|
|
||||||
return es;
|
return es;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
import { connectStream } from '../stream';
|
import { connectStream } from '../stream';
|
||||||
import {
|
import {
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
|
@ -19,24 +21,59 @@ import { getLocale } from '../locales';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
const { messages } = getLocale();
|
||||||
|
|
||||||
export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) {
|
/**
|
||||||
|
* @param {number} max
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
const randomUpTo = max =>
|
||||||
|
Math.floor(Math.random() * Math.floor(max));
|
||||||
|
|
||||||
return connectStream (path, pollingRefresh, (dispatch, getState) => {
|
/**
|
||||||
|
* @param {string} timelineId
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(Function, Function): void} [options.fallback]
|
||||||
|
* @param {function(object): boolean} [options.accept]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
|
||||||
|
connectStream(channelName, params, (dispatch, getState) => {
|
||||||
const locale = getState().getIn(['meta', 'locale']);
|
const locale = getState().getIn(['meta', 'locale']);
|
||||||
|
|
||||||
|
let pollingId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {function(Function, Function): void} fallback
|
||||||
|
*/
|
||||||
|
const useFallback = fallback => {
|
||||||
|
fallback(dispatch, () => {
|
||||||
|
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onConnect() {
|
onConnect() {
|
||||||
dispatch(connectTimeline(timelineId));
|
dispatch(connectTimeline(timelineId));
|
||||||
|
|
||||||
|
if (pollingId) {
|
||||||
|
clearTimeout(pollingId);
|
||||||
|
pollingId = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onDisconnect() {
|
onDisconnect() {
|
||||||
dispatch(disconnectTimeline(timelineId));
|
dispatch(disconnectTimeline(timelineId));
|
||||||
|
|
||||||
|
if (options.fallback) {
|
||||||
|
pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onReceive (data) {
|
onReceive (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
case 'update':
|
case 'update':
|
||||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), accept));
|
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
dispatch(deleteFromTimelines(data.payload));
|
dispatch(deleteFromTimelines(data.payload));
|
||||||
|
@ -63,17 +100,59 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Function} dispatch
|
||||||
|
* @param {function(): void} done
|
||||||
|
*/
|
||||||
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||||
dispatch(expandHomeTimeline({}, () =>
|
dispatch(expandHomeTimeline({}, () =>
|
||||||
dispatch(expandNotifications({}, () =>
|
dispatch(expandNotifications({}, () =>
|
||||||
dispatch(fetchAnnouncements(done))))));
|
dispatch(fetchAnnouncements(done))))));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
/**
|
||||||
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
* @return {function(): void}
|
||||||
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
|
*/
|
||||||
export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
|
export const connectUserStream = () =>
|
||||||
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification });
|
||||||
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {boolean} [options.onlyMedia]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||||
|
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {boolean} [options.onlyMedia]
|
||||||
|
* @param {boolean} [options.onlyRemote]
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
|
||||||
|
connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} columnId
|
||||||
|
* @param {string} tagName
|
||||||
|
* @param {boolean} onlyLocal
|
||||||
|
* @param {function(object): boolean} accept
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
|
||||||
|
connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectDirectStream = () =>
|
||||||
|
connectTimelineStream('direct', 'direct');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} listId
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectListStream = listId =>
|
||||||
|
connectTimelineStream(`list:${listId}`, 'list', { list: listId });
|
||||||
|
|
|
@ -1,87 +1,236 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
import WebSocketClient from '@gamestdio/websocket';
|
import WebSocketClient from '@gamestdio/websocket';
|
||||||
|
|
||||||
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
|
/**
|
||||||
|
* @type {WebSocketClient | undefined}
|
||||||
|
*/
|
||||||
|
let sharedConnection;
|
||||||
|
|
||||||
const knownEventTypes = [
|
/**
|
||||||
'update',
|
* @typedef Subscription
|
||||||
'delete',
|
* @property {string} channelName
|
||||||
'notification',
|
* @property {Object.<string, string>} params
|
||||||
'conversation',
|
* @property {function(): void} onConnect
|
||||||
'filters_changed',
|
* @property {function(StreamEvent): void} onReceive
|
||||||
];
|
* @property {function(): void} onDisconnect
|
||||||
|
*/
|
||||||
|
|
||||||
export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
|
/**
|
||||||
return (dispatch, getState) => {
|
* @typedef StreamEvent
|
||||||
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
|
* @property {string} event
|
||||||
const accessToken = getState().getIn(['meta', 'access_token']);
|
* @property {object} payload
|
||||||
const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
|
*/
|
||||||
|
|
||||||
let polling = null;
|
/**
|
||||||
|
* @type {Array.<Subscription>}
|
||||||
|
*/
|
||||||
|
const subscriptions = [];
|
||||||
|
|
||||||
const setupPolling = () => {
|
/**
|
||||||
pollingRefresh(dispatch, () => {
|
* @type {Object.<string, number>}
|
||||||
polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
|
*/
|
||||||
});
|
const subscriptionCounters = {};
|
||||||
};
|
|
||||||
|
|
||||||
const clearPolling = () => {
|
/**
|
||||||
if (polling) {
|
* @param {Subscription} subscription
|
||||||
clearTimeout(polling);
|
*/
|
||||||
polling = null;
|
const addSubscription = subscription => {
|
||||||
|
subscriptions.push(subscription);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const removeSubscription = subscription => {
|
||||||
|
const index = subscriptions.indexOf(subscription);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
subscriptions.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const subscribe = ({ channelName, params, onConnect }) => {
|
||||||
|
const key = channelNameWithInlineParams(channelName, params);
|
||||||
|
|
||||||
|
subscriptionCounters[key] = subscriptionCounters[key] || 0;
|
||||||
|
|
||||||
|
if (subscriptionCounters[key] === 0) {
|
||||||
|
sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params }));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCounters[key] += 1;
|
||||||
|
onConnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Subscription} subscription
|
||||||
|
*/
|
||||||
|
const unsubscribe = ({ channelName, params, onDisconnect }) => {
|
||||||
|
const key = channelNameWithInlineParams(channelName, params);
|
||||||
|
|
||||||
|
subscriptionCounters[key] = subscriptionCounters[key] || 1;
|
||||||
|
|
||||||
|
if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) {
|
||||||
|
sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params }));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCounters[key] -= 1;
|
||||||
|
onDisconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedCallbacks = {
|
||||||
|
connected () {
|
||||||
|
subscriptions.forEach(subscription => subscribe(subscription));
|
||||||
|
},
|
||||||
|
|
||||||
|
received (data) {
|
||||||
|
const { stream } = data;
|
||||||
|
|
||||||
|
subscriptions.filter(({ channelName, params }) => {
|
||||||
|
const streamChannelName = stream[0];
|
||||||
|
|
||||||
|
if (stream.length === 1) {
|
||||||
|
return channelName === streamChannelName;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const subscription = getStream(streamingAPIBaseURL, accessToken, path, {
|
const streamIdentifier = stream[1];
|
||||||
|
|
||||||
|
if (['hashtag', 'hashtag:local'].includes(channelName)) {
|
||||||
|
return channelName === streamChannelName && params.tag === streamIdentifier;
|
||||||
|
} else if (channelName === 'list') {
|
||||||
|
return channelName === streamChannelName && params.list === streamIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}).forEach(subscription => {
|
||||||
|
subscription.onReceive(data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnected () {
|
||||||
|
subscriptions.forEach(({ onDisconnect }) => onDisconnect());
|
||||||
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
subscriptions.forEach(subscription => subscribe(subscription));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
const channelNameWithInlineParams = (channelName, params) => {
|
||||||
|
if (Object.keys(params).length === 0) {
|
||||||
|
return channelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {Object.<string, string>} params
|
||||||
|
* @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks
|
||||||
|
* @return {function(): void}
|
||||||
|
*/
|
||||||
|
export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
|
||||||
|
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
|
||||||
|
const accessToken = getState().getIn(['meta', 'access_token']);
|
||||||
|
const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState);
|
||||||
|
|
||||||
|
// If we cannot use a websockets connection, we must fall back
|
||||||
|
// to using individual connections for each channel
|
||||||
|
if (!streamingAPIBaseURL.startsWith('ws')) {
|
||||||
|
const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), {
|
||||||
connected () {
|
connected () {
|
||||||
if (pollingRefresh) {
|
|
||||||
clearPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnect();
|
onConnect();
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnected () {
|
|
||||||
if (pollingRefresh) {
|
|
||||||
polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
|
|
||||||
}
|
|
||||||
|
|
||||||
onDisconnect();
|
|
||||||
},
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
onReceive(data);
|
onReceive(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
reconnected () {
|
disconnected () {
|
||||||
if (pollingRefresh) {
|
onDisconnect();
|
||||||
clearPolling();
|
|
||||||
pollingRefresh(dispatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnect();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
onConnect();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const disconnect = () => {
|
return () => {
|
||||||
if (subscription) {
|
connection.close();
|
||||||
subscription.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPolling();
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return disconnect;
|
const subscription = {
|
||||||
|
channelName,
|
||||||
|
params,
|
||||||
|
onConnect,
|
||||||
|
onReceive,
|
||||||
|
onDisconnect,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
addSubscription(subscription);
|
||||||
|
|
||||||
export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
|
// If a connection is open, we can execute the subscription right now. Otherwise,
|
||||||
const params = stream.split('&');
|
// because we have already registered it, it will be executed on connect
|
||||||
stream = params.shift();
|
|
||||||
|
if (!sharedConnection) {
|
||||||
|
sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks));
|
||||||
|
} else if (sharedConnection.readyState === WebSocketClient.OPEN) {
|
||||||
|
subscribe(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeSubscription(subscription);
|
||||||
|
unsubscribe(subscription);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_EVENT_TYPES = [
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'notification',
|
||||||
|
'conversation',
|
||||||
|
'filters_changed',
|
||||||
|
'encrypted_message',
|
||||||
|
'announcement',
|
||||||
|
'announcement.delete',
|
||||||
|
'announcement.reaction',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MessageEvent} e
|
||||||
|
* @param {function(StreamEvent): void} received
|
||||||
|
*/
|
||||||
|
const handleEventSourceMessage = (e, received) => {
|
||||||
|
received({
|
||||||
|
event: e.type,
|
||||||
|
payload: e.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} streamingAPIBaseURL
|
||||||
|
* @param {string} accessToken
|
||||||
|
* @param {string} channelName
|
||||||
|
* @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks
|
||||||
|
* @return {WebSocketClient | EventSource}
|
||||||
|
*/
|
||||||
|
const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => {
|
||||||
|
const params = channelName.split('&');
|
||||||
|
|
||||||
|
channelName = params.shift();
|
||||||
|
|
||||||
if (streamingAPIBaseURL.startsWith('ws')) {
|
if (streamingAPIBaseURL.startsWith('ws')) {
|
||||||
params.unshift(`stream=${stream}`);
|
|
||||||
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
|
||||||
|
|
||||||
ws.onopen = connected;
|
ws.onopen = connected;
|
||||||
|
@ -92,11 +241,19 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
stream = stream.replace(/:/g, '/');
|
channelName = channelName.replace(/:/g, '/');
|
||||||
|
|
||||||
|
if (channelName.endsWith(':media')) {
|
||||||
|
channelName = channelName.replace('/media', '');
|
||||||
|
params.push('only_media=true');
|
||||||
|
}
|
||||||
|
|
||||||
params.push(`access_token=${accessToken}`);
|
params.push(`access_token=${accessToken}`);
|
||||||
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${stream}?${params.join('&')}`);
|
|
||||||
|
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
|
||||||
|
|
||||||
let firstConnect = true;
|
let firstConnect = true;
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
if (firstConnect) {
|
if (firstConnect) {
|
||||||
firstConnect = false;
|
firstConnect = false;
|
||||||
|
@ -105,15 +262,12 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co
|
||||||
reconnected();
|
reconnected();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (let type of knownEventTypes) {
|
|
||||||
es.addEventListener(type, (e) => {
|
KNOWN_EVENT_TYPES.forEach(type => {
|
||||||
received({
|
es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received));
|
||||||
event: e.type,
|
});
|
||||||
payload: e.data,
|
|
||||||
});
|
es.onerror = /** @type {function(): void} */ (disconnected);
|
||||||
});
|
|
||||||
}
|
|
||||||
es.onerror = disconnected;
|
|
||||||
|
|
||||||
return es;
|
return es;
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,7 +30,7 @@ class Form::CustomEmojiBatch
|
||||||
private
|
private
|
||||||
|
|
||||||
def custom_emojis
|
def custom_emojis
|
||||||
CustomEmoji.where(id: custom_emoji_ids)
|
@custom_emojis ||= CustomEmoji.where(id: custom_emoji_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update!
|
def update!
|
||||||
|
|
|
@ -173,11 +173,7 @@ Rails.application.routes.draw do
|
||||||
get '/dashboard', to: 'dashboard#index'
|
get '/dashboard', to: 'dashboard#index'
|
||||||
|
|
||||||
resources :domain_allows, only: [:new, :create, :show, :destroy]
|
resources :domain_allows, only: [:new, :create, :show, :destroy]
|
||||||
resources :domain_blocks, only: [:new, :create, :show, :destroy, :update] do
|
resources :domain_blocks, only: [:new, :create, :show, :destroy, :update, :edit]
|
||||||
member do
|
|
||||||
get :edit
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
|
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
|
||||||
resources :action_logs, only: [:index]
|
resources :action_logs, only: [:index]
|
||||||
|
|
|
@ -89,7 +89,7 @@ module Mastodon
|
||||||
path_segments = object.key.split('/')
|
path_segments = object.key.split('/')
|
||||||
path_segments.delete('cache')
|
path_segments.delete('cache')
|
||||||
|
|
||||||
if path_segments.size != 7
|
unless [7, 10].include?(path_segments.size)
|
||||||
progress.log(pastel.yellow("Unrecognized file found: #{object.key}"))
|
progress.log(pastel.yellow("Unrecognized file found: #{object.key}"))
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
@ -133,7 +133,7 @@ module Mastodon
|
||||||
path_segments = key.split(File::SEPARATOR)
|
path_segments = key.split(File::SEPARATOR)
|
||||||
path_segments.delete('cache')
|
path_segments.delete('cache')
|
||||||
|
|
||||||
if path_segments.size != 7
|
unless [7, 10].include?(path_segments.size)
|
||||||
progress.log(pastel.yellow("Unrecognized file found: #{key}"))
|
progress.log(pastel.yellow("Unrecognized file found: #{key}"))
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
@ -258,7 +258,7 @@ module Mastodon
|
||||||
path_segments = path.split('/')[2..-1]
|
path_segments = path.split('/')[2..-1]
|
||||||
path_segments.delete('cache')
|
path_segments.delete('cache')
|
||||||
|
|
||||||
if path_segments.size != 7
|
unless [7, 10].include?(path_segments.size)
|
||||||
say('Not a media URL', :red)
|
say('Not a media URL', :red)
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
end
|
||||||
|
@ -311,7 +311,7 @@ module Mastodon
|
||||||
segments = object.key.split('/')
|
segments = object.key.split('/')
|
||||||
segments.delete('cache')
|
segments.delete('cache')
|
||||||
|
|
||||||
next if segments.size != 7
|
next unless [7, 10].include?(segments.size)
|
||||||
|
|
||||||
model_name = segments.first.classify
|
model_name = segments.first.classify
|
||||||
record_id = segments[2..-2].join.to_i
|
record_id = segments[2..-2].join.to_i
|
||||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue