diff --git a/public/registry/index.json b/public/registry/index.json index 86f4a53..e1115c3 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-08-11T04:14:55.998Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "plugins": { - "count": 24, - "updatedAt": "2026-08-11T04:14:55.998Z" + "count": 26, + "updatedAt": "2026-08-12T04:29:41.587Z" }, "themes": { "count": 54, - "updatedAt": "2026-08-11T04:14:55.998Z" + "updatedAt": "2026-08-12T04:29:41.587Z" }, "widgets": { "count": 45, - "updatedAt": "2026-08-11T04:14:55.998Z" + "updatedAt": "2026-08-12T04:29:41.587Z" }, "skills": { "count": 29, - "updatedAt": "2026-08-11T04:14:55.998Z" + "updatedAt": "2026-08-12T04:29:41.587Z" }, "queries": { "count": 24, - "updatedAt": "2026-08-11T04:14:55.998Z" + "updatedAt": "2026-08-12T04:29:41.587Z" } } diff --git a/public/registry/plugins.json b/public/registry/plugins.json index 0af99d4..24662c9 100644 --- a/public/registry/plugins.json +++ b/public/registry/plugins.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-11T04:14:55.998Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "plugins": [ { "id": "plugin-smith", @@ -21,7 +21,7 @@ "apiUrl": "https://store.notedeck.io/registry/plugins/plugin-smith/api.json", "sha512": "e3c15224e18886a0021380b7f001d58383421958b1128384fc2d25749cb9e82bab527a7cf5042222051f6ee434111bdee8efe7b89f305d902cbe15b4592748ad", "createdAt": "2026-05-12T11:12:35+09:00", - "updatedAt": "2026-07-20T23:42:10+09:00", + "updatedAt": "2026-08-11T13:12:16+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "permissions": [ @@ -382,6 +382,35 @@ ], "iconUrl": "https://store.notedeck.io/registry/plugins/block-combo/icon.svg" }, + { + "id": "reaction-piggyback", + "name": "リアクション相乗り", + "version": "1.0.0", + "author": "hitalin", + "description": "ノートに既に付いているリアクションと同じ絵文字で相乗りします。リモートのカスタム絵文字は自鯖の同名絵文字に読み替えます", + "category": "utility", + "capabilities": [ + "misskey-api", + "misskey-account" + ], + "tags": [ + "emoji", + "reaction", + "custom-emoji", + "remote" + ], + "sourceUrl": "https://store.notedeck.io/registry/plugins/reaction-piggyback/plugin.is", + "apiUrl": "https://store.notedeck.io/registry/plugins/reaction-piggyback/api.json", + "sha512": "f237acfb9f0532b64caecfdd1817691810064d8a844e613ac36269e38577a1a64d71903261aa0952dbfcb284c27e64dff224356629a7cfdff612324c5a8a507c", + "createdAt": "2026-08-12T04:29:41.822Z", + "updatedAt": "2026-08-12T04:29:41.822Z", + "authorUrl": "https://github.com/hitalin", + "license": "MIT", + "permissions": [ + "write:reactions" + ], + "iconUrl": "https://store.notedeck.io/registry/plugins/reaction-piggyback/icon.svg" + }, { "id": "kakuyomu-ruby", "name": "ルビ振り", @@ -405,6 +434,35 @@ "license": "MIT", "iconUrl": "https://store.notedeck.io/registry/plugins/kakuyomu-ruby/icon.svg" }, + { + "id": "same-emoji-react", + "name": "同じ絵文字でリアクション", + "version": "1.0.0", + "author": "hitalin", + "description": "ノートに含まれるカスタム絵文字や Unicode 絵文字 (Twemoji) を拾い、同じ絵文字でリアクションします", + "category": "utility", + "capabilities": [ + "misskey-api", + "misskey-account" + ], + "tags": [ + "emoji", + "reaction", + "custom-emoji", + "twemoji" + ], + "sourceUrl": "https://store.notedeck.io/registry/plugins/same-emoji-react/plugin.is", + "apiUrl": "https://store.notedeck.io/registry/plugins/same-emoji-react/api.json", + "sha512": "5c6deb32aaf324f5a0699f99f88a6d481b96f71e0e1b77932f13f60b264faf0b4b923e657caa78328c25042c3b0e8d464428d097bb6a316bf1d301f9cdae567e", + "createdAt": "2026-08-12T13:21:58+09:00", + "updatedAt": "2026-08-12T13:21:58+09:00", + "authorUrl": "https://github.com/hitalin", + "license": "MIT", + "permissions": [ + "write:reactions" + ], + "iconUrl": "https://store.notedeck.io/registry/plugins/same-emoji-react/icon.svg" + }, { "id": "snippet-palette", "name": "定型文パレット", diff --git a/public/registry/plugins/reaction-piggyback/api.json b/public/registry/plugins/reaction-piggyback/api.json new file mode 100644 index 0000000..763c72a --- /dev/null +++ b/public/registry/plugins/reaction-piggyback/api.json @@ -0,0 +1 @@ +{"type":"plugin","data":"/// @ 1.2.1\n\n// リアクション相乗り — 既に付いているリアクションと同じ絵文字で乗る\n//\n// ノートに付いているリアクションのうち、自分のサーバーでもそのまま使える\n// ものだけを候補にして順に確認する。リモートのカスタム絵文字\n// (:hoge@example.com:) は自鯖に同名の :hoge: があるときだけ、その :hoge: に\n// 読み替えて候補にする (他鯖の絵文字はそのままリアクションに使えないため)。\n// 候補は付いている数の多い順。絵文字一覧は起動中 1 回だけ取得してキャッシュする。\n\n### {\n\tname: \"リアクション相乗り\"\n\tversion: \"1.0.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"ノートに既に付いているリアクションと同じ絵文字でリアクションします。リモートのカスタム絵文字は自鯖の同名絵文字に読み替えます\"\n\tpermissions: [\"write:reactions\"]\n\tconfig: {\n\t\tuseUnicodeEmoji: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"Unicode 絵文字 (Twemoji) のリアクションも候補にする\"\n\t\t\tdefault: true\n\t\t}\n\t\tmaxCandidates: {\n\t\t\ttype: \"number\"\n\t\t\tlabel: \"確認する候補の最大数\"\n\t\t\tdefault: 8\n\t\t}\n\t}\n}\n\nlet TITLE = \"リアクション相乗り\"\n\n// Misskey Web の Mk:api は失敗時に throw せずエラー値を返す。\n// notes/reactions/create は成功時 204 (= null) なので obj 判定では弾けない\n@failed(res) {\n\tCore:type(res) == \"error\"\n}\n\n// 自鯖のカスタム絵文字名の索引を起動中キャッシュ (取得できなければ null)\nvar localNames = null\n\n@getLocalNames() {\n\tif (localNames == null) {\n\t\tlet res = Mk:api(\"emojis\", {})\n\t\tif (failed(res) || Core:type(res) != \"obj\" || !Obj:has(res, \"emojis\")) return null\n\t\tvar names = {}\n\t\teach (let e, res.emojis) {\n\t\t\tObj:set(names, e.name, true)\n\t\t}\n\t\tlocalNames = names\n\t}\n\tlocalNames\n}\n\n// リアクション文字列からカスタム絵文字名を取り出す\n// \":hoge:\" / \":hoge@example.com:\" → \"hoge\"、Unicode 絵文字なら null\n@customEmojiName(reaction) {\n\tif (reaction.len < 3) return null\n\tif (!reaction.starts_with(\":\")) return null\n\tif (!reaction.ends_with(\":\")) return null\n\tlet name = reaction.slice(1, reaction.len - 1).split(\"@\")[0]\n\tif (name == \"\") null else name\n}\n\n// 付いているリアクションを、自鯖で使える形の候補 [{ reaction, count }] に畳む。\n// available が null (絵文字一覧を取れなかった) ときはカスタム絵文字を諦める\n@buildCandidates(reactions, useUnicodeEmoji, available) {\n\tvar list = []\n\tvar seen = {} // reaction → list 内の位置\n\teach (let key, Obj:keys(reactions)) {\n\t\tlet count = Obj:get(reactions, key)\n\t\tlet name = customEmojiName(key)\n\t\tvar reaction = null\n\t\tif (name == null) {\n\t\t\t// カスタム絵文字として読めないのにコロンを含むキーは壊れているので捨てる\n\t\t\tif (useUnicodeEmoji && !key.incl(\":\")) reaction = key\n\t\t} elif (available != null && Obj:has(available, name)) {\n\t\t\t// :hoge@example.com: も自鯖に hoge があれば :hoge: として乗れる\n\t\t\treaction = `:{name}:`\n\t\t}\n\t\tif (reaction != null) {\n\t\t\tif (Obj:has(seen, reaction)) {\n\t\t\t\t// :hoge: と :hoge@example.com: が混在していたら数をまとめる\n\t\t\t\tlet at = Obj:get(seen, reaction)\n\t\t\t\tlet entry = list[at]\n\t\t\t\tentry.count = entry.count + count\n\t\t\t} else {\n\t\t\t\tObj:set(seen, reaction, list.len)\n\t\t\t\tlist.push({ reaction: reaction, count: count })\n\t\t\t}\n\t\t}\n\t}\n\tlist.sort(@(a, b) { b.count - a.count })\n}\n\nPlugin:register_note_action(TITLE, @(note) {\n\tif (Obj:has(note, \"myReaction\") && note.myReaction != null) {\n\t\tMk:dialog(TITLE, `このノートには既に {note.myReaction} でリアクションしています`, \"info\")\n\t\treturn null\n\t}\n\n\tvar reactions = {}\n\tif (Obj:has(note, \"reactions\") && note.reactions != null) reactions = note.reactions\n\tif (Obj:keys(reactions).len == 0) {\n\t\tMk:dialog(TITLE, \"このノートにはまだリアクションが付いていません\", \"info\")\n\t\treturn null\n\t}\n\n\t// カスタム絵文字のリアクションが 1 つも無ければ絵文字一覧は要らない\n\tvar needsEmojiList = false\n\teach (let key, Obj:keys(reactions)) {\n\t\tif (customEmojiName(key) != null) needsEmojiList = true\n\t}\n\tvar available = null\n\tif (needsEmojiList) {\n\t\tavailable = getLocalNames()\n\t\tif (available == null) {\n\t\t\tMk:dialog(TITLE, \"絵文字一覧を取得できなかったので、カスタム絵文字は候補から外します\", \"warning\")\n\t\t}\n\t}\n\n\tlet candidates = buildCandidates(reactions, Plugin:config.useUnicodeEmoji, available)\n\tif (candidates.len == 0) {\n\t\tMk:dialog(TITLE, \"相乗りできる絵文字がありません (自鯖に無いリモート絵文字だけです)\", \"info\")\n\t\treturn null\n\t}\n\n\tlet limit = Math:min(candidates.len, Math:max(Plugin:config.maxCandidates, 1))\n\tvar i = 0\n\tloop {\n\t\tif (i >= limit) break\n\t\tlet c = candidates[i]\n\t\tlet head = if (limit == 1) \"\" else `({i + 1}/{limit}) `\n\t\tif (Mk:confirm(TITLE, `{head}{c.reaction} ({c.count}) に相乗りしますか?`)) {\n\t\t\tlet res = Mk:api(\"notes/reactions/create\", { noteId: note.id, reaction: c.reaction })\n\t\t\tif (failed(res)) {\n\t\t\t\tMk:dialog(TITLE, \"リアクションできませんでした (アクセス許可が未付与か、既にリアクション済みの可能性があります)\", \"error\")\n\t\t\t} else {\n\t\t\t\tMk:toast(`{c.reaction} に相乗りしました`, \"success\")\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\ti = i + 1\n\t}\n})\n"} diff --git a/public/registry/plugins/reaction-piggyback/icon.svg b/public/registry/plugins/reaction-piggyback/icon.svg new file mode 100644 index 0000000..38e7e1d --- /dev/null +++ b/public/registry/plugins/reaction-piggyback/icon.svg @@ -0,0 +1,14 @@ + + + diff --git a/public/registry/plugins/reaction-piggyback/meta.json b/public/registry/plugins/reaction-piggyback/meta.json new file mode 100644 index 0000000..315ef58 --- /dev/null +++ b/public/registry/plugins/reaction-piggyback/meta.json @@ -0,0 +1,14 @@ +{ + "id": "reaction-piggyback", + "name": "リアクション相乗り", + "version": "1.0.0", + "author": "hitalin", + "authorUrl": "https://github.com/hitalin", + "description": "ノートに既に付いているリアクションと同じ絵文字で相乗りします。リモートのカスタム絵文字は自鯖の同名絵文字に読み替えます", + "license": "MIT", + "category": "utility", + "capabilities": ["misskey-api", "misskey-account"], + "tags": ["emoji", "reaction", "custom-emoji", "remote"], + "permissions": ["write:reactions"], + "minNoteDeckVersion": "1.8.0" +} diff --git a/public/registry/plugins/reaction-piggyback/plugin.is b/public/registry/plugins/reaction-piggyback/plugin.is new file mode 100644 index 0000000..6c2ac3c --- /dev/null +++ b/public/registry/plugins/reaction-piggyback/plugin.is @@ -0,0 +1,145 @@ +/// @ 1.2.1 + +// リアクション相乗り — 既に付いているリアクションと同じ絵文字で乗る +// +// ノートに付いているリアクションのうち、自分のサーバーでもそのまま使える +// ものだけを候補にして順に確認する。リモートのカスタム絵文字 +// (:hoge@example.com:) は自鯖に同名の :hoge: があるときだけ、その :hoge: に +// 読み替えて候補にする (他鯖の絵文字はそのままリアクションに使えないため)。 +// 候補は付いている数の多い順。絵文字一覧は起動中 1 回だけ取得してキャッシュする。 + +### { + name: "リアクション相乗り" + version: "1.0.0" + author: "@hitalin" + description: "ノートに既に付いているリアクションと同じ絵文字でリアクションします。リモートのカスタム絵文字は自鯖の同名絵文字に読み替えます" + permissions: ["write:reactions"] + config: { + useUnicodeEmoji: { + type: "boolean" + label: "Unicode 絵文字 (Twemoji) のリアクションも候補にする" + default: true + } + maxCandidates: { + type: "number" + label: "確認する候補の最大数" + default: 8 + } + } +} + +let TITLE = "リアクション相乗り" + +// Misskey Web の Mk:api は失敗時に throw せずエラー値を返す。 +// notes/reactions/create は成功時 204 (= null) なので obj 判定では弾けない +@failed(res) { + Core:type(res) == "error" +} + +// 自鯖のカスタム絵文字名の索引を起動中キャッシュ (取得できなければ null) +var localNames = null + +@getLocalNames() { + if (localNames == null) { + let res = Mk:api("emojis", {}) + if (failed(res) || Core:type(res) != "obj" || !Obj:has(res, "emojis")) return null + var names = {} + each (let e, res.emojis) { + Obj:set(names, e.name, true) + } + localNames = names + } + localNames +} + +// リアクション文字列からカスタム絵文字名を取り出す +// ":hoge:" / ":hoge@example.com:" → "hoge"、Unicode 絵文字なら null +@customEmojiName(reaction) { + if (reaction.len < 3) return null + if (!reaction.starts_with(":")) return null + if (!reaction.ends_with(":")) return null + let name = reaction.slice(1, reaction.len - 1).split("@")[0] + if (name == "") null else name +} + +// 付いているリアクションを、自鯖で使える形の候補 [{ reaction, count }] に畳む。 +// available が null (絵文字一覧を取れなかった) ときはカスタム絵文字を諦める +@buildCandidates(reactions, useUnicodeEmoji, available) { + var list = [] + var seen = {} // reaction → list 内の位置 + each (let key, Obj:keys(reactions)) { + let count = Obj:get(reactions, key) + let name = customEmojiName(key) + var reaction = null + if (name == null) { + // カスタム絵文字として読めないのにコロンを含むキーは壊れているので捨てる + if (useUnicodeEmoji && !key.incl(":")) reaction = key + } elif (available != null && Obj:has(available, name)) { + // :hoge@example.com: も自鯖に hoge があれば :hoge: として乗れる + reaction = `:{name}:` + } + if (reaction != null) { + if (Obj:has(seen, reaction)) { + // :hoge: と :hoge@example.com: が混在していたら数をまとめる + let at = Obj:get(seen, reaction) + let entry = list[at] + entry.count = entry.count + count + } else { + Obj:set(seen, reaction, list.len) + list.push({ reaction: reaction, count: count }) + } + } + } + list.sort(@(a, b) { b.count - a.count }) +} + +Plugin:register_note_action(TITLE, @(note) { + if (Obj:has(note, "myReaction") && note.myReaction != null) { + Mk:dialog(TITLE, `このノートには既に {note.myReaction} でリアクションしています`, "info") + return null + } + + var reactions = {} + if (Obj:has(note, "reactions") && note.reactions != null) reactions = note.reactions + if (Obj:keys(reactions).len == 0) { + Mk:dialog(TITLE, "このノートにはまだリアクションが付いていません", "info") + return null + } + + // カスタム絵文字のリアクションが 1 つも無ければ絵文字一覧は要らない + var needsEmojiList = false + each (let key, Obj:keys(reactions)) { + if (customEmojiName(key) != null) needsEmojiList = true + } + var available = null + if (needsEmojiList) { + available = getLocalNames() + if (available == null) { + Mk:dialog(TITLE, "絵文字一覧を取得できなかったので、カスタム絵文字は候補から外します", "warning") + } + } + + let candidates = buildCandidates(reactions, Plugin:config.useUnicodeEmoji, available) + if (candidates.len == 0) { + Mk:dialog(TITLE, "相乗りできる絵文字がありません (自鯖に無いリモート絵文字だけです)", "info") + return null + } + + let limit = Math:min(candidates.len, Math:max(Plugin:config.maxCandidates, 1)) + var i = 0 + loop { + if (i >= limit) break + let c = candidates[i] + let head = if (limit == 1) "" else `({i + 1}/{limit}) ` + if (Mk:confirm(TITLE, `{head}{c.reaction} ({c.count}) に相乗りしますか?`)) { + let res = Mk:api("notes/reactions/create", { noteId: note.id, reaction: c.reaction }) + if (failed(res)) { + Mk:dialog(TITLE, "リアクションできませんでした (アクセス許可が未付与か、既にリアクション済みの可能性があります)", "error") + } else { + Mk:toast(`{c.reaction} に相乗りしました`, "success") + } + break + } + i = i + 1 + } +}) diff --git a/public/registry/plugins/same-emoji-react/api.json b/public/registry/plugins/same-emoji-react/api.json new file mode 100644 index 0000000..ca85757 --- /dev/null +++ b/public/registry/plugins/same-emoji-react/api.json @@ -0,0 +1 @@ +{"type":"plugin","data":"/// @ 1.2.1\n\n// 同じ絵文字でリアクション — ノートに写っている絵文字をそのまま返す\n//\n// ノート本文と CW に含まれるカスタム絵文字 (:name: / :name@host:) と\n// Unicode 絵文字 (Twemoji) を拾い、候補を順に確認しながらリアクションする。\n// カスタム絵文字は自分のサーバーに存在する名前だけを候補にする\n// (他鯖の絵文字はそのままリアクションに使えないため)。\n// 絵文字一覧はプラグイン起動中 1 回だけ取得してキャッシュする。\n\n### {\n\tname: \"同じ絵文字でリアクション\"\n\tversion: \"1.0.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"ノートに含まれるカスタム絵文字や Unicode 絵文字 (Twemoji) と同じ絵文字でリアクションします\"\n\tpermissions: [\"write:reactions\"]\n\tconfig: {\n\t\tuseCustomEmoji: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"カスタム絵文字を候補にする\"\n\t\t\tdefault: true\n\t\t}\n\t\tuseUnicodeEmoji: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"Unicode 絵文字 (Twemoji) を候補にする\"\n\t\t\tdefault: true\n\t\t}\n\t\tmaxCandidates: {\n\t\t\ttype: \"number\"\n\t\t\tlabel: \"確認する候補の最大数\"\n\t\t\tdefault: 8\n\t\t}\n\t}\n}\n\nlet TITLE = \"同じ絵文字でリアクション\"\n\n// AiScript に 16 進リテラルが無いのでコードポイントは 10 進で持つ\nlet CP_ZWJ = 8205 // U+200D\nlet CP_VS16 = 65039 // U+FE0F 異体字セレクタ (絵文字表示)\nlet CP_KEYCAP = 8419 // U+20E3 囲みキーキャップ\n\n@isRegional(cp) { cp >= 127462 && cp <= 127487 } // U+1F1E6..U+1F1FF 国旗用\n@isSkinTone(cp) { cp >= 127995 && cp <= 127999 } // U+1F3FB..U+1F3FF 肌の色\n@isKeycapBase(cp) { (cp >= 48 && cp <= 57) || cp == 35 || cp == 42 } // 0-9 # *\n\n// 単独で絵文字として表示されるコードポイントの範囲 [開始, 終了]\nlet PICTOGRAPH_RANGES = [\n\t[127744, 129791] // U+1F300..U+1FAFF 絵文字本体\n\t[126976, 127231] // U+1F000..U+1F0FF 麻雀牌・トランプ\n\t[9728, 10175] // U+2600..U+27BF 記号・装飾記号\n\t[8986, 9210] // U+231A..U+23FA 時計・再生記号\n\t[11008, 11263] // U+2B00..U+2BFF 矢印・図形\n]\n\n@isPictograph(cp) {\n\tvar hit = false\n\teach (let r, PICTOGRAPH_RANGES) {\n\t\tif (cp >= r[0] && cp <= r[1]) hit = true\n\t}\n\thit\n}\n\n// start から続く修飾子 (VS16 / 肌の色 / キーキャップ) を読み飛ばした位置を返す\n@skipModifiers(cps, start) {\n\tvar j = start\n\tloop {\n\t\tif (j >= cps.len) break\n\t\tlet c = cps[j]\n\t\tif (c == CP_VS16 || c == CP_KEYCAP || isSkinTone(c)) {\n\t\t\tj = j + 1\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\tj\n}\n\n// 修飾子と ZWJ 連結 (家族絵文字など) をまとめて読み飛ばした位置を返す\n@skipSequence(cps, start) {\n\tvar j = skipModifiers(cps, start)\n\tloop {\n\t\tif (j + 1 >= cps.len) break\n\t\tif (cps[j] != CP_ZWJ) break\n\t\tj = skipModifiers(cps, j + 2)\n\t}\n\tj\n}\n\n// 本文から Unicode 絵文字を書字素のまとまりとして抽出する\n@extractUnicodeEmojis(text) {\n\tlet cps = text.to_unicode_codepoint_arr()\n\tvar found = []\n\tvar i = 0\n\tloop {\n\t\tif (i >= cps.len) break\n\t\tlet cp = cps[i]\n\t\tvar to = 0\n\t\tif (isRegional(cp) && i + 1 < cps.len && isRegional(cps[i + 1])) {\n\t\t\t// 地域表示記号 2 つで 1 つの国旗\n\t\t\tto = i + 2\n\t\t} elif (isPictograph(cp)) {\n\t\t\tto = skipSequence(cps, i + 1)\n\t\t} elif (isKeycapBase(cp)) {\n\t\t\t// 1️⃣ のように U+20E3 が続くときだけ絵文字扱い\n\t\t\tlet end = skipModifiers(cps, i + 1)\n\t\t\tif (cps.slice(i + 1, end).incl(CP_KEYCAP)) to = end\n\t\t} elif (cp >= 169 && i + 1 < cps.len && cps[i + 1] == CP_VS16) {\n\t\t\t// ©️ ↔️ など、VS16 が付いて初めて絵文字になるもの\n\t\t\tto = skipSequence(cps, i + 1)\n\t\t}\n\t\tif (to == 0) {\n\t\t\ti = i + 1\n\t\t} else {\n\t\t\tlet emoji = Str:from_unicode_codepoints(cps.slice(i, to))\n\t\t\tif (!found.incl(emoji)) found.push(emoji)\n\t\t\ti = to\n\t\t}\n\t}\n\tfound\n}\n\n@isEmojiToken(token) {\n\tif (token.len == 0 || token.len > 64) {\n\t\tfalse\n\t} else {\n\t\tlet allowed = \"abcdefghijklmnopqrstuvwxyz0123456789_+-.@\"\n\t\tvar ok = true\n\t\teach (let ch, token.lower().split()) {\n\t\t\tif (!allowed.incl(ch)) ok = false\n\t\t}\n\t\tok\n\t}\n}\n\n// 本文から :token: を抽出し、@host を落とした名前を返す\n// mfm.js と同じく、絵文字として成立したら閉じコロンを消費して次へ進む\n@extractCustomNames(text) {\n\tlet parts = text.split(\":\")\n\tvar found = []\n\tvar i = 1\n\tloop {\n\t\tif (i > parts.len - 2) break\n\t\tlet token = parts[i]\n\t\tif (isEmojiToken(token)) {\n\t\t\tlet name = token.split(\"@\")[0]\n\t\t\tif (name != \"\" && !found.incl(name)) found.push(name)\n\t\t\ti = i + 2\n\t\t} else {\n\t\t\ti = i + 1\n\t\t}\n\t}\n\tfound\n}\n\n// Misskey Web の Mk:api は失敗時に throw せずエラー値を返す。\n// notes/reactions/create は成功時 204 (= null) なので obj 判定では弾けない\n@failed(res) {\n\tCore:type(res) == \"error\"\n}\n\n// 自鯖のカスタム絵文字名の索引を起動中キャッシュ (取得できなければ null)\nvar localNames = null\n\n@getLocalNames() {\n\tif (localNames == null) {\n\t\tlet res = Mk:api(\"emojis\", {})\n\t\tif (failed(res) || Core:type(res) != \"obj\" || !Obj:has(res, \"emojis\")) return null\n\t\tvar names = {}\n\t\teach (let e, res.emojis) {\n\t\t\tObj:set(names, e.name, true)\n\t\t}\n\t\tlocalNames = names\n\t}\n\tlocalNames\n}\n\nPlugin:register_note_action(TITLE, @(note) {\n\tif (Obj:has(note, \"myReaction\") && note.myReaction != null) {\n\t\tMk:dialog(TITLE, `このノートには既に {note.myReaction} でリアクションしています`, \"info\")\n\t\treturn null\n\t}\n\n\tlet body = if (note.text == null) \"\" else note.text\n\tlet text = if (note.cw == null) body else `{note.cw}{Str:lf}{body}`\n\tif (text == \"\") {\n\t\tMk:dialog(TITLE, \"本文が空のノートです\", \"info\")\n\t\treturn null\n\t}\n\n\tvar candidates = []\n\tif (Plugin:config.useCustomEmoji) {\n\t\tlet names = extractCustomNames(text)\n\t\tif (names.len > 0) {\n\t\t\tlet available = getLocalNames()\n\t\t\tif (available == null) {\n\t\t\t\tMk:dialog(TITLE, \"絵文字一覧を取得できなかったので、カスタム絵文字は候補から外します\", \"warning\")\n\t\t\t} else {\n\t\t\t\teach (let name, names) {\n\t\t\t\t\t// 自鯖に無い絵文字はリアクションに使えないので候補から外す\n\t\t\t\t\tif (Obj:has(available, name)) candidates.push(`:{name}:`)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif (Plugin:config.useUnicodeEmoji) {\n\t\teach (let emoji, extractUnicodeEmojis(text)) {\n\t\t\tcandidates.push(emoji)\n\t\t}\n\t}\n\n\tif (candidates.len == 0) {\n\t\tMk:dialog(TITLE, \"リアクションに使える絵文字がノートに見つかりません\", \"info\")\n\t\treturn null\n\t}\n\n\tlet limit = Math:min(candidates.len, Math:max(Plugin:config.maxCandidates, 1))\n\tvar i = 0\n\tloop {\n\t\tif (i >= limit) break\n\t\tlet emoji = candidates[i]\n\t\tlet question = if (limit == 1) {\n\t\t\t`{emoji} でリアクションしますか?`\n\t\t} else {\n\t\t\t`({i + 1}/{limit}) {emoji} でリアクションしますか?`\n\t\t}\n\t\tif (Mk:confirm(TITLE, question)) {\n\t\t\tlet res = Mk:api(\"notes/reactions/create\", { noteId: note.id, reaction: emoji })\n\t\t\tif (failed(res)) {\n\t\t\t\tMk:dialog(TITLE, \"リアクションできませんでした (アクセス許可が未付与か、既にリアクション済みの可能性があります)\", \"error\")\n\t\t\t} else {\n\t\t\t\tMk:toast(`{emoji} でリアクションしました`, \"success\")\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\ti = i + 1\n\t}\n})\n"} diff --git a/public/registry/plugins/same-emoji-react/icon.svg b/public/registry/plugins/same-emoji-react/icon.svg new file mode 100644 index 0000000..2ba2ef6 --- /dev/null +++ b/public/registry/plugins/same-emoji-react/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/public/registry/plugins/same-emoji-react/meta.json b/public/registry/plugins/same-emoji-react/meta.json new file mode 100644 index 0000000..fc6d6cb --- /dev/null +++ b/public/registry/plugins/same-emoji-react/meta.json @@ -0,0 +1,14 @@ +{ + "id": "same-emoji-react", + "name": "同じ絵文字でリアクション", + "version": "1.0.0", + "author": "hitalin", + "authorUrl": "https://github.com/hitalin", + "description": "ノートに含まれるカスタム絵文字や Unicode 絵文字 (Twemoji) を拾い、同じ絵文字でリアクションします", + "license": "MIT", + "category": "utility", + "capabilities": ["misskey-api", "misskey-account"], + "tags": ["emoji", "reaction", "custom-emoji", "twemoji"], + "permissions": ["write:reactions"], + "minNoteDeckVersion": "1.8.0" +} diff --git a/public/registry/plugins/same-emoji-react/plugin.is b/public/registry/plugins/same-emoji-react/plugin.is new file mode 100644 index 0000000..d642c50 --- /dev/null +++ b/public/registry/plugins/same-emoji-react/plugin.is @@ -0,0 +1,238 @@ +/// @ 1.2.1 + +// 同じ絵文字でリアクション — ノートに写っている絵文字をそのまま返す +// +// ノート本文と CW に含まれるカスタム絵文字 (:name: / :name@host:) と +// Unicode 絵文字 (Twemoji) を拾い、候補を順に確認しながらリアクションする。 +// カスタム絵文字は自分のサーバーに存在する名前だけを候補にする +// (他鯖の絵文字はそのままリアクションに使えないため)。 +// 絵文字一覧はプラグイン起動中 1 回だけ取得してキャッシュする。 + +### { + name: "同じ絵文字でリアクション" + version: "1.0.0" + author: "@hitalin" + description: "ノートに含まれるカスタム絵文字や Unicode 絵文字 (Twemoji) と同じ絵文字でリアクションします" + permissions: ["write:reactions"] + config: { + useCustomEmoji: { + type: "boolean" + label: "カスタム絵文字を候補にする" + default: true + } + useUnicodeEmoji: { + type: "boolean" + label: "Unicode 絵文字 (Twemoji) を候補にする" + default: true + } + maxCandidates: { + type: "number" + label: "確認する候補の最大数" + default: 8 + } + } +} + +let TITLE = "同じ絵文字でリアクション" + +// AiScript に 16 進リテラルが無いのでコードポイントは 10 進で持つ +let CP_ZWJ = 8205 // U+200D +let CP_VS16 = 65039 // U+FE0F 異体字セレクタ (絵文字表示) +let CP_KEYCAP = 8419 // U+20E3 囲みキーキャップ + +@isRegional(cp) { cp >= 127462 && cp <= 127487 } // U+1F1E6..U+1F1FF 国旗用 +@isSkinTone(cp) { cp >= 127995 && cp <= 127999 } // U+1F3FB..U+1F3FF 肌の色 +@isKeycapBase(cp) { (cp >= 48 && cp <= 57) || cp == 35 || cp == 42 } // 0-9 # * + +// 単独で絵文字として表示されるコードポイントの範囲 [開始, 終了] +let PICTOGRAPH_RANGES = [ + [127744, 129791] // U+1F300..U+1FAFF 絵文字本体 + [126976, 127231] // U+1F000..U+1F0FF 麻雀牌・トランプ + [9728, 10175] // U+2600..U+27BF 記号・装飾記号 + [8986, 9210] // U+231A..U+23FA 時計・再生記号 + [11008, 11263] // U+2B00..U+2BFF 矢印・図形 +] + +@isPictograph(cp) { + var hit = false + each (let r, PICTOGRAPH_RANGES) { + if (cp >= r[0] && cp <= r[1]) hit = true + } + hit +} + +// start から続く修飾子 (VS16 / 肌の色 / キーキャップ) を読み飛ばした位置を返す +@skipModifiers(cps, start) { + var j = start + loop { + if (j >= cps.len) break + let c = cps[j] + if (c == CP_VS16 || c == CP_KEYCAP || isSkinTone(c)) { + j = j + 1 + } else { + break + } + } + j +} + +// 修飾子と ZWJ 連結 (家族絵文字など) をまとめて読み飛ばした位置を返す +@skipSequence(cps, start) { + var j = skipModifiers(cps, start) + loop { + if (j + 1 >= cps.len) break + if (cps[j] != CP_ZWJ) break + j = skipModifiers(cps, j + 2) + } + j +} + +// 本文から Unicode 絵文字を書字素のまとまりとして抽出する +@extractUnicodeEmojis(text) { + let cps = text.to_unicode_codepoint_arr() + var found = [] + var i = 0 + loop { + if (i >= cps.len) break + let cp = cps[i] + var to = 0 + if (isRegional(cp) && i + 1 < cps.len && isRegional(cps[i + 1])) { + // 地域表示記号 2 つで 1 つの国旗 + to = i + 2 + } elif (isPictograph(cp)) { + to = skipSequence(cps, i + 1) + } elif (isKeycapBase(cp)) { + // 1️⃣ のように U+20E3 が続くときだけ絵文字扱い + let end = skipModifiers(cps, i + 1) + if (cps.slice(i + 1, end).incl(CP_KEYCAP)) to = end + } elif (cp >= 169 && i + 1 < cps.len && cps[i + 1] == CP_VS16) { + // ©️ ↔️ など、VS16 が付いて初めて絵文字になるもの + to = skipSequence(cps, i + 1) + } + if (to == 0) { + i = i + 1 + } else { + let emoji = Str:from_unicode_codepoints(cps.slice(i, to)) + if (!found.incl(emoji)) found.push(emoji) + i = to + } + } + found +} + +@isEmojiToken(token) { + if (token.len == 0 || token.len > 64) { + false + } else { + let allowed = "abcdefghijklmnopqrstuvwxyz0123456789_+-.@" + var ok = true + each (let ch, token.lower().split()) { + if (!allowed.incl(ch)) ok = false + } + ok + } +} + +// 本文から :token: を抽出し、@host を落とした名前を返す +// mfm.js と同じく、絵文字として成立したら閉じコロンを消費して次へ進む +@extractCustomNames(text) { + let parts = text.split(":") + var found = [] + var i = 1 + loop { + if (i > parts.len - 2) break + let token = parts[i] + if (isEmojiToken(token)) { + let name = token.split("@")[0] + if (name != "" && !found.incl(name)) found.push(name) + i = i + 2 + } else { + i = i + 1 + } + } + found +} + +// Misskey Web の Mk:api は失敗時に throw せずエラー値を返す。 +// notes/reactions/create は成功時 204 (= null) なので obj 判定では弾けない +@failed(res) { + Core:type(res) == "error" +} + +// 自鯖のカスタム絵文字名の索引を起動中キャッシュ (取得できなければ null) +var localNames = null + +@getLocalNames() { + if (localNames == null) { + let res = Mk:api("emojis", {}) + if (failed(res) || Core:type(res) != "obj" || !Obj:has(res, "emojis")) return null + var names = {} + each (let e, res.emojis) { + Obj:set(names, e.name, true) + } + localNames = names + } + localNames +} + +Plugin:register_note_action(TITLE, @(note) { + if (Obj:has(note, "myReaction") && note.myReaction != null) { + Mk:dialog(TITLE, `このノートには既に {note.myReaction} でリアクションしています`, "info") + return null + } + + let body = if (note.text == null) "" else note.text + let text = if (note.cw == null) body else `{note.cw}{Str:lf}{body}` + if (text == "") { + Mk:dialog(TITLE, "本文が空のノートです", "info") + return null + } + + var candidates = [] + if (Plugin:config.useCustomEmoji) { + let names = extractCustomNames(text) + if (names.len > 0) { + let available = getLocalNames() + if (available == null) { + Mk:dialog(TITLE, "絵文字一覧を取得できなかったので、カスタム絵文字は候補から外します", "warning") + } else { + each (let name, names) { + // 自鯖に無い絵文字はリアクションに使えないので候補から外す + if (Obj:has(available, name)) candidates.push(`:{name}:`) + } + } + } + } + if (Plugin:config.useUnicodeEmoji) { + each (let emoji, extractUnicodeEmojis(text)) { + candidates.push(emoji) + } + } + + if (candidates.len == 0) { + Mk:dialog(TITLE, "リアクションに使える絵文字がノートに見つかりません", "info") + return null + } + + let limit = Math:min(candidates.len, Math:max(Plugin:config.maxCandidates, 1)) + var i = 0 + loop { + if (i >= limit) break + let emoji = candidates[i] + let question = if (limit == 1) { + `{emoji} でリアクションしますか?` + } else { + `({i + 1}/{limit}) {emoji} でリアクションしますか?` + } + if (Mk:confirm(TITLE, question)) { + let res = Mk:api("notes/reactions/create", { noteId: note.id, reaction: emoji }) + if (failed(res)) { + Mk:dialog(TITLE, "リアクションできませんでした (アクセス許可が未付与か、既にリアクション済みの可能性があります)", "error") + } else { + Mk:toast(`{emoji} でリアクションしました`, "success") + } + break + } + i = i + 1 + } +}) diff --git a/public/registry/queries.json b/public/registry/queries.json index b644d0f..07e22a3 100644 --- a/public/registry/queries.json +++ b/public/registry/queries.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-07T01:20:05.144Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "queries": [ { "id": "no-cw", diff --git a/public/registry/skills.json b/public/registry/skills.json index 798dca3..7f401e8 100644 --- a/public/registry/skills.json +++ b/public/registry/skills.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-08T22:56:18.692Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "skills": [ { "id": "ai-cost-pulse", @@ -73,7 +73,7 @@ "apiUrl": "https://store.notedeck.io/registry/skills/aiscript-author/api.json", "sha512": "00921e29b194b4d759d40d86c68310d59966ade69681889b850d3c88eb2d43dc267e011e3527902d90b27763ba6afa812d6ebbc140354fdd3456e9a51e284057", "createdAt": "2026-08-04T12:07:50+09:00", - "updatedAt": "2026-08-04T12:10:17+09:00", + "updatedAt": "2026-08-09T07:56:55+09:00", "iconUrl": "https://store.notedeck.io/registry/skills/aiscript-author/icon.svg" }, { diff --git a/public/registry/themes.json b/public/registry/themes.json index 62f0855..90f4792 100644 --- a/public/registry/themes.json +++ b/public/registry/themes.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-07T01:20:05.144Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "themes": [ { "id": "adwaita-dark", @@ -78,8 +78,8 @@ "scrollbarHandleHover": "rgba(255, 255, 255, 0.55)", "deckBg": "#1d1d20" }, - "createdAt": "2026-08-07T01:20:05.539Z", - "updatedAt": "2026-08-07T01:20:05.539Z", + "createdAt": "2026-08-07T10:11:28+09:00", + "updatedAt": "2026-08-07T10:11:28+09:00", "license": "CC0-1.0" }, { @@ -158,8 +158,8 @@ "scrollbarHandleHover": "rgba(0, 0, 6, 0.55)", "deckBg": "#ebebed" }, - "createdAt": "2026-08-07T01:20:05.556Z", - "updatedAt": "2026-08-07T01:20:05.556Z", + "createdAt": "2026-08-07T10:11:28+09:00", + "updatedAt": "2026-08-07T10:11:28+09:00", "license": "CC0-1.0" }, { diff --git a/public/registry/widgets.json b/public/registry/widgets.json index c86c793..c153876 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-08-09T10:23:15.471Z", + "updatedAt": "2026-08-12T04:29:41.587Z", "widgets": [ { "id": "atcoder", @@ -421,7 +421,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/shodan-instance-audit/api.json", "sha512": "7a68aceb44cd53b609b22b632011fcbf37988e3997dc7357ed40c9a9c3070cbe16a15f76b55fce8f1346c6a1a5e5b2439063bf5ec9dc44da6cf70734c14413f7", "createdAt": "2026-08-09T07:54:40+09:00", - "updatedAt": "2026-08-09T19:06:28+09:00", + "updatedAt": "2026-08-09T19:23:55+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "iconUrl": "https://store.notedeck.io/registry/widgets/shodan-instance-audit/icon.svg" diff --git a/scripts/sync-icons.js b/scripts/sync-icons.js index 4e1922f..81de2f2 100644 --- a/scripts/sync-icons.js +++ b/scripts/sync-icons.js @@ -22,6 +22,8 @@ const ICON_MAP = { 'plugins/live-hashtag': { pack: 'lucide', name: 'hash' }, 'plugins/note-attachments-download': { pack: 'lucide', name: 'download' }, 'plugins/plugin-smith': { pack: 'lucide', name: 'hammer' }, + 'plugins/reaction-piggyback': { pack: 'lucide', name: 'heart-handshake' }, + 'plugins/same-emoji-react': { pack: 'lucide', name: 'smile-plus' }, 'plugins/smart-delete': { pack: 'lucide', name: 'trash-2' }, 'plugins/tech-autolink': { pack: 'lucide', name: 'link' }, 'plugins/youtube-link-cleaner': { pack: 'simple', name: 'youtube' },