omnisocials 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b3fed727cadc18b6ea190937598241ea06e5ac3451f5a01e47e993f641f5332
4
- data.tar.gz: 6adeeba6ddc69911b79c4b620ddc418e0dd372d6e76e3a417bd285ae54b44e9d
3
+ metadata.gz: 43d0cc71e753d98ddbaa8b1732f140ecfc3d578d02a8e8df81e248c6c9c75131
4
+ data.tar.gz: a66ae890324f9adc7b37b57add4e76ce1be1b110d4b15f751c611bcbfa6adf61
5
5
  SHA512:
6
- metadata.gz: ad4c11a0f91aa25cd0dfab676f0a28b5973d139a82137c0d753de697a453d710bd9f59f6c212596230fe727e37d20e117635d7b23d06f2f6aeeefce3d66b5ffa
7
- data.tar.gz: 1b53388871737de150a7d71387472915b3e1bfcc7c8e245f45e5c3d87ea4ef2ee9d8047855876d33e1028c0b24272accfe663ef482e83bbdc4e60a72fe9268af
6
+ metadata.gz: 0b30af0ebca987fe7bebb4710d7648d2c0361e8709cdf716897d4f1f82808dc916159742dcae7c9c0512863e01f68435d642a9341e8254d9cc11511459b496ce
7
+ data.tar.gz: e50bd7f05fe6a2e516ff9ea31bb28307af242c4118ca1a30583008b88591e3b2d9c080fb50447137133f036e21d6fed8721b90bd380cfb670dc3870868ac7710
data/README.md CHANGED
@@ -208,6 +208,20 @@ client.posts.reject("123", comment: "Wrong CTA link, please fix.") # reject and
208
208
 
209
209
  Only works on a post with `approval_status: "pending"` (`status: "in_approval"`). Both act on behalf of the user who owns the API key, who must be a listed approver for the workflow's CURRENT step — steps approve in order, so being an approver on a later step is not enough yet (raises a 403 `OmniSocials::PermissionDeniedError`). Approving the last step finalizes the post (`scheduled` or `posting`); rejecting stops the whole workflow immediately, not just the current step.
210
210
 
211
+ ### Read the approval review
212
+
213
+ ```ruby
214
+ review = client.posts.get_approval("123")["data"]
215
+ if review["status"] == "rejected" && review["rejection"]
216
+ puts "Rejected by #{review["rejection"]["by"]["name"]}: #{review["rejection"]["reason"]}"
217
+ end
218
+ review["steps"].each do |step|
219
+ puts "#{step["order"]} #{step["name"]} #{step["status"]}"
220
+ end
221
+ ```
222
+
223
+ `get_approval` returns the review of a post that went through an approval workflow: `status` (`none`, `pending`, `approved`, `rejected`), the `workflow`, who requested it and when, `current_step` (the step the post waits on, `nil` when the review ended), every step with its approvers and their decisions, the `rejection` (`by`, `reason`, `at`, `step`; `nil` when nobody rejected) and the `comments` thread, oldest first. A post without an approval workflow returns `status: "none"` with empty `steps` and `comments`. Read-only; needs the `posts:read` scope.
224
+
211
225
  ## Media
212
226
 
213
227
  ### Upload from a URL (recommended, up to 1GB)
@@ -231,7 +245,7 @@ media = client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")
231
245
 
232
246
  `file` accepts a file path (String or Pathname), an IO (`File.open("...", "rb")`, StringIO), or raw bytes as a binary-encoded String (e.g. from `File.binread`).
233
247
 
234
- Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (a native swipeable document on LinkedIn, an image carousel elsewhere).
248
+ Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (on LinkedIn a native swipeable document made from the original PDF file, which is kept so text and links stay intact; an image carousel elsewhere). To keep the PDF as ONE library item instead of one item per page, pass `pdf_mode` = `"document"`: the response then has a single `data` item of type `document` (its page images in `pdf.pages`) and `media_ids` holds that one id, which expands into every page at post time.
235
249
 
