2022-07-13 13:56:36 +08:00
|
|
|
/*
|
|
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { mocked } from "jest-mock";
|
2022-10-13 01:59:07 +08:00
|
|
|
import { IImageInfo, ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix";
|
|
|
|
import { defer } from "matrix-js-sdk/src/utils";
|
|
|
|
import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment";
|
2022-07-13 13:56:36 +08:00
|
|
|
|
2022-10-13 01:59:07 +08:00
|
|
|
import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages";
|
2022-07-13 13:56:36 +08:00
|
|
|
import { doMaybeLocalRoomAction } from "../src/utils/local-room";
|
2023-03-23 19:47:40 +08:00
|
|
|
import { createTestClient, mkEvent } from "./test-utils";
|
2022-10-13 01:59:07 +08:00
|
|
|
import { BlurhashEncoder } from "../src/BlurhashEncoder";
|
|
|
|
|
|
|
|
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
|
|
|
|
|
|
|
|
jest.mock("../src/BlurhashEncoder", () => ({
|
|
|
|
BlurhashEncoder: {
|
|
|
|
instance: {
|
|
|
|
getBlurhash: jest.fn(),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}));
|
2022-07-13 13:56:36 +08:00
|
|
|
|
|
|
|
jest.mock("../src/utils/local-room", () => ({
|
|
|
|
doMaybeLocalRoomAction: jest.fn(),
|
|
|
|
}));
|
|
|
|
|
2022-10-13 01:59:07 +08:00
|
|
|
const createElement = document.createElement.bind(document);
|
|
|
|
|
2022-07-13 13:56:36 +08:00
|
|
|
describe("ContentMessages", () => {
|
|
|
|
const stickerUrl = "https://example.com/sticker";
|
|
|
|
const roomId = "!room:example.com";
|
|
|
|
const imageInfo = {} as unknown as IImageInfo;
|
|
|
|
const text = "test sticker";
|
|
|
|
let client: MatrixClient;
|
|
|
|
let contentMessages: ContentMessages;
|
|
|
|
let prom: Promise<ISendEventResponse>;
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
client = {
|
2023-03-23 19:47:40 +08:00
|
|
|
getSafeUserId: jest.fn().mockReturnValue("@alice:test"),
|
2022-07-13 13:56:36 +08:00
|
|
|
sendStickerMessage: jest.fn(),
|
2022-10-13 01:59:07 +08:00
|
|
|
sendMessage: jest.fn(),
|
|
|
|
isRoomEncrypted: jest.fn().mockReturnValue(false),
|
|
|
|
uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }),
|
2022-07-13 13:56:36 +08:00
|
|
|
} as unknown as MatrixClient;
|
|
|
|
contentMessages = new ContentMessages();
|
2023-02-17 01:21:44 +08:00
|
|
|
prom = Promise.resolve<ISendEventResponse>({ event_id: "$event_id" });
|
2022-07-13 13:56:36 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
describe("sendStickerContentToRoom", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
mocked(client.sendStickerMessage).mockReturnValue(prom);
|
2022-11-04 18:48:08 +08:00
|
|
|
mocked(doMaybeLocalRoomAction).mockImplementation(
|
|
|
|
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>, client?: MatrixClient) => {
|
2022-07-13 13:56:36 +08:00
|
|
|
return fn(roomId);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should forward the call to doMaybeLocalRoomAction", async () => {
|
|
|
|
await contentMessages.sendStickerContentToRoom(stickerUrl, roomId, null, imageInfo, text, client);
|
|
|
|
expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text);
|
|
|
|
});
|
|
|
|
});
|
2022-10-13 01:59:07 +08:00
|
|
|
|
|
|
|
describe("sendContentToRoom", () => {
|
|
|
|
const roomId = "!roomId:server";
|
|
|
|
beforeEach(() => {
|
|
|
|
Object.defineProperty(global.Image.prototype, "src", {
|
|
|
|
// Define the property setter
|
|
|
|
set(src) {
|
2022-11-30 19:32:56 +08:00
|
|
|
window.setTimeout(() => this.onload());
|
2022-10-13 01:59:07 +08:00
|
|
|
},
|
|
|
|
});
|
|
|
|
Object.defineProperty(global.Image.prototype, "height", {
|
|
|
|
get() {
|
|
|
|
return 600;
|
|
|
|
},
|
|
|
|
});
|
|
|
|
Object.defineProperty(global.Image.prototype, "width", {
|
|
|
|
get() {
|
|
|
|
return 800;
|
|
|
|
},
|
|
|
|
});
|
2022-11-04 18:48:08 +08:00
|
|
|
mocked(doMaybeLocalRoomAction).mockImplementation(
|
|
|
|
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
|
2022-10-13 01:59:07 +08:00
|
|
|
);
|
2023-02-17 01:21:44 +08:00
|
|
|
mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue("blurhashstring");
|
2022-10-13 01:59:07 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should use m.image for image files", async () => {
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "image/jpeg" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.image",
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2023-03-07 23:48:23 +08:00
|
|
|
it("should use m.image for PNG files which cannot be parsed but successfully thumbnail", async () => {
|
2022-10-13 01:59:07 +08:00
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "image/png" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
2023-03-07 23:48:23 +08:00
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.image",
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should fall back to m.file for invalid image files", async () => {
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "image/jpeg" });
|
|
|
|
mocked(BlurhashEncoder.instance.getBlurhash).mockRejectedValue("NOT_AN_IMAGE");
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
2022-10-13 01:59:07 +08:00
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.file",
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should use m.video for video files", async () => {
|
|
|
|
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
|
|
|
|
const element = createElement(tagName);
|
|
|
|
if (tagName === "video") {
|
2022-11-21 19:24:59 +08:00
|
|
|
(<HTMLVideoElement>element).load = jest.fn();
|
2023-02-17 01:21:44 +08:00
|
|
|
(<HTMLVideoElement>element).play = () => element.onloadeddata!(new Event("loadeddata"));
|
2022-11-21 19:24:59 +08:00
|
|
|
(<HTMLVideoElement>element).pause = jest.fn();
|
2022-10-13 01:59:07 +08:00
|
|
|
Object.defineProperty(element, "videoHeight", {
|
|
|
|
get() {
|
|
|
|
return 600;
|
|
|
|
},
|
|
|
|
});
|
|
|
|
Object.defineProperty(element, "videoWidth", {
|
|
|
|
get() {
|
|
|
|
return 800;
|
|
|
|
},
|
|
|
|
});
|
2023-07-17 20:07:58 +08:00
|
|
|
Object.defineProperty(element, "duration", {
|
|
|
|
get() {
|
|
|
|
return 123;
|
|
|
|
},
|
|
|
|
});
|
2022-10-13 01:59:07 +08:00
|
|
|
}
|
|
|
|
return element;
|
|
|
|
});
|
|
|
|
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "video/mp4" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.video",
|
2023-07-17 20:07:58 +08:00
|
|
|
info: expect.objectContaining({
|
|
|
|
duration: 123000,
|
|
|
|
}),
|
2022-10-13 01:59:07 +08:00
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should use m.audio for audio files", async () => {
|
2023-07-17 20:07:58 +08:00
|
|
|
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
|
|
|
|
const element = createElement(tagName);
|
|
|
|
if (tagName === "audio") {
|
|
|
|
Object.defineProperty(element, "duration", {
|
|
|
|
get() {
|
|
|
|
return 621;
|
|
|
|
},
|
|
|
|
});
|
|
|
|
Object.defineProperty(element, "src", {
|
|
|
|
set() {
|
|
|
|
element.onloadedmetadata!(new Event("loadedmetadata"));
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return element;
|
|
|
|
});
|
|
|
|
|
2022-10-13 01:59:07 +08:00
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "audio/mp3" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.audio",
|
2023-07-17 20:07:58 +08:00
|
|
|
info: expect.objectContaining({
|
|
|
|
duration: 621000,
|
|
|
|
}),
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should fall back to m.file for invalid audio files", async () => {
|
|
|
|
jest.spyOn(document, "createElement").mockImplementation((tagName) => {
|
|
|
|
const element = createElement(tagName);
|
|
|
|
if (tagName === "audio") {
|
|
|
|
Object.defineProperty(element, "src", {
|
|
|
|
set() {
|
|
|
|
element.onerror!("fail");
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return element;
|
|
|
|
});
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "audio/mp3" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.file",
|
2022-10-13 01:59:07 +08:00
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should default to name 'Attachment' if file doesn't have a name", async () => {
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "", { type: "text/plain" });
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
msgtype: "m.file",
|
|
|
|
body: "Attachment",
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should keep RoomUpload's total and loaded values up to date", async () => {
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "", { type: "text/plain" });
|
|
|
|
const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
|
|
|
|
const [upload] = contentMessages.getCurrentUploads();
|
|
|
|
|
|
|
|
expect(upload.loaded).toBe(0);
|
|
|
|
expect(upload.total).toBe(file.size);
|
2023-02-17 01:21:44 +08:00
|
|
|
const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]!;
|
|
|
|
progressHandler!({ loaded: 123, total: 1234 });
|
2022-10-13 01:59:07 +08:00
|
|
|
expect(upload.loaded).toBe(123);
|
|
|
|
expect(upload.total).toBe(1234);
|
|
|
|
await prom;
|
|
|
|
});
|
2023-03-23 19:47:40 +08:00
|
|
|
|
|
|
|
it("properly handles replies", async () => {
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const file = new File([], "fileName", { type: "image/jpeg" });
|
|
|
|
const replyToEvent = mkEvent({
|
|
|
|
type: "m.room.message",
|
|
|
|
user: "@bob:test",
|
|
|
|
room: roomId,
|
|
|
|
content: {},
|
|
|
|
event: true,
|
|
|
|
});
|
|
|
|
await contentMessages.sendContentToRoom(file, roomId, undefined, client, replyToEvent);
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
|
|
roomId,
|
|
|
|
null,
|
|
|
|
expect.objectContaining({
|
|
|
|
"url": "mxc://server/file",
|
|
|
|
"msgtype": "m.image",
|
2023-07-12 06:29:54 +08:00
|
|
|
"m.mentions": {
|
2023-03-23 19:47:40 +08:00
|
|
|
user_ids: ["@bob:test"],
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
});
|
2022-10-13 01:59:07 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
describe("getCurrentUploads", () => {
|
|
|
|
const file1 = new File([], "file1");
|
|
|
|
const file2 = new File([], "file2");
|
|
|
|
const roomId = "!roomId:server";
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2022-11-04 18:48:08 +08:00
|
|
|
mocked(doMaybeLocalRoomAction).mockImplementation(
|
|
|
|
<T>(roomId: string, fn: (actualRoomId: string) => Promise<T>) => fn(roomId),
|
2022-10-13 01:59:07 +08:00
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should return only uploads for the given relation", async () => {
|
|
|
|
const relation = {
|
|
|
|
rel_type: RelationType.Thread,
|
|
|
|
event_id: "!threadId:server",
|
|
|
|
};
|
|
|
|
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
|
|
|
|
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
|
|
|
|
|
|
|
|
const uploads = contentMessages.getCurrentUploads(relation);
|
|
|
|
expect(uploads).toHaveLength(1);
|
|
|
|
expect(uploads[0].relation).toEqual(relation);
|
|
|
|
expect(uploads[0].fileName).toEqual("file1");
|
|
|
|
await Promise.all([p1, p2]);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should return only uploads for no relation when not passed one", async () => {
|
|
|
|
const relation = {
|
|
|
|
rel_type: RelationType.Thread,
|
|
|
|
event_id: "!threadId:server",
|
|
|
|
};
|
|
|
|
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
|
|
|
|
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
|
|
|
|
|
|
|
|
const uploads = contentMessages.getCurrentUploads();
|
|
|
|
expect(uploads).toHaveLength(1);
|
|
|
|
expect(uploads[0].relation).toEqual(undefined);
|
|
|
|
expect(uploads[0].fileName).toEqual("file2");
|
|
|
|
await Promise.all([p1, p2]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("cancelUpload", () => {
|
|
|
|
it("should cancel in-flight upload", async () => {
|
|
|
|
const deferred = defer<UploadResponse>();
|
|
|
|
mocked(client.uploadContent).mockReturnValue(deferred.promise);
|
|
|
|
const file1 = new File([], "file1");
|
|
|
|
const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined);
|
2023-02-17 01:21:44 +08:00
|
|
|
const { abortController } = mocked(client.uploadContent).mock.calls[0][1]!;
|
|
|
|
expect(abortController!.signal.aborted).toBeFalsy();
|
2022-10-13 01:59:07 +08:00
|
|
|
const [upload] = contentMessages.getCurrentUploads();
|
|
|
|
contentMessages.cancelUpload(upload);
|
2023-02-17 01:21:44 +08:00
|
|
|
expect(abortController!.signal.aborted).toBeTruthy();
|
2022-10-13 01:59:07 +08:00
|
|
|
deferred.resolve({} as UploadResponse);
|
|
|
|
await prom;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("uploadFile", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
jest.clearAllMocks();
|
|
|
|
});
|
|
|
|
|
|
|
|
const client = createTestClient();
|
|
|
|
|
|
|
|
it("should not encrypt the file if the room isn't encrypted", async () => {
|
|
|
|
mocked(client.isRoomEncrypted).mockReturnValue(false);
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
const progressHandler = jest.fn();
|
|
|
|
const file = new Blob([]);
|
|
|
|
|
|
|
|
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
|
|
|
|
|
|
|
|
expect(res.url).toBe("mxc://server/file");
|
|
|
|
expect(res.file).toBeFalsy();
|
|
|
|
expect(encrypt.encryptAttachment).not.toHaveBeenCalled();
|
|
|
|
expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler }));
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should encrypt the file if the room is encrypted", async () => {
|
|
|
|
mocked(client.isRoomEncrypted).mockReturnValue(true);
|
|
|
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
|
|
|
mocked(encrypt.encryptAttachment).mockResolvedValue({
|
|
|
|
data: new ArrayBuffer(123),
|
|
|
|
info: {} as IEncryptedFile,
|
|
|
|
});
|
|
|
|
const progressHandler = jest.fn();
|
|
|
|
const file = new Blob(["123"]);
|
|
|
|
|
|
|
|
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
|
|
|
|
|
|
|
|
expect(res.url).toBeFalsy();
|
|
|
|
expect(res.file).toEqual(
|
|
|
|
expect.objectContaining({
|
|
|
|
url: "mxc://server/file",
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
expect(encrypt.encryptAttachment).toHaveBeenCalled();
|
|
|
|
expect(client.uploadContent).toHaveBeenCalledWith(
|
|
|
|
expect.any(Blob),
|
|
|
|
expect.objectContaining({
|
|
|
|
progressHandler,
|
|
|
|
includeFilename: false,
|
2022-12-30 16:34:38 +08:00
|
|
|
type: "application/octet-stream",
|
2022-10-13 01:59:07 +08:00
|
|
|
}),
|
|
|
|
);
|
|
|
|
expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should throw UploadCanceledError upon aborting the upload", async () => {
|
|
|
|
mocked(client.isRoomEncrypted).mockReturnValue(false);
|
|
|
|
const deferred = defer<UploadResponse>();
|
|
|
|
mocked(client.uploadContent).mockReturnValue(deferred.promise);
|
|
|
|
const file = new Blob([]);
|
|
|
|
|
|
|
|
const prom = uploadFile(client, "!roomId:server", file);
|
2023-02-17 01:21:44 +08:00
|
|
|
mocked(client.uploadContent).mock.calls[0][1]!.abortController!.abort();
|
2022-10-13 01:59:07 +08:00
|
|
|
deferred.resolve({ content_uri: "mxc://foo/bar" });
|
2023-03-01 23:23:35 +08:00
|
|
|
await expect(prom).rejects.toThrow(UploadCanceledError);
|
2022-10-13 01:59:07 +08:00
|
|
|
});
|
2022-07-13 13:56:36 +08:00
|
|
|
});
|