Fix for unread syncs and ooo reactions
This commit is contained in:
parent
55f0beaa6d
commit
62e04a1bbd
7 changed files with 178 additions and 102 deletions
|
@ -69,6 +69,7 @@
|
||||||
if (message.isUnread()) {
|
if (message.isUnread()) {
|
||||||
await message.markRead(readAt, { skipSave: true });
|
await message.markRead(readAt, { skipSave: true });
|
||||||
|
|
||||||
|
const updateConversation = () => {
|
||||||
// onReadMessage may result in messages older than this one being
|
// onReadMessage may result in messages older than this one being
|
||||||
// marked read. We want those messages to have the same expire timer
|
// marked read. We want those messages to have the same expire timer
|
||||||
// start time as this one, so we pass the readAt value through.
|
// start time as this one, so we pass the readAt value through.
|
||||||
|
@ -76,6 +77,19 @@
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.onReadMessage(message, readAt);
|
conversation.onReadMessage(message, readAt);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.startupProcessingQueue) {
|
||||||
|
const conversation = message.getConversation();
|
||||||
|
if (conversation) {
|
||||||
|
window.startupProcessingQueue.add(
|
||||||
|
conversation.get('id'),
|
||||||
|
updateConversation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateConversation();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existingTimestamp = message.get('expirationStartTimestamp');
|
const existingTimestamp = message.get('expirationStartTimestamp');
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { isWindowDragElement } from './util/isWindowDragElement';
|
||||||
import { assert } from './util/assert';
|
import { assert } from './util/assert';
|
||||||
|
|
||||||
export async function startApp(): Promise<void> {
|
export async function startApp(): Promise<void> {
|
||||||
|
window.startupProcessingQueue = new window.Signal.Util.StartupQueue();
|
||||||
window.attachmentDownloadQueue = [];
|
window.attachmentDownloadQueue = [];
|
||||||
try {
|
try {
|
||||||
window.log.info('Initializing SQL in renderer');
|
window.log.info('Initializing SQL in renderer');
|
||||||
|
@ -2061,13 +2062,18 @@ export async function startApp(): Promise<void> {
|
||||||
clearInterval(interval!);
|
clearInterval(interval!);
|
||||||
interval = null;
|
interval = null;
|
||||||
view.onEmpty();
|
view.onEmpty();
|
||||||
|
|
||||||
window.logAppLoadedEvent();
|
window.logAppLoadedEvent();
|
||||||
|
if (messageReceiver) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'App loaded - messages:',
|
'App loaded - messages:',
|
||||||
messageReceiver.getProcessedCount()
|
messageReceiver.getProcessedCount()
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
window.sqlInitializer.goBackToMainProcess();
|
window.sqlInitializer.goBackToMainProcess();
|
||||||
window.Signal.Util.setBatchingStrategy(false);
|
window.Signal.Util.setBatchingStrategy(false);
|
||||||
|
|
||||||
const attachmentDownloadQueue = window.attachmentDownloadQueue || [];
|
const attachmentDownloadQueue = window.attachmentDownloadQueue || [];
|
||||||
const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000;
|
const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000;
|
||||||
const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250;
|
const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250;
|
||||||
|
@ -2081,7 +2087,12 @@ export async function startApp(): Promise<void> {
|
||||||
attachmentsToDownload.length,
|
attachmentsToDownload.length,
|
||||||
attachmentDownloadQueue.length
|
attachmentDownloadQueue.length
|
||||||
);
|
);
|
||||||
window.attachmentDownloadQueue = undefined;
|
|
||||||
|
if (window.startupProcessingQueue) {
|
||||||
|
window.startupProcessingQueue.flush();
|
||||||
|
window.startupProcessingQueue = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const messagesWithDownloads = await Promise.all(
|
const messagesWithDownloads = await Promise.all(
|
||||||
attachmentsToDownload.map(message =>
|
attachmentsToDownload.map(message =>
|
||||||
message.queueAttachmentDownloads()
|
message.queueAttachmentDownloads()
|
||||||
|
|
|
@ -3532,21 +3532,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'outgoing') {
|
|
||||||
const receipts = window.Whisper.DeliveryReceipts.forMessage(
|
|
||||||
conversation,
|
|
||||||
message
|
|
||||||
);
|
|
||||||
receipts.forEach(receipt =>
|
|
||||||
message.set({
|
|
||||||
delivered: (message.get('delivered') || 0) + 1,
|
|
||||||
delivered_to: _.union(message.get('delivered_to') || [], [
|
|
||||||
receipt.get('deliveredTo'),
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
attributes.active_at = now;
|
attributes.active_at = now;
|
||||||
conversation.set(attributes);
|
conversation.set(attributes);
|
||||||
|
|
||||||
|
@ -3608,6 +3593,158 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dataMessage.profileKey) {
|
||||||
|
const profileKey = dataMessage.profileKey.toString('base64');
|
||||||
|
if (
|
||||||
|
source === window.textsecure.storage.user.getNumber() ||
|
||||||
|
sourceUuid === window.textsecure.storage.user.getUuid()
|
||||||
|
) {
|
||||||
|
conversation.set({ profileSharing: true });
|
||||||
|
} else if (conversation.isPrivate()) {
|
||||||
|
conversation.setProfileKey(profileKey);
|
||||||
|
} else {
|
||||||
|
const localId = window.ConversationController.ensureContactIds({
|
||||||
|
e164: source,
|
||||||
|
uuid: sourceUuid,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
window.ConversationController.get(localId)!.setProfileKey(
|
||||||
|
profileKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.isTapToView() && type === 'outgoing') {
|
||||||
|
await message.eraseContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === 'incoming' &&
|
||||||
|
message.isTapToView() &&
|
||||||
|
!message.isValidTapToView()
|
||||||
|
) {
|
||||||
|
window.log.warn(
|
||||||
|
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
|
||||||
|
);
|
||||||
|
message.set({
|
||||||
|
isTapToViewInvalid: true,
|
||||||
|
});
|
||||||
|
await message.eraseContents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationTimestamp = conversation.get('timestamp');
|
||||||
|
if (
|
||||||
|
!conversationTimestamp ||
|
||||||
|
message.get('sent_at') > conversationTimestamp
|
||||||
|
) {
|
||||||
|
conversation.set({
|
||||||
|
lastMessage: message.getNotificationText(),
|
||||||
|
timestamp: message.get('sent_at'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MessageController.register(
|
||||||
|
message.id,
|
||||||
|
message as typeof window.WhatIsThis
|
||||||
|
);
|
||||||
|
conversation.incrementMessageCount();
|
||||||
|
window.Signal.Data.updateConversation(conversation.attributes);
|
||||||
|
|
||||||
|
// Only queue attachments for downloads if this is an outgoing message
|
||||||
|
// or we've accepted the conversation
|
||||||
|
const reduxState = window.reduxStore.getState();
|
||||||
|
const attachments = this.get('attachments') || [];
|
||||||
|
const shouldHoldOffDownload =
|
||||||
|
(isImage(attachments) || isVideo(attachments)) &&
|
||||||
|
isInCall(reduxState);
|
||||||
|
if (
|
||||||
|
this.hasAttachmentDownloads() &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
(this.getConversation()!.getAccepted() || message.isOutgoing()) &&
|
||||||
|
!shouldHoldOffDownload
|
||||||
|
) {
|
||||||
|
if (window.attachmentDownloadQueue) {
|
||||||
|
window.attachmentDownloadQueue.unshift(message);
|
||||||
|
window.log.info(
|
||||||
|
'Adding to attachmentDownloadQueue',
|
||||||
|
message.get('sent_at')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await message.queueAttachmentDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.modifyTargetMessage(conversation, isGroupV2);
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
'handleDataMessage: Batching save for',
|
||||||
|
message.get('sent_at')
|
||||||
|
);
|
||||||
|
this.saveAndNotify(conversation, isGroupV2, confirm);
|
||||||
|
} catch (error) {
|
||||||
|
const errorForLog = error && error.stack ? error.stack : error;
|
||||||
|
window.log.error(
|
||||||
|
'handleDataMessage',
|
||||||
|
message.idForLogging(),
|
||||||
|
'error:',
|
||||||
|
errorForLog
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAndNotify(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
isGroupV2: boolean,
|
||||||
|
confirm: () => void
|
||||||
|
): Promise<void> {
|
||||||
|
await window.Signal.Util.saveNewMessageBatcher.add(this.attributes);
|
||||||
|
|
||||||
|
window.log.info('Message saved', this.get('sent_at'));
|
||||||
|
|
||||||
|
conversation.trigger('newmessage', this);
|
||||||
|
|
||||||
|
await this.modifyTargetMessage(conversation, isGroupV2);
|
||||||
|
|
||||||
|
if (this.get('unread')) {
|
||||||
|
await conversation.notify(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the sent message count if this is an outgoing message
|
||||||
|
if (this.get('type') === 'outgoing') {
|
||||||
|
conversation.incrementSentMessageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Whisper.events.trigger('incrementProgress');
|
||||||
|
confirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async modifyTargetMessage(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
isGroupV2: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
const message = this;
|
||||||
|
const type = message.get('type');
|
||||||
|
|
||||||
|
if (type === 'outgoing') {
|
||||||
|
const receipts = window.Whisper.DeliveryReceipts.forMessage(
|
||||||
|
conversation,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
receipts.forEach(receipt =>
|
||||||
|
message.set({
|
||||||
|
delivered: (message.get('delivered') || 0) + 1,
|
||||||
|
delivered_to: _.union(message.get('delivered_to') || [], [
|
||||||
|
receipt.get('deliveredTo'),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGroupV2) {
|
||||||
if (type === 'incoming') {
|
if (type === 'incoming') {
|
||||||
const readSync = window.Whisper.ReadSyncs.forMessage(message);
|
const readSync = window.Whisper.ReadSyncs.forMessage(message);
|
||||||
if (readSync) {
|
if (readSync) {
|
||||||
|
@ -3661,44 +3798,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
message.set({ recipients: conversation.getRecipients() });
|
message.set({ recipients: conversation.getRecipients() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataMessage.profileKey) {
|
|
||||||
const profileKey = dataMessage.profileKey.toString('base64');
|
|
||||||
if (
|
|
||||||
source === window.textsecure.storage.user.getNumber() ||
|
|
||||||
sourceUuid === window.textsecure.storage.user.getUuid()
|
|
||||||
) {
|
|
||||||
conversation.set({ profileSharing: true });
|
|
||||||
} else if (conversation.isPrivate()) {
|
|
||||||
conversation.setProfileKey(profileKey);
|
|
||||||
} else {
|
|
||||||
const localId = window.ConversationController.ensureContactIds({
|
|
||||||
e164: source,
|
|
||||||
uuid: sourceUuid,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
window.ConversationController.get(localId)!.setProfileKey(
|
|
||||||
profileKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.isTapToView() && type === 'outgoing') {
|
|
||||||
await message.eraseContents();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === 'incoming' &&
|
|
||||||
message.isTapToView() &&
|
|
||||||
!message.isValidTapToView()
|
|
||||||
) {
|
|
||||||
window.log.warn(
|
|
||||||
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
|
|
||||||
);
|
|
||||||
message.set({
|
|
||||||
isTapToViewInvalid: true,
|
|
||||||
});
|
|
||||||
await message.eraseContents();
|
|
||||||
}
|
|
||||||
// Check for out-of-order view syncs
|
// Check for out-of-order view syncs
|
||||||
if (type === 'incoming' && message.isTapToView()) {
|
if (type === 'incoming' && message.isTapToView()) {
|
||||||
const viewSync = window.Whisper.ViewSyncs.forMessage(message);
|
const viewSync = window.Whisper.ViewSyncs.forMessage(message);
|
||||||
|
@ -3708,48 +3807,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversationTimestamp = conversation.get('timestamp');
|
|
||||||
if (
|
|
||||||
!conversationTimestamp ||
|
|
||||||
message.get('sent_at') > conversationTimestamp
|
|
||||||
) {
|
|
||||||
conversation.set({
|
|
||||||
lastMessage: message.getNotificationText(),
|
|
||||||
timestamp: message.get('sent_at'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.MessageController.register(
|
|
||||||
message.id,
|
|
||||||
message as typeof window.WhatIsThis
|
|
||||||
);
|
|
||||||
conversation.incrementMessageCount();
|
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
|
|
||||||
// Only queue attachments for downloads if this is an outgoing message
|
|
||||||
// or we've accepted the conversation
|
|
||||||
const reduxState = window.reduxStore.getState();
|
|
||||||
const attachments = this.get('attachments') || [];
|
|
||||||
const shouldHoldOffDownload =
|
|
||||||
(isImage(attachments) || isVideo(attachments)) &&
|
|
||||||
isInCall(reduxState);
|
|
||||||
if (
|
|
||||||
this.hasAttachmentDownloads() &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
(this.getConversation()!.getAccepted() || message.isOutgoing()) &&
|
|
||||||
!shouldHoldOffDownload
|
|
||||||
) {
|
|
||||||
if (window.attachmentDownloadQueue) {
|
|
||||||
window.attachmentDownloadQueue.unshift(message);
|
|
||||||
window.log.info(
|
|
||||||
'Adding to attachmentDownloadQueue',
|
|
||||||
message.get('sent_at')
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await message.queueAttachmentDownloads();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Does this message have any pending, previously-received associated reactions?
|
// Does this message have any pending, previously-received associated reactions?
|
||||||
const reactions = window.Whisper.Reactions.forMessage(message);
|
const reactions = window.Whisper.Reactions.forMessage(message);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
@ -3764,46 +3821,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
window.Signal.Util.deleteForEveryone(message, del, false)
|
window.Signal.Util.deleteForEveryone(message, del, false)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
window.log.info(
|
|
||||||
'handleDataMessage: Batching save for',
|
|
||||||
message.get('sent_at')
|
|
||||||
);
|
|
||||||
this.saveAndNotify(conversation, confirm);
|
|
||||||
} catch (error) {
|
|
||||||
const errorForLog = error && error.stack ? error.stack : error;
|
|
||||||
window.log.error(
|
|
||||||
'handleDataMessage',
|
|
||||||
message.idForLogging(),
|
|
||||||
'error:',
|
|
||||||
errorForLog
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAndNotify(
|
|
||||||
conversation: ConversationModel,
|
|
||||||
confirm: () => void
|
|
||||||
): Promise<void> {
|
|
||||||
await window.Signal.Util.saveNewMessageBatcher.add(this.attributes);
|
|
||||||
|
|
||||||
window.log.info('Message saved', this.get('sent_at'));
|
|
||||||
|
|
||||||
conversation.trigger('newmessage', this);
|
|
||||||
|
|
||||||
if (this.get('unread')) {
|
|
||||||
await conversation.notify(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment the sent message count if this is an outgoing message
|
|
||||||
if (this.get('type') === 'outgoing') {
|
|
||||||
conversation.incrementSentMessageCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Whisper.events.trigger('incrementProgress');
|
|
||||||
confirm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleReaction(
|
async handleReaction(
|
||||||
|
|
|
@ -441,6 +441,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.cacheAndHandle(envelope, plaintext, request);
|
this.cacheAndHandle(envelope, plaintext, request);
|
||||||
|
this.processedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request.respond(500, 'Bad encrypted websocket message');
|
request.respond(500, 'Bad encrypted websocket message');
|
||||||
window.log.error(
|
window.log.error(
|
||||||
|
@ -787,7 +788,6 @@ class MessageReceiverInner extends EventTarget {
|
||||||
removeFromCache(envelope: EnvelopeClass) {
|
removeFromCache(envelope: EnvelopeClass) {
|
||||||
const { id } = envelope;
|
const { id } = envelope;
|
||||||
this.cacheRemoveBatcher.add(id);
|
this.cacheRemoveBatcher.add(id);
|
||||||
this.processedCount += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same as handleEnvelope, just without the decryption step. Necessary for handling
|
// Same as handleEnvelope, just without the decryption step. Necessary for handling
|
||||||
|
|
30
ts/util/StartupQueue.ts
Normal file
30
ts/util/StartupQueue.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export class StartupQueue {
|
||||||
|
set: Set<string>;
|
||||||
|
|
||||||
|
items: Array<() => void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.set = new Set();
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
add(id: string, f: () => void): void {
|
||||||
|
if (this.set.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items.push(f);
|
||||||
|
this.set.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): void {
|
||||||
|
const { items } = this;
|
||||||
|
window.log.info('StartupQueue: Processing', items.length, 'actions');
|
||||||
|
items.forEach(f => f());
|
||||||
|
this.items = [];
|
||||||
|
this.set.clear();
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,10 +33,12 @@ import {
|
||||||
sessionStructureToArrayBuffer,
|
sessionStructureToArrayBuffer,
|
||||||
} from './sessionTranslation';
|
} from './sessionTranslation';
|
||||||
import * as zkgroup from './zkgroup';
|
import * as zkgroup from './zkgroup';
|
||||||
|
import { StartupQueue } from './StartupQueue';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
GoogleChrome,
|
GoogleChrome,
|
||||||
Registration,
|
Registration,
|
||||||
|
StartupQueue,
|
||||||
arrayBufferToObjectURL,
|
arrayBufferToObjectURL,
|
||||||
combineNames,
|
combineNames,
|
||||||
createBatcher,
|
createBatcher,
|
||||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -94,6 +94,7 @@ import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
|
||||||
import { MIMEType } from './types/MIME';
|
import { MIMEType } from './types/MIME';
|
||||||
import { ElectronLocaleType } from './util/mapToSupportLocale';
|
import { ElectronLocaleType } from './util/mapToSupportLocale';
|
||||||
import { SignalProtocolStore } from './LibSignalStore';
|
import { SignalProtocolStore } from './LibSignalStore';
|
||||||
|
import { StartupQueue } from './util/StartupQueue';
|
||||||
|
|
||||||
export { Long } from 'long';
|
export { Long } from 'long';
|
||||||
|
|
||||||
|
@ -138,6 +139,7 @@ declare global {
|
||||||
WhatIsThis: WhatIsThis;
|
WhatIsThis: WhatIsThis;
|
||||||
|
|
||||||
attachmentDownloadQueue: Array<MessageModel> | undefined;
|
attachmentDownloadQueue: Array<MessageModel> | undefined;
|
||||||
|
startupProcessingQueue: StartupQueue | undefined;
|
||||||
baseAttachmentsPath: string;
|
baseAttachmentsPath: string;
|
||||||
baseStickersPath: string;
|
baseStickersPath: string;
|
||||||
baseTempPath: string;
|
baseTempPath: string;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue