Jelajahi Sumber

Forum mappings&tests: runtime changes adjustments and tags functionality

Leszek Wiesner 3 tahun lalu
induk
melakukan
270d494841
33 mengubah file dengan 1207 tambahan dan 391 penghapusan
  1. 96 0
      metadata-protobuf/compiled/index.d.ts
  2. 226 0
      metadata-protobuf/compiled/index.js
  3. 17 0
      metadata-protobuf/doc/index.md
  4. 2 1
      metadata-protobuf/package.json
  5. 12 0
      metadata-protobuf/proto/Forum.proto
  6. 1 0
      metadata-protobuf/src/consts.ts
  7. 13 0
      metadata-protobuf/test/forum-tags.ts
  8. 3 4
      query-node/manifest.yml
  9. 166 34
      query-node/mappings/forum.ts
  10. 19 2
      query-node/schemas/forum.graphql
  11. 2 17
      query-node/schemas/forumEvents.graphql
  12. 2 1
      tests/integration-tests/package.json
  13. 2 1
      tests/integration-tests/src/Api.ts
  14. 10 8
      tests/integration-tests/src/QueryNodeApi.ts
  15. 4 4
      tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts
  16. 35 11
      tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts
  17. 9 10
      tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts
  18. 1 1
      tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts
  19. 0 103
      tests/integration-tests/src/fixtures/forum/UpdateThreadTitlesFixture.ts
  20. 135 0
      tests/integration-tests/src/fixtures/forum/UpdateThreadsMetadataFixture.ts
  21. 1 1
      tests/integration-tests/src/fixtures/forum/index.ts
  22. 1 1
      tests/integration-tests/src/flows/forum/polls.ts
  23. 64 0
      tests/integration-tests/src/flows/forum/threadTags.ts
  24. 8 8
      tests/integration-tests/src/flows/forum/threads.ts
  25. 24 14
      tests/integration-tests/src/graphql/generated/queries.ts
  26. 285 160
      tests/integration-tests/src/graphql/generated/schema.ts
  27. 6 1
      tests/integration-tests/src/graphql/queries/forum.graphql
  28. 6 4
      tests/integration-tests/src/graphql/queries/forumEvents.graphql
  29. 2 0
      tests/integration-tests/src/scenarios/forum.ts
  30. 2 0
      tests/integration-tests/src/scenarios/full.ts
  31. 2 1
      tests/integration-tests/src/types.ts
  32. 42 1
      types/src/common.ts
  33. 9 3
      types/src/forum.ts

+ 96 - 0
metadata-protobuf/compiled/index.d.ts

@@ -513,6 +513,102 @@ export class ForumPostMetadata implements IForumPostMetadata {
     public toJSON(): { [k: string]: any };
 }
 
+/** Properties of a ForumThreadMetadata. */
+export interface IForumThreadMetadata {
+
+    /** ForumThreadMetadata title */
+    title?: (string|null);
+
+    /** ForumThreadMetadata tags */
+    tags?: (string[]|null);
+}
+
+/** Represents a ForumThreadMetadata. */
+export class ForumThreadMetadata implements IForumThreadMetadata {
+
+    /**
+     * Constructs a new ForumThreadMetadata.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IForumThreadMetadata);
+
+    /** ForumThreadMetadata title. */
+    public title: string;
+
+    /** ForumThreadMetadata tags. */
+    public tags: string[];
+
+    /**
+     * Creates a new ForumThreadMetadata instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ForumThreadMetadata instance
+     */
+    public static create(properties?: IForumThreadMetadata): ForumThreadMetadata;
+
+    /**
+     * Encodes the specified ForumThreadMetadata message. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @param message ForumThreadMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IForumThreadMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ForumThreadMetadata message, length delimited. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @param message ForumThreadMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IForumThreadMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): ForumThreadMetadata;
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): ForumThreadMetadata;
+
+    /**
+     * Verifies a ForumThreadMetadata message.
+     * @param message Plain object to verify
+     * @returns `null` if valid, otherwise the reason why it is not
+     */
+    public static verify(message: { [k: string]: any }): (string|null);
+
+    /**
+     * Creates a ForumThreadMetadata message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ForumThreadMetadata
+     */
+    public static fromObject(object: { [k: string]: any }): ForumThreadMetadata;
+
+    /**
+     * Creates a plain object from a ForumThreadMetadata message. Also converts values to other types if specified.
+     * @param message ForumThreadMetadata
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ForumThreadMetadata, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ForumThreadMetadata to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
 /** Properties of a MembershipMetadata. */
 export interface IMembershipMetadata {
 

+ 226 - 0
metadata-protobuf/compiled/index.js

@@ -1185,6 +1185,232 @@ $root.ForumPostMetadata = (function() {
     return ForumPostMetadata;
 })();
 
+$root.ForumThreadMetadata = (function() {
+
+    /**
+     * Properties of a ForumThreadMetadata.
+     * @exports IForumThreadMetadata
+     * @interface IForumThreadMetadata
+     * @property {string|null} [title] ForumThreadMetadata title
+     * @property {Array.<string>|null} [tags] ForumThreadMetadata tags
+     */
+
+    /**
+     * Constructs a new ForumThreadMetadata.
+     * @exports ForumThreadMetadata
+     * @classdesc Represents a ForumThreadMetadata.
+     * @implements IForumThreadMetadata
+     * @constructor
+     * @param {IForumThreadMetadata=} [properties] Properties to set
+     */
+    function ForumThreadMetadata(properties) {
+        this.tags = [];
+        if (properties)
+            for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)
+                if (properties[keys[i]] != null)
+                    this[keys[i]] = properties[keys[i]];
+    }
+
+    /**
+     * ForumThreadMetadata title.
+     * @member {string} title
+     * @memberof ForumThreadMetadata
+     * @instance
+     */
+    ForumThreadMetadata.prototype.title = "";
+
+    /**
+     * ForumThreadMetadata tags.
+     * @member {Array.<string>} tags
+     * @memberof ForumThreadMetadata
+     * @instance
+     */
+    ForumThreadMetadata.prototype.tags = $util.emptyArray;
+
+    /**
+     * Creates a new ForumThreadMetadata instance using the specified properties.
+     * @function create
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata=} [properties] Properties to set
+     * @returns {ForumThreadMetadata} ForumThreadMetadata instance
+     */
+    ForumThreadMetadata.create = function create(properties) {
+        return new ForumThreadMetadata(properties);
+    };
+
+    /**
+     * Encodes the specified ForumThreadMetadata message. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @function encode
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata} message ForumThreadMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumThreadMetadata.encode = function encode(message, writer) {
+        if (!writer)
+            writer = $Writer.create();
+        if (message.title != null && Object.hasOwnProperty.call(message, "title"))
+            writer.uint32(/* id 1, wireType 2 =*/10).string(message.title);
+        if (message.tags != null && message.tags.length)
+            for (var i = 0; i < message.tags.length; ++i)
+                writer.uint32(/* id 2, wireType 2 =*/18).string(message.tags[i]);
+        return writer;
+    };
+
+    /**
+     * Encodes the specified ForumThreadMetadata message, length delimited. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata} message ForumThreadMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumThreadMetadata.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer.
+     * @function decode
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumThreadMetadata.decode = function decode(reader, length) {
+        if (!(reader instanceof $Reader))
+            reader = $Reader.create(reader);
+        var end = length === undefined ? reader.len : reader.pos + length, message = new $root.ForumThreadMetadata();
+        while (reader.pos < end) {
+            var tag = reader.uint32();
+            switch (tag >>> 3) {
+            case 1:
+                message.title = reader.string();
+                break;
+            case 2:
+                if (!(message.tags && message.tags.length))
+                    message.tags = [];
+                message.tags.push(reader.string());
+                break;
+            default:
+                reader.skipType(tag & 7);
+                break;
+            }
+        }
+        return message;
+    };
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumThreadMetadata.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ForumThreadMetadata message.
+     * @function verify
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ForumThreadMetadata.verify = function verify(message) {
+        if (typeof message !== "object" || message === null)
+            return "object expected";
+        if (message.title != null && message.hasOwnProperty("title"))
+            if (!$util.isString(message.title))
+                return "title: string expected";
+        if (message.tags != null && message.hasOwnProperty("tags")) {
+            if (!Array.isArray(message.tags))
+                return "tags: array expected";
+            for (var i = 0; i < message.tags.length; ++i)
+                if (!$util.isString(message.tags[i]))
+                    return "tags: string[] expected";
+        }
+        return null;
+    };
+
+    /**
+     * Creates a ForumThreadMetadata message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     */
+    ForumThreadMetadata.fromObject = function fromObject(object) {
+        if (object instanceof $root.ForumThreadMetadata)
+            return object;
+        var message = new $root.ForumThreadMetadata();
+        if (object.title != null)
+            message.title = String(object.title);
+        if (object.tags) {
+            if (!Array.isArray(object.tags))
+                throw TypeError(".ForumThreadMetadata.tags: array expected");
+            message.tags = [];
+            for (var i = 0; i < object.tags.length; ++i)
+                message.tags[i] = String(object.tags[i]);
+        }
+        return message;
+    };
+
+    /**
+     * Creates a plain object from a ForumThreadMetadata message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {ForumThreadMetadata} message ForumThreadMetadata
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ForumThreadMetadata.toObject = function toObject(message, options) {
+        if (!options)
+            options = {};
+        var object = {};
+        if (options.arrays || options.defaults)
+            object.tags = [];
+        if (options.defaults)
+            object.title = "";
+        if (message.title != null && message.hasOwnProperty("title"))
+            object.title = message.title;
+        if (message.tags && message.tags.length) {
+            object.tags = [];
+            for (var j = 0; j < message.tags.length; ++j)
+                object.tags[j] = message.tags[j];
+        }
+        return object;
+    };
+
+    /**
+     * Converts this ForumThreadMetadata to JSON.
+     * @function toJSON
+     * @memberof ForumThreadMetadata
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ForumThreadMetadata.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    return ForumThreadMetadata;
+})();
+
 $root.MembershipMetadata = (function() {
 
     /**

+ 17 - 0
metadata-protobuf/doc/index.md

@@ -13,6 +13,7 @@
 - [proto/Forum.proto](#proto/Forum.proto)
     - [ForumPostMetadata](#.ForumPostMetadata)
     - [ForumPostReaction](#.ForumPostReaction)
+    - [ForumThreadMetadata](#.ForumThreadMetadata)
   
     - [ForumPostReaction.Reaction](#.ForumPostReaction.Reaction)
   
@@ -171,6 +172,22 @@ The enum must be wrapped inside &#34;message&#34;, otherwide it breaks protobufj
 
 
 
+
+<a name=".ForumThreadMetadata"></a>
+
+### ForumThreadMetadata
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| title | [string](#string) | optional | Thread title |
+| tags | [string](#string) | repeated | Tags accociated with the thread. Any update overrides all current tags. Only the first {MAX_TAGS_PER_FORUM_THREAD} (const exposed via @joystream/metadata-protobuf/consts) tags are taken into account. In order to unset current tags, [&#39;&#39;] (array with empty string) must be provided as value. |
+
+
+
+
+
  
 
 

+ 2 - 1
metadata-protobuf/package.json

@@ -7,7 +7,8 @@
   "exports": {
     ".": "./lib/index.js",
     "./utils": "./lib/utils.js",
-    "./licenses": "./lib/licenses.js"
+    "./licenses": "./lib/licenses.js",
+    "./consts": "./lib/consts.js"
   },
   "typesVersions": {
     "*": {

+ 12 - 0
metadata-protobuf/proto/Forum.proto

@@ -12,3 +12,15 @@ message ForumPostMetadata {
   optional string text = 1; // Post text content (md-formatted)
   optional uint32 repliesTo = 2; // Id of the post that given post replies to (if any)
 }
+
+
+message ForumThreadMetadata {
+  optional string title = 1; // Thread title
+  /**
+   * Tags accociated with the thread.
+   * Any update overrides all current tags.
+   * Only the first {MAX_TAGS_PER_FORUM_THREAD} (const exposed via @joystream/metadata-protobuf/consts) tags are taken into account.
+   * In order to unset current tags, [''] (array with empty string) must be provided as value.
+   */
+  repeated string tags = 2;
+}

+ 1 - 0
metadata-protobuf/src/consts.ts

@@ -0,0 +1 @@
+export const MAX_TAGS_PER_FORUM_THREAD = 5

+ 13 - 0
metadata-protobuf/test/forum-tags.ts

@@ -0,0 +1,13 @@
+import { ForumThreadMetadata } from '../src'
+import { assert } from 'chai'
+import { encodeDecode, metaToObject } from '../src/utils'
+
+describe('Forum tags', () => {
+  it('Skip vs unsetting', () => {
+    const messageSkip = new ForumThreadMetadata()
+    const messageUnset = new ForumThreadMetadata({ tags: [''] })
+
+    assert.equal(metaToObject(ForumThreadMetadata, messageSkip).tags, undefined)
+    assert.deepEqual(encodeDecode(ForumThreadMetadata, messageUnset).tags, [''])
+  })
+})

+ 3 - 4
query-node/manifest.yml

@@ -69,8 +69,7 @@ typegen:
     - forum.CategoryDeleted
     - forum.ThreadCreated
     - forum.ThreadModerated
-    # - forum.ThreadUpdated FIXME: Not emitted by the runtime
-    - forum.ThreadTitleUpdated
+    - forum.ThreadMetadataUpdated
     - forum.ThreadDeleted
     - forum.ThreadMoved
     - forum.VoteOnPoll
@@ -495,8 +494,8 @@ mappings:
       handler: forum_ThreadCreated
     - event: forum.ThreadModerated
       handler: forum_ThreadModerated
-    - event: forum.ThreadTitleUpdated
-      handler: forum_ThreadTitleUpdated
+    - event: forum.ThreadMetadataUpdated
+      handler: forum_ThreadMetadataUpdated
     - event: forum.ThreadDeleted
       handler: forum_ThreadDeleted
     - event: forum.ThreadMoved

+ 166 - 34
query-node/mappings/forum.ts

@@ -2,7 +2,14 @@
 eslint-disable @typescript-eslint/naming-convention
 */
 import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common'