236
250
  Every upload response also includes a `compatibility` block listing any connected platforms that would reject the file.
237
251
 
@@ -383,7 +397,7 @@ conversations["data"].each do |conversation|
383
397
  puts "#{conversation["platform"]}: #{conversation["preview"]}"
384
398
  end
385
399
 
386
- conversation_id = conversations["data"][0]["id"]
400
+ conversation_id = conversations["data"][0]["conversation_id"]
387
401
  messages = client.inbox.get_messages(conversation_id)
388
402
  messages["data"].each do |message|
389
403
  puts "#{message["direction"]}: #{message["text"]}" # direction is "incoming" or "outgoing"
@@ -401,9 +415,9 @@ client.inbox.hide(message_id, hide: false) # unhide
401
415
  client.inbox.delete_message(message_id)
402
416
  ```
403
417
 
404
- `platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, `"youtube"`, `"x"`, or `"threads"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. Threads conversations are comments (replies people leave on your Threads posts) and mentions; there are no Threads DMs. Comments can be hidden on Facebook, Instagram, TikTok, YouTube, and Threads (Threads: incoming top-level replies only), and a hidden message keeps its place in the conversation with its `hidden` flag set; `hidden` is `true`/`false` on comments and `nil` on DMs. A comment/mention's `post` carries `url` (public link when the platform provides one) and `media_type` (the platform's own label) next to `id`, `caption`, and `thumbnail`. Threads inbox is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error, and it needs a Threads connection with the reply permission (a 401 `reauth_required` means reconnect Threads). TikTok and YouTube replies are comments only; TikTok replies are capped at 150 characters. Conversation ids are URL-encoded for you, so pass them exactly as returned - LinkedIn ids contain `":"` and `"()"` (e.g. `"linkedin_comment_urn:li:activity:123"`).
418
+ `platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, `"youtube"`, `"x"`, or `"threads"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. Threads conversations are comments (replies people leave on your Threads posts) and mentions; there are no Threads DMs. Comments can be hidden on Facebook, Instagram, TikTok, YouTube, and Threads (Threads: incoming top-level replies only), and a hidden message keeps its place in the conversation with its `hidden` flag set; `hidden` is `true`/`false` on comments and `nil` on DMs. A comment/mention's `post` carries `url` (public link when the platform provides one) and `media_type` (the platform's own label) next to `id`, `caption`, and `thumbnail`. The Threads inbox needs a Threads connection with the reply permission (a 401 `reauth_required` means reconnect Threads; an account connected before 2026-09-14 needs this once). TikTok and YouTube replies are comments only; TikTok replies are capped at 150 characters. Conversation ids are URL-encoded for you, so pass them exactly as returned - LinkedIn ids contain `":"` and `"()"` (e.g. `"linkedin_comment_urn:li:activity:123"`).
405
419
 
406
- `next` hands out the next conversation that still needs a reply (the customer's latest DM with no reply after it, or an unreplied comment/mention that is not hidden), with the whole thread and the post it belongs to, so a reply can be drafted from one call. Replies typed in the native apps count as answers. Only unread items are served by default, so `mark_read` is the durable way to skip one; `exclude` skips conversation ids for the current session only. Pass `include_next: true` to `reply` to get the following item in the same response. `list_conversations(unanswered: true)` gives the same set as a plain list.
420
+ `next` hands out the next conversation that still needs a reply (the customer's latest DM with no reply after it, or an unreplied comment/mention that is not hidden), with the whole thread and the post it belongs to, so a reply can be drafted from one call. DMs that can still be answered come first, then Instagram/Facebook DMs whose 24-hour window has closed (`reply_window["open"]` is `false`: answer those from the native app or mark them read), then comments and mentions, oldest first. Replies typed in the native apps count as answers. Only unread items are served by default, so `mark_read` is the durable way to skip one; `exclude` skips conversation ids for the current session only. Always pass `message_id: message["id"]` to `reply` on comment threads: every comment on a post shares one conversation, and without it the reply goes under the newest comment on the post. Pass `include_next: true` to `reply` to get the following item in the same response. `list_conversations(unanswered: true)` gives the same set as a plain list.
407
421
 
408
422
  ```ruby
