Stories: Fix sender key persistence, pipe story: true into sends
This commit is contained in:
parent
67c706a7ef
commit
2b2594c20a
7 changed files with 76 additions and 30 deletions
|
@ -71,10 +71,11 @@ export async function sendStory(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageTimestamp = message.get('timestamp');
|
||||||
const messageConversation = message.getConversation();
|
const messageConversation = message.getConversation();
|
||||||
if (messageConversation !== conversation) {
|
if (messageConversation !== conversation) {
|
||||||
log.error(
|
log.error(
|
||||||
`stories.sendStory(${messageId}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
`stories.sendStory(${messageTimestamp}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -84,7 +85,7 @@ export async function sendStory(
|
||||||
|
|
||||||
if (!attachment) {
|
if (!attachment) {
|
||||||
log.info(
|
log.info(
|
||||||
`stories.sendStory(${messageId}): message does not have any attachments to send. Giving up on sending it`
|
`stories.sendStory(${messageTimestamp}): message does not have any attachments to send. Giving up on sending it`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -163,17 +164,18 @@ export async function sendStory(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageTimestamp = message.get('timestamp');
|
||||||
const messageConversation = message.getConversation();
|
const messageConversation = message.getConversation();
|
||||||
if (messageConversation !== conversation) {
|
if (messageConversation !== conversation) {
|
||||||
log.error(
|
log.error(
|
||||||
`stories.sendStory(${messageId}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
`stories.sendStory(${messageTimestamp}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.isErased() || message.get('deletedForEveryone')) {
|
if (message.isErased() || message.get('deletedForEveryone')) {
|
||||||
log.info(
|
log.info(
|
||||||
`stories.sendStory(${messageId}): message was erased. Giving up on sending it`
|
`stories.sendStory(${messageTimestamp}): message was erased. Giving up on sending it`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -185,7 +187,7 @@ export async function sendStory(
|
||||||
|
|
||||||
if (!receiverId) {
|
if (!receiverId) {
|
||||||
log.info(
|
log.info(
|
||||||
`stories.sendStory(${messageId}): did not get a valid recipient ID for message. Giving up on sending it`
|
`stories.sendStory(${messageTimestamp}): did not get a valid recipient ID for message. Giving up on sending it`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -212,7 +214,7 @@ export async function sendStory(
|
||||||
|
|
||||||
if (!shouldContinue) {
|
if (!shouldContinue) {
|
||||||
log.info(
|
log.info(
|
||||||
`stories.sendStory(${messageId}): ran out of time. Giving up on sending it`
|
`stories.sendStory(${messageTimestamp}): ran out of time. Giving up on sending it`
|
||||||
);
|
);
|
||||||
await markMessageFailed(message, [
|
await markMessageFailed(message, [
|
||||||
new Error('Message send ran out of time'),
|
new Error('Message send ran out of time'),
|
||||||
|
@ -242,7 +244,7 @@ export async function sendStory(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`stories.sendStory(${messageId}): sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.`
|
`stories.sendStory(${messageTimestamp}): sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -266,7 +268,7 @@ export async function sendStory(
|
||||||
);
|
);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`stories.sendStory(${messageId}): sending story to ${receiverId}`
|
`stories.sendStory(${messageTimestamp}): sending story to ${receiverId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const storyMessage = new Proto.StoryMessage();
|
const storyMessage = new Proto.StoryMessage();
|
||||||
|
@ -314,6 +316,7 @@ export async function sendStory(
|
||||||
sendOptions,
|
sendOptions,
|
||||||
sendTarget,
|
sendTarget,
|
||||||
sendType: 'story',
|
sendType: 'story',
|
||||||
|
story: true,
|
||||||
timestamp: message.get('timestamp'),
|
timestamp: message.get('timestamp'),
|
||||||
urgent: false,
|
urgent: false,
|
||||||
});
|
});
|
||||||
|
|
|
@ -4556,7 +4556,7 @@ async function getStoryDistributionWithMembers(
|
||||||
id: string
|
id: string
|
||||||
): Promise<StoryDistributionWithMembersType | undefined> {
|
): Promise<StoryDistributionWithMembersType | undefined> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const storyDistribution = prepare(
|
const storyDistribution: StoryDistributionForDatabase | undefined = prepare(
|
||||||
db,
|
db,
|
||||||
'SELECT * FROM storyDistributions WHERE id = $id;'
|
'SELECT * FROM storyDistributions WHERE id = $id;'
|
||||||
).get({
|
).get({
|
||||||
|
@ -4575,7 +4575,7 @@ async function getStoryDistributionWithMembers(
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...storyDistribution,
|
...hydrateStoryDistribution(storyDistribution),
|
||||||
members: members.map(({ uuid }) => uuid),
|
members: members.map(({ uuid }) => uuid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -134,6 +134,8 @@ export default class OutgoingMessage {
|
||||||
|
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
|
|
||||||
|
story?: boolean;
|
||||||
|
|
||||||
recipients: Record<string, Array<number>>;
|
recipients: Record<string, Array<number>>;
|
||||||
|
|
||||||
sendLogCallback?: SendLogCallbackType;
|
sendLogCallback?: SendLogCallbackType;
|
||||||
|
@ -147,6 +149,7 @@ export default class OutgoingMessage {
|
||||||
options,
|
options,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
server,
|
server,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
}: {
|
}: {
|
||||||
|
@ -158,6 +161,7 @@ export default class OutgoingMessage {
|
||||||
options?: OutgoingMessageOptionsType;
|
options?: OutgoingMessageOptionsType;
|
||||||
sendLogCallback?: SendLogCallbackType;
|
sendLogCallback?: SendLogCallbackType;
|
||||||
server: WebAPIType;
|
server: WebAPIType;
|
||||||
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
@ -175,6 +179,7 @@ export default class OutgoingMessage {
|
||||||
this.contentHint = contentHint;
|
this.contentHint = contentHint;
|
||||||
this.groupId = groupId;
|
this.groupId = groupId;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
this.story = story;
|
||||||
this.urgent = urgent;
|
this.urgent = urgent;
|
||||||
|
|
||||||
this.identifiersCompleted = 0;
|
this.identifiersCompleted = 0;
|
||||||
|
@ -310,11 +315,17 @@ export default class OutgoingMessage {
|
||||||
identifier,
|
identifier,
|
||||||
jsonData,
|
jsonData,
|
||||||
timestamp,
|
timestamp,
|
||||||
{ accessKey, online: this.online, urgent: this.urgent }
|
{
|
||||||
|
accessKey,
|
||||||
|
online: this.online,
|
||||||
|
story: this.story,
|
||||||
|
urgent: this.urgent,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
promise = this.server.sendMessages(identifier, jsonData, timestamp, {
|
promise = this.server.sendMessages(identifier, jsonData, timestamp, {
|
||||||
online: this.online,
|
online: this.online,
|
||||||
|
story: this.story,
|
||||||
urgent: this.urgent,
|
urgent: this.urgent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1203,6 +1203,7 @@ export default class MessageSender {
|
||||||
proto,
|
proto,
|
||||||
recipients,
|
recipients,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
@ -1213,6 +1214,7 @@ export default class MessageSender {
|
||||||
proto: Proto.Content | Proto.DataMessage | PlaintextContent;
|
proto: Proto.Content | Proto.DataMessage | PlaintextContent;
|
||||||
recipients: ReadonlyArray<string>;
|
recipients: ReadonlyArray<string>;
|
||||||
sendLogCallback?: SendLogCallbackType;
|
sendLogCallback?: SendLogCallbackType;
|
||||||
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}>): void {
|
}>): void {
|
||||||
|
@ -1233,6 +1235,7 @@ export default class MessageSender {
|
||||||
options,
|
options,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
server: this.server,
|
server: this.server,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
});
|
});
|
||||||
|
@ -2220,6 +2223,7 @@ export default class MessageSender {
|
||||||
proto,
|
proto,
|
||||||
recipients,
|
recipients,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
|
story,
|
||||||
timestamp = Date.now(),
|
timestamp = Date.now(),
|
||||||
urgent,
|
urgent,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
@ -2229,6 +2233,7 @@ export default class MessageSender {
|
||||||
proto: Proto.Content;
|
proto: Proto.Content;
|
||||||
recipients: ReadonlyArray<string>;
|
recipients: ReadonlyArray<string>;
|
||||||
sendLogCallback?: SendLogCallbackType;
|
sendLogCallback?: SendLogCallbackType;
|
||||||
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}>): Promise<CallbackResultType> {
|
}>): Promise<CallbackResultType> {
|
||||||
|
@ -2269,6 +2274,7 @@ export default class MessageSender {
|
||||||
proto,
|
proto,
|
||||||
recipients: identifiers,
|
recipients: identifiers,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
});
|
});
|
||||||
|
@ -2341,6 +2347,7 @@ export default class MessageSender {
|
||||||
groupId,
|
groupId,
|
||||||
identifiers,
|
identifiers,
|
||||||
throwIfNotInDatabase,
|
throwIfNotInDatabase,
|
||||||
|
story,
|
||||||
urgent,
|
urgent,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
|
@ -2348,6 +2355,7 @@ export default class MessageSender {
|
||||||
groupId: string | undefined;
|
groupId: string | undefined;
|
||||||
identifiers: ReadonlyArray<string>;
|
identifiers: ReadonlyArray<string>;
|
||||||
throwIfNotInDatabase?: boolean;
|
throwIfNotInDatabase?: boolean;
|
||||||
|
story?: boolean;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}>,
|
}>,
|
||||||
options?: Readonly<SendOptionsType>
|
options?: Readonly<SendOptionsType>
|
||||||
|
@ -2380,6 +2388,7 @@ export default class MessageSender {
|
||||||
proto: contentMessage,
|
proto: contentMessage,
|
||||||
recipients: identifiers,
|
recipients: identifiers,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
});
|
});
|
||||||
|
|
|
@ -917,13 +917,18 @@ export type WebAPIType = {
|
||||||
destination: string,
|
destination: string,
|
||||||
messageArray: ReadonlyArray<MessageType>,
|
messageArray: ReadonlyArray<MessageType>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: { online?: boolean; urgent?: boolean }
|
options: { online?: boolean; story?: boolean; urgent?: boolean }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendMessagesUnauth: (
|
sendMessagesUnauth: (
|
||||||
destination: string,
|
destination: string,
|
||||||
messageArray: ReadonlyArray<MessageType>,
|
messageArray: ReadonlyArray<MessageType>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
options: { accessKey?: string; online?: boolean; urgent?: boolean }
|
options: {
|
||||||
|
accessKey?: string;
|
||||||
|
online?: boolean;
|
||||||
|
story?: boolean;
|
||||||
|
urgent?: boolean;
|
||||||
|
}
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendWithSenderKey: (
|
sendWithSenderKey: (
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
|
@ -2084,12 +2089,19 @@ export function initialize({
|
||||||
accessKey,
|
accessKey,
|
||||||
online,
|
online,
|
||||||
urgent = true,
|
urgent = true,
|
||||||
}: { accessKey?: string; online?: boolean; urgent?: boolean }
|
story = false,
|
||||||
|
}: {
|
||||||
|
accessKey?: string;
|
||||||
|
online?: boolean;
|
||||||
|
story?: boolean;
|
||||||
|
urgent?: boolean;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
messages,
|
messages,
|
||||||
timestamp,
|
timestamp,
|
||||||
online: Boolean(online),
|
online: Boolean(online),
|
||||||
|
story,
|
||||||
urgent,
|
urgent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2108,12 +2120,17 @@ export function initialize({
|
||||||
destination: string,
|
destination: string,
|
||||||
messages: ReadonlyArray<MessageType>,
|
messages: ReadonlyArray<MessageType>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
{ online, urgent = true }: { online?: boolean; urgent?: boolean }
|
{
|
||||||
|
online,
|
||||||
|
urgent = true,
|
||||||
|
story = false,
|
||||||
|
}: { online?: boolean; story?: boolean; urgent?: boolean }
|
||||||
) {
|
) {
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
messages,
|
messages,
|
||||||
timestamp,
|
timestamp,
|
||||||
online: Boolean(online),
|
online: Boolean(online),
|
||||||
|
story,
|
||||||
urgent,
|
urgent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
conversationQueueJobEnum,
|
conversationQueueJobEnum,
|
||||||
} from '../jobs/conversationJobQueue';
|
} from '../jobs/conversationJobQueue';
|
||||||
import { formatJobForInsert } from '../jobs/formatJobForInsert';
|
|
||||||
import { getRecipients } from './getRecipients';
|
import { getRecipients } from './getRecipients';
|
||||||
import { getSignalConnections } from './getSignalConnections';
|
import { getSignalConnections } from './getSignalConnections';
|
||||||
import { incrementMessageCounter } from './incrementMessageCounter';
|
import { incrementMessageCounter } from './incrementMessageCounter';
|
||||||
|
@ -144,6 +143,8 @@ export async function sendStoryMessage(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: we use the same sent_at for these messages because we want de-duplication
|
||||||
|
// on the receiver side.
|
||||||
return window.Signal.Migrations.upgradeMessageSchema({
|
return window.Signal.Migrations.upgradeMessageSchema({
|
||||||
attachments,
|
attachments,
|
||||||
conversationId: ourConversation.id,
|
conversationId: ourConversation.id,
|
||||||
|
@ -247,7 +248,9 @@ export async function sendStoryMessage(
|
||||||
|
|
||||||
ourConversation.addSingleMessage(model, { isJustSent: true });
|
ourConversation.addSingleMessage(model, { isJustSent: true });
|
||||||
|
|
||||||
log.info(`stories.sendStoryMessage: saving message ${message.id}`);
|
log.info(
|
||||||
|
`stories.sendStoryMessage: saving message ${messageAttributes.timestamp}`
|
||||||
|
);
|
||||||
return dataInterface.saveMessage(message.attributes, {
|
return dataInterface.saveMessage(message.attributes, {
|
||||||
forceSave: true,
|
forceSave: true,
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
@ -258,18 +261,12 @@ export async function sendStoryMessage(
|
||||||
// * Send to the distribution lists
|
// * Send to the distribution lists
|
||||||
// * Place into job queue
|
// * Place into job queue
|
||||||
// * Save the job
|
// * Save the job
|
||||||
await conversationJobQueue.add(
|
await conversationJobQueue.add({
|
||||||
{
|
type: conversationQueueJobEnum.enum.Story,
|
||||||
type: conversationQueueJobEnum.enum.Story,
|
conversationId: ourConversation.id,
|
||||||
conversationId: ourConversation.id,
|
messageIds: distributionListMessages.map(m => m.id),
|
||||||
messageIds: distributionListMessages.map(m => m.id),
|
timestamp,
|
||||||
timestamp,
|
});
|
||||||
},
|
|
||||||
async jobToInsert => {
|
|
||||||
log.info(`stories.sendStoryMessage: saving job ${jobToInsert.id}`);
|
|
||||||
await dataInterface.insertJob(formatJobForInsert(jobToInsert));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// * Send to groups
|
// * Send to groups
|
||||||
// * Save the message models
|
// * Save the message models
|
||||||
|
@ -301,7 +298,9 @@ export async function sendStoryMessage(
|
||||||
const conversation = message.getConversation();
|
const conversation = message.getConversation();
|
||||||
conversation?.addSingleMessage(model, { isJustSent: true });
|
conversation?.addSingleMessage(model, { isJustSent: true });
|
||||||
|
|
||||||
log.info(`stories.sendStoryMessage: saving message ${message.id}`);
|
log.info(
|
||||||
|
`stories.sendStoryMessage: saving message ${messageAttributes.timestamp}`
|
||||||
|
);
|
||||||
await dataInterface.saveMessage(message.attributes, {
|
await dataInterface.saveMessage(message.attributes, {
|
||||||
forceSave: true,
|
forceSave: true,
|
||||||
jobToInsert,
|
jobToInsert,
|
||||||
|
|
|
@ -156,6 +156,7 @@ export async function sendContentMessageToGroup({
|
||||||
sendOptions,
|
sendOptions,
|
||||||
sendTarget,
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
}: {
|
}: {
|
||||||
|
@ -168,6 +169,7 @@ export async function sendContentMessageToGroup({
|
||||||
sendOptions?: SendOptionsType;
|
sendOptions?: SendOptionsType;
|
||||||
sendTarget: SenderKeyTargetType;
|
sendTarget: SenderKeyTargetType;
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}): Promise<CallbackResultType> {
|
}): Promise<CallbackResultType> {
|
||||||
|
@ -199,6 +201,7 @@ export async function sendContentMessageToGroup({
|
||||||
sendOptions,
|
sendOptions,
|
||||||
sendTarget,
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
});
|
});
|
||||||
|
@ -235,6 +238,7 @@ export async function sendContentMessageToGroup({
|
||||||
proto: contentMessage,
|
proto: contentMessage,
|
||||||
recipients,
|
recipients,
|
||||||
sendLogCallback,
|
sendLogCallback,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
});
|
});
|
||||||
|
@ -253,6 +257,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
sendOptions?: SendOptionsType;
|
sendOptions?: SendOptionsType;
|
||||||
sendTarget: SenderKeyTargetType;
|
sendTarget: SenderKeyTargetType;
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
|
story?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
}): Promise<CallbackResultType> {
|
}): Promise<CallbackResultType> {
|
||||||
|
@ -267,6 +272,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
sendOptions,
|
sendOptions,
|
||||||
sendTarget,
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
|
story,
|
||||||
timestamp,
|
timestamp,
|
||||||
urgent,
|
urgent,
|
||||||
} = options;
|
} = options;
|
||||||
|
@ -433,6 +439,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
distributionId,
|
distributionId,
|
||||||
groupId,
|
groupId,
|
||||||
identifiers: newToMemberUuids,
|
identifiers: newToMemberUuids,
|
||||||
|
story,
|
||||||
urgent,
|
urgent,
|
||||||
},
|
},
|
||||||
sendOptions ? { ...sendOptions, online: false } : undefined
|
sendOptions ? { ...sendOptions, online: false } : undefined
|
||||||
|
|
Loading…
Add table
Reference in a new issue