refactor: refactor thread, message, and gap models to Typescript
Bug: twpowertools:230
Change-Id: I6729546ec4e84f4ef2e801a3c479b63c351008f9
diff --git a/src/models/Thread.ts b/src/models/Thread.ts
new file mode 100644
index 0000000..bc8f8e7
--- /dev/null
+++ b/src/models/Thread.ts
@@ -0,0 +1,241 @@
+import { ProtobufNumber, ProtobufObject } from '../common/protojs.types.js';
+import GapModel from './Gap.js';
+import MessageModel from './Message';
+
+// Keys of the PromotedMessages protobuf message which contain lists of promoted
+// messages.
+const kPromotedMessagesKeys = [1, 2, 3, 4, 5, 6];
+
+/**
+ * Model for the `ThreadView` protobuf message.
+ */
+export default class ThreadModel {
+ private data: ProtobufObject;
+
+ constructor(data?: ProtobufObject) {
+ this.data = data ?? {};
+ }
+
+ getId() {
+ return (this.data[2]?.[1]?.[1] as ProtobufNumber) ?? null;
+ }
+
+ getForumId() {
+ return (this.data[2]?.[1]?.[3] as ProtobufNumber) ?? null;
+ }
+
+ getRawCommentsAndGaps(): ProtobufObject[] {
+ return (this.data[40] as ProtobufObject[]) ?? [];
+ }
+
+ setRawCommentsAndGaps(cogs: ProtobufObject[] | null) {
+ this.data[40] = cogs;
+ }
+
+ getMessageOrGapModels() {
+ const rawMogs = this.getRawCommentsAndGaps();
+ return rawMogs
+ .filter((mog) => mog !== undefined)
+ .map((mog) => {
+ if (mog[1]) return new MessageModel(mog[1], this);
+ if (mog[2]) return new GapModel(mog[2], this);
+ throw new Error('Expected message or gap.');
+ });
+ }
+
+ setLastMessage(message: ProtobufObject | null) {
+ if (!this.data[17]) this.data[17] = [];
+ this.data[17][3] = message;
+ }
+
+ setNumMessages(num: ProtobufNumber | null) {
+ this.data[8] = num;
+ }
+
+ isLocked() {
+ // TODO: When a forum is read-only, this should also return true.
+ return this.data[2]?.[5] == true;
+ }
+
+ isSoftLocked() {
+ return this.data[2]?.[51] == true;
+ }
+
+ isAuthoredByUser() {
+ return this.data[9] == true;
+ }
+
+ toRawThread(): ProtobufObject {
+ return this.data;
+ }
+
+ getPromotedMessagesList(): MessageModel[] {
+ const promotedMessages: MessageModel[] = [];
+ for (const key of kPromotedMessagesKeys) {
+ const messagesList = this.data[17][key] ?? [];
+ for (const rawMessage of messagesList) {
+ const message = new MessageModel(rawMessage);
+ if (message.getId() === null) continue;
+
+ const isMessageAlreadyIncluded = promotedMessages.some(
+ (existingMessage) => existingMessage.getId() == message.getId(),
+ );
+ if (isMessageAlreadyIncluded) continue;
+
+ promotedMessages.push(message);
+ }
+ }
+ return promotedMessages;
+ }
+
+ /**
+ * Get a list with all the messages contained in the model.
+ */
+ getAllMessagesList(): MessageModel[] {
+ const messages: MessageModel[] = [];
+
+ for (const messageOrGap of this.getMessageOrGapModels()) {
+ if (!(messageOrGap instanceof MessageModel)) continue;
+ messages.push(messageOrGap);
+ for (const subMessageOrGap of messageOrGap.getCommentsAndGaps()) {
+ if (!(subMessageOrGap instanceof MessageModel)) continue;
+ messages.push(subMessageOrGap);
+ }
+ }
+
+ const promotedMessages = this.getPromotedMessagesList();
+ for (const message of promotedMessages) {
+ const isMessageAlreadyIncluded = messages.some(
+ (existingMessage) => existingMessage.getId() == message.getId(),
+ );
+ if (isMessageAlreadyIncluded) continue;
+
+ messages.push(message);
+ }
+
+ return messages;
+ }
+
+ /**
+ * The following code is based on logic written by Googlers in the TW frontend
+ * and thus is not included as part of the MIT license.
+ */
+ static mergeMessageOrGaps(
+ a: Array<MessageModel | GapModel>,
+ b: Array<MessageModel | GapModel>,
+ ): Array<MessageModel | GapModel> {
+ if (a.length == 0 || b.length == 0)
+ return a.length > 0 ? a : b.length > 0 ? b : [];
+
+ let e: Array<MessageModel | GapModel> = [];
+ for (
+ let g = 0, k = 0, m = 0, q = a[g], u = b[k];
+ g < a.length && k < b.length;
+
+ ) {
+ if (q instanceof MessageModel && u instanceof MessageModel) {
+ if (q.getCreatedMicroseconds() === u.getCreatedMicroseconds()) {
+ u.mergeCommentOrGapViews(q);
+ }
+
+ e.push(u);
+
+ if (g === a.length - 1 || k === b.length - 1) {
+ for (; ++g < a.length; ) e.push(a[g]);
+ for (; ++k < b.length; ) e.push(b[k]);
+ break;
+ }
+
+ q = a[++g];
+ u = b[++k];
+ } else {
+ if (u instanceof GapModel) {
+ let z: bigint;
+ for (
+ z =
+ q instanceof MessageModel
+ ? q.getCreatedMicroseconds()
+ : q.getEndTimestamp();
+ z < u.getEndTimestamp();
+
+ ) {
+ e.push(q);
+ m += q instanceof GapModel ? q.getCount() : 1;
+ if (g === a.length - 1) break;
+ q = a[++g];
+ z =
+ q instanceof MessageModel
+ ? q.getCreatedMicroseconds()
+ : q.getEndTimestamp();
+ }
+ if (
+ q instanceof GapModel &&
+ u.getCount() - m > 0 &&
+ z >= u.getEndTimestamp()
+ ) {
+ const gm = new GapModel();
+ gm.setCount(u.getCount() - m);
+ gm.setStartMicroseconds('' + q.getStartTimestamp());
+ gm.setEndMicroseconds('' + u.getEndTimestamp());
+ gm.setParentId(u.getParentId());
+ e.push(gm);
+ m = u.getCount() - m;
+ } else {
+ m = 0;
+ }
+ if (k === b.length - 1) break;
+ u = b[++k];
+ }
+ if (q instanceof GapModel) {
+ let z: bigint;
+ for (
+ z =
+ u instanceof MessageModel
+ ? u.getCreatedMicroseconds()
+ : u.getEndTimestamp();
+ z < q.getEndTimestamp();
+
+ ) {
+ e.push(u);
+ m += u instanceof GapModel ? u.getCount() : 1;
+ if (k === b.length - 1) break;
+ u = b[++k];
+ z =
+ u instanceof MessageModel
+ ? u.getCreatedMicroseconds()
+ : u.getEndTimestamp();
+ }
+ if (
+ u instanceof GapModel &&
+ q.getCount() - m > 0 &&
+ z >= q.getEndTimestamp()
+ ) {
+ const gm = new GapModel();
+ gm.setCount(q.getCount() - m);
+ gm.setStartMicroseconds('' + u.getStartTimestamp());
+ gm.setEndMicroseconds('' + q.getEndTimestamp());
+ gm.setParentId(q.getParentId());
+ e.push(gm);
+ m = q.getCount() - m;
+ } else {
+ m = 0;
+ }
+ if (g === a.length - 1) break;
+ q = a[++g];
+ }
+ }
+ }
+ return e;
+ }
+
+ static mergeMessageOrGapsMultiarray(
+ mogsModels: Array<Array<MessageModel | GapModel>>,
+ ) {
+ if (mogsModels.length < 1) return [];
+ let mergeResult = mogsModels[0];
+ for (let i = 1; i < mogsModels.length; ++i) {
+ mergeResult = ThreadModel.mergeMessageOrGaps(mergeResult, mogsModels[i]);
+ }
+ return mergeResult;
+ }
+}