409
423
  item = client.inbox.next(platform: "instagram")
@@ -411,7 +425,20 @@ while item["data"]
411
425
  message = item["data"]["message"]
412
426
  puts "#{item["remaining"]} left. #{message["sender"]["username"]}: #{message["text"]}"
413
427
 
414
- reply = client.inbox.reply(message["conversation_id"], text: "Thanks! DM sent.", include_next: true)
428
+ unless item["data"]["reply_window"]["open"]
429
+ # An Instagram/Facebook DM past Meta's 24-hour window: reply would raise
430
+ # 422 outside_messaging_window. Answer it in the app, or skip it.
431
+ client.inbox.mark_read(message["conversation_id"])
432
+ item = client.inbox.next(platform: "instagram")
433
+ next
434
+ end
435
+
436
+ reply = client.inbox.reply(
437
+ message["conversation_id"],
438
+ text: "Thanks! DM sent.",
439
+ message_id: message["id"], # the comment being answered, not the newest one
440
+ include_next: true
441
+ )
415
442
  item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
416
443
  end
417
444
  ```
@@ -437,7 +464,7 @@ end
437
464
 
438
465
  ## Webhooks
439
466
 
440
- Subscribe to `post.scheduled`, `post.published`, and `post.failed` events:
467
+ Subscribe to `post.scheduled`, `post.published`, `post.failed`, `post.approved`, and `post.rejected` events. `post.approved` fires when the last step of a post's approval workflow is approved, `post.rejected` when an approver rejects the post (it will not publish). These two carry `data["approval"]` with `status`, `decided_by` (the approver's user id) and `reason` (`nil` on `post.approved`), and an empty `data["targets"]`.
441
468
 
442
469
  ```ruby
443
470
  webhook = client.webhooks.create(
@@ -480,6 +507,8 @@ class OmnisocialsWebhooksController < ApplicationController
480
507
  event["data"]["targets"].each do |target|
481
508
  Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
482
509
  end
510
+ elsif event["type"] == "post.rejected"
511
+ Rails.logger.info "Rejected: #{event["data"]["post_id"]} #{event["data"]["approval"]["reason"]}"
483
512
  end
484
513
 
485
514
  head :ok
@@ -26,12 +26,14 @@ module OmniSocials
26
26
  # "linkedin", "tiktok", "youtube", "x", "threads"), type ("dm", "comment",
27
27
  # "mention"), unread (only conversations with unread messages),
28
28
  # unanswered (only conversations that still need an answer: the
29
- # customer's latest DM has no reply after it, for Instagram/Facebook DMs
30
- # within the 24-hour messaging window only, or a comment/mention that
31
- # has not been replied to and is not hidden; replies typed in the native
32
- # apps count as answers, and read state is ignored, so use `next` for a
33
- # work queue), limit (1-100), and cursor (an opaque cursor from a
34
- # previous response's pagination["next_cursor"]).
29
+ # customer's latest DM has no reply after it, Instagram/Facebook DMs
30
+ # past Meta's 24-hour messaging window included (they cannot be answered
31
+ # through the API, but the customer is still waiting), or a
32
+ # comment/mention that has not been replied to and is not hidden;
33
+ # replies typed in the native apps count as answers, and read state is
34
+ # ignored, so use `next` for a work queue), limit (1-100), and cursor
35
+ # (an opaque cursor from a previous response's
36
+ # pagination["next_cursor"]).
35
37
  #
36
38
  # Threads conversations are type "comment" (replies people leave on the
37
39
  # user's Threads posts; conversation ids look like
@@ -91,17 +93,33 @@ module OmniSocials
91
93
  # in the dashboard to resume; DMs that arrived while suspended are
92
94
  # not recovered).
93
95
  #
96
+ # On comment and mention threads, pass message_id: (the "id" of the
97
+ # comment being answered: message["id"] from `next`, or a message "id"
98
+ # from `get_messages`). Every comment on a post shares one
99
+ # conversation, so without it the reply is posted under the newest
100
+ # comment on the post, which may be a different person than the one you
101
+ # drafted for. Ignored for DMs. 404 "not_found" when it is not an
102
+ # incoming message of this conversation.
103
+ #
104
+ # Instagram and Facebook DMs can only be answered within 24 hours of
105
+ # the customer's last message (Meta policy). That is checked before the
106
+ # send: a closed window raises 422 "outside_messaging_window" and
107
+ # nothing is sent (`next` reports the same in "reply_window"). Answer
108
+ # such a DM from the Instagram or Facebook app (mirrored into the
109
+ # inbox) or mark the conversation read; do not retry.
110
+ #
94
111
  # Pass include_next: true to also get "next" (the next conversation
95
112
  # that needs an answer, the same object `next` returns under "data",
96
113
  # using its default queue order and filters; nil when nothing is
97
114
  # waiting) and "remaining" in the response. Saves the extra call when
98
115
  # working through the inbox.
99
- def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, include_next: nil)
116
+ def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, message_id: nil, include_next: nil)
100
117
  body = Internal.drop_nil(
101
118
  {
102
119
  "text" => text,
103
120
  "attachment_url" => attachment_url,
104
121
  "attachment_type" => attachment_type,
122
+ "message_id" => message_id,
105
123
  "include_next" => include_next
106
124
  }
107
125
  )
