9.1 KiB
Multi-select copy
Copy several consecutive chat messages in one shot on Aliucord Android.
Problem
On desktop Discord you drag across messages, release, Ctrl-C. One gesture, free-form text.
On Android (and thus Aliucord) there is no cross-message selection. The loop is:
- Long-press message
- Tap Copy
- Leave Discord / switch apps
- Paste
- Repeat for the next message
That breaks as soon as you need a contiguous chunk of conversation.
True desktop-style drag-select is a poor fit here. The chat list is a vertical RecyclerView: vertical drag already means scroll. Fighting that for text selection is fragile and fights every other gesture plugin (TapTap, etc.).
So the goal is not "recreate desktop selection". It is: pick a start message, pick an end message, put the inclusive range on the clipboard, with as few new UI surfaces as possible.
Constraints from existing plugins
Patterns that already work in examples/ and the docs:
| Pattern | Examples | Use for |
|---|---|---|
| Add a row to the long-press sheet | MessageLinkContext, ForwardMessages, FavoriteMessages |
Entry point |
| Patch message click | TapTap (onMessageClicked) |
Optional second tap while selecting |
| Clipboard | Utils.setClipboard(label, text) (HoldAuthorToCopyId, FavoriteMessages) |
Output |
| Message store lookup | StoreStream.getMessages().getMessage(channelId, id) |
Resolve content by id |
| Skip bad messages | TapTap skips ephemeral / local / failed / loading | Reliability |
Stay inside those. No custom floating windows, no replacing the chat recycler adapter, no slash command as the primary path.
Recommended UX: range anchors (v1)
Actions on the existing message long-press sheet (WidgetChatListActions). Which rows appear depends on whether a start is armed.
| Sheet state | Rows shown |
|---|---|
| No start armed | Copy from here only |
| Start armed (same channel) | Copy from here (re-arm) + Copy through here |
Copy through here must not appear until a start exists. That removes the dead-end "set a start first" toast and keeps the sheet minimal on normal long-presses.
Flow
Long-press message A → "Copy from here"
→ arm start = (channelId, messageId A)
→ toast: "Start set. Long-press the last message and choose Copy through here."
→ sheet dismisses
Long-press message A again (mistake) → "Copy from here"
→ replace start with A (same message is fine; just re-arms)
→ toast: "Start updated."
→ sheet dismisses
Long-press message C (different message, still a mistake) → "Copy from here"
→ replace start with C
→ toast: "Start updated."
→ sheet dismisses
Long-press message B → "Copy through here" // only visible because start is armed
→ if other channel than start: toast "Start was in another channel", clear start, return
→ lo = min(startId, B), hi = max(startId, B)
→ if startId == B: range is that single message
→ collect loaded messages in channel with id in [lo, hi] inclusive
→ join text, Utils.setClipboard(...), toast "Copied N messages" (or "Copied 1 message")
→ clear start
Order of start vs end does not matter for multi-message ranges. Snowflake IDs are time-ordered.
Copy from here twice (correcting a mistake)
Copy from here is always a set-or-replace, never a toggle and never an error.
- First tap: arm start, toast "Start set…"
- Any later tap (same message or another): overwrite start, toast "Start updated."
That way a wrong first pick is fixed by long-pressing the right message and choosing Copy from here again. No separate clear action.
Same message as start and end
Choosing Copy through here on the same message that is already the start is valid. Treat it as a one-message copy of that message's content (same as Discord's built-in Copy for that bubble), then clear the start. Toast: Copied 1 message.
Useful when the user armed a start, then decided they only needed that one after all, without hunting for Discord's own Copy row.
Why this wins for v1
- Reuses Discord's sheet. Same patch style as wingio / ForwardMessages (~one
configureUIhook). - Sheet stays clean: end action only shows when it can succeed.
- No selection mode chrome, no checkboxes, no sticky action bar.
- One piece of plugin state: optional start anchor.
- Mistakes are cheap: re-arm start in place.
- Does not steal single taps or double-taps from TapTap / reply / jump.
What the user sees
Sheet items (icon + label, UiKit_Settings_Item_Icon, same as MessageLinkContext):
- Always when relevant: Copy from here
- Only after a start is armed in this channel: Copy through here
When Copy through here is shown, a one-line hint is fine, e.g. Start: message from Alice. No bottomsheet redesign.
Toasts only. No modal confirm for the happy path.
Cancel
- Replacing via Copy from here on another (or the same) message, or
- Leaving the channel clears the start (recommended: clear on channel change).
Alternative UX: selection mode (v1.5)
Closer to Gmail / gallery multi-select. Heavier.
Flow
Long-press message → "Select to copy"
→ enter selection mode
→ that message is selected
→ taps toggle messages in the same channel (or expand range to tapped message)
→ top or bottom bar: "Copy (N)" | "Cancel"
Visuals
While selecting, patch WidgetChatListAdapterItemMessage.onConfigure (template already does this for embeds) to tint selected rows or draw a checkbox. That is the main reliability cost: view recycling, grouped messages, embeds, blocked messages.
When to build this
Only if range anchors feel too hidden after trying v1. Selection mode is better for non-contiguous picks; range anchors only do contiguous. For "consecutive messages" (the stated problem), range is enough.
Rejected approaches
| Idea | Why not |
|---|---|
| Drag across messages like desktop | Conflicts with scroll; hard to get right on a recycler |
| Double-tap starts selection | Collides with TapTap reply/edit |
Slash command /copyrange |
Awkward for browsing chat; fine as a debug escape hatch only |
| Replace Discord's single-message Copy | Surprising; breaks muscle memory |
| Persistent floating bubble | Extra surface, theme/z-order pain, not how examples work |
Copy format
Default, keep it plain and paste-friendly:
message one content
message two content
message three content
Blank line between messages. Skip empty content (stickers-only, etc.) or emit a one-line placeholder like [sticker] / [attachment: name] if present.
Optional settings (bottomsheet, Hastebin-style, off by default):
| Setting | Default | Effect |
|---|---|---|
| Include author | off | Alice: hello |
| Include timestamp | off | Prefixed time string |
| Separator | blank line | Or --- |
| Max messages | e.g. 100 | Cap runaway ranges; toast if truncated |
v1 can ship with content-only and a hard max. Settings come later.
Edge cases
- Start/end not both in memory. Chat only keeps a window of loaded messages. Copy only what
StoreStream.getMessages()has for that channel between the ids. Toast if the range looks sparse:Copied 12 messages (some may be unloaded). Do not fetch history over HTTP in v1. - Same message as start and end. Copy that one message, clear start. See above.
- Copy from here twice. Always replace start. Same message or different message, same behavior.
- Copy through here with no start. Should be unreachable in the UI (row hidden). If state races, no-op + clear.
- Ephemeral / failed / local / loading. Do not allow arming start on these; skip them in the collected range. Same idea as TapTap.
- Channel switch / guild switch. Clear armed start (hides Copy through here again).
- Plugin stop / disable. Clear state in
stop.
Implementation sketch (aligned with docs/examples)
Keep the plugin tiny. Rough shape:
plugins/multi-select-copy/
UX.md ← this file
README.md ← user-facing summary once coded
(later) MultiSelectCopy.kt
(later) build.gradle.kts
Code path for v1:
patcher.after/HookonWidgetChatListActions.configureUI— always add Copy from here; add Copy through here only ifstart != nullandstart.channelId == current message's channel. Mirror MessageLinkContext / ForwardMessages / FavoriteMessages' conditional row.- In-memory
var start: Pair<Long, Long>?= channelId + messageId. - On Copy from here:
start = (channelId, messageId)(overwrite if already set); toast set vs updated. - On Copy through here: if
startId == endId, copy that one message; else walk loaded messages with id in[lo, hi];Utils.setClipboard("Messages", text); toast; clear start. stop→patcher.unpatchAll()+ clear start.
No commands required. Optional later: a settings bottomsheet for format flags.
Recommendation
Ship range anchors first. It matches the desktop outcome (contiguous chunk on the clipboard) without pretending Android has mouse-drag selection, and it stays inside the patch styles already proven in examples/.
Selection mode is a follow-up if people need cherry-picking or clearer affordances.