letagents 0.12.34 → 0.12.36
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.
- package/README.md +0 -2
- package/dist/mcp/server/runtime/messages.js +18 -1
- package/dist/mcp/server/runtime/workspace-capture.js +16 -5
- package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
- package/package.json +5 -3
- package/shared/activation-routing.d.mts +21 -3
- package/shared/activation-routing.mjs +37 -0
- package/shared/code-block.css +193 -0
- package/shared/code-highlighting.d.mts +109 -0
- package/shared/code-highlighting.mjs +345 -0
- package/shared/codex-owner-isolation.mjs +5 -3
- package/shared/codex-read-only-room-tools.d.mts +2 -0
- package/shared/codex-read-only-room-tools.mjs +24 -0
- package/shared/mcp-workspace-capture-worker.mjs +2 -1
- package/shared/message-link-preview-store.d.mts +10 -0
- package/shared/message-link-preview-store.mjs +77 -0
- package/shared/message-link-previews.d.mts +17 -0
- package/shared/message-link-previews.mjs +76 -0
- package/shared/message-pin-store.d.mts +20 -0
- package/shared/message-pin-store.mjs +102 -0
- package/shared/message-pins.d.mts +25 -0
- package/shared/message-pins.mjs +16 -0
- package/shared/message-reaction-store.d.mts +51 -0
- package/shared/message-reaction-store.mjs +290 -0
- package/shared/message-reactions.d.mts +73 -0
- package/shared/message-reactions.mjs +130 -0
- package/shared/message-search-controller.d.mts +38 -0
- package/shared/message-search-controller.mjs +99 -0
- package/shared/message-search.d.mts +46 -0
- package/shared/message-search.mjs +114 -0
- package/shared/room-message-urls.d.mts +25 -0
- package/shared/room-message-urls.mjs +119 -0
- package/shared/room-notification-client.d.mts +18 -0
- package/shared/room-notification-client.mjs +140 -0
- package/shared/room-notification-preferences.d.mts +17 -0
- package/shared/room-notification-preferences.mjs +34 -0
- package/shared/room-resource-invalidation.d.mts +5 -0
- package/shared/room-resource-invalidation.mjs +6 -0
- package/shared/room-settings.d.mts +16 -0
- package/shared/room-settings.mjs +14 -0
- package/shared/room-typing.d.mts +18 -0
- package/shared/room-typing.mjs +129 -0
- package/shared/room-unread-client.ts +128 -0
- package/shared/room-unread.d.mts +25 -0
- package/shared/room-unread.mjs +57 -0
- package/shared/room-unread.test.mjs +91 -0
- package/shared/rooms/RoomsDirectory.vue +1 -1
- package/shared/rooms/directory.ts +1 -0
- package/shared/slash-commands.d.mts +31 -0
- package/shared/slash-commands.mjs +48 -0
- package/shared/slash-commands.test.mjs +26 -0
- package/shared/ui/GitHubEventCard.vue +229 -0
- package/shared/ui/MessageReactionBar.vue +65 -0
- package/shared/ui/MessageReactionPicker.vue +291 -0
- package/shared/ui/MessageSearchResults.vue +106 -0
- package/shared/ui/PinnedMessages.vue +129 -0
- package/shared/ui/TypingIndicator.vue +134 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/room-agent-work.css +178 -0
- package/shared/ui/room-message-motion.ts +143 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
- package/shared/ui/usePinnedMessageVisibility.ts +20 -0
- package/shared/ui/useRoomMessageMotion.ts +195 -0
- package/shared/ui/useSlidingTabIndicator.ts +72 -0
- package/shared/workspace-change-capture.mjs +3 -1
- package/shared/workspace-turn-capture.d.mts +4 -1
- package/shared/workspace-turn-capture.mjs +203 -6
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="github-event-card" :class="{ 'is-preview': compact, 'is-room-event': room, 'is-activity': room && !['pull-request', 'issue'].includes(event.kind) }" :data-tone="event.tone" :data-kind="event.kind" :data-status="event.statusLabel">
|
|
3
|
+
<div class="github-event-header">
|
|
4
|
+
<svg class="github-event-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
5
|
+
<template v-if="event.kind === 'pull-request'">
|
|
6
|
+
<circle cx="4" cy="3" r="1.5" /><circle cx="4" cy="13" r="1.5" /><circle cx="12" cy="13" r="1.5" />
|
|
7
|
+
<path d="M4 4.5v7M12 11.5V6a3 3 0 0 0-3-3H8m2-2L8 3l2 2" />
|
|
8
|
+
</template>
|
|
9
|
+
<template v-else-if="event.kind === 'issue'">
|
|
10
|
+
<circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" />
|
|
11
|
+
</template>
|
|
12
|
+
<template v-else-if="event.kind === 'review' || event.kind === 'comment'">
|
|
13
|
+
<path d="M3 3h10v8H8l-3 2v-2H3z" />
|
|
14
|
+
<path v-if="event.kind === 'review'" d="m5.5 7 1.5 1.5 3.5-3" />
|
|
15
|
+
<path v-else d="M5.5 6h5M5.5 8h3" />
|
|
16
|
+
</template>
|
|
17
|
+
<template v-else-if="event.kind === 'check'">
|
|
18
|
+
<rect x="2.5" y="2.5" width="11" height="11" rx="3" />
|
|
19
|
+
<path v-if="/^(success|passed)$/i.test(event.statusLabel || '')" d="m5 8 2 2 4-4" />
|
|
20
|
+
<path v-else-if="event.tone === 'rose'" d="m5.5 5.5 5 5m0-5-5 5" />
|
|
21
|
+
<path v-else d="M8 5v3l2 1" />
|
|
22
|
+
</template>
|
|
23
|
+
<path v-else-if="event.kind === 'repository'" d="M2.5 4h4l1.5 2h5.5v6.5h-11z" />
|
|
24
|
+
<template v-else><circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" /></template>
|
|
25
|
+
</svg>
|
|
26
|
+
<p v-if="room" class="github-event-title">{{ title }}</p>
|
|
27
|
+
<span v-else class="github-event-kind">{{ event.kindLabel }}</span>
|
|
28
|
+
<span v-if="event.statusLabel" class="github-event-status">{{ statusLabel }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-if="!room || description" class="github-event-content">
|
|
32
|
+
<p v-if="!room" class="github-event-title">{{ title }}</p>
|
|
33
|
+
<p v-if="description" class="github-event-description">{{ description }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div v-if="event.repository || event.taskId" class="github-event-context">
|
|
37
|
+
<span v-if="event.repository" class="github-event-repository">{{ event.repository }}</span>
|
|
38
|
+
<span v-if="event.repository && event.taskId" class="github-event-separator" aria-hidden="true">·</span>
|
|
39
|
+
<button
|
|
40
|
+
v-if="event.taskId && taskLinkEnabled"
|
|
41
|
+
type="button"
|
|
42
|
+
class="github-event-task"
|
|
43
|
+
:title="`Open ${event.taskId} on the Board`"
|
|
44
|
+
:data-task-reference-id="event.taskId"
|
|
45
|
+
@click="emit('openTask', event.taskId)"
|
|
46
|
+
>{{ event.taskId }}</button>
|
|
47
|
+
<span v-else-if="event.taskId" class="github-event-task">{{ event.taskId }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div v-if="event.url" class="github-event-actions">
|
|
51
|
+
<slot name="actions" />
|
|
52
|
+
<a class="github-event-action" :href="event.url" target="_blank" rel="noopener noreferrer">
|
|
53
|
+
{{ event.urlLabel }}
|
|
54
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11 11 5M5 5h6v6" /></svg>
|
|
55
|
+
</a>
|
|
56
|
+
<button v-if="showEventLink && !compact" type="button" class="github-event-action is-secondary" @click="emit('openEvent', event.url)">
|
|
57
|
+
View in Events
|
|
58
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4" /></svg>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</article>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup lang="ts">
|
|
65
|
+
import { computed } from 'vue';
|
|
66
|
+
|
|
67
|
+
const props = defineProps<{
|
|
68
|
+
event: {
|
|
69
|
+
kind: string;
|
|
70
|
+
tone: string;
|
|
71
|
+
kindLabel: string;
|
|
72
|
+
statusLabel: string | null;
|
|
73
|
+
headline: string;
|
|
74
|
+
detail: string | null;
|
|
75
|
+
repository: string | null;
|
|
76
|
+
taskId: string | null;
|
|
77
|
+
url: string | null;
|
|
78
|
+
urlLabel: string;
|
|
79
|
+
};
|
|
80
|
+
compact?: boolean;
|
|
81
|
+
room?: boolean;
|
|
82
|
+
taskLinkEnabled?: boolean;
|
|
83
|
+
showEventLink?: boolean;
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const emit = defineEmits<{
|
|
87
|
+
openTask: [taskId: string];
|
|
88
|
+
openEvent: [url: string];
|
|
89
|
+
}>();
|
|
90
|
+
|
|
91
|
+
// PR/issue details carry the artifact title; other events carry supporting text.
|
|
92
|
+
const titleFirst = computed(() => Boolean(props.event.detail) && ['pull-request', 'issue'].includes(props.event.kind));
|
|
93
|
+
const title = computed(() => titleFirst.value ? props.event.detail : props.event.headline);
|
|
94
|
+
const description = computed(() => titleFirst.value ? props.event.headline : props.event.detail);
|
|
95
|
+
const statusLabel = computed(() => {
|
|
96
|
+
const label = props.event.statusLabel || '';
|
|
97
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.github-event-card {
|
|
103
|
+
--event-accent: var(--text-secondary);
|
|
104
|
+
display: grid;
|
|
105
|
+
gap: 10px;
|
|
106
|
+
width: 100%;
|
|
107
|
+
max-width: 560px;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
box-sizing: border-box;
|
|
110
|
+
padding: 14px 16px 10px;
|
|
111
|
+
border: 1px solid var(--border-strong);
|
|
112
|
+
border-radius: var(--radius-lg, 12px);
|
|
113
|
+
background: var(--bg-card);
|
|
114
|
+
color: var(--text);
|
|
115
|
+
font-family: var(--font-sans);
|
|
116
|
+
}
|
|
117
|
+
.github-event-card:not(.is-preview) { container-type: inline-size; }
|
|
118
|
+
.github-event-card[data-tone="violet"] { --event-accent: var(--task-assigned); }
|
|
119
|
+
.github-event-card[data-tone="emerald"] { --event-accent: var(--green-text); }
|
|
120
|
+
.github-event-card[data-tone="rose"] { --event-accent: var(--red-text); }
|
|
121
|
+
.github-event-card[data-tone="amber"] { --event-accent: var(--amber-text); }
|
|
122
|
+
.github-event-card[data-tone="sky"] { --event-accent: var(--blue-text); }
|
|
123
|
+
.github-event-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
124
|
+
.github-event-icon { width: 16px; height: 16px; flex: none; color: var(--event-accent); }
|
|
125
|
+
.github-event-kind { color: var(--text-secondary); font-size: 12px; font-weight: 500; line-height: 1.5; }
|
|
126
|
+
.github-event-status {
|
|
127
|
+
margin-left: auto;
|
|
128
|
+
padding: 2px 7px;
|
|
129
|
+
border-radius: 5px;
|
|
130
|
+
background: color-mix(in srgb, var(--event-accent) 10%, transparent);
|
|
131
|
+
color: var(--text);
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
font-weight: 500;
|
|
134
|
+
line-height: 1.5;
|
|
135
|
+
overflow-wrap: anywhere;
|
|
136
|
+
}
|
|
137
|
+
.github-event-content { display: grid; gap: 4px; min-width: 0; }
|
|
138
|
+
.github-event-title { margin: 0; color: var(--text); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
|
139
|
+
.github-event-description { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
|
|
140
|
+
.github-event-context { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.5; }
|
|
141
|
+
.github-event-repository { overflow-wrap: anywhere; }
|
|
142
|
+
.github-event-task { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; border: 0; border-radius: 4px; background: transparent; color: var(--text-secondary); font: inherit; font-family: var(--font-mono); }
|
|
143
|
+
button.github-event-task { cursor: pointer; }
|
|
144
|
+
.github-event-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border); }
|
|
145
|
+
.github-event-action,
|
|
146
|
+
.github-event-actions :deep(.pull-request-changes-button) {
|
|
147
|
+
display: inline-flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
gap: 5px;
|
|
151
|
+
min-height: 32px;
|
|
152
|
+
max-width: 100%;
|
|
153
|
+
box-sizing: border-box;
|
|
154
|
+
padding: 5px 9px;
|
|
155
|
+
border: 1px solid transparent;
|
|
156
|
+
border-radius: 6px;
|
|
157
|
+
background: transparent;
|
|
158
|
+
color: var(--text-secondary);
|
|
159
|
+
font: 500 12px/1.5 var(--font-sans);
|
|
160
|
+
text-align: center;
|
|
161
|
+
text-decoration: none;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
transition: background-color 120ms ease, color 120ms ease;
|
|
164
|
+
}
|
|
165
|
+
.github-event-action:first-child,
|
|
166
|
+
.github-event-actions :deep(.pull-request-changes-button) { border-color: var(--border); background: var(--accent-dim); color: var(--text); }
|
|
167
|
+
@container (min-width: 420px) {
|
|
168
|
+
.github-event-action.is-secondary { margin-left: auto; }
|
|
169
|
+
}
|
|
170
|
+
.github-event-action svg { width: 14px; height: 14px; flex: none; }
|
|
171
|
+
@media (hover: hover) and (pointer: fine) {
|
|
172
|
+
.github-event-action:hover,
|
|
173
|
+
.github-event-actions :deep(.pull-request-changes-button:hover),
|
|
174
|
+
button.github-event-task:hover { background: var(--accent-hover); color: var(--text); text-decoration: none; }
|
|
175
|
+
}
|
|
176
|
+
.github-event-action:active,
|
|
177
|
+
.github-event-actions :deep(.pull-request-changes-button:active),
|
|
178
|
+
button.github-event-task:active { background: var(--accent-active); color: var(--text); transition: none; }
|
|
179
|
+
.github-event-action:focus-visible,
|
|
180
|
+
.github-event-actions :deep(.pull-request-changes-button:focus-visible),
|
|
181
|
+
button.github-event-task:focus-visible { outline: 2px solid var(--blue-text); outline-offset: 2px; }
|
|
182
|
+
.github-event-card.is-preview { margin-top: 6px; gap: 8px; padding: 12px 14px 8px; }
|
|
183
|
+
@media (pointer: coarse) {
|
|
184
|
+
.github-event-action,
|
|
185
|
+
.github-event-actions :deep(.pull-request-changes-button),
|
|
186
|
+
button.github-event-task { min-height: 44px; }
|
|
187
|
+
}
|
|
188
|
+
@media (prefers-reduced-motion: reduce) {
|
|
189
|
+
.github-event-action,
|
|
190
|
+
.github-event-actions :deep(.pull-request-changes-button) { transition: none; }
|
|
191
|
+
}
|
|
192
|
+
@media (prefers-contrast: more) {
|
|
193
|
+
.github-event-card { border-color: var(--text-secondary); }
|
|
194
|
+
.github-event-status { outline: 1px solid var(--event-accent); }
|
|
195
|
+
}
|
|
196
|
+
/* Room events share the conversation column, with flat, semantic borders. */
|
|
197
|
+
.github-event-card.is-room-event {
|
|
198
|
+
max-width: 100%;
|
|
199
|
+
gap: 6px;
|
|
200
|
+
padding: 11px 14px 8px;
|
|
201
|
+
border-color: color-mix(in srgb, var(--event-accent) 45%, var(--border-strong));
|
|
202
|
+
border-radius: 9px;
|
|
203
|
+
background: color-mix(in srgb, var(--bg-card) 65%, var(--bg));
|
|
204
|
+
box-shadow: none;
|
|
205
|
+
}
|
|
206
|
+
.is-room-event .github-event-title { font-size: 13px; font-weight: 550; }
|
|
207
|
+
.is-room-event .github-event-description { font-size: 11px; }
|
|
208
|
+
.is-room-event .github-event-actions { border: 0; padding-top: 0; gap: 8px; }
|
|
209
|
+
.is-room-event .github-event-action,
|
|
210
|
+
.is-room-event .github-event-actions :deep(.pull-request-changes-button) {
|
|
211
|
+
min-height: 26px; padding: 3px 0; border: 0; background: transparent; font-size: 11px;
|
|
212
|
+
}
|
|
213
|
+
.is-activity { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; }
|
|
214
|
+
.is-activity .github-event-header { flex: 1 1 220px; }
|
|
215
|
+
.is-activity .github-event-kind { display: none; }
|
|
216
|
+
.is-activity .github-event-status { margin-left: 0; }
|
|
217
|
+
.is-activity .github-event-content { display: none; }
|
|
218
|
+
.is-activity .github-event-title { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
|
|
219
|
+
.is-activity .github-event-description, .is-activity .github-event-repository, .is-activity .github-event-separator { display: none; }
|
|
220
|
+
.is-activity .github-event-context,
|
|
221
|
+
.is-activity .github-event-actions { grid-column: 1 / -1; }
|
|
222
|
+
@media (pointer: coarse) {
|
|
223
|
+
.is-room-event .github-event-action,
|
|
224
|
+
.is-room-event .github-event-actions :deep(.pull-request-changes-button) { min-height: 44px; }
|
|
225
|
+
}
|
|
226
|
+
@media (prefers-contrast: more) {
|
|
227
|
+
.github-event-card.is-room-event { border-color: var(--event-accent); }
|
|
228
|
+
}
|
|
229
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="reactions.length" class="message-reactions" role="group" aria-label="Reactions">
|
|
3
|
+
<component
|
|
4
|
+
:is="canReact ? 'button' : 'span'"
|
|
5
|
+
v-for="reaction in reactions"
|
|
6
|
+
:key="reaction.emoji"
|
|
7
|
+
class="message-reaction"
|
|
8
|
+
:type="canReact ? 'button' : undefined"
|
|
9
|
+
:role="canReact ? undefined : 'img'"
|
|
10
|
+
:aria-pressed="canReact ? viewerReacted(reaction.emoji) : undefined"
|
|
11
|
+
:aria-label="label(reaction)"
|
|
12
|
+
:title="label(reaction)"
|
|
13
|
+
@click="canReact && emit('toggle', reaction.emoji)"
|
|
14
|
+
>
|
|
15
|
+
<span class="message-reaction-emoji" aria-hidden="true">{{ reaction.emoji }}</span>
|
|
16
|
+
<span class="message-reaction-count" aria-hidden="true">{{ reaction.count }}</span>
|
|
17
|
+
</component>
|
|
18
|
+
<button
|
|
19
|
+
v-if="canReact"
|
|
20
|
+
class="message-reaction message-reaction-add"
|
|
21
|
+
type="button"
|
|
22
|
+
aria-label="Add reaction"
|
|
23
|
+
title="Add reaction"
|
|
24
|
+
aria-haspopup="dialog"
|
|
25
|
+
:aria-expanded="pickerOpen ?? false"
|
|
26
|
+
@click="emit('add', $event)"
|
|
27
|
+
>
|
|
28
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
29
|
+
<path d="M22 11v1a10 10 0 1 1-9-10" />
|
|
30
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
31
|
+
<path d="M9 9h.01M15 9h.01" />
|
|
32
|
+
<path d="M16 5h6M19 2v6" />
|
|
33
|
+
</svg>
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { describeMessageReaction, type MessageReaction } from "../message-reactions.mjs";
|
|
40
|
+
|
|
41
|
+
const props = defineProps<{
|
|
42
|
+
reactions: readonly MessageReaction[];
|
|
43
|
+
viewerLogin: string | null;
|
|
44
|
+
/** Whether the viewer reacted with this emoji, including when they are past the listed reactors. */
|
|
45
|
+
viewerReacted: (emoji: string) => boolean;
|
|
46
|
+
canReact: boolean;
|
|
47
|
+
pickerOpen?: boolean;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
toggle: [emoji: string];
|
|
52
|
+
/** The click that asked for the picker; its target is the anchor. */
|
|
53
|
+
add: [event: MouseEvent];
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
function label(reaction: MessageReaction): string {
|
|
57
|
+
const description = describeMessageReaction(reaction, props.viewerLogin);
|
|
58
|
+
// The list may not name the viewer when many people reacted.
|
|
59
|
+
return props.viewerReacted(reaction.emoji) && !description.startsWith("You")
|
|
60
|
+
? `You and others reacted with ${reaction.emoji}`
|
|
61
|
+
: description;
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style src="./message-reactions.css"></style>
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
v-if="anchor"
|
|
5
|
+
ref="panel"
|
|
6
|
+
class="message-reaction-picker"
|
|
7
|
+
role="dialog"
|
|
8
|
+
aria-label="Add a reaction"
|
|
9
|
+
:style="{ left: `${position.left}px`, top: `${position.top}px` }"
|
|
10
|
+
:data-side="position.side"
|
|
11
|
+
:data-align="position.align"
|
|
12
|
+
:data-placed="placed"
|
|
13
|
+
:data-instant="instant"
|
|
14
|
+
@keydown="handleKeydown"
|
|
15
|
+
@focusout="handleFocusOut"
|
|
16
|
+
@pointerdown.stop
|
|
17
|
+
@contextmenu.stop
|
|
18
|
+
>
|
|
19
|
+
<div class="message-reaction-picker-grid" role="group" aria-label="Common reactions">
|
|
20
|
+
<button
|
|
21
|
+
v-for="(emoji, index) in MESSAGE_REACTION_QUICK_EMOJI"
|
|
22
|
+
:key="emoji"
|
|
23
|
+
class="message-reaction-picker-emoji"
|
|
24
|
+
type="button"
|
|
25
|
+
:tabindex="index === activeIndex ? 0 : -1"
|
|
26
|
+
:aria-pressed="viewerReacted(emoji)"
|
|
27
|
+
@click="choose(emoji)"
|
|
28
|
+
@focus="activeIndex = index"
|
|
29
|
+
>
|
|
30
|
+
{{ emoji }}
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
<form class="message-reaction-picker-custom" novalidate @submit.prevent="chooseCustom">
|
|
34
|
+
<input
|
|
35
|
+
ref="customInput"
|
|
36
|
+
v-model="custom"
|
|
37
|
+
class="message-reaction-picker-input"
|
|
38
|
+
type="text"
|
|
39
|
+
autocomplete="off"
|
|
40
|
+
autocapitalize="off"
|
|
41
|
+
spellcheck="false"
|
|
42
|
+
maxlength="32"
|
|
43
|
+
placeholder="Or type any emoji, then Enter"
|
|
44
|
+
aria-label="React with any emoji"
|
|
45
|
+
:aria-invalid="customInvalid"
|
|
46
|
+
:aria-describedby="customInvalid ? hintId : undefined"
|
|
47
|
+
@input="customInvalid = false"
|
|
48
|
+
/>
|
|
49
|
+
<p v-if="customInvalid" :id="hintId" class="message-reaction-picker-hint" role="alert">
|
|
50
|
+
Enter one emoji.
|
|
51
|
+
</p>
|
|
52
|
+
</form>
|
|
53
|
+
</div>
|
|
54
|
+
</Teleport>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<script setup lang="ts">
|
|
58
|
+
import { nextTick, onBeforeUnmount, reactive, ref, useId, watch } from "vue";
|
|
59
|
+
import { MESSAGE_REACTION_QUICK_EMOJI, normalizeMessageReactionEmoji } from "../message-reactions.mjs";
|
|
60
|
+
|
|
61
|
+
export interface MessageReactionPickerAnchor {
|
|
62
|
+
element: HTMLElement;
|
|
63
|
+
/** Context-menu position, kept relative to the message as it scrolls. */
|
|
64
|
+
point?: { x: number; y: number };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const COLUMNS = 8;
|
|
68
|
+
const GAP = 6;
|
|
69
|
+
const MARGIN = 8;
|
|
70
|
+
|
|
71
|
+
const props = defineProps<{
|
|
72
|
+
/** Live control or message anchoring the picker; null closes it. */
|
|
73
|
+
anchor: MessageReactionPickerAnchor | null;
|
|
74
|
+
viewerReacted: (emoji: string) => boolean;
|
|
75
|
+
/** Opened from the keyboard: appear without motion. */
|
|
76
|
+
instant?: boolean;
|
|
77
|
+
}>();
|
|
78
|
+
|
|
79
|
+
const emit = defineEmits<{
|
|
80
|
+
select: [emoji: string];
|
|
81
|
+
/**
|
|
82
|
+
* `restoreFocus` is true when the person dismissed it from the keyboard or
|
|
83
|
+
* chose an emoji. `pressed` is what a dismissing press landed on, so the
|
|
84
|
+
* host can tell a press on the control that opened the picker.
|
|
85
|
+
*/
|
|
86
|
+
close: [restoreFocus: boolean, pressed?: EventTarget | null];
|
|
87
|
+
}>();
|
|
88
|
+
|
|
89
|
+
const panel = ref<HTMLElement | null>(null);
|
|
90
|
+
const customInput = ref<HTMLInputElement | null>(null);
|
|
91
|
+
const activeIndex = ref(0);
|
|
92
|
+
const custom = ref("");
|
|
93
|
+
const customInvalid = ref(false);
|
|
94
|
+
const placed = ref(false);
|
|
95
|
+
const position = reactive({ left: 0, top: 0, side: "above" as "above" | "below", align: "start" as "start" | "end" });
|
|
96
|
+
const hintId = useId();
|
|
97
|
+
let frame: number | null = null;
|
|
98
|
+
let pointOffset: { x: number; y: number } | null = null;
|
|
99
|
+
let focusOnPlacement = false;
|
|
100
|
+
let scrollViewport: HTMLElement | null = null;
|
|
101
|
+
|
|
102
|
+
function nearestScrollViewport(element: HTMLElement): HTMLElement | null {
|
|
103
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
104
|
+
if (/^(auto|scroll)$/.test(window.getComputedStyle(parent).overflowY)) return parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function place(): void {
|
|
110
|
+
const element = panel.value;
|
|
111
|
+
const source = props.anchor;
|
|
112
|
+
if (!element || !source) return;
|
|
113
|
+
if (!source.element.isConnected) {
|
|
114
|
+
dismiss();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// All geometry is read in this animation frame, before reactive style writes.
|
|
118
|
+
const rect = source.element.getBoundingClientRect();
|
|
119
|
+
const viewportWidth = window.innerWidth;
|
|
120
|
+
const viewportHeight = window.innerHeight;
|
|
121
|
+
const clip = scrollViewport?.getBoundingClientRect();
|
|
122
|
+
const visible = {
|
|
123
|
+
left: Math.max(0, clip?.left ?? 0),
|
|
124
|
+
right: Math.min(viewportWidth, clip?.right ?? viewportWidth),
|
|
125
|
+
top: Math.max(0, clip?.top ?? 0),
|
|
126
|
+
bottom: Math.min(viewportHeight, clip?.bottom ?? viewportHeight),
|
|
127
|
+
};
|
|
128
|
+
if (source.point && !pointOffset) {
|
|
129
|
+
pointOffset = { x: source.point.x - rect.left, y: source.point.y - rect.top };
|
|
130
|
+
}
|
|
131
|
+
const anchor = pointOffset
|
|
132
|
+
? { left: rect.left + pointOffset.x, right: rect.left + pointOffset.x,
|
|
133
|
+
top: rect.top + pointOffset.y, bottom: rect.top + pointOffset.y }
|
|
134
|
+
: rect;
|
|
135
|
+
// A context menu follows its click point, even when the rest of a tall
|
|
136
|
+
// message is still visible. Controls stay anchored while partially visible.
|
|
137
|
+
if (anchor.bottom <= visible.top || anchor.top >= visible.bottom
|
|
138
|
+
|| anchor.right <= visible.left || anchor.left >= visible.right
|
|
139
|
+
|| visible.bottom <= visible.top || visible.right <= visible.left) {
|
|
140
|
+
dismiss();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const { width, height } = element.getBoundingClientRect();
|
|
144
|
+
const fitsAbove = anchor.top - GAP - height >= MARGIN;
|
|
145
|
+
const fitsBelow = anchor.bottom + GAP + height <= viewportHeight - MARGIN;
|
|
146
|
+
position.side = fitsAbove || !fitsBelow ? "above" : "below";
|
|
147
|
+
const top = position.side === "above" ? anchor.top - GAP - height : anchor.bottom + GAP;
|
|
148
|
+
// Grow away from whichever edge of the window the control is nearer to.
|
|
149
|
+
position.align = (anchor.left + anchor.right) / 2 > viewportWidth / 2 ? "end" : "start";
|
|
150
|
+
const left = position.align === "end" ? anchor.right - width : anchor.left;
|
|
151
|
+
position.left = Math.round(Math.min(Math.max(left, MARGIN), Math.max(MARGIN, viewportWidth - width - MARGIN)));
|
|
152
|
+
position.top = Math.round(Math.min(Math.max(top, MARGIN), Math.max(MARGIN, viewportHeight - height - MARGIN)));
|
|
153
|
+
placed.value = true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function schedulePlace(): void {
|
|
157
|
+
if (frame !== null || !listening) return;
|
|
158
|
+
frame = window.requestAnimationFrame(() => {
|
|
159
|
+
frame = null;
|
|
160
|
+
place();
|
|
161
|
+
if (focusOnPlacement && placed.value) {
|
|
162
|
+
focusOnPlacement = false;
|
|
163
|
+
// Position must be rendered before the initially hidden picker can focus.
|
|
164
|
+
void nextTick(() => { if (listening && props.anchor) focusEmoji(0); });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function focusEmoji(index: number): void {
|
|
170
|
+
const count = MESSAGE_REACTION_QUICK_EMOJI.length;
|
|
171
|
+
activeIndex.value = Math.min(Math.max(index, 0), count - 1);
|
|
172
|
+
panel.value?.querySelectorAll<HTMLButtonElement>(".message-reaction-picker-emoji")[activeIndex.value]?.focus();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function choose(emoji: string): void {
|
|
176
|
+
emit("select", emoji);
|
|
177
|
+
emit("close", true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function chooseCustom(): void {
|
|
181
|
+
const emoji = normalizeMessageReactionEmoji(custom.value);
|
|
182
|
+
if (!emoji) {
|
|
183
|
+
customInvalid.value = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
choose(emoji);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
190
|
+
if (event.key === "Escape") {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
event.stopPropagation();
|
|
193
|
+
emit("close", true);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const inInput = event.target === customInput.value;
|
|
197
|
+
// There are two tab stops: the active emoji and the custom input. Exiting
|
|
198
|
+
// either end returns to the opener instead of losing focus in the teleport.
|
|
199
|
+
if (event.key === "Tab" && (event.shiftKey ? !inInput : inInput)) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
emit("close", true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (inInput) {
|
|
206
|
+
// Leave the caret keys to the text field, except the way back up to the grid.
|
|
207
|
+
if (event.key === "ArrowUp") {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
focusEmoji(activeIndex.value);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const index = activeIndex.value;
|
|
214
|
+
const last = MESSAGE_REACTION_QUICK_EMOJI.length - 1;
|
|
215
|
+
const moves: Record<string, number | "input"> = {
|
|
216
|
+
ArrowRight: Math.min(index + 1, last),
|
|
217
|
+
ArrowLeft: Math.max(index - 1, 0),
|
|
218
|
+
ArrowUp: index - COLUMNS >= 0 ? index - COLUMNS : index,
|
|
219
|
+
ArrowDown: index + COLUMNS <= last ? index + COLUMNS : "input",
|
|
220
|
+
Home: 0,
|
|
221
|
+
End: last,
|
|
222
|
+
};
|
|
223
|
+
const move = moves[event.key];
|
|
224
|
+
if (move === undefined) return;
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
if (move === "input") customInput.value?.focus();
|
|
227
|
+
else focusEmoji(move);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleFocusOut(event: FocusEvent): void {
|
|
231
|
+
// Focus left the picker for something else in the app: it is done.
|
|
232
|
+
const next = event.relatedTarget;
|
|
233
|
+
if (next instanceof Node && !panel.value?.contains(next)) emit("close", false);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function dismiss(): void {
|
|
237
|
+
emit("close", false);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// These listen in the capture phase, so they also see events from inside the picker.
|
|
241
|
+
function dismissUnlessInside(event: Event): void {
|
|
242
|
+
if (event.target instanceof Node && panel.value?.contains(event.target)) return;
|
|
243
|
+
emit("close", false, event.type === "pointerdown" ? event.target : null);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let listening = false;
|
|
247
|
+
function listen(on: boolean): void {
|
|
248
|
+
if (on === listening) return;
|
|
249
|
+
listening = on;
|
|
250
|
+
if (on) {
|
|
251
|
+
window.addEventListener("pointerdown", dismissUnlessInside, true);
|
|
252
|
+
window.addEventListener("scroll", schedulePlace, true);
|
|
253
|
+
window.addEventListener("resize", schedulePlace);
|
|
254
|
+
window.addEventListener("blur", dismiss);
|
|
255
|
+
} else {
|
|
256
|
+
window.removeEventListener("pointerdown", dismissUnlessInside, true);
|
|
257
|
+
window.removeEventListener("scroll", schedulePlace, true);
|
|
258
|
+
window.removeEventListener("resize", schedulePlace);
|
|
259
|
+
window.removeEventListener("blur", dismiss);
|
|
260
|
+
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
261
|
+
frame = null;
|
|
262
|
+
focusOnPlacement = false;
|
|
263
|
+
scrollViewport = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
watch(
|
|
268
|
+
() => props.anchor,
|
|
269
|
+
async (anchor, previous) => {
|
|
270
|
+
if (anchor && !previous) {
|
|
271
|
+
placed.value = false;
|
|
272
|
+
custom.value = "";
|
|
273
|
+
customInvalid.value = false;
|
|
274
|
+
activeIndex.value = 0;
|
|
275
|
+
pointOffset = null;
|
|
276
|
+
scrollViewport = nearestScrollViewport(anchor.element);
|
|
277
|
+
focusOnPlacement = true;
|
|
278
|
+
listen(true);
|
|
279
|
+
await nextTick();
|
|
280
|
+
schedulePlace();
|
|
281
|
+
} else if (!anchor && previous) {
|
|
282
|
+
listen(false);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{ immediate: true },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
onBeforeUnmount(() => listen(false));
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<style src="./message-reactions.css"></style>
|