@@ -133,9 +151,15 @@ module OmniSocials
133
151
  # "not_found" (message not in this workspace) or
134
152
  # "account_not_connected", 429 "quota_exceeded" (YouTube's daily API
135
153
  # quota is used up; retry after midnight Pacific), 502 "platform_error"
136
- # (the platform rejected the call). Threads inbox is currently rolling
137
- # out; until Meta approves the permissions it is disabled on production
138
- # and Threads calls return a clear error.
154
+ # (the platform rejected the call), 502 "hide_not_applied" (Instagram
155
+ # accepted the call but, read back, still reports the comment in its
156
+ # old state; this happens with comments Instagram shows under "Comments
157
+ # from Facebook" on a reel that is also shared to Facebook, which live
158
+ # on Facebook where Instagram's hide does not reach them; the inbox row
159
+ # is left unchanged, so hide it in the Instagram or Facebook app and do
160
+ # not retry). A Threads account connected before 2026-09-14 needs a
161
+ # one-time reconnect; until then a Threads hide answers 401
162
+ # "reauth_required".
139
163
  def hide(message_id, hide: true)
140
164
  @client.request(
141
165
  "POST", "/inbox/messages/#{encode_id(message_id)}/hide",
@@ -165,13 +189,17 @@ module OmniSocials
165
189
  end
166
190
 
167
191
  # GET /inbox/next - the next conversation that needs an answer: a work
168
- # queue for answering the inbox. Returns the oldest (by default) item
169
- # that still needs a reply, together with its conversation so far and
170
- # the post it belongs to, so a reply can be drafted from one call. An
171
- # item needs an answer when it is the customer's latest DM with no
172
- # reply after it (Instagram/Facebook DMs within the 24-hour messaging
173
- # window only, since Meta refuses replies outside it), or a
192
+ # queue for answering the inbox. Returns one item that still needs a
193
+ # reply, together with its conversation so far and the post it belongs
194
+ # to, so a reply can be drafted from one call. An item needs an answer
195
+ # when it is the customer's latest DM with no reply after it, or a
174
196
  # comment/mention that has not been replied to and is not hidden.
197
+ # Order: DMs that can still be answered come first (Instagram/Facebook
198
+ # DMs inside Meta's 24-hour window, the one whose window closes soonest
199
+ # first, and X DMs), then Instagram/Facebook DMs whose window has
200
+ # closed (served with "reply_window"["open"] false: answer them from
201
+ # the native app or mark them read), then comments and mentions, oldest
202
+ # first by default.
175
203
  # Replies typed in the native apps count as answers (they are mirrored
176
204
  # into the inbox), so a thread a colleague answered on their phone is
177
205
  # not served again. Instagram mentions are skipped (no reply path).
@@ -182,16 +210,21 @@ module OmniSocials
182
210
  # include read-but-unanswered items. exclude is a session-local skip:
183
211
  # conversation ids (an Array, or a comma-separated String) to leave out
184
212
  # of this call, up to 100. order is "oldest" (default: the item that
185
- # has waited longest first) or "newest". platform and type ("dm",
186
- # "comment", "mention") narrow the queue.
213
+ # has waited longest first) or "newest"; it reverses the order within
214
+ # each group. platform and type ("dm", "comment", "mention") narrow the
215
+ # queue.
187
216
  #
188
217
  # Returns { "data" => ..., "remaining" => Integer }. "data" is
189
- # { "conversation", "message", "messages" }, or nil when nothing is
190
- # waiting. "message" is the unanswered incoming item itself (the
191
- # customer's latest DM, or the specific comment): its "id" is what
192
- # `hide` and `delete_message` take, its "conversation_id" is what
193
- # `reply` takes. "messages" is the conversation so far, oldest first
194
- # (the most recent 50 messages for long DM threads). "remaining" is the
218
+ # { "conversation", "message", "messages", "reply_window" }, or nil
219
+ # when nothing is waiting. "message" is the unanswered incoming item
220
+ # itself (the customer's latest DM, or the specific comment): its "id"
221
+ # is what `hide` and `delete_message` take and the message_id: to pass
222
+ # to `reply` on comment threads, its "conversation_id" is what `reply`
223
+ # takes. "messages" is the conversation so far, oldest first (the most
224
+ # recent 50 messages for long DM threads). "reply_window" is
225
+ # { "open" => Boolean, "closes_at" => String|nil }: "open" is false
226
+ # only for an Instagram/Facebook DM past its 24-hour window, which
227
+ # `reply` refuses with 422 "outside_messaging_window". "remaining" is the
195
228
  # number of unanswered items still waiting after this one (capped at
196
229
  # 500), 0 when "data" is nil. To chain the queue, pass
197
230
  # include_next: true to `reply` and it returns the next item in the
@@ -32,13 +32,15 @@ module OmniSocials
32
32
  # binary-encoded String, e.g. from File.binread).
33
33
  #
34
34
  # A PDF is split into image slides and the response carries `slides`
35
- # plus a `media_ids` array instead of a single `data` item. For files
35
+ # plus a `media_ids` array instead of a single `data` item; pass
36
+ # pdf_mode: "document" to keep it as ONE item of type "document" whose
37
+ # single id in media_ids expands into every page. For files
36
38
  # over 100MB use upload_from_url (up to 1GB) or the create_upload_url
37
39
  # presigned flow.
38
- def upload(file:, filename: nil, name: nil, folder: nil, folder_id: nil)
40
+ def upload(file:, filename: nil, name: nil, folder: nil, folder_id: nil, pdf_mode: nil)
39
41
  upload_name, data = Internal.coerce_file(file, filename)
40
42
  fields = Internal.drop_nil(
41
- { "name" => name, "folder" => folder, "folder_id" => folder_id }
43
+ { "name" => name, "folder" => folder, "folder_id" => folder_id, "pdf_mode" => pdf_mode }
42
44
  )
43
45
  @client.request(
44
46
  "POST", "/media/upload",
@@ -50,14 +52,15 @@ module OmniSocials
50
52
  #
51
53
  # Files over 100MB are streamed in the background: the response has
52
54
  # data["status"] == "processing"; poll get() until it is "ready".
53
- def upload_from_url(url:, filename: nil, name: nil, folder: nil, folder_id: nil)
55
+ def upload_from_url(url:, filename: nil, name: nil, folder: nil, folder_id: nil, pdf_mode: nil)
54
56
  body = Internal.drop_nil(
55
57
  {
56
58
  "url" => url,
57
59
  "filename" => filename,
58
60
  "name" => name,
59
61
  "folder" => folder,
60
- "folder_id" => folder_id
62
+ "folder_id" => folder_id,
63
+ "pdf_mode" => pdf_mode
61
64
  }
62
65
  )
63
66
  @client.request("POST", "/media/upload-from-url", json: body)
@@ -66,7 +69,7 @@ module OmniSocials
66
69
  # POST /media/upload-from-base64 - upload base64-encoded data (without
67
70
  # a data URI prefix).
68
71
  def upload_from_base64(data:, mime_type:, filename: nil, name: nil,
69
- folder: nil, folder_id: nil)
72
+ folder: nil, folder_id: nil, pdf_mode: nil)
70
73
  body = Internal.drop_nil(
71
74
  {
72
75
  "data" => data,
@@ -74,7 +77,8 @@ module OmniSocials
74
77
  "filename" => filename,
75
78
  "name" => name,
76
79
  "folder" => folder,
77
- "folder_id" => folder_id
80
+ "folder_id" => folder_id,
81
+ "pdf_mode" => pdf_mode
78
82
  }
79
83
  )
80
84
  @client.request("POST", "/media/upload-from-base64", json: body)
@@ -238,6 +238,22 @@ module OmniSocials
238
238
  @client.request("POST", "/posts/#{post_id}/reject", json: body)
239
239
  end
240
240
 
241
+ # GET /posts/{id}/approval - the approval review of a post: every step
242
+ # with its approvers and their decisions, the rejection with its
243
+ # reason, and the comment thread. Use it when approval_status is
244
+ # "rejected" to learn who rejected the post and why, or while it is
245
+ # "pending" to see who the post waits for.
246
+ #
247
+ # `data` carries post_id, status ("none", "pending", "approved",
248
+ # "rejected"), workflow, requested_by, requested_at, current_step,
249
+ # steps, rejection and comments (oldest first). A post without an
250
+ # approval workflow returns status "none" with the object fields nil
251
+ # and empty steps and comments. Read-only; requires the posts:read
252
+ # scope.
253
+ def get_approval(post_id)
254
+ @client.request("GET", "/posts/#{post_id}/approval")
255
+ end
256
+
241
257
  private
242
258
 
243
259
  def create_body(content:, channels:, scheduled_at:, media_ids:,
@@ -3,7 +3,7 @@
3
3
  module OmniSocials
4
4
  module Resources
5
5
  # Webhooks resource: manage event subscriptions (post.scheduled,
6
- # post.published, post.failed).
6
+ # post.published, post.failed, post.approved, post.rejected).
7
7
  #
8
8
  # For verifying incoming deliveries, see OmniSocials::Webhooks.verify.
9
9
  class Webhooks
@@ -24,8 +24,9 @@ module OmniSocials
24
24
  # POST /webhooks - create a webhook subscription.
25
25
  #
26
26
  # `url` must be HTTPS. `events` is a non-empty subset of
27
- # post.scheduled, post.published, post.failed. The signing `secret` is
28
- # only returned once, in this response - store it.
27
+ # post.scheduled, post.published, post.failed, post.approved,
28
+ # post.rejected. The signing `secret` is only returned once, in this
29
+ # response - store it.
29
30
  def create(url:, events:)
30
31
  @client.request(
31
32
  "POST", "/webhooks",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniSocials
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnisocials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OmniSocials
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Schedule and publish social media posts, upload media, and read analytics
14
14
  across Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Bluesky, Threads,