-import { bytesToString, deserializeMetadata, genericEventFields, getWorker } from './common'
+import {
+  bytesToString,
+  deserializeMetadata,
+  genericEventFields,
+  getWorker,
+  inconsistentState,
+  perpareString,
+} from './common'
 import {
   CategoryCreatedEvent,
   CategoryStatusActive,
@@ -20,7 +27,7 @@ import {
   ForumPollAlternative,
   ThreadModeratedEvent,
   ThreadStatusModerated,
-  ThreadTitleUpdatedEvent,
+  ThreadMetadataUpdatedEvent,
   ThreadDeletedEvent,
   ThreadStatusLocked,
   ThreadStatusRemoved,
@@ -46,11 +53,20 @@ import {
   PostTextUpdatedEvent,
   PostDeletedEvent,
   PostStatusRemoved,
+  ForumThreadTag,
 } from 'query-node/dist/model'
 import { Forum } from './generated/types'
 import { PostReactionId, PrivilegedActor } from '@joystream/types/augment/all'
-import { ForumPostMetadata, ForumPostReaction as SupportedPostReactions } from '@joystream/metadata-protobuf'
+import {
+  ForumPostMetadata,
+  ForumPostReaction as SupportedPostReactions,
+  ForumThreadMetadata,
+} from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
+import { MAX_TAGS_PER_FORUM_THREAD } from '@joystream/metadata-protobuf/consts'
 import { Not, In } from 'typeorm'
+import { Bytes } from '@polkadot/types'
+import _ from 'lodash'
 
 async function getCategory(store: DatabaseManager, categoryId: string, relations?: string[]): Promise<ForumCategory> {
   const category = await store.get(ForumCategory, { where: { id: categoryId }, relations })
@@ -70,8 +86,8 @@ async function getThread(store: DatabaseManager, threadId: string): Promise<Foru
   return thread
 }
 
-async function getPost(store: DatabaseManager, postId: string): Promise<ForumPost> {
-  const post = await store.get(ForumPost, { where: { id: postId } })
+async function getPost(store: DatabaseManager, postId: string, relations?: 'thread'[]): Promise<ForumPost> {
+  const post = await store.get(ForumPost, { where: { id: postId }, relations })
   if (!post) {
     throw new Error(`Forum post not found by id: ${postId.toString()}`)
   }
@@ -108,6 +124,60 @@ async function getActorWorker(store: DatabaseManager, actor: PrivilegedActor): P
   return worker
 }
 
+function normalizeForumTagLabel(label: string): string {
+  // Optionally: normalize to lowercase & ASCII only?
+  return perpareString(label)
+}
+
+function parseThreadMetadata(metaBytes: Bytes) {
+  const meta = deserializeMetadata(ForumThreadMetadata, metaBytes)
+  return {
+    title: meta ? meta.title : bytesToString(metaBytes),
+    tags:
+      meta && isSet(meta.tags)
+        ? _.uniq(meta.tags.slice(0, MAX_TAGS_PER_FORUM_THREAD).map((label) => normalizeForumTagLabel(label))).filter(
+            (v) => v // Filter out empty strings
+          )
+        : undefined,
+  }
+}
+
+async function prepareThreadTagsToSet(
+  { event, store }: StoreContext & EventContext,
+  labels: string[]
+): Promise<ForumThreadTag[]> {
+  const eventTime = new Date(event.blockTimestamp)
+  return Promise.all(
+    labels.map(async (label) => {
+      const forumTag =
+        (await store.get(ForumThreadTag, { where: { id: label } })) ||
+        new ForumThreadTag({
+          id: label,
+          createdAt: eventTime,
+          visibleThreadsCount: 0,
+        })
+      forumTag.updatedAt = eventTime
+      ++forumTag.visibleThreadsCount
+      await store.save<ForumThreadTag>(forumTag)
+      return forumTag
+    })
+  )
+}
+
+async function unsetThreadTags({ event, store }: StoreContext & EventContext, tags: ForumThreadTag[]): Promise<void> {
+  const eventTime = new Date(event.blockTimestamp)
+  await Promise.all(
+    tags.map(async (forumTag) => {
+      --forumTag.visibleThreadsCount
+      if (forumTag.visibleThreadsCount < 0) {
+        inconsistentState('Trying to update forumTag.visibleThreadsCount to a number below 0!')
+      }
+      forumTag.updatedAt = eventTime
+      await store.save<ForumThreadTag>(forumTag)
+    })
+  )
+}
+
 // Get standarized PostReactionResult by PostReactionId
 function parseReaction(reactionId: PostReactionId): typeof PostReactionResult {
   switch (reactionId.toNumber()) {
@@ -201,11 +271,21 @@ export async function forum_CategoryDeleted({ event, store }: EventContext & Sto
   await store.save<ForumCategory>(category)
 }
 
-export async function forum_ThreadCreated({ event, store }: EventContext & StoreContext): Promise<void> {
-  const { forumUserId, categoryId, title, text, poll } = new Forum.CreateThreadCall(event).args
-  const [threadId] = new Forum.ThreadCreatedEvent(event).params
+export async function forum_ThreadCreated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
+  const [
+    categoryId,
+    threadId,
+    postId,
+    memberId,
+    threadMetaBytes,
+    postTextBytes,
+    pollInput,
+  ] = new Forum.ThreadCreatedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
-  const author = new Membership({ id: forumUserId.toString() })
+  const author = new Membership({ id: memberId.toString() })
+
+  const { title, tags } = parseThreadMetadata(threadMetaBytes)
 
   const thread = new ForumThread({
     createdAt: eventTime,
@@ -213,28 +293,30 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
     id: threadId.toString(),
     author,
     category: new ForumCategory({ id: categoryId.toString() }),
-    title: bytesToString(title),
+    title: title || '',
     isSticky: false,
     status: new ThreadStatusActive(),
+    visiblePostsCount: 1,
+    tags: tags ? await prepareThreadTagsToSet(ctx, tags) : [],
   })
   await store.save<ForumThread>(thread)
 
-  if (poll.isSome) {
+  if (pollInput.isSome) {
     const threadPoll = new ForumPoll({
       createdAt: eventTime,
       updatedAt: eventTime,
-      description: bytesToString(poll.unwrap().description_hash), // FIXME: This should be raw description!
-      endTime: new Date(poll.unwrap().end_time.toNumber()),
+      description: bytesToString(pollInput.unwrap().description),
+      endTime: new Date(pollInput.unwrap().end_time.toNumber()),
       thread,
     })
     await store.save<ForumPoll>(threadPoll)
     await Promise.all(
-      poll.unwrap().poll_alternatives.map(async (alt, index) => {
+      pollInput.unwrap().poll_alternatives.map(async (alt, index) => {
         const alternative = new ForumPollAlternative({
           createdAt: eventTime,
           updatedAt: eventTime,
           poll: threadPoll,
-          text: bytesToString(alt.alternative_text_hash), // FIXME: This should be raw text!
+          text: bytesToString(alt.alternative_text),
           index,
         })
 
@@ -246,8 +328,8 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
   const threadCreatedEvent = new ThreadCreatedEvent({
     ...genericEventFields(event),
     thread,
-    title: bytesToString(title),
-    text: bytesToString(text),
+    title: title || '',
+    text: bytesToString(postTextBytes),
   })
   await store.save<ThreadCreatedEvent>(threadCreatedEvent)
 
@@ -255,19 +337,20 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
   postOrigin.threadCreatedEventId = threadCreatedEvent.id
 
   const initialPost = new ForumPost({
-    // FIXME: The postId is unknown
+    id: postId.toString(),
     createdAt: eventTime,
     updatedAt: eventTime,
     author,
     thread,
-    text: bytesToString(text),
+    text: bytesToString(postTextBytes),
     status: new PostStatusActive(),
     origin: postOrigin,
   })
   await store.save<ForumPost>(initialPost)
 }
 
-export async function forum_ThreadModerated({ event, store }: EventContext & StoreContext): Promise<void> {
+export async function forum_ThreadModerated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
   const [threadId, rationaleBytes, privilegedActor] = new Forum.ThreadModeratedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const actorWorker = await getActorWorker(store, privilegedActor)
@@ -287,28 +370,50 @@ export async function forum_ThreadModerated({ event, store }: EventContext & Sto
 
   thread.updatedAt = eventTime
   thread.status = newStatus
+  thread.visiblePostsCount = 0
+  await unsetThreadTags(ctx, thread.tags || [])
   await store.save<ForumThread>(thread)
 }
 
-export async function forum_ThreadTitleUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
-  const [threadId, , , newTitleBytes] = new Forum.ThreadTitleUpdatedEvent(event).params
+export async function forum_ThreadMetadataUpdated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
+  const [threadId, , , newMetadataBytes] = new Forum.ThreadMetadataUpdatedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const thread = await getThread(store, threadId.toString())
 
-  const threadTitleUpdatedEvent = new ThreadTitleUpdatedEvent({
-    ...genericEventFields(event),
-    thread,
-    newTitle: bytesToString(newTitleBytes),
-  })
+  const { title: newTitle, tags: newTagIds } = parseThreadMetadata(newMetadataBytes)
+
+  // Only update tags if set
+  if (isSet(newTagIds)) {
+    const currentTagIds = (thread.tags || []).map((t) => t.id)
+    const tagIdsToSet = _.difference(newTagIds, currentTagIds)
+    const tagIdsToUnset = _.difference(currentTagIds, newTagIds)
+    const newTags = await prepareThreadTagsToSet(ctx, tagIdsToSet)
+    await unsetThreadTags(
+      ctx,
+      (thread.tags || []).filter((t) => tagIdsToUnset.includes(t.id))
+    )
+    thread.tags = newTags
+  }
 
-  await store.save<ThreadTitleUpdatedEvent>(threadTitleUpdatedEvent)
+  if (isSet(newTitle)) {
+    thread.title = newTitle
+  }
 
   thread.updatedAt = eventTime
-  thread.title = bytesToString(newTitleBytes)
   await store.save<ForumThread>(thread)
+
+  const threadMetadataUpdatedEvent = new ThreadMetadataUpdatedEvent({
+    ...genericEventFields(event),
+    thread,
+    newTitle: newTitle || undefined,
+  })
+
+  await store.save<ThreadMetadataUpdatedEvent>(threadMetadataUpdatedEvent)
 }
 
-export async function forum_ThreadDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+export async function forum_ThreadDeleted(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
   const [threadId, , , hide] = new Forum.ThreadDeletedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const thread = await getThread(store, threadId.toString())
@@ -324,6 +429,10 @@ export async function forum_ThreadDeleted({ event, store }: EventContext & Store
   status.threadDeletedEventId = threadDeletedEvent.id
   thread.status = status
   thread.updatedAt = eventTime
+  if (hide.valueOf()) {
+    thread.visiblePostsCount = 0
+    await unsetThreadTags(ctx, thread.tags || [])
+  }
   await store.save<ForumThread>(thread)
 }
 
@@ -366,6 +475,7 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
   const [postId, forumUserId, , threadId, metadataBytes, isEditable] = new Forum.PostAddedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
 
+  const thread = await getThread(store, threadId.toString())
   const metadata = deserializeMetadata(ForumPostMetadata, metadataBytes)
   const postText = metadata ? metadata.text || '' : bytesToString(metadataBytes)
   const repliesToPost =
@@ -380,7 +490,7 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
     createdAt: eventTime,
     updatedAt: eventTime,
     text: postText,
-    thread: new ForumThread({ id: threadId.toString() }),
+    thread,
     status: postStatus,
     author: new Membership({ id: forumUserId.toString() }),
     origin: postOrigin,
@@ -399,6 +509,10 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
   // Update the other side of cross-relationship
   postOrigin.postAddedEventId = postAddedEvent.id
   await store.save<ForumPost>(post)
+
+  ++thread.visiblePostsCount
+  thread.updatedAt = eventTime
+  await store.save<ForumThread>(thread)
 }
 
 export async function forum_CategoryStickyThreadUpdate({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -469,7 +583,7 @@ export async function forum_PostModerated({ event, store }: EventContext & Store
   const [postId, rationaleBytes, privilegedActor] = new Forum.PostModeratedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const actorWorker = await getActorWorker(store, privilegedActor)
-  const post = await getPost(store, postId.toString())
+  const post = await getPost(store, postId.toString(), ['thread'])
 
   const postModeratedEvent = new PostModeratedEvent({
     ...genericEventFields(event),
@@ -486,6 +600,11 @@ export async function forum_PostModerated({ event, store }: EventContext & Store
   post.updatedAt = eventTime
   post.status = newStatus
   await store.save<ForumPost>(post)
+
+  const { thread } = post
+  --thread.visiblePostsCount
+  thread.updatedAt = eventTime
+  await store.save<ForumThread>(thread)
 }
 
 export async function forum_PostReacted({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -546,6 +665,12 @@ export async function forum_PostTextUpdated({ event, store }: EventContext & Sto
 }
 
 export async function forum_PostDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // FIXME: Custom posts BTreeMap fix (because of invalid BTreeMap json encoding/decoding)
+  // See: https://github.com/polkadot-js/api/pull/3789
+  event.params[2].value = new Map(
+    Object.entries(event.params[2].value).map(([key, val]) => [JSON.parse(key), val])
+  ) as any
+
   const [rationaleBytes, userId, postsData] = new Forum.PostDeletedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
 
@@ -558,14 +683,21 @@ export async function forum_PostDeleted({ event, store }: EventContext & StoreCo
   await store.save<PostDeletedEvent>(postDeletedEvent)
 
   await Promise.all(
-    postsData.map(async ([, , postId, hideFlag]) => {
-      const post = await getPost(store, postId.toString())
+    Array.from(postsData.entries()).map(async ([[, , postId], hideFlag]) => {
+      const post = await getPost(store, postId.toString(), ['thread'])
       const newStatus = hideFlag.valueOf() ? new PostStatusRemoved() : new PostStatusLocked()
       newStatus.postDeletedEventId = postDeletedEvent.id
       post.updatedAt = eventTime
       post.status = newStatus
       post.deletedInEvent = postDeletedEvent
       await store.save<ForumPost>(post)
+
+      if (hideFlag.valueOf()) {
+        const { thread } = post
+        --thread.visiblePostsCount
+        thread.updatedAt = eventTime
+        await store.save<ForumThread>(thread)
+      }
     })
   )
 }

+ 19 - 2
query-node/schemas/forum.graphql

@@ -83,6 +83,9 @@ type ForumThread @entity {
   "All posts in the thread"
   posts: [ForumPost!] @derivedFrom(field: "thread")
 
+  "Number of non-deleted posts in the thread"
+  visiblePostsCount: Int!
+
   "Optional poll associated with the thread"
   poll: ForumPoll @derivedFrom(field: "thread")
 
@@ -95,8 +98,8 @@ type ForumThread @entity {
   "Current thread status"
   status: ThreadStatus!
 
-  "Theread title update events"
-  titleUpdates: [ThreadTitleUpdatedEvent!] @derivedFrom(field: "thread")
+  "Theread metadata update events"
+  metadataUpdates: [ThreadMetadataUpdatedEvent!] @derivedFrom(field: "thread")
 
   # Required to create Many-to-Many relation
   "The events the thred was made sticky in"
@@ -104,6 +107,20 @@ type ForumThread @entity {
 
   "List of events that moved the thread to a different category"
   movedInEvents: [ThreadMovedEvent!] @derivedFrom(field: "thread")
+
+  "Assigned thread tags"
+  tags: [ForumThreadTag!]
+}
+
+type ForumThreadTag @entity {
+  "Tag id (and simultaneously - tag label)"
+  id: ID!
+
+  "Forum threads assigned to the tag"
+  threads: [ForumThread!] @derivedFrom(field: "tags")
+
+  "Number of non-removed threads currently assigned to the tag"
+  visibleThreadsCount: Int!
 }
 
 type ForumPoll @entity {

+ 2 - 17
query-node/schemas/forumEvents.graphql

@@ -143,22 +143,7 @@ type ThreadModeratedEvent @entity {
   actor: Worker!
 }
 
-# FIXME: Not emitted by the runtime
-# type ThreadUpdatedEvent @entity {
-#   "Generic event data"
-#   event: Event!
-
-#   "The thread beeing updated"
-#   thread: ForumThread!
-
-#   "The new archival status of the thread (true = archived)"
-#   newArchivalStatus: Boolean!
-
-#   "Actor responsible for the update"
-#   actor: Worker!
-# }
-
-type ThreadTitleUpdatedEvent @entity {
+type ThreadMetadataUpdatedEvent @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -182,7 +167,7 @@ type ThreadTitleUpdatedEvent @entity {
   thread: ForumThread!
 
   "New title of the thread"
-  newTitle: String!
+  newTitle: String
 
   # Only author can update the thread title, so no actor information required
 }

+ 2 - 1
tests/integration-tests/package.json

@@ -10,7 +10,8 @@
     "lint": "eslint . --quiet --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "format": "prettier ./ --write",
-    "generate:graphql-types": "graphql-codegen"
+    "generate:graphql-types": "graphql-codegen",
+    "generate:all": "yarn generate:graphql-types"
   },
   "dependencies": {
     "@apollo/client": "^3.2.5",

+ 2 - 1
tests/integration-tests/src/Api.ts

@@ -551,7 +551,8 @@ export class Api {
     const details = await this.retrieveForumEventDetails(result, 'ThreadCreated')
     return {
       ...details,
-      threadId: details.event.data[0] as ThreadId,
+      threadId: details.event.data[1] as ThreadId,
+      postId: details.event.data[2] as PostId,
     }
   }
 

+ 10 - 8
tests/integration-tests/src/QueryNodeApi.ts

@@ -226,10 +226,10 @@ import {
   GetPostAddedEventsByEventIdsQuery,
   GetPostAddedEventsByEventIdsQueryVariables,
   GetPostAddedEventsByEventIds,
-  ThreadTitleUpdatedEventFieldsFragment,
-  GetThreadTitleUpdatedEventsByEventIdsQuery,
-  GetThreadTitleUpdatedEventsByEventIdsQueryVariables,
-  GetThreadTitleUpdatedEventsByEventIds,
+  ThreadMetadataUpdatedEventFieldsFragment,
+  GetThreadMetadataUpdatedEventsByEventIds,
+  GetThreadMetadataUpdatedEventsByEventIdsQuery,
+  GetThreadMetadataUpdatedEventsByEventIdsQueryVariables,
   ThreadMovedEventFieldsFragment,
   GetThreadMovedEventsByEventIdsQuery,
   GetThreadMovedEventsByEventIdsQueryVariables,
@@ -834,12 +834,14 @@ export class QueryNodeApi {
     >(GetThreadCreatedEventsByEventIds, { eventIds }, 'threadCreatedEvents')
   }
 
-  public async getThreadTitleUpdatedEvents(events: EventDetails[]): Promise<ThreadTitleUpdatedEventFieldsFragment[]> {
+  public async getThreadMetadataUpdatedEvents(
+    events: EventDetails[]
+  ): Promise<ThreadMetadataUpdatedEventFieldsFragment[]> {
     const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
     return this.multipleEntitiesQuery<
-      GetThreadTitleUpdatedEventsByEventIdsQuery,
-      GetThreadTitleUpdatedEventsByEventIdsQueryVariables
-    >(GetThreadTitleUpdatedEventsByEventIds, { eventIds }, 'threadTitleUpdatedEvents')
+      GetThreadMetadataUpdatedEventsByEventIdsQuery,
+      GetThreadMetadataUpdatedEventsByEventIdsQueryVariables
+    >(GetThreadMetadataUpdatedEventsByEventIds, { eventIds }, 'threadMetadataUpdatedEvents')
   }
 
   public async getThreadsWithPostsByIds(ids: ThreadId[]): Promise<ForumThreadWithPostsFieldsFragment[]> {

+ 4 - 4
tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts

@@ -68,10 +68,10 @@ export class AddPostsFixture extends StandardizedFixture {
     return this.api.retrievePostAddedEventDetails(result)
   }
 
-  protected getPostExpectedText(postParams: PostParams): string {
-    const expectedMetadata = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, postParams.metadata)
-    const metadataBytes = Utils.getMetadataBytesFromInput(ForumPostMetadata, postParams.metadata)
-    return typeof expectedMetadata?.text === 'string' ? expectedMetadata.text : Utils.bytesToString(metadataBytes)
+  protected getPostExpectedText({ metadata: inputMeta }: PostParams): string {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, inputMeta)
+    const metaBytes = Utils.getMetadataBytesFromInput(ForumPostMetadata, inputMeta)
+    return meta ? meta.text || '' : Utils.bytesToString(metaBytes)
   }
 
   protected assertQueriedPostsAreValid(

+ 35 - 11
tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts

@@ -1,16 +1,18 @@
 import { Api } from '../../Api'
 import { QueryNodeApi } from '../../QueryNodeApi'
-import { ThreadCreatedEventDetails } from '../../types'
+import { MetadataInput, ThreadCreatedEventDetails } from '../../types'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Utils } from '../../utils'
 import { ISubmittableResult } from '@polkadot/types/types/'
 import { ForumThreadWithPostsFieldsFragment, ThreadCreatedEventFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
-import { CategoryId, Poll } from '@joystream/types/forum'
+import { CategoryId, PollAlternativeInput, PollInput } from '@joystream/types/forum'
 import { MemberId, ThreadId } from '@joystream/types/common'
 import { CreateInterface } from '@joystream/types'
 import { POST_DEPOSIT, THREAD_DEPOSIT } from '../../consts'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
 
 export type PollParams = {
   description: string
@@ -19,7 +21,7 @@ export type PollParams = {
 }
 
 export type ThreadParams = {
-  title: string
+  metadata: MetadataInput<IForumThreadMetadata>
   text: string
   categoryId: CategoryId
   asMember: MemberId
@@ -58,18 +60,19 @@ export class CreateThreadsFixture extends StandardizedFixture {
     await super.execute()
   }
 
-  protected parsePollParams(pollParams?: PollParams): CreateInterface<Poll> | null {
+  protected parsePollParams(pollParams?: PollParams): CreateInterface<PollInput> | null {
     if (!pollParams) {
       return null
     }
 
+    const alternatives: CreateInterface<PollAlternativeInput>[] = pollParams.alternatives.map((a) => ({
+      alternative_text: a,
+    }))
+
     return {
-      description_hash: pollParams.description,
+      description: pollParams.description,
       end_time: pollParams.endTime.getTime(),
-      poll_alternatives: pollParams.alternatives.map((a) => ({
-        alternative_text_hash: a,
-        vote_count: 0,
-      })),
+      poll_alternatives: alternatives,
     }
   }
 
@@ -78,7 +81,7 @@ export class CreateThreadsFixture extends StandardizedFixture {
       this.api.tx.forum.createThread(
         params.asMember,
         params.categoryId,
-        params.title,
+        Utils.getMetadataBytesFromInput(ForumThreadMetadata, params.metadata),
         params.text,
         this.parsePollParams(params.poll)
       )
@@ -89,6 +92,12 @@ export class CreateThreadsFixture extends StandardizedFixture {
     return this.api.retrieveThreadCreatedEventDetails(result)
   }
 
+  protected getExpectedThreadTitle({ metadata: inputMeta }: ThreadParams): string {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, inputMeta)
+    const metaBytes = Utils.getMetadataBytesFromInput(ForumThreadMetadata, inputMeta)
+    return meta ? meta.title || '' : Utils.bytesToString(metaBytes)
+  }
+
   protected assertQueriedThreadsAreValid(
     qThreads: ForumThreadWithPostsFieldsFragment[],
     qEvents: ThreadCreatedEventFieldsFragment[]
@@ -97,8 +106,10 @@ export class CreateThreadsFixture extends StandardizedFixture {
       const qThread = qThreads.find((t) => t.id === e.threadId.toString())
       const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
       const threadParams = this.threadsParams[i]
+      const metadata = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, threadParams.metadata)
+      const expectedTitle = this.getExpectedThreadTitle(threadParams)
       Utils.assert(qThread, 'Query node: Thread not found')
-      assert.equal(qThread.title, threadParams.title)
+      assert.equal(qThread.title, expectedTitle)
       assert.equal(qThread.category.id, threadParams.categoryId.toString())
       assert.equal(qThread.author.id, threadParams.asMember.toString())
       assert.equal(qThread.status.__typename, 'ThreadStatusActive')
@@ -106,6 +117,7 @@ export class CreateThreadsFixture extends StandardizedFixture {
       assert.equal(qThread.createdInEvent.id, qEvent.id)
       const initialPost = qThread.posts.find((p) => p.origin.__typename === 'PostOriginThreadInitial')
       Utils.assert(initialPost, "Query node: Thread's initial post not found!")
+      assert.equal(initialPost.id, e.postId.toString())
       assert.equal(initialPost.text, threadParams.text)
       Utils.assert(initialPost.origin.__typename === 'PostOriginThreadInitial')
       // FIXME: Temporarly not working (https://github.com/Joystream/hydra/issues/396)
@@ -116,13 +128,25 @@ export class CreateThreadsFixture extends StandardizedFixture {
       if (threadParams.poll) {
         Utils.assert(qThread.poll, 'Query node: Thread poll is missing')
         assert.equal(qThread.poll.description, threadParams.poll.description)
+        assert.sameDeepMembers(
+          qThread.poll.pollAlternatives.map((a) => [a.text, a.index]),
+          threadParams.poll.alternatives.map((text, index) => [text, index])
+        )
         assert.equal(new Date(qThread.poll.endTime).getTime(), threadParams.poll.endTime.getTime())
       }
+      if (metadata && isSet(metadata?.tags)) {
+        assert.sameDeepMembers(
+          qThread.tags.map((t) => t.id),
+          metadata.tags
+        )
+      }
     })
   }
 
   protected assertQueryNodeEventIsValid(qEvent: ThreadCreatedEventFieldsFragment, i: number): void {
     assert.equal(qEvent.thread.id, this.events[i].threadId.toString())
+    assert.equal(qEvent.title, this.getExpectedThreadTitle(this.threadsParams[i]))
+    assert.equal(qEvent.text, this.threadsParams[i].text)
   }
 
   async runQueryNodeChecks(): Promise<void> {

+ 9 - 10
tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts

@@ -8,8 +8,9 @@ import { ForumPostFieldsFragment, PostDeletedEventFieldsFragment } from '../../g
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
 import { MemberId, PostId, ThreadId } from '@joystream/types/common'
-import { CategoryId } from '@joystream/types/forum'
+import { CategoryId, PostsToDeleteMap } from '@joystream/types/forum'
 import _ from 'lodash'
+import { registry } from '../../../../../types'
 
 const DEFAULT_RATIONALE = 'State cleanup'
 
@@ -40,16 +41,14 @@ export class DeletePostsFixture extends StandardizedFixture {
   }
 
   protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
-    return this.removals.map((r) =>
-      this.api.tx.forum.deletePosts(
-        r.asMember,
-        r.posts.map(
-          ({ categoryId, threadId, postId, hide }) =>
-            [categoryId, threadId, postId, hide === undefined || hide] as [CategoryId, ThreadId, PostId, boolean]
-        ),
-        r.rationale || DEFAULT_RATIONALE
+    return this.removals.map((r) => {
+      const postsToDeleteEntries = r.posts.map(
+        ({ categoryId, threadId, postId, hide }) =>
+          [[categoryId, threadId, postId], hide === undefined || hide] as [[CategoryId, ThreadId, PostId], boolean]
       )
-    )
+      const postsToDeleteMap = new PostsToDeleteMap(registry, new Map(postsToDeleteEntries))
+      return this.api.tx.forum.deletePosts(r.asMember, postsToDeleteMap, r.rationale || DEFAULT_RATIONALE)
+    })
   }
 
   protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {

+ 1 - 1
tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts

@@ -177,7 +177,7 @@ export class InitializeForumFixture extends BaseQueryNodeFixture {
             Array.from({ length: threadsPerCategory }, (v, i) => ({
               categoryId,
               asMember: forumMemberIds[i % forumMemberIds.length],
-              title: `Thread ${i} in category ${categoryId.toString()}`,
+              metadata: { value: { title: `Thread ${i} in category ${categoryId.toString()}` } },
               text: `Initialize forum test thread ${i} in category ${categoryId.toString()}`,
             }))
           ))

+ 0 - 103
tests/integration-tests/src/fixtures/forum/UpdateThreadTitlesFixture.ts

@@ -1,103 +0,0 @@
-import { Api } from '../../Api'
-import { QueryNodeApi } from '../../QueryNodeApi'
-import { EventDetails, MemberContext } from '../../types'
-import { SubmittableExtrinsic } from '@polkadot/api/types'
-import { Utils } from '../../utils'
-import { ISubmittableResult } from '@polkadot/types/types/'
-import {
-  ForumThreadWithPostsFieldsFragment,
-  ThreadTitleUpdatedEventFieldsFragment,
-} from '../../graphql/generated/queries'
-import { assert } from 'chai'
-import { CategoryId } from '@joystream/types/forum'
-import { StandardizedFixture } from '../../Fixture'
-import { ThreadId } from '@joystream/types/common'
-import _ from 'lodash'
-
-export type ThreadTitleUpdate = {
-  categoryId: CategoryId
-  threadId: ThreadId
-  newTitle: string
-}
-
-export class UpdateThreadTitlesFixture extends StandardizedFixture {
-  protected threadAuthors: MemberContext[] = []
-  protected updates: ThreadTitleUpdate[]
-
-  public constructor(api: Api, query: QueryNodeApi, updates: ThreadTitleUpdate[]) {
-    super(api, query)
-    this.updates = updates
-  }
-
-  protected async loadAuthors(): Promise<void> {
-    this.threadAuthors = await Promise.all(
-      this.updates.map(async (u) => {
-        const thread = await this.api.query.forum.threadById(u.categoryId, u.threadId)
-        const member = await this.api.query.members.membershipById(thread.author_id)
-        return { account: member.controller_account.toString(), memberId: thread.author_id }
-      })
-    )
-  }
-
-  protected async getSignerAccountOrAccounts(): Promise<string[]> {
-    return this.threadAuthors.map((a) => a.account)
-  }
-
-  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
-    return this.updates.map((u, i) =>
-      this.api.tx.forum.editThreadTitle(this.threadAuthors[i].memberId, u.categoryId, u.threadId, u.newTitle)
-    )
-  }
-
-  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
-    return this.api.retrieveForumEventDetails(result, 'ThreadTitleUpdated')
-  }
-
-  public async execute(): Promise<void> {
-    await this.loadAuthors()
-    await super.execute()
-  }
-
-  protected assertQueriedThreadsAreValid(
-    qThreads: ForumThreadWithPostsFieldsFragment[],
-    qEvents: ThreadTitleUpdatedEventFieldsFragment[]
-  ): void {
-    // Check titleUpdates array
-    this.events.forEach((e, i) => {
-      const update = this.updates[i]
-      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
-      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
-      Utils.assert(qThread, 'Query node: Thread not found')
-      assert.include(
-        qThread.titleUpdates.map((u) => u.id),
-        qEvent.id
-      )
-    })
-
-    // Check updated titles (against lastest update per thread)
-    _.uniqBy([...this.updates].reverse(), (v) => v.threadId).map((update) => {
-      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
-      Utils.assert(qThread, 'Query node: Thread not found')
-      assert.equal(qThread.title, update.newTitle)
-    })
-  }
-
-  protected assertQueryNodeEventIsValid(qEvent: ThreadTitleUpdatedEventFieldsFragment, i: number): void {
-    const { threadId, newTitle } = this.updates[i]
-    assert.equal(qEvent.thread.id, threadId.toString())
-    assert.equal(qEvent.newTitle, newTitle)
-  }
-
-  async runQueryNodeChecks(): Promise<void> {
-    await super.runQueryNodeChecks()
-    // Query the events
-    const qEvents = await this.query.tryQueryWithTimeout(
-      () => this.query.getThreadTitleUpdatedEvents(this.events),
-      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
-    )
-
-    // Query the threads
-    const qThreads = await this.query.getThreadsWithPostsByIds(this.updates.map((u) => u.threadId))
-    this.assertQueriedThreadsAreValid(qThreads, qEvents)
-  }
-}

+ 135 - 0
tests/integration-tests/src/fixtures/forum/UpdateThreadsMetadataFixture.ts

@@ -0,0 +1,135 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, MemberContext, MetadataInput } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ForumThreadWithPostsFieldsFragment,
+  ThreadMetadataUpdatedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { StandardizedFixture } from '../../Fixture'
+import { ThreadId } from '@joystream/types/common'
+import _ from 'lodash'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { isSet } from '../../../../../metadata-protobuf/lib/utils'
+
+export type ThreadMetadataUpdate = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  newMetadata: MetadataInput<IForumThreadMetadata>
+  preUpdateValues?: {
+    title: string
+    tags: string[]
+  }
+}
+
+export class UpdateThreadsMetadataFixture extends StandardizedFixture {
+  protected threadAuthors: MemberContext[] = []
+  protected updates: ThreadMetadataUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: ThreadMetadataUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async loadAuthors(): Promise<void> {
+    this.threadAuthors = await Promise.all(
+      this.updates.map(async (u) => {
+        const thread = await this.api.query.forum.threadById(u.categoryId, u.threadId)
+        const member = await this.api.query.members.membershipById(thread.author_id)
+        return { account: member.controller_account.toString(), memberId: thread.author_id }
+      })
+    )
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.threadAuthors.map((a) => a.account)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u, i) =>
+      this.api.tx.forum.editThreadMetadata(
+        this.threadAuthors[i].memberId,
+        u.categoryId,
+        u.threadId,
+        Utils.getMetadataBytesFromInput(ForumThreadMetadata, u.newMetadata)
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'ThreadMetadataUpdated')
+  }
+
+  public async execute(): Promise<void> {
+    await this.loadAuthors()
+    await super.execute()
+  }
+
+  protected getNewThreadTitle({ newMetadata: inputMeta }: ThreadMetadataUpdate): string | null {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, inputMeta)
+    const metaBytes = Utils.getMetadataBytesFromInput(ForumThreadMetadata, inputMeta)
+    return meta ? meta.title || null : Utils.bytesToString(metaBytes)
+  }
+
+  protected getNewThreadTags({ newMetadata: inputMeta }: ThreadMetadataUpdate): string[] | null {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, inputMeta)
+    return meta && isSet(meta.tags) ? meta.tags : null
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadMetadataUpdatedEventFieldsFragment[]
+  ): void {
+    // Check metadataUpdates array
+    this.events.forEach((e, i) => {
+      const update = this.updates[i]
+      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.include(
+        qThread.metadataUpdates.map((u) => u.id),
+        qEvent.id
+      )
+    })
+
+    // Check updated titles/tags (against lastest update per thread that affected them)
+    _.uniq(this.updates.map((u) => u.threadId)).map((threadId) => {
+      const qThread = qThreads.find((t) => t.id === threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found')
+      const threadUpdates = this.updates.filter((u) => u.threadId === threadId)
+      const lastNewTitle = _.last(threadUpdates.map((u) => this.getNewThreadTitle(u)).filter((v) => v !== null))
+      const lastNewTags = _.last(threadUpdates.map((u) => this.getNewThreadTags(u)).filter((v) => v !== null))
+      const expectedTitle = lastNewTitle ?? (threadUpdates[0].preUpdateValues?.title || qThread.createdInEvent.title)
+      const expectedTags = (lastNewTags ?? (threadUpdates[0].preUpdateValues?.tags || [])).filter((v) => v)
+      assert.equal(qThread.title, expectedTitle)
+      assert.sameMembers(
+        qThread.tags.map((t) => t.id),
+        expectedTags
+      )
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadMetadataUpdatedEventFieldsFragment, i: number): void {
+    const update = this.updates[i]
+    const newTitle = this.getNewThreadTitle(update)
+    assert.equal(qEvent.thread.id, update.threadId.toString())
+    assert.equal(qEvent.newTitle, newTitle)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadMetadataUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.updates.map((u) => u.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 1 - 1
tests/integration-tests/src/fixtures/forum/index.ts

@@ -5,7 +5,7 @@ export { CreateThreadsFixture, ThreadParams } from './CreateThreadsFixture'
 export { DeleteThreadsFixture, ThreadRemovalInput } from './DeleteThreadsFixture'
 export { VoteOnPollFixture, VoteParams } from './VoteOnPollFixture'
 export { AddPostsFixture, PostParams } from './AddPostsFixture'
-export { UpdateThreadTitlesFixture, ThreadTitleUpdate } from './UpdateThreadTitlesFixture'
+export { UpdateThreadsMetadataFixture, ThreadMetadataUpdate } from './UpdateThreadsMetadataFixture'
 export { MoveThreadsFixture, MoveThreadParams } from './MoveThreadsFixture'
 export { SetStickyThreadsFixture, StickyThreadsParams } from './SetStickyThreadsFixture'
 export { UpdateCategoryModeratorsFixture, CategoryModeratorStatusUpdate } from './UpdateCategoryModeratorsFixture'

+ 1 - 1
tests/integration-tests/src/flows/forum/polls.ts

@@ -32,7 +32,7 @@ export default async function polls({ api, query }: FlowProps): Promise<void> {
   const pollThreads: ThreadParams[] = memberIds.map((memberId, i) => ({
     categoryId,
     asMember: memberId,
-    title: `Poll ${i}`,
+    metadata: { value: { title: `Poll ${i}` } },
     text: `Poll ${i} desc`,
     poll: {
       description: `Poll ${i} question?`,

+ 64 - 0
tests/integration-tests/src/flows/forum/threadTags.ts

@@ -0,0 +1,64 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import {
+  CreateThreadsFixture,
+  ThreadParams,
+  InitializeForumFixture,
+  ThreadMetadataUpdate,
+  UpdateThreadsMetadataFixture,
+} from '../../fixtures/forum'
+import { integrateMeta } from '@joystream/metadata-protobuf/utils'
+import { IForumThreadMetadata } from '@joystream/metadata-protobuf'
+
+export default async function threadTags({ api, query }: FlowProps): Promise<void> {
+  const debug = extendDebug(`flow:threadTags`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // Initialize forum
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 1,
+    numberOfCategories: 1,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
+
+  const [categoryId] = initializeForumFixture.getCreatedCategoryIds()
+  const [memberId] = initializeForumFixture.getCreatedForumMemberIds()
+
+  const originalMeta = { title: 'Test thread with tags', tags: ['tag1', 'tag2', 'tag3'] }
+  const threadParams: ThreadParams = {
+    categoryId,
+    asMember: memberId,
+    text: 'Test thread with tags',
+    metadata: { value: originalMeta },
+  }
+  const createThreadsFixture = new CreateThreadsFixture(api, query, [threadParams])
+  await new FixtureRunner(createThreadsFixture).runWithQueryNodeChecks()
+  const [threadId] = createThreadsFixture.getCreatedThreadsIds()
+
+  const updateMetas: IForumThreadMetadata[] = [
+    { title: 'New title' },
+    { tags: ['newTag1', 'tag2', 'tag3'] },
+    { tags: [''] },
+    { title: 'Final update title', tags: ['finalTag1', 'finalTag2', 'finalTag3'] },
+  ]
+  let previousPreUpdateValues = { ...originalMeta }
+  const updates: ThreadMetadataUpdate[] = updateMetas.map((meta) => {
+    const preUpdateValues = { ...previousPreUpdateValues }
+    integrateMeta(preUpdateValues, meta, ['title', 'tags'])
+    previousPreUpdateValues = { ...preUpdateValues }
+    return {
+      categoryId,
+      threadId,
+      newMetadata: { value: meta },
+      preUpdateValues,
+    }
+  })
+  const updateFixtures = updates.map((u) => new UpdateThreadsMetadataFixture(api, query, [u]))
+  for (const fixutre of updateFixtures) {
+    await new FixtureRunner(fixutre).runWithQueryNodeChecks()
+  }
+
+  debug('Done')
+}

+ 8 - 8
tests/integration-tests/src/flows/forum/threads.ts

@@ -9,8 +9,8 @@ import {
   SetStickyThreadsFixture,
   StickyThreadsParams,
   ThreadRemovalInput,
-  ThreadTitleUpdate,
-  UpdateThreadTitlesFixture,
+  ThreadMetadataUpdate,
+  UpdateThreadsMetadataFixture,
 } from '../../fixtures/forum'
 import { CategoryId } from '@joystream/types/forum'
 
@@ -43,17 +43,17 @@ export default async function threads({ api, query }: FlowProps): Promise<void>
   const setStickyThreadsRunner = new FixtureRunner(setStickyThreadsFixture)
   await setStickyThreadsRunner.run()
 
-  // Update titles
-  let titleUpdates: ThreadTitleUpdate[] = []
+  // Metadata updates
+  let metadataUpdates: ThreadMetadataUpdate[] = []
   initializeForumFixture.getThreadPaths().forEach(
     (threadPath, i) =>
-      (titleUpdates = titleUpdates.concat([
-        { ...threadPath, newTitle: '' },
-        { ...threadPath, newTitle: `Test updated title ${i}` },
+      (metadataUpdates = metadataUpdates.concat([
+        { ...threadPath, newMetadata: { value: { title: '', tags: [] } } },
+        { ...threadPath, newMetadata: { value: { title: `Test updated title ${i}`, tags: [] } } },
       ]))
   )
 
-  const updateThreadTitlesFixture = new UpdateThreadTitlesFixture(api, query, titleUpdates)
+  const updateThreadTitlesFixture = new UpdateThreadsMetadataFixture(api, query, metadataUpdates)
   const updateThreadTitlesRunner = new FixtureRunner(updateThreadTitlesFixture)
   await updateThreadTitlesRunner.run()
 

+ 24 - 14
tests/integration-tests/src/graphql/generated/queries.ts

@@ -51,14 +51,15 @@ export type ForumThreadWithPostsFieldsFragment = {
     endTime: any
     pollAlternatives: Array<{ index: number; text: string; votes: Array<{ votingMember: { id: string } }> }>
   }>
-  createdInEvent: { id: string }
+  createdInEvent: { id: string; title: string; text: string }
   status:
     | { __typename: 'ThreadStatusActive' }
     | { __typename: 'ThreadStatusLocked'; threadDeletedEvent?: Types.Maybe<{ id: string }> }
     | { __typename: 'ThreadStatusModerated'; threadModeratedEvent?: Types.Maybe<{ id: string }> }
     | { __typename: 'ThreadStatusRemoved'; threadDeletedEvent?: Types.Maybe<{ id: string }> }
-  titleUpdates: Array<{ id: string }>
+  metadataUpdates: Array<{ id: string }>
   movedInEvents: Array<{ id: string }>
+  tags: Array<{ id: string }>
 }
 
 export type GetCategoriesByIdsQueryVariables = Types.Exact<{
@@ -143,6 +144,8 @@ export type ThreadCreatedEventFieldsFragment = {
   network: Types.Network
   inExtrinsic?: Types.Maybe<string>
   indexInBlock: number
+  title: string
+  text: string
   thread: { id: string }
 }
 
@@ -152,7 +155,7 @@ export type GetThreadCreatedEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetThreadCreatedEventsByEventIdsQuery = { threadCreatedEvents: Array<ThreadCreatedEventFieldsFragment> }
 
-export type ThreadTitleUpdatedEventFieldsFragment = {
+export type ThreadMetadataUpdatedEventFieldsFragment = {
   id: string
   createdAt: any
   inBlock: number
@@ -163,12 +166,12 @@ export type ThreadTitleUpdatedEventFieldsFragment = {
   thread: { id: string }
 }
 
-export type GetThreadTitleUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+export type GetThreadMetadataUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
   eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
 }>
 
-export type GetThreadTitleUpdatedEventsByEventIdsQuery = {
-  threadTitleUpdatedEvents: Array<ThreadTitleUpdatedEventFieldsFragment>
+export type GetThreadMetadataUpdatedEventsByEventIdsQuery = {
+  threadMetadataUpdatedEvents: Array<ThreadMetadataUpdatedEventFieldsFragment>
 }
 
 export type VoteOnPollEventFieldsFragment = {
@@ -1844,6 +1847,8 @@ export const ForumThreadWithPostsFields = gql`
     isSticky
     createdInEvent {
       id
+      title
+      text
     }
     status {
       __typename
@@ -1863,12 +1868,15 @@ export const ForumThreadWithPostsFields = gql`
         }
       }
     }
-    titleUpdates {
+    metadataUpdates {
       id
     }
     movedInEvents {
       id
     }
+    tags {
+      id
+    }
   }
   ${ForumPostFields}
 `
@@ -1926,13 +1934,15 @@ export const ThreadCreatedEventFields = gql`
     network
     inExtrinsic
     indexInBlock
+    title
+    text
     thread {
       id
     }
   }
 `
-export const ThreadTitleUpdatedEventFields = gql`
-  fragment ThreadTitleUpdatedEventFields on ThreadTitleUpdatedEvent {
+export const ThreadMetadataUpdatedEventFields = gql`
+  fragment ThreadMetadataUpdatedEventFields on ThreadMetadataUpdatedEvent {
     id
     createdAt
     inBlock
@@ -3356,13 +3366,13 @@ export const GetThreadCreatedEventsByEventIds = gql`
   }
   ${ThreadCreatedEventFields}
 `
-export const GetThreadTitleUpdatedEventsByEventIds = gql`
-  query getThreadTitleUpdatedEventsByEventIds($eventIds: [ID!]) {
-    threadTitleUpdatedEvents(where: { id_in: $eventIds }) {
-      ...ThreadTitleUpdatedEventFields
+export const GetThreadMetadataUpdatedEventsByEventIds = gql`
+  query getThreadMetadataUpdatedEventsByEventIds($eventIds: [ID!]) {
+    threadMetadataUpdatedEvents(where: { id_in: $eventIds }) {
+      ...ThreadMetadataUpdatedEventFields
     }
   }
-  ${ThreadTitleUpdatedEventFields}
+  ${ThreadMetadataUpdatedEventFields}
 `
 export const GetVoteOnPollEventsByEventIds = gql`
   query getVoteOnPollEventsByEventIds($eventIds: [ID!]) {

+ 285 - 160
tests/integration-tests/src/graphql/generated/schema.ts

@@ -3623,15 +3623,18 @@ export type ForumThread = BaseGraphQlObject & {
   /** Thread title */
   title: Scalars['String']
   posts: Array<ForumPost>
+  /** Number of non-deleted posts in the thread */
+  visiblePostsCount: Scalars['Int']
   poll?: Maybe<ForumPoll>
   /** Whether the thread is sticky in the category */
   isSticky: Scalars['Boolean']
   createdInEvent: ThreadCreatedEvent
   /** Current thread status */
   status: ThreadStatus
-  titleUpdates: Array<ThreadTitleUpdatedEvent>
+  metadataUpdates: Array<ThreadMetadataUpdatedEvent>
   madeStickyInEvents: Array<CategoryStickyThreadUpdateEvent>
   movedInEvents: Array<ThreadMovedEvent>
+  tags: Array<ForumThreadTag>
   threaddeletedeventthread?: Maybe<Array<ThreadDeletedEvent>>
   threadmoderatedeventthread?: Maybe<Array<ThreadModeratedEvent>>
 }
@@ -3646,6 +3649,7 @@ export type ForumThreadCreateInput = {
   author: Scalars['ID']
   category: Scalars['ID']
   title: Scalars['String']
+  visiblePostsCount: Scalars['Float']
   isSticky: Scalars['Boolean']
   status: Scalars['JSONObject']
 }
@@ -3668,14 +3672,103 @@ export enum ForumThreadOrderByInput {
   CategoryDesc = 'category_DESC',
   TitleAsc = 'title_ASC',
   TitleDesc = 'title_DESC',
+  VisiblePostsCountAsc = 'visiblePostsCount_ASC',
+  VisiblePostsCountDesc = 'visiblePostsCount_DESC',
   IsStickyAsc = 'isSticky_ASC',
   IsStickyDesc = 'isSticky_DESC',
 }
 
+export type ForumThreadTag = BaseGraphQlObject & {
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  threads: Array<ForumThread>
+  /** Number of non-deleted threads currently assigned to the tag */
+  visibleThreadsCount: Scalars['Int']
+}
+
+export type ForumThreadTagConnection = {
+  totalCount: Scalars['Int']
+  edges: Array<ForumThreadTagEdge>
+  pageInfo: PageInfo
+}
+
+export type ForumThreadTagCreateInput = {
+  visibleThreadsCount: Scalars['Float']
+}
+
+export type ForumThreadTagEdge = {
+  node: ForumThreadTag
+  cursor: Scalars['String']
+}
+
+export enum ForumThreadTagOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  VisibleThreadsCountAsc = 'visibleThreadsCount_ASC',
+  VisibleThreadsCountDesc = 'visibleThreadsCount_DESC',
+}
+
+export type ForumThreadTagUpdateInput = {
+  visibleThreadsCount?: Maybe<Scalars['Float']>
+}
+
+export type ForumThreadTagWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  visibleThreadsCount_eq?: Maybe<Scalars['Int']>
+  visibleThreadsCount_gt?: Maybe<Scalars['Int']>
+  visibleThreadsCount_gte?: Maybe<Scalars['Int']>
+  visibleThreadsCount_lt?: Maybe<Scalars['Int']>
+  visibleThreadsCount_lte?: Maybe<Scalars['Int']>
+  visibleThreadsCount_in?: Maybe<Array<Scalars['Int']>>
+  threads_none?: Maybe<ForumThreadWhereInput>
+  threads_some?: Maybe<ForumThreadWhereInput>
+  threads_every?: Maybe<ForumThreadWhereInput>
+  AND?: Maybe<Array<ForumThreadTagWhereInput>>
+  OR?: Maybe<Array<ForumThreadTagWhereInput>>
+}
+
+export type ForumThreadTagWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type ForumThreadUpdateInput = {
   author?: Maybe<Scalars['ID']>
   category?: Maybe<Scalars['ID']>
   title?: Maybe<Scalars['String']>
+  visiblePostsCount?: Maybe<Scalars['Float']>
   isSticky?: Maybe<Scalars['Boolean']>
   status?: Maybe<Scalars['JSONObject']>
 }
@@ -3714,6 +3807,12 @@ export type ForumThreadWhereInput = {
   title_startsWith?: Maybe<Scalars['String']>
   title_endsWith?: Maybe<Scalars['String']>
   title_in?: Maybe<Array<Scalars['String']>>
+  visiblePostsCount_eq?: Maybe<Scalars['Int']>
+  visiblePostsCount_gt?: Maybe<Scalars['Int']>
+  visiblePostsCount_gte?: Maybe<Scalars['Int']>
+  visiblePostsCount_lt?: Maybe<Scalars['Int']>
+  visiblePostsCount_lte?: Maybe<Scalars['Int']>
+  visiblePostsCount_in?: Maybe<Array<Scalars['Int']>>
   isSticky_eq?: Maybe<Scalars['Boolean']>
   isSticky_in?: Maybe<Array<Scalars['Boolean']>>
   status_json?: Maybe<Scalars['JSONObject']>
@@ -3724,15 +3823,18 @@ export type ForumThreadWhereInput = {
   posts_every?: Maybe<ForumPostWhereInput>
   poll?: Maybe<ForumPollWhereInput>
   createdInEvent?: Maybe<ThreadCreatedEventWhereInput>
-  titleUpdates_none?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  titleUpdates_some?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  titleUpdates_every?: Maybe<ThreadTitleUpdatedEventWhereInput>
+  metadataUpdates_none?: Maybe<ThreadMetadataUpdatedEventWhereInput>
+  metadataUpdates_some?: Maybe<ThreadMetadataUpdatedEventWhereInput>
+  metadataUpdates_every?: Maybe<ThreadMetadataUpdatedEventWhereInput>
   madeStickyInEvents_none?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
   madeStickyInEvents_some?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
   madeStickyInEvents_every?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
   movedInEvents_none?: Maybe<ThreadMovedEventWhereInput>
   movedInEvents_some?: Maybe<ThreadMovedEventWhereInput>
   movedInEvents_every?: Maybe<ThreadMovedEventWhereInput>
+  tags_none?: Maybe<ForumThreadTagWhereInput>
+  tags_some?: Maybe<ForumThreadTagWhereInput>
+  tags_every?: Maybe<ForumThreadTagWhereInput>
   threaddeletedeventthread_none?: Maybe<ThreadDeletedEventWhereInput>
   threaddeletedeventthread_some?: Maybe<ThreadDeletedEventWhereInput>
   threaddeletedeventthread_every?: Maybe<ThreadDeletedEventWhereInput>
@@ -10484,6 +10586,9 @@ export type Query = {
   forumPosts: Array<ForumPost>
   forumPostByUniqueInput?: Maybe<ForumPost>
   forumPostsConnection: ForumPostConnection
+  forumThreadTags: Array<ForumThreadTag>
+  forumThreadTagByUniqueInput?: Maybe<ForumThreadTag>
+  forumThreadTagsConnection: ForumThreadTagConnection
   forumThreads: Array<ForumThread>
   forumThreadByUniqueInput?: Maybe<ForumThread>
   forumThreadsConnection: ForumThreadConnection
@@ -10666,15 +10771,15 @@ export type Query = {
   threadDeletedEvents: Array<ThreadDeletedEvent>
   threadDeletedEventByUniqueInput?: Maybe<ThreadDeletedEvent>
   threadDeletedEventsConnection: ThreadDeletedEventConnection
+  threadMetadataUpdatedEvents: Array<ThreadMetadataUpdatedEvent>
+  threadMetadataUpdatedEventByUniqueInput?: Maybe<ThreadMetadataUpdatedEvent>
+  threadMetadataUpdatedEventsConnection: ThreadMetadataUpdatedEventConnection
   threadModeratedEvents: Array<ThreadModeratedEvent>
   threadModeratedEventByUniqueInput?: Maybe<ThreadModeratedEvent>
   threadModeratedEventsConnection: ThreadModeratedEventConnection
   threadMovedEvents: Array<ThreadMovedEvent>
   threadMovedEventByUniqueInput?: Maybe<ThreadMovedEvent>
   threadMovedEventsConnection: ThreadMovedEventConnection
-  threadTitleUpdatedEvents: Array<ThreadTitleUpdatedEvent>
-  threadTitleUpdatedEventByUniqueInput?: Maybe<ThreadTitleUpdatedEvent>
-  threadTitleUpdatedEventsConnection: ThreadTitleUpdatedEventConnection
   upcomingWorkingGroupOpenings: Array<UpcomingWorkingGroupOpening>
   upcomingWorkingGroupOpeningByUniqueInput?: Maybe<UpcomingWorkingGroupOpening>
   upcomingWorkingGroupOpeningsConnection: UpcomingWorkingGroupOpeningConnection
@@ -11135,6 +11240,26 @@ export type QueryForumPostsConnectionArgs = {
   orderBy?: Maybe<Array<ForumPostOrderByInput>>
 }
 
+export type QueryForumThreadTagsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<ForumThreadTagWhereInput>
+  orderBy?: Maybe<Array<ForumThreadTagOrderByInput>>
+}
+
+export type QueryForumThreadTagByUniqueInputArgs = {
+  where: ForumThreadTagWhereUniqueInput
+}
+
+export type QueryForumThreadTagsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<ForumThreadTagWhereInput>
+  orderBy?: Maybe<Array<ForumThreadTagOrderByInput>>
+}
+
 export type QueryForumThreadsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -12352,6 +12477,26 @@ export type QueryThreadDeletedEventsConnectionArgs = {
   orderBy?: Maybe<Array<ThreadDeletedEventOrderByInput>>
 }
 
+export type QueryThreadMetadataUpdatedEventsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<ThreadMetadataUpdatedEventWhereInput>
+  orderBy?: Maybe<Array<ThreadMetadataUpdatedEventOrderByInput>>
+}
+
+export type QueryThreadMetadataUpdatedEventByUniqueInputArgs = {
+  where: ThreadMetadataUpdatedEventWhereUniqueInput
+}
+
+export type QueryThreadMetadataUpdatedEventsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<ThreadMetadataUpdatedEventWhereInput>
+  orderBy?: Maybe<Array<ThreadMetadataUpdatedEventOrderByInput>>
+}
+
 export type QueryThreadModeratedEventsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -12392,26 +12537,6 @@ export type QueryThreadMovedEventsConnectionArgs = {
   orderBy?: Maybe<Array<ThreadMovedEventOrderByInput>>
 }
 
-export type QueryThreadTitleUpdatedEventsArgs = {
-  offset?: Maybe<Scalars['Int']>
-  limit?: Maybe<Scalars['Int']>
-  where?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  orderBy?: Maybe<Array<ThreadTitleUpdatedEventOrderByInput>>
-}
-
-export type QueryThreadTitleUpdatedEventByUniqueInputArgs = {
-  where: ThreadTitleUpdatedEventWhereUniqueInput
-}
-
-export type QueryThreadTitleUpdatedEventsConnectionArgs = {
-  first?: Maybe<Scalars['Int']>
-  after?: Maybe<Scalars['String']>
-  last?: Maybe<Scalars['Int']>
-  before?: Maybe<Scalars['String']>
-  where?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  orderBy?: Maybe<Array<ThreadTitleUpdatedEventOrderByInput>>
-}
-
 export type QueryUpcomingWorkingGroupOpeningsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -15377,6 +15502,139 @@ export type ThreadDeletedEventWhereUniqueInput = {
   id: Scalars['ID']
 }
 
+export type ThreadMetadataUpdatedEvent = BaseGraphQlObject & {
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  /** Hash of the extrinsic which caused the event to be emitted */
+  inExtrinsic?: Maybe<Scalars['String']>
+  /** Blocknumber of the block in which the event was emitted. */
+  inBlock: Scalars['Int']
+  /** Network the block was produced in */
+  network: Network
+  /** Index of event in block from which it was emitted. */
+  indexInBlock: Scalars['Int']
+  thread: ForumThread
+  threadId: Scalars['String']
+  /** New title of the thread */
+  newTitle: Scalars['String']
+}
+
+export type ThreadMetadataUpdatedEventConnection = {
+  totalCount: Scalars['Int']
+  edges: Array<ThreadMetadataUpdatedEventEdge>
+  pageInfo: PageInfo
+}
+
+export type ThreadMetadataUpdatedEventCreateInput = {
+  inExtrinsic?: Maybe<Scalars['String']>
+  inBlock: Scalars['Float']
+  network: Network
+  indexInBlock: Scalars['Float']
+  thread: Scalars['ID']
+  newTitle: Scalars['String']
+}
+
+export type ThreadMetadataUpdatedEventEdge = {
+  node: ThreadMetadataUpdatedEvent
+  cursor: Scalars['String']
+}
+
+export enum ThreadMetadataUpdatedEventOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  InExtrinsicAsc = 'inExtrinsic_ASC',
+  InExtrinsicDesc = 'inExtrinsic_DESC',
+  InBlockAsc = 'inBlock_ASC',
+  InBlockDesc = 'inBlock_DESC',
+  NetworkAsc = 'network_ASC',
+  NetworkDesc = 'network_DESC',
+  IndexInBlockAsc = 'indexInBlock_ASC',
+  IndexInBlockDesc = 'indexInBlock_DESC',
+  ThreadAsc = 'thread_ASC',
+  ThreadDesc = 'thread_DESC',
+  NewTitleAsc = 'newTitle_ASC',
+  NewTitleDesc = 'newTitle_DESC',
+}
+
+export type ThreadMetadataUpdatedEventUpdateInput = {
+  inExtrinsic?: Maybe<Scalars['String']>
+  inBlock?: Maybe<Scalars['Float']>
+  network?: Maybe<Network>
+  indexInBlock?: Maybe<Scalars['Float']>
+  thread?: Maybe<Scalars['ID']>
+  newTitle?: Maybe<Scalars['String']>
+}
+
+export type ThreadMetadataUpdatedEventWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  inExtrinsic_eq?: Maybe<Scalars['String']>
+  inExtrinsic_contains?: Maybe<Scalars['String']>
+  inExtrinsic_startsWith?: Maybe<Scalars['String']>
+  inExtrinsic_endsWith?: Maybe<Scalars['String']>
+  inExtrinsic_in?: Maybe<Array<Scalars['String']>>
+  inBlock_eq?: Maybe<Scalars['Int']>
+  inBlock_gt?: Maybe<Scalars['Int']>
+  inBlock_gte?: Maybe<Scalars['Int']>
+  inBlock_lt?: Maybe<Scalars['Int']>
+  inBlock_lte?: Maybe<Scalars['Int']>
+  inBlock_in?: Maybe<Array<Scalars['Int']>>
+  network_eq?: Maybe<Network>
+  network_in?: Maybe<Array<Network>>
+  indexInBlock_eq?: Maybe<Scalars['Int']>
+  indexInBlock_gt?: Maybe<Scalars['Int']>
+  indexInBlock_gte?: Maybe<Scalars['Int']>
+  indexInBlock_lt?: Maybe<Scalars['Int']>
+  indexInBlock_lte?: Maybe<Scalars['Int']>
+  indexInBlock_in?: Maybe<Array<Scalars['Int']>>
+  thread_eq?: Maybe<Scalars['ID']>
+  thread_in?: Maybe<Array<Scalars['ID']>>
+  newTitle_eq?: Maybe<Scalars['String']>
+  newTitle_contains?: Maybe<Scalars['String']>
+  newTitle_startsWith?: Maybe<Scalars['String']>
+  newTitle_endsWith?: Maybe<Scalars['String']>
+  newTitle_in?: Maybe<Array<Scalars['String']>>
+  thread?: Maybe<ForumThreadWhereInput>
+  AND?: Maybe<Array<ThreadMetadataUpdatedEventWhereInput>>
+  OR?: Maybe<Array<ThreadMetadataUpdatedEventWhereInput>>
+}
+
+export type ThreadMetadataUpdatedEventWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type ThreadModeratedEvent = BaseGraphQlObject & {
   id: Scalars['ID']
   createdAt: Scalars['DateTime']
@@ -15745,139 +16003,6 @@ export type ThreadStatusRemoved = {
   threadDeletedEvent?: Maybe<ThreadDeletedEvent>
 }
 
-export type ThreadTitleUpdatedEvent = BaseGraphQlObject & {
-  id: Scalars['ID']
-  createdAt: Scalars['DateTime']
-  createdById: Scalars['String']
-  updatedAt?: Maybe<Scalars['DateTime']>
-  updatedById?: Maybe<Scalars['String']>
-  deletedAt?: Maybe<Scalars['DateTime']>
-  deletedById?: Maybe<Scalars['String']>
-  version: Scalars['Int']
-  /** Hash of the extrinsic which caused the event to be emitted */
-  inExtrinsic?: Maybe<Scalars['String']>
-  /** Blocknumber of the block in which the event was emitted. */
-  inBlock: Scalars['Int']
-  /** Network the block was produced in */
-  network: Network
-  /** Index of event in block from which it was emitted. */
-  indexInBlock: Scalars['Int']
-  thread: ForumThread
-  threadId: Scalars['String']
-  /** New title of the thread */
-  newTitle: Scalars['String']
-}
-
-export type ThreadTitleUpdatedEventConnection = {
-  totalCount: Scalars['Int']
-  edges: Array<ThreadTitleUpdatedEventEdge>
-  pageInfo: PageInfo
-}
-
-export type ThreadTitleUpdatedEventCreateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock: Scalars['Float']
-  network: Network
-  indexInBlock: Scalars['Float']
-  thread: Scalars['ID']
-  newTitle: Scalars['String']
-}
-
-export type ThreadTitleUpdatedEventEdge = {
-  node: ThreadTitleUpdatedEvent
-  cursor: Scalars['String']
-}
-
-export enum ThreadTitleUpdatedEventOrderByInput {
-  CreatedAtAsc = 'createdAt_ASC',
-  CreatedAtDesc = 'createdAt_DESC',
-  UpdatedAtAsc = 'updatedAt_ASC',
-  UpdatedAtDesc = 'updatedAt_DESC',
-  DeletedAtAsc = 'deletedAt_ASC',
-  DeletedAtDesc = 'deletedAt_DESC',
-  InExtrinsicAsc = 'inExtrinsic_ASC',
-  InExtrinsicDesc = 'inExtrinsic_DESC',
-  InBlockAsc = 'inBlock_ASC',
-  InBlockDesc = 'inBlock_DESC',
-  NetworkAsc = 'network_ASC',
-  NetworkDesc = 'network_DESC',
-  IndexInBlockAsc = 'indexInBlock_ASC',
-  IndexInBlockDesc = 'indexInBlock_DESC',
-  ThreadAsc = 'thread_ASC',
-  ThreadDesc = 'thread_DESC',
-  NewTitleAsc = 'newTitle_ASC',
-  NewTitleDesc = 'newTitle_DESC',
-}
-
-export type ThreadTitleUpdatedEventUpdateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock?: Maybe<Scalars['Float']>
-  network?: Maybe<Network>
-  indexInBlock?: Maybe<Scalars['Float']>
-  thread?: Maybe<Scalars['ID']>
-  newTitle?: Maybe<Scalars['String']>
-}
-
-export type ThreadTitleUpdatedEventWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  inExtrinsic_eq?: Maybe<Scalars['String']>
-  inExtrinsic_contains?: Maybe<Scalars['String']>
-  inExtrinsic_startsWith?: Maybe<Scalars['String']>
-  inExtrinsic_endsWith?: Maybe<Scalars['String']>
-  inExtrinsic_in?: Maybe<Array<Scalars['String']>>
-  inBlock_eq?: Maybe<Scalars['Int']>
-  inBlock_gt?: Maybe<Scalars['Int']>
-  inBlock_gte?: Maybe<Scalars['Int']>
-  inBlock_lt?: Maybe<Scalars['Int']>
-  inBlock_lte?: Maybe<Scalars['Int']>
-  inBlock_in?: Maybe<Array<Scalars['Int']>>
-  network_eq?: Maybe<Network>
-  network_in?: Maybe<Array<Network>>
-  indexInBlock_eq?: Maybe<Scalars['Int']>
-  indexInBlock_gt?: Maybe<Scalars['Int']>
-  indexInBlock_gte?: Maybe<Scalars['Int']>
-  indexInBlock_lt?: Maybe<Scalars['Int']>
-  indexInBlock_lte?: Maybe<Scalars['Int']>
-  indexInBlock_in?: Maybe<Array<Scalars['Int']>>
-  thread_eq?: Maybe<Scalars['ID']>
-  thread_in?: Maybe<Array<Scalars['ID']>>
-  newTitle_eq?: Maybe<Scalars['String']>
-  newTitle_contains?: Maybe<Scalars['String']>
-  newTitle_startsWith?: Maybe<Scalars['String']>
-  newTitle_endsWith?: Maybe<Scalars['String']>
-  newTitle_in?: Maybe<Array<Scalars['String']>>
-  thread?: Maybe<ForumThreadWhereInput>
-  AND?: Maybe<Array<ThreadTitleUpdatedEventWhereInput>>
-  OR?: Maybe<Array<ThreadTitleUpdatedEventWhereInput>>
-}
-
-export type ThreadTitleUpdatedEventWhereUniqueInput = {
-  id: Scalars['ID']
-}
-
 export type UnlockBlogPostProposalDetails = {
   /** The blog post that should be unlocked */
   blogPost: Scalars['String']

+ 6 - 1
tests/integration-tests/src/graphql/queries/forum.graphql

@@ -120,6 +120,8 @@ fragment ForumThreadWithPostsFields on ForumThread {
   isSticky
   createdInEvent {
     id
+    title
+    text
   }
   status {
     __typename
@@ -139,12 +141,15 @@ fragment ForumThreadWithPostsFields on ForumThread {
       }
     }
   }
-  titleUpdates {
+  metadataUpdates {
     id
   }
   movedInEvents {
     id
   }
+  tags {
+    id
+  }
 }
 
 query getCategoriesByIds($ids: [ID!]) {

+ 6 - 4
tests/integration-tests/src/graphql/queries/forumEvents.graphql

@@ -66,6 +66,8 @@ fragment ThreadCreatedEventFields on ThreadCreatedEvent {
   network
   inExtrinsic
   indexInBlock
+  title
+  text
   thread {
     id
   }
@@ -77,7 +79,7 @@ query getThreadCreatedEventsByEventIds($eventIds: [ID!]) {
   }
 }
 
-fragment ThreadTitleUpdatedEventFields on ThreadTitleUpdatedEvent {
+fragment ThreadMetadataUpdatedEventFields on ThreadMetadataUpdatedEvent {
   id
   createdAt
   inBlock
@@ -90,9 +92,9 @@ fragment ThreadTitleUpdatedEventFields on ThreadTitleUpdatedEvent {
   newTitle
 }
 
-query getThreadTitleUpdatedEventsByEventIds($eventIds: [ID!]) {
-  threadTitleUpdatedEvents(where: { id_in: $eventIds }) {
-    ...ThreadTitleUpdatedEventFields
+query getThreadMetadataUpdatedEventsByEventIds($eventIds: [ID!]) {
+  threadMetadataUpdatedEvents(where: { id_in: $eventIds }) {
+    ...ThreadMetadataUpdatedEventFields
   }
 }
 

+ 2 - 0
tests/integration-tests/src/scenarios/forum.ts

@@ -4,12 +4,14 @@ import threads from '../flows/forum/threads'
 import posts from '../flows/forum/posts'
 import moderation from '../flows/forum/moderation'
 import leadOpening from '../flows/working-groups/leadOpening'
+import threadTags from '../flows/forum/threadTags'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job }) => {
   const sudoHireLead = job('hiring working group leads', leadOpening)
   job('forum categories', categories).requires(sudoHireLead)
   job('forum threads', threads).requires(sudoHireLead)
+  job('forum thread tags', threadTags).requires(sudoHireLead)
   job('forum polls', polls).requires(sudoHireLead)
   job('forum posts', posts).requires(sudoHireLead)
   job('forum moderation', moderation).requires(sudoHireLead)

+ 2 - 0
tests/integration-tests/src/scenarios/full.ts

@@ -3,6 +3,7 @@ import polls from '../flows/forum/polls'
 import threads from '../flows/forum/threads'
 import posts from '../flows/forum/posts'
 import moderation from '../flows/forum/moderation'
+import threadTags from '../flows/forum/threadTags'
 import leadOpening from '../flows/working-groups/leadOpening'
 import creatingMemberships from '../flows/membership/creatingMemberships'
 import updatingMemberProfile from '../flows/membership/updatingProfile'
@@ -68,6 +69,7 @@ scenario(async ({ job, env }) => {
   // Forum:
   job('forum categories', categories).requires(sudoHireLead)
   job('forum threads', threads).requires(sudoHireLead)
+  job('forum thread tags', threadTags).requires(sudoHireLead)
   job('forum polls', polls).requires(sudoHireLead)
   job('forum posts', posts).requires(sudoHireLead)
   job('forum moderation', moderation).requires(sudoHireLead)

+ 2 - 1
tests/integration-tests/src/types.ts

@@ -139,6 +139,7 @@ export interface CategoryCreatedEventDetails extends EventDetails {
 
 export interface ThreadCreatedEventDetails extends EventDetails {
   threadId: ThreadId
+  postId: PostId
 }
 
 export interface PostAddedEventDetails extends EventDetails {
@@ -152,7 +153,7 @@ export type ForumEventName =
   | 'ThreadCreated'
   | 'ThreadModerated'
   | 'ThreadUpdated'
-  | 'ThreadTitleUpdated'
+  | 'ThreadMetadataUpdated'
   | 'ThreadDeleted'
   | 'ThreadMoved'
   | 'PostAdded'

+ 42 - 1
types/src/common.ts

@@ -1,4 +1,4 @@
-import { Struct, Option, Text, bool, u16, u32, u64, Null, U8aFixed, BTreeSet, UInt } from '@polkadot/types'
+import { Struct, Option, Text, bool, u16, u32, u64, Null, U8aFixed, BTreeSet, UInt, BTreeMap } from '@polkadot/types'
 import { BlockNumber, Hash as PolkadotHash, Moment } from '@polkadot/types/interfaces'
 import { Codec, Constructor, RegistryTypes } from '@polkadot/types/types'
 // we get 'moment' because it is a dependency of @polkadot/util, via @polkadot/keyring
@@ -6,9 +6,13 @@ import moment from 'moment'
 import { JoyStructCustom, JoyStructDecorated } from './JoyStruct'
 import { JoyEnum } from './JoyEnum'
 import { GenericAccountId } from '@polkadot/types/generic/AccountId'
+import { AbstractArray } from '@polkadot/types/codec/AbstractArray'
+import { AbstractInt } from '@polkadot/types/codec/AbstractInt'
 
 export { JoyEnum, JoyStructCustom, JoyStructDecorated }
 
+// See: https://github.com/polkadot-js/api/issues/3636
+
 // Adds sorting during BTreeSet toU8a encoding (required by the runtime)
 // Currently only supports values that extend UInt
 // FIXME: Will not cover cases where BTreeSet is part of extrinsic args metadata
@@ -29,6 +33,43 @@ export function JoyBTreeSet<V extends UInt>(valType: Constructor<V>): Constructo
   }
 }
 
+export interface ExtendedBTreeMap<K extends Codec, V extends Codec> extends BTreeMap<K, V> {
+  toMap(): Map<K, V>
+}
+
+export function JoyBTreeMap<K extends Codec, V extends Codec>(
+  keyType: Constructor<K>,
+  valType: Constructor<V>
+): Constructor<ExtendedBTreeMap<K, V>> {
+  return class extends BTreeMap.with(keyType, valType) {
+    public forEach(callbackFn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void {
+      const sorted = this.toMap()
+      return new Map(sorted).forEach(callbackFn, thisArg)
+    }
+
+    private sortFn([keyA]: [K, V], [keyB]: [K, V]) {
+      if (keyA instanceof AbstractArray) {
+        let sortRes = 0
+        for (const i in keyA) {
+          sortRes = this.sortFn(keyA[i], (keyB as typeof keyA)[i])
+          if (sortRes !== 0) {
+            return sortRes
+          }
+        }
+        return sortRes
+      } else if (keyA instanceof AbstractInt) {
+        return keyA.eq(keyB) ? 0 : keyA.lt(keyB as typeof keyA) ? -1 : 1
+      } else {
+        throw new Error(`JoyBTreeMap: Unsupported key type: ${keyA.toRawType()}`)
+      }
+    }
+
+    public toMap() {
+      return new Map<K, V>(Array.from(this.entries()).sort(this.sortFn.bind(this)))
+    }
+  }
+}
+
 export class ActorId extends u64 {}
 export class MemberId extends u64 {}
 export class Url extends Text {}

+ 9 - 3
types/src/forum.ts

@@ -1,7 +1,7 @@
-import { bool, u32, u64, Option, Vec, Null, Bytes } from '@polkadot/types'
+import { bool, u32, u64, Option, Vec, Null, Bytes, Tuple } from '@polkadot/types'
 import { Moment } from '@polkadot/types/interfaces'
-import { Hash, ThreadId, PostId, JoyStructDecorated, JoyEnum } from './common'
-import { RegistryTypes } from '@polkadot/types/types'
+import { Hash, ThreadId, PostId, JoyStructDecorated, JoyEnum, JoyBTreeMap } from './common'
+import { Constructor, ITuple, RegistryTypes } from '@polkadot/types/types'
 
 export class ForumUserId extends u64 {}
 export class ModeratorId extends u64 {}
@@ -73,6 +73,12 @@ export class PollInput extends JoyStructDecorated({
   poll_alternatives: Vec.with(PollAlternativeInput),
 }) {}
 
+export class PostIdTuple extends ((Tuple.with([CategoryId, ThreadId, PostId]) as unknown) as Constructor<
+  ITuple<[CategoryId, ThreadId, PostId]>
+>) {}
+
+export class PostsToDeleteMap extends JoyBTreeMap(PostIdTuple, bool) {}
+
 export const forumTypes: RegistryTypes = {
   ForumUserId,
   ModeratorId,