forked from Mirrors/gomuks
parent
4885dab2e1
commit
a14f01a3ec
15 changed files with 89 additions and 22 deletions
|
@ -79,7 +79,7 @@ require (
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
maunium.net/go/mautrix v0.23.1 // indirect
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca // indirect
|
||||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -261,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.23.1 h1:xZtX43YZF3WRxkdR+oMUrpiQe+jbjc+LeXLxHuXP5IM=
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca h1:xPbRPallD4qh/XuQWheRsvxsf/5stfdA+uIj0S0P2kQ=
|
||||||
maunium.net/go/mautrix v0.23.1/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -27,7 +27,7 @@ require (
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mauflag v1.0.0
|
maunium.net/go/mauflag v1.0.0
|
||||||
maunium.net/go/mautrix v0.23.1
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca
|
||||||
mvdan.cc/xurls/v2 v2.6.0
|
mvdan.cc/xurls/v2 v2.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -100,7 +100,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
maunium.net/go/mautrix v0.23.1 h1:xZtX43YZF3WRxkdR+oMUrpiQe+jbjc+LeXLxHuXP5IM=
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca h1:xPbRPallD4qh/XuQWheRsvxsf/5stfdA+uIj0S0P2kQ=
|
||||||
maunium.net/go/mautrix v0.23.1/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
|
|
|
@ -123,6 +123,9 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
})
|
})
|
||||||
case "get_event":
|
case "get_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
||||||
|
if params.Unredact {
|
||||||
|
return h.GetUnredactedEvent(ctx, params.RoomID, params.EventID)
|
||||||
|
}
|
||||||
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
||||||
})
|
})
|
||||||
case "get_related_events":
|
case "get_related_events":
|
||||||
|
@ -313,6 +316,7 @@ type setProfileFieldParams struct {
|
||||||
type getEventParams struct {
|
type getEventParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventID id.EventID `json:"event_id"`
|
EventID id.EventID `json:"event_id"`
|
||||||
|
Unredact bool `json:"unredact"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type getRelatedEventsParams struct {
|
type getRelatedEventsParams struct {
|
||||||
|
|
|
@ -35,6 +35,26 @@ func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Ev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HiClient) GetUnredactedEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) {
|
||||||
|
if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get event from database: %w", err)
|
||||||
|
// TODO this check doesn't handle events which keep some fields on redaction
|
||||||
|
} else if evt != nil && len(evt.Content) > 2 {
|
||||||
|
h.ReprocessExistingEvent(ctx, evt)
|
||||||
|
return evt, nil
|
||||||
|
} else if serverEvt, err := h.Client.GetUnredactedEventContent(ctx, roomID, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get event from server: %w", err)
|
||||||
|
} else if redactedServerEvt, err := h.Client.GetEvent(ctx, roomID, eventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get redacted event from server: %w", err)
|
||||||
|
// TODO this check will have false positives on actually empty events
|
||||||
|
} else if len(serverEvt.Content.VeryRaw) == 2 {
|
||||||
|
return nil, fmt.Errorf("server didn't return content")
|
||||||
|
} else {
|
||||||
|
serverEvt.Unsigned.RedactedBecause = redactedServerEvt.Unsigned.RedactedBecause
|
||||||
|
return h.processEvent(ctx, serverEvt, nil, nil, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error {
|
||||||
var evts []*event.Event
|
var evts []*event.Event
|
||||||
if refetch {
|
if refetch {
|
||||||
|
|
|
@ -222,17 +222,27 @@ export default class Client {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {
|
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) {
|
||||||
if (typeof room === "string") {
|
if (typeof room === "string") {
|
||||||
room = this.store.rooms.get(room)
|
room = this.store.rooms.get(room)
|
||||||
}
|
}
|
||||||
if (!room || room.eventsByID.has(eventID) || room.requestedEvents.has(eventID)) {
|
if (!room || (!unredact && room.eventsByID.has(eventID)) ||room.requestedEvents.has(eventID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
room.requestedEvents.add(eventID)
|
room.requestedEvents.add(eventID)
|
||||||
this.rpc.getEvent(room.roomID, eventID).then(
|
this.rpc.getEvent(room.roomID, eventID, unredact).then(
|
||||||
evt => room.applyEvent(evt),
|
evt => {
|
||||||
err => console.error(`Failed to fetch event ${eventID}`, err),
|
room.applyEvent(evt, false, unredact)
|
||||||
|
if (unredact) {
|
||||||
|
room.notifyTimelineSubscribers()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
console.error(`Failed to fetch event ${eventID}`, err)
|
||||||
|
if (unredact) {
|
||||||
|
window.alert(`Failed to get unredacted content: ${err}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -218,8 +218,8 @@ export default abstract class RPCClient {
|
||||||
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
return this.request("get_room_state", { room_id, include_members, fetch_members, refetch })
|
||||||
}
|
}
|
||||||
|
|
||||||
getEvent(room_id: RoomID, event_id: EventID): Promise<RawDBEvent> {
|
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> {
|
||||||
return this.request("get_event", { room_id, event_id })
|
return this.request("get_event", { room_id, event_id, unredact })
|
||||||
}
|
}
|
||||||
|
|
||||||
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
|
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
|
||||||
|
|
|
@ -352,13 +352,22 @@ export class RoomStateStore {
|
||||||
return this.applyEvent(evt)
|
return this.applyEvent(evt)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyEvent(evt: RawDBEvent, pending: boolean = false) {
|
setViewingRedacted(evt: MemDBEvent, view: boolean) {
|
||||||
|
evt.viewing_redacted = view
|
||||||
|
this.eventSubs.notify(evt.event_id)
|
||||||
|
this.notifyTimelineSubscribers()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEvent(evt: RawDBEvent, pending: boolean = false, viewRedacted: boolean = false) {
|
||||||
const memEvt = evt as MemDBEvent
|
const memEvt = evt as MemDBEvent
|
||||||
memEvt.mem = true
|
memEvt.mem = true
|
||||||
memEvt.pending = pending
|
memEvt.pending = pending
|
||||||
if (pending) {
|
if (pending) {
|
||||||
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
|
||||||
}
|
}
|
||||||
|
if (viewRedacted) {
|
||||||
|
memEvt.viewing_redacted = true
|
||||||
|
}
|
||||||
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
|
||||||
memEvt.type = evt.decrypted_type
|
memEvt.type = evt.decrypted_type
|
||||||
memEvt.encrypted = evt.content as EncryptedEventContent
|
memEvt.encrypted = evt.content as EncryptedEventContent
|
||||||
|
|
|
@ -158,6 +158,7 @@ export interface MemDBEvent extends BaseDBEvent {
|
||||||
orig_content?: UnknownEventContent
|
orig_content?: UnknownEventContent
|
||||||
orig_local_content?: LocalContent
|
orig_local_content?: LocalContent
|
||||||
last_edit?: MemDBEvent
|
last_edit?: MemDBEvent
|
||||||
|
viewing_redacted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DBAccountData {
|
export interface DBAccountData {
|
||||||
|
|
1
web/src/icons/restore-trash.svg
Normal file
1
web/src/icons/restore-trash.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-320h80v-166l64 62 56-56-160-160-160 160 56 56 64-62v166ZM280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520Zm-400 0v520-520Z"/></svg>
|
After Width: | Height: | Size: 332 B |
|
@ -138,7 +138,8 @@ const TimelineEvent = ({
|
||||||
if (evt.unread_type & UnreadType.Highlight) {
|
if (evt.unread_type & UnreadType.Highlight) {
|
||||||
wrapperClassNames.push("highlight")
|
wrapperClassNames.push("highlight")
|
||||||
}
|
}
|
||||||
if (evt.redacted_by) {
|
const isRedacted = evt.redacted_by && !evt.viewing_redacted
|
||||||
|
if (isRedacted) {
|
||||||
wrapperClassNames.push("redacted-event")
|
wrapperClassNames.push("redacted-event")
|
||||||
}
|
}
|
||||||
if (evt.type === "m.room.member") {
|
if (evt.type === "m.room.member") {
|
||||||
|
@ -173,7 +174,7 @@ const TimelineEvent = ({
|
||||||
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
||||||
let replyAboveMessage: JSX.Element | null = null
|
let replyAboveMessage: JSX.Element | null = null
|
||||||
let replyInMessage: JSX.Element | null = null
|
let replyInMessage: JSX.Element | null = null
|
||||||
if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by && !editHistoryView) {
|
if (isEventID(replyTo) && BodyType !== HiddenEvent && !isRedacted && !editHistoryView) {
|
||||||
const replyElem = <ReplyIDBody
|
const replyElem = <ReplyIDBody
|
||||||
room={roomCtx.store}
|
room={roomCtx.store}
|
||||||
eventID={replyTo}
|
eventID={replyTo}
|
||||||
|
|
|
@ -65,10 +65,11 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
|
||||||
return PolicyRuleBody
|
return PolicyRuleBody
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const isRedacted = evt.redacted_by && !evt.viewing_redacted
|
||||||
// Non-state events
|
// Non-state events
|
||||||
switch (evt.type) {
|
switch (evt.type) {
|
||||||
case "m.room.message":
|
case "m.room.message":
|
||||||
if (evt.redacted_by) {
|
if (isRedacted) {
|
||||||
return RedactedBody
|
return RedactedBody
|
||||||
}
|
}
|
||||||
switch (evt.content?.msgtype) {
|
switch (evt.content?.msgtype) {
|
||||||
|
@ -93,14 +94,14 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
|
||||||
return UnknownMessageBody
|
return UnknownMessageBody
|
||||||
}
|
}
|
||||||
case "m.sticker":
|
case "m.sticker":
|
||||||
if (evt.redacted_by) {
|
if (isRedacted) {
|
||||||
return RedactedBody
|
return RedactedBody
|
||||||
} else if (forReply) {
|
} else if (forReply) {
|
||||||
return TextMessageBody
|
return TextMessageBody
|
||||||
}
|
}
|
||||||
return MediaMessageBody
|
return MediaMessageBody
|
||||||
case "m.room.encrypted":
|
case "m.room.encrypted":
|
||||||
if (evt.redacted_by) {
|
if (isRedacted) {
|
||||||
return RedactedBody
|
return RedactedBody
|
||||||
}
|
}
|
||||||
return EncryptedBody
|
return EncryptedBody
|
||||||
|
|
|
@ -79,7 +79,7 @@ export const usePrimaryItems = (
|
||||||
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
.catch(err => window.alert(`Failed to resend message: ${err}`))
|
||||||
}
|
}
|
||||||
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const moreMenuHeight = 4 * 40
|
const moreMenuHeight = 5 * 40
|
||||||
setForceOpen!(true)
|
setForceOpen!(true)
|
||||||
openModal({
|
openModal({
|
||||||
content: <EventExtraMenu
|
content: <EventExtraMenu
|
||||||
|
|
|
@ -27,6 +27,7 @@ import ViewSourceIcon from "@/icons/code.svg?react"
|
||||||
import DeleteIcon from "@/icons/delete.svg?react"
|
import DeleteIcon from "@/icons/delete.svg?react"
|
||||||
import PinIcon from "@/icons/pin.svg?react"
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
import ReportIcon from "@/icons/report.svg?react"
|
import ReportIcon from "@/icons/report.svg?react"
|
||||||
|
import RestoreTrashIcon from "@/icons/restore-trash.svg?react"
|
||||||
import ShareIcon from "@/icons/share.svg?react"
|
import ShareIcon from "@/icons/share.svg?react"
|
||||||
import UnpinIcon from "@/icons/unpin.svg?react"
|
import UnpinIcon from "@/icons/unpin.svg?react"
|
||||||
|
|
||||||
|
@ -85,6 +86,18 @@ export const useSecondaryItems = (
|
||||||
</RoomContext>,
|
</RoomContext>,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const onClickHideUnredacted = () => {
|
||||||
|
closeModal()
|
||||||
|
roomCtx.store.setViewingRedacted(evt, false)
|
||||||
|
}
|
||||||
|
const onClickUnredact = () => {
|
||||||
|
closeModal()
|
||||||
|
if (Object.entries(evt.content).length > 0) {
|
||||||
|
roomCtx.store.setViewingRedacted(evt, true)
|
||||||
|
} else {
|
||||||
|
client.requestEvent(roomCtx.store, evt.event_id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
const onClickPin = (pin: boolean) => () => {
|
const onClickPin = (pin: boolean) => () => {
|
||||||
closeModal()
|
closeModal()
|
||||||
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
client.pinMessage(roomCtx.store, evt.event_id, pin)
|
||||||
|
@ -146,6 +159,8 @@ export const useSecondaryItems = (
|
||||||
const canRedact = !evt.redacted_by
|
const canRedact = !evt.redacted_by
|
||||||
&& ownPL >= redactEvtPL
|
&& ownPL >= redactEvtPL
|
||||||
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
&& (evt.sender === client.userID || ownPL >= redactOtherPL)
|
||||||
|
// TODO check server admin status and room PLs
|
||||||
|
const canUnredact = Boolean(evt.redacted_by)
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
|
||||||
|
@ -166,5 +181,10 @@ export const useSecondaryItems = (
|
||||||
title={pendingTitle}
|
title={pendingTitle}
|
||||||
className="redact-button"
|
className="redact-button"
|
||||||
><DeleteIcon/>{names && "Remove"}</button>}
|
><DeleteIcon/>{names && "Remove"}</button>}
|
||||||
|
{canUnredact && (evt.viewing_redacted ? <button onClick={onClickHideUnredacted}>
|
||||||
|
<DeleteIcon/>{names && "Hide content"}
|
||||||
|
</button> : <button onClick={onClickUnredact}>
|
||||||
|
<RestoreTrashIcon/>{names && "View content"}
|
||||||
|
</button>)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue