diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index e09d07f14..5330d080f 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -407,6 +407,73 @@ definitions:
         type: object
         x-go-name: Relationship
         x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+    adminEmoji:
+        properties:
+            category:
+                description: Used for sorting custom emoji in the picker.
+                example: blobcats
+                type: string
+                x-go-name: Category
+            content_type:
+                description: The MIME content type of the emoji.
+                example: image/png
+                type: string
+                x-go-name: ContentType
+            disabled:
+                description: True if this emoji has been disabled by an admin action.
+                example: false
+                type: boolean
+                x-go-name: Disabled
+            domain:
+                description: The domain from which the emoji originated. Only defined for remote domains, otherwise key will not be set.
+                example: example.org
+                type: string
+                x-go-name: Domain
+            id:
+                description: The ID of the emoji.
+                example: 01GEM7SFDZ7GZNRXFVZ3X4E4N1
+                type: string
+                x-go-name: ID
+            shortcode:
+                description: The name of the custom emoji.
+                example: blobcat_uwu
+                type: string
+                x-go-name: Shortcode
+            static_url:
+                description: A link to a static copy of the custom emoji.
+                example: https://example.org/fileserver/emojis/blogcat_uwu.png
+                type: string
+                x-go-name: StaticURL
+            total_file_size:
+                description: The total file size taken up by the emoji in bytes, including static and animated versions.
+                example: 69420
+                format: int64
+                type: integer
+                x-go-name: TotalFileSize
+            updated_at:
+                description: Time when the emoji image was last updated.
+                example: "2022-10-05T09:21:26.419Z"
+                type: string
+                x-go-name: UpdatedAt
+            uri:
+                description: The ActivityPub URI of the emoji.
+                example: https://example.org/emojis/016T5Q3SQKBT337DAKVSKNXXW1
+                type: string
+                x-go-name: URI
+            url:
+                description: Web URL of the custom emoji.
+                example: https://example.org/fileserver/emojis/blogcat_uwu.gif
+                type: string
+                x-go-name: URL
+            visible_in_picker:
+                description: Emoji is visible in the emoji picker of the instance.
+                example: true
+                type: boolean
+                x-go-name: VisibleInPicker
+        title: AdminEmoji models the admin view of a custom emoji.
+        type: object
+        x-go-name: AdminEmoji
+        x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
     advancedStatusCreateForm:
         description: |-
             AdvancedStatusCreateForm wraps the mastodon-compatible status create form along with the GTS advanced
@@ -2677,6 +2744,80 @@ paths:
             tags:
                 - admin
     /api/v1/admin/custom_emojis:
+        get:
+            description: |-
+                The next and previous queries can be parsed from the returned Link header.
+                Example:
+
+                `<http://localhost:8080/api/v1/admin/custom_emojis?limit=30&max_shortcode_domain=yell@fossbros-anonymous.io&filter=domain:all>; rel="next", <http://localhost:8080/api/v1/admin/custom_emojis?limit=30&min_shortcode_domain=rainbow@&filter=domain:all>; rel="prev"`
+            operationId: emojisGet
+            parameters:
+                - default: domain:all
+                  description: |-
+                    Comma-separated list of filters to apply to results. Recognized filters are:
+
+                    `domain:[domain]` -- show emojis from the given domain, eg `?filter=domain:example.org` will show emojis from `example.org` only.
+                    Instead of giving a specific domain, you can also give either one of the key words `local` or `all` to show either local emojis only (`domain:local`) or show all emojis from all domains (`domain:all`).
+                    Note: `domain:*` is equivalent to `domain:all` (including local).
+                    If no domain filter is provided, `domain:all` will be assumed.
+
+                    `disabled` -- include emojis that have been disabled.
+
+                    `enabled` -- include emojis that are enabled.
+
+                    `shortcode:[shortcode]` -- show only emojis with the given shortcode, eg `?filter=shortcode:blob_cat_uwu` will show only emojis with the shortcode `blob_cat_uwu` (case sensitive).
+
+                    If neither `disabled` or `enabled` are provided, both disabled and enabled emojis will be shown.
+
+                    If no filter query string is provided, the default `domain:all` will be used, which will show all emojis from all domains.
+                  in: query
+                  name: filter
+                  type: string
+                - default: 30
+                  description: Number of emojis to return. If below 1, will be set to 1, if greater than 50, will be set to 50.
+                  in: query
+                  name: limit
+                  type: integer
+                - description: |-
+                    Return only emojis with `[shortcode]@[domain]` *LOWER* (alphabetically) than given `[shortcode]@[domain]`. For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with `[shortcode]@[domain]`s like `car@example.org`, `debian@aaa.com`, `test@` (local emoji), etc.
+                    Emoji with the given `[shortcode]@[domain]` will not be included in the result set.
+                  in: query
+                  name: max_shortcode_domain
+                  type: string
+                - description: |-
+                    Return only emojis with `[shortcode]@[domain]` *HIGHER* (alphabetically) than given `[shortcode]@[domain]`. For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with `[shortcode]@[domain]`s like `arse@test.com`, `0101_binary@hackers.net`, `bee@` (local emoji), etc.
+                    Emoji with the given `[shortcode]@[domain]` will not be included in the result set.
+                  in: query
+                  name: min_shortcode_domain
+                  type: string
+            produces:
+                - application/json
+            responses:
+                "200":
+                    description: An array of emojis, arranged alphabetically by shortcode and domain.
+                    headers:
+                        Link:
+                            description: Links to the next and previous queries.
+                            type: string
+                    schema:
+                        items:
+                            $ref: '#/definitions/adminEmoji'
+                        type: array
+                "400":
+                    description: bad request
+                "401":
+                    description: unauthorized
+                "403":
+                    description: forbidden
+                "404":
+                    description: not found
+                "406":
+                    description: not acceptable
+                "500":
+                    description: internal server error
+            summary: View local and remote emojis available to / known by this instance.
+            tags:
+                - admin
         post:
             consumes:
                 - multipart/form-data
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go
index 2044c4ab0..e8aac0dee 100644
--- a/internal/api/client/admin/admin.go
+++ b/internal/api/client/admin/admin.go
@@ -31,6 +31,8 @@ const (
 	BasePath = "/api/v1/admin"
 	// EmojiPath is used for posting/deleting custom emojis.
 	EmojiPath = BasePath + "/custom_emojis"
+	// EmojiPathWithID is used for interacting with a single emoji.
+	EmojiPathWithID = EmojiPath + "/:" + IDKey
 	// DomainBlocksPath is used for posting domain blocks.
 	DomainBlocksPath = BasePath + "/domain_blocks"
 	// DomainBlocksPathWithID is used for interacting with a single domain block.
@@ -49,6 +51,16 @@ const (
 	ImportQueryKey = "import"
 	// IDKey specifies the ID of a single item being interacted with.
 	IDKey = "id"
+	// FilterKey is for applying filters to admin views of accounts, emojis, etc.
+	FilterQueryKey = "filter"
+	// MaxShortcodeDomainKey is the url query for returning emoji results lower (alphabetically)
+	// than the given `[shortcode]@[domain]` parameter.
+	MaxShortcodeDomainKey = "max_shortcode_domain"
+	// MaxShortcodeDomainKey is the url query for returning emoji results higher (alphabetically)
+	// than the given `[shortcode]@[domain]` parameter.
+	MinShortcodeDomainKey = "min_shortcode_domain"
+	// LimitKey is for specifying maximum number of results to return.
+	LimitKey = "limit"
 )
 
 // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
@@ -66,6 +78,7 @@ func New(processor processing.Processor) api.ClientModule {
 // Route attaches all routes from this module to the given router
 func (m *Module) Route(r router.Router) error {
 	r.AttachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler)
+	r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler)
 	r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler)
 	r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler)
 	r.AttachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)
diff --git a/internal/api/client/admin/emojiget.go b/internal/api/client/admin/emojiget.go
new file mode 100644
index 000000000..7c44f45d4
--- /dev/null
+++ b/internal/api/client/admin/emojiget.go
@@ -0,0 +1,211 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"github.com/superseriousbusiness/gotosocial/internal/api"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// EmojisGETHandler swagger:operation GET /api/v1/admin/custom_emojis emojisGet
+//
+// View local and remote emojis available to / known by this instance.
+//
+// The next and previous queries can be parsed from the returned Link header.
+// Example:
+//
+// `<http://localhost:8080/api/v1/admin/custom_emojis?limit=30&max_shortcode_domain=yell@fossbros-anonymous.io&filter=domain:all>; rel="next", <http://localhost:8080/api/v1/admin/custom_emojis?limit=30&min_shortcode_domain=rainbow@&filter=domain:all>; rel="prev"`
+//
+//	---
+//	tags:
+//	- admin
+//
+//	produces:
+//	- application/json
+//
+//	parameters:
+//	-
+//		name: filter
+//		type: string
+//		description: |-
+//			Comma-separated list of filters to apply to results. Recognized filters are:
+//
+//			`domain:[domain]` -- show emojis from the given domain, eg `?filter=domain:example.org` will show emojis from `example.org` only.
+//			Instead of giving a specific domain, you can also give either one of the key words `local` or `all` to show either local emojis only (`domain:local`) or show all emojis from all domains (`domain:all`).
+//			Note: `domain:*` is equivalent to `domain:all` (including local).
+//			If no domain filter is provided, `domain:all` will be assumed.
+//
+//			`disabled` -- include emojis that have been disabled.
+//
+//			`enabled` -- include emojis that are enabled.
+//
+//			`shortcode:[shortcode]` -- show only emojis with the given shortcode, eg `?filter=shortcode:blob_cat_uwu` will show only emojis with the shortcode `blob_cat_uwu` (case sensitive).
+//
+//			If neither `disabled` or `enabled` are provided, both disabled and enabled emojis will be shown.
+//
+//			If no filter query string is provided, the default `domain:all` will be used, which will show all emojis from all domains.
+//		in: query
+//		required: false
+//		default: "domain:all"
+//	-
+//		name: limit
+//		type: integer
+//		description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
+//		default: 50
+//		in: query
+//	-
+//		name: max_shortcode_domain
+//		type: string
+//		description: >-
+//			Return only emojis with `[shortcode]@[domain]` *LOWER* (alphabetically) than given `[shortcode]@[domain]`.
+//			For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with
+//			`[shortcode]@[domain]`s like `car@example.org`, `debian@aaa.com`, `test@` (local emoji), etc.
+//
+//			Emoji with the given `[shortcode]@[domain]` will not be included in the result set.
+//		in: query
+//	-
+//		name: min_shortcode_domain
+//		type: string
+//		description: >-
+//			Return only emojis with `[shortcode]@[domain]` *HIGHER* (alphabetically) than given `[shortcode]@[domain]`.
+//			For example, if `max_shortcode_domain=beep@example.org`, then returned values might include emojis with
+//			`[shortcode]@[domain]`s like `arse@test.com`, `0101_binary@hackers.net`, `bee@` (local emoji), etc.
+//
+//			Emoji with the given `[shortcode]@[domain]` will not be included in the result set.
+//		in: query
+//
+//	responses:
+//		'200':
+//			headers:
+//				Link:
+//					type: string
+//					description: Links to the next and previous queries.
+//			description: An array of emojis, arranged alphabetically by shortcode and domain.
+//			schema:
+//				type: array
+//				items:
+//					"$ref": "#/definitions/adminEmoji"
+//		'400':
+//			description: bad request
+//		'401':
+//			description: unauthorized
+//		'403':
+//			description: forbidden
+//		'404':
+//			description: not found
+//		'406':
+//			description: not acceptable
+//		'500':
+//			description: internal server error
+func (m *Module) EmojisGETHandler(c *gin.Context) {
+	authed, err := oauth.Authed(c, true, true, true, true)
+	if err != nil {
+		api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
+		return
+	}
+
+	if !*authed.User.Admin {
+		err := fmt.Errorf("user %s not an admin", authed.User.ID)
+		api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet)
+		return
+	}
+
+	if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
+		api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+		return
+	}
+
+	maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
+	minShortcodeDomain := c.Query(MinShortcodeDomainKey)
+
+	limit := 50
+	limitString := c.Query(LimitKey)
+	if limitString != "" {
+		i, err := strconv.ParseInt(limitString, 10, 64)
+		if err != nil {
+			err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
+			api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+			return
+		}
+		limit = int(i)
+	}
+	if limit < 0 {
+		limit = 0
+	}
+
+	var domain string
+	var includeDisabled bool
+	var includeEnabled bool
+	var shortcode string
+	if filterParam := c.Query(FilterQueryKey); filterParam != "" {
+		filters := strings.Split(filterParam, ",")
+		for _, filter := range filters {
+			lower := strings.ToLower(filter)
+			switch {
+			case strings.HasPrefix(lower, "domain:"):
+				domain = strings.TrimPrefix(lower, "domain:")
+			case lower == "disabled":
+				includeDisabled = true
+			case lower == "enabled":
+				includeEnabled = true
+			case strings.HasPrefix(lower, "shortcode:"):
+				shortcode = strings.Trim(filter[10:], ":") // remove any errant ":"
+			default:
+				err := fmt.Errorf("filter %s not recognized; accepted values are 'domain:[domain]', 'disabled', 'enabled', 'shortcode:[shortcode]'", filter)
+				api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+				return
+			}
+		}
+	}
+
+	if domain == "" {
+		// default is to show all domains
+		domain = db.EmojiAllDomains
+	} else if domain == "local" || domain == config.GetHost() || domain == config.GetAccountDomain() {
+		// pass empty string for local domain
+		domain = ""
+	}
+
+	// normalize filters
+	if !includeDisabled && !includeEnabled {
+		// include both if neither specified
+		includeDisabled = true
+		includeEnabled = true
+	}
+
+	resp, errWithCode := m.processor.AdminEmojisGet(c.Request.Context(), authed, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit)
+	if errWithCode != nil {
+		api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+		return
+	}
+
+	if resp.LinkHeader != "" {
+		c.Header("Link", resp.LinkHeader)
+	}
+	c.JSON(http.StatusOK, resp.Items)
+}
diff --git a/internal/api/client/admin/emojiget_test.go b/internal/api/client/admin/emojiget_test.go
new file mode 100644
index 000000000..bba5561af
--- /dev/null
+++ b/internal/api/client/admin/emojiget_test.go
@@ -0,0 +1,114 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin_test
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+)
+
+type EmojiGetTestSuite struct {
+	AdminStandardTestSuite
+}
+
+func (suite *EmojiGetTestSuite) TestEmojiGet() {
+	recorder := httptest.NewRecorder()
+
+	path := admin.EmojiPath + "?filter=domain:all&limit=1"
+	ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
+
+	suite.adminModule.EmojisGETHandler(ctx)
+	suite.Equal(http.StatusOK, recorder.Code)
+
+	b, err := io.ReadAll(recorder.Body)
+	suite.NoError(err)
+	suite.NotNil(b)
+
+	apiEmojis := []*apimodel.AdminEmoji{}
+	if err := json.Unmarshal(b, &apiEmojis); err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Len(apiEmojis, 1)
+	suite.Equal("rainbow", apiEmojis[0].Shortcode)
+	suite.Equal("", apiEmojis[0].Domain)
+
+	suite.Equal(`<http://localhost:8080/api/v1/admin/custom_emojis?limit=1&max_shortcode_domain=rainbow@&filter=domain:all>; rel="next", <http://localhost:8080/api/v1/admin/custom_emojis?limit=1&min_shortcode_domain=rainbow@&filter=domain:all>; rel="prev"`, recorder.Header().Get("link"))
+}
+
+func (suite *EmojiGetTestSuite) TestEmojiGet2() {
+	recorder := httptest.NewRecorder()
+
+	path := admin.EmojiPath + "?filter=domain:all&limit=1&max_shortcode_domain=rainbow@"
+	ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
+
+	suite.adminModule.EmojisGETHandler(ctx)
+	suite.Equal(http.StatusOK, recorder.Code)
+
+	b, err := io.ReadAll(recorder.Body)
+	suite.NoError(err)
+	suite.NotNil(b)
+
+	apiEmojis := []*apimodel.AdminEmoji{}
+	if err := json.Unmarshal(b, &apiEmojis); err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Len(apiEmojis, 1)
+	suite.Equal("yell", apiEmojis[0].Shortcode)
+	suite.Equal("fossbros-anonymous.io", apiEmojis[0].Domain)
+
+	suite.Equal(`<http://localhost:8080/api/v1/admin/custom_emojis?limit=1&max_shortcode_domain=yell@fossbros-anonymous.io&filter=domain:all>; rel="next", <http://localhost:8080/api/v1/admin/custom_emojis?limit=1&min_shortcode_domain=yell@fossbros-anonymous.io&filter=domain:all>; rel="prev"`, recorder.Header().Get("link"))
+}
+
+func (suite *EmojiGetTestSuite) TestEmojiGet3() {
+	recorder := httptest.NewRecorder()
+
+	path := admin.EmojiPath + "?filter=domain:all&limit=1&min_shortcode_domain=yell@fossbros-anonymous.io"
+	ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
+
+	suite.adminModule.EmojisGETHandler(ctx)
+	suite.Equal(http.StatusOK, recorder.Code)
+
+	b, err := io.ReadAll(recorder.Body)
+	suite.NoError(err)
+	suite.NotNil(b)
+
+	apiEmojis := []*apimodel.AdminEmoji{}
+	if err := json.Unmarshal(b, &apiEmojis); err != nil {
+		suite.FailNow(err.Error())
+	}
+
+	suite.Len(apiEmojis, 1)
+	suite.Equal("rainbow", apiEmojis[0].Shortcode)
+	suite.Equal("", apiEmojis[0].Domain)
+
+	suite.Equal(`<http://localhost:8080/api/v1/admin/custom_emojis?limit=1&max_shortcode_domain=rainbow@&filter=domain:all>; rel="next", <http://localhost:8080/api/v1/admin/custom_emojis?limit=1&min_shortcode_domain=rainbow@&filter=domain:all>; rel="prev"`, recorder.Header().Get("link"))
+}
+
+func TestEmojiGetTestSuite(t *testing.T) {
+	suite.Run(t, &EmojiGetTestSuite{})
+}
diff --git a/internal/api/client/admin/mediacleanup_test.go b/internal/api/client/admin/mediacleanup_test.go
index 039bb7598..d2713084e 100644
--- a/internal/api/client/admin/mediacleanup_test.go
+++ b/internal/api/client/admin/mediacleanup_test.go
@@ -40,7 +40,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanup() {
 
 	// set up the request
 	recorder := httptest.NewRecorder()
-	ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 1}"), admin.EmojiPath, "application/json")
+	ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 1}"), admin.MediaCleanupPath, "application/json")
 
 	// call the handler
 	suite.adminModule.MediaCleanupPOSTHandler(ctx)
@@ -66,7 +66,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNoArg() {
 
 	// set up the request
 	recorder := httptest.NewRecorder()
-	ctx := suite.newContext(recorder, http.MethodPost, []byte("{}"), admin.EmojiPath, "application/json")
+	ctx := suite.newContext(recorder, http.MethodPost, []byte("{}"), admin.MediaCleanupPath, "application/json")
 
 	// call the handler
 	suite.adminModule.MediaCleanupPOSTHandler(ctx)
@@ -90,7 +90,7 @@ func (suite *MediaCleanupTestSuite) TestMediaCleanupNotOldEnough() {
 
 	// set up the request
 	recorder := httptest.NewRecorder()
-	ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 10000}"), admin.EmojiPath, "application/json")
+	ctx := suite.newContext(recorder, http.MethodPost, []byte("{\"remote_cache_days\": 10000}"), admin.MediaCleanupPath, "application/json")
 
 	// call the handler
 	suite.adminModule.MediaCleanupPOSTHandler(ctx)
diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go
index 023ba42b1..e5c956e0d 100644
--- a/internal/api/model/admin.go
+++ b/internal/api/model/admin.go
@@ -80,6 +80,35 @@ type AdminReportInfo struct {
 	Statuses []Status `json:"statuses"`
 }
 
+// AdminEmoji models the admin view of a custom emoji.
+//
+// swagger:model adminEmoji
+type AdminEmoji struct {
+	Emoji
+	// The ID of the emoji.
+	// example: 01GEM7SFDZ7GZNRXFVZ3X4E4N1
+	ID string `json:"id"`
+	// True if this emoji has been disabled by an admin action.
+	// example: false
+	Disabled bool `json:"disabled"`
+	// The domain from which the emoji originated. Only defined for remote domains, otherwise key will not be set.
+	//
+	// example: example.org
+	Domain string `json:"domain,omitempty"`
+	// Time when the emoji image was last updated.
+	// example: 2022-10-05T09:21:26.419Z
+	UpdatedAt string `json:"updated_at"`
+	// The total file size taken up by the emoji in bytes, including static and animated versions.
+	// example: 69420
+	TotalFileSize int `json:"total_file_size"`
+	// The MIME content type of the emoji.
+	// example: image/png
+	ContentType string `json:"content_type"`
+	// The ActivityPub URI of the emoji.
+	// example: https://example.org/emojis/016T5Q3SQKBT337DAKVSKNXXW1
+	URI string `json:"uri"`
+}
+
 // AdminAccountActionRequest models the admin view of an account's details.
 //
 // swagger:ignore
diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go
index e781e2f00..640e354c4 100644
--- a/internal/db/bundb/emoji.go
+++ b/internal/db/bundb/emoji.go
@@ -27,6 +27,7 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
 	"github.com/uptrace/bun"
+	"github.com/uptrace/bun/dialect"
 )
 
 type emojiDB struct {
@@ -49,7 +50,124 @@ func (e *emojiDB) PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) db.Error
 	return nil
 }
 
-func (e *emojiDB) GetCustomEmojis(ctx context.Context) ([]*gtsmodel.Emoji, db.Error) {
+func (e *emojiDB) GetEmojis(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, db.Error) {
+	emojiIDs := []string{}
+
+	subQuery := e.conn.
+		NewSelect().
+		ColumnExpr("? AS ?", bun.Ident("emoji.id"), bun.Ident("emoji_ids"))
+
+	// To ensure consistent ordering and make paging possible, we sort not by shortcode
+	// but by [shortcode]@[domain]. Because sqlite and postgres have different syntax
+	// for concatenation, that means we need to switch here. Depending on which driver
+	// is in use, query will look something like this (sqlite):
+	//
+	//	SELECT
+	//		"emoji"."id" AS "emoji_ids",
+	//		lower("emoji"."shortcode" || '@' || COALESCE("emoji"."domain", '')) AS "shortcode_domain"
+	//	FROM
+	//		"emojis" AS "emoji"
+	//	ORDER BY
+	//		"shortcode_domain" ASC
+	//
+	// Or like this (postgres):
+	//
+	//	SELECT
+	//		"emoji"."id" AS "emoji_ids",
+	//		LOWER(CONCAT("emoji"."shortcode", '@', COALESCE("emoji"."domain", ''))) AS "shortcode_domain"
+	//	FROM
+	//		"emojis" AS "emoji"
+	//	ORDER BY
+	//		"shortcode_domain" ASC
+	switch e.conn.Dialect().Name() {
+	case dialect.SQLite:
+		subQuery = subQuery.ColumnExpr("LOWER(? || ? || COALESCE(?, ?)) AS ?", bun.Ident("emoji.shortcode"), "@", bun.Ident("emoji.domain"), "", bun.Ident("shortcode_domain"))
+	case dialect.PG:
+		subQuery = subQuery.ColumnExpr("LOWER(CONCAT(?, ?, COALESCE(?, ?))) AS ?", bun.Ident("emoji.shortcode"), "@", bun.Ident("emoji.domain"), "", bun.Ident("shortcode_domain"))
+	default:
+		panic("db conn was neither pg not sqlite")
+	}
+
+	subQuery = subQuery.TableExpr("? AS ?", bun.Ident("emojis"), bun.Ident("emoji"))
+
+	if domain == "" {
+		subQuery = subQuery.Where("? IS NULL", bun.Ident("emoji.domain"))
+	} else if domain != db.EmojiAllDomains {
+		subQuery = subQuery.Where("? = ?", bun.Ident("emoji.domain"), domain)
+	}
+
+	switch {
+	case includeDisabled && !includeEnabled:
+		// show only disabled emojis
+		subQuery = subQuery.Where("? = ?", bun.Ident("emoji.disabled"), true)
+	case includeEnabled && !includeDisabled:
+		// show only enabled emojis
+		subQuery = subQuery.Where("? = ?", bun.Ident("emoji.disabled"), false)
+	default:
+		// show emojis regardless of emoji.disabled value
+	}
+
+	if shortcode != "" {
+		subQuery = subQuery.Where("LOWER(?) = LOWER(?)", bun.Ident("emoji.shortcode"), shortcode)
+	}
+
+	// assume we want to sort ASC (a-z) unless informed otherwise
+	order := "ASC"
+
+	if maxShortcodeDomain != "" {
+		subQuery = subQuery.Where("? > LOWER(?)", bun.Ident("shortcode_domain"), maxShortcodeDomain)
+	}
+
+	if minShortcodeDomain != "" {
+		subQuery = subQuery.Where("? < LOWER(?)", bun.Ident("shortcode_domain"), minShortcodeDomain)
+		// if we have a minShortcodeDomain we're paging upwards/backwards
+		order = "DESC"
+	}
+
+	subQuery = subQuery.Order("shortcode_domain " + order)
+
+	if limit > 0 {
+		subQuery = subQuery.Limit(limit)
+	}
+
+	// Wrap the subQuery in a query, since we don't need to select the shortcode_domain column.
+	//
+	// The final query will come out looking something like...
+	//
+	//	SELECT
+	//		"subquery"."emoji_ids"
+	//	FROM (
+	//		SELECT
+	//			"emoji"."id" AS "emoji_ids",
+	//			LOWER("emoji"."shortcode" || '@' || COALESCE("emoji"."domain", '')) AS "shortcode_domain"
+	//		FROM
+	//			"emojis" AS "emoji"
+	//		ORDER BY
+	//			"shortcode_domain" ASC
+	//	) AS "subquery"
+	if err := e.conn.
+		NewSelect().
+		Column("subquery.emoji_ids").
+		TableExpr("(?) AS ?", subQuery, bun.Ident("subquery")).
+		Scan(ctx, &emojiIDs); err != nil {
+		return nil, e.conn.ProcessError(err)
+	}
+
+	if order == "DESC" {
+		// Reverse the slice order so the caller still
+		// gets emojis in expected a-z alphabetical order.
+		//
+		// See https://github.com/golang/go/wiki/SliceTricks#reversing
+		for i := len(emojiIDs)/2 - 1; i >= 0; i-- {
+			opp := len(emojiIDs) - 1 - i
+			emojiIDs[i], emojiIDs[opp] = emojiIDs[opp], emojiIDs[i]
+		}
+	}
+
+	return e.emojisFromIDs(ctx, emojiIDs)
+}
+
+func (e *emojiDB) GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, db.Error) {
 	emojiIDs := []string{}
 
 	q := e.conn.
diff --git a/internal/db/bundb/emoji_test.go b/internal/db/bundb/emoji_test.go
index 0a1546d91..3c61fb620 100644
--- a/internal/db/bundb/emoji_test.go
+++ b/internal/db/bundb/emoji_test.go
@@ -23,20 +23,108 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/suite"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
 )
 
 type EmojiTestSuite struct {
 	BunDBStandardTestSuite
 }
 
-func (suite *EmojiTestSuite) TestGetCustomEmojis() {
-	emojis, err := suite.db.GetCustomEmojis(context.Background())
+func (suite *EmojiTestSuite) TestGetUseableEmojis() {
+	emojis, err := suite.db.GetUseableEmojis(context.Background())
 
 	suite.NoError(err)
 	suite.Equal(1, len(emojis))
 	suite.Equal("rainbow", emojis[0].Shortcode)
 }
 
+func (suite *EmojiTestSuite) TestGetAllEmojis() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(2, len(emojis))
+	suite.Equal("rainbow", emojis[0].Shortcode)
+	suite.Equal("yell", emojis[1].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetAllEmojisLimit1() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "", 1)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("rainbow", emojis[0].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetAllEmojisMaxID() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "rainbow@", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("yell", emojis[0].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetAllEmojisMinID() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, true, "", "", "yell@fossbros-anonymous.io", 0)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("rainbow", emojis[0].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetAllDisabledEmojis() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, true, false, "", "", "", 0)
+
+	suite.ErrorIs(err, db.ErrNoEntries)
+	suite.Equal(0, len(emojis))
+}
+
+func (suite *EmojiTestSuite) TestGetAllEnabledEmojis() {
+	emojis, err := suite.db.GetEmojis(context.Background(), db.EmojiAllDomains, false, true, "", "", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(2, len(emojis))
+	suite.Equal("rainbow", emojis[0].Shortcode)
+	suite.Equal("yell", emojis[1].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetLocalEnabledEmojis() {
+	emojis, err := suite.db.GetEmojis(context.Background(), "", false, true, "", "", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("rainbow", emojis[0].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetLocalDisabledEmojis() {
+	emojis, err := suite.db.GetEmojis(context.Background(), "", true, false, "", "", "", 0)
+
+	suite.ErrorIs(err, db.ErrNoEntries)
+	suite.Equal(0, len(emojis))
+}
+
+func (suite *EmojiTestSuite) TestGetAllEmojisFromDomain() {
+	emojis, err := suite.db.GetEmojis(context.Background(), "peepee.poopoo", true, true, "", "", "", 0)
+
+	suite.ErrorIs(err, db.ErrNoEntries)
+	suite.Equal(0, len(emojis))
+}
+
+func (suite *EmojiTestSuite) TestGetAllEmojisFromDomain2() {
+	emojis, err := suite.db.GetEmojis(context.Background(), "fossbros-anonymous.io", true, true, "", "", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("yell", emojis[0].Shortcode)
+}
+
+func (suite *EmojiTestSuite) TestGetSpecificEmojisFromDomain2() {
+	emojis, err := suite.db.GetEmojis(context.Background(), "fossbros-anonymous.io", true, true, "yell", "", "", 0)
+
+	suite.NoError(err)
+	suite.Equal(1, len(emojis))
+	suite.Equal("yell", emojis[0].Shortcode)
+}
+
 func TestEmojiTestSuite(t *testing.T) {
 	suite.Run(t, new(EmojiTestSuite))
 }
diff --git a/internal/db/emoji.go b/internal/db/emoji.go
index 374fd7b12..4316a43ef 100644
--- a/internal/db/emoji.go
+++ b/internal/db/emoji.go
@@ -24,12 +24,18 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 )
 
+// EmojiAllDomains can be used as the `domain` value in a GetEmojis
+// query to indicate that emojis from all domains should be returned.
+const EmojiAllDomains string = "all"
+
 // Emoji contains functions for getting emoji in the database.
 type Emoji interface {
 	// PutEmoji puts one emoji in the database.
 	PutEmoji(ctx context.Context, emoji *gtsmodel.Emoji) Error
-	// GetCustomEmojis gets all custom emoji for the instance
-	GetCustomEmojis(ctx context.Context) ([]*gtsmodel.Emoji, Error)
+	// GetUseableEmojis gets all emojis which are useable by accounts on this instance.
+	GetUseableEmojis(ctx context.Context) ([]*gtsmodel.Emoji, Error)
+	// GetEmojis gets emojis based on given parameters. Useful for admin actions.
+	GetEmojis(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, Error)
 	// GetEmojiByID gets a specific emoji by its database ID.
 	GetEmojiByID(ctx context.Context, id string) (*gtsmodel.Emoji, Error)
 	// GetEmojiByShortcodeDomain gets an emoji based on its shortcode and domain.
diff --git a/internal/processing/admin.go b/internal/processing/admin.go
index cbbea05b1..59a4f8f1b 100644
--- a/internal/processing/admin.go
+++ b/internal/processing/admin.go
@@ -34,6 +34,10 @@ func (p *processor) AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, fo
 	return p.adminProcessor.EmojiCreate(ctx, authed.Account, authed.User, form)
 }
 
+func (p *processor) AdminEmojisGet(ctx context.Context, authed *oauth.Auth, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
+	return p.adminProcessor.EmojisGet(ctx, authed.Account, authed.User, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit)
+}
+
 func (p *processor) AdminDomainBlockCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) {
 	return p.adminProcessor.DomainBlockCreate(ctx, authed.Account, form.Domain, form.Obfuscate, form.PublicComment, form.PrivateComment, "")
 }
diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go
index c528f0fb8..0de165fb9 100644
--- a/internal/processing/admin/admin.go
+++ b/internal/processing/admin/admin.go
@@ -41,6 +41,7 @@ type Processor interface {
 	DomainBlockDelete(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode)
 	AccountAction(ctx context.Context, account *gtsmodel.Account, form *apimodel.AdminAccountActionRequest) gtserror.WithCode
 	EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)
+	EmojisGet(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
 	MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
 }
 
diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/createemoji.go
similarity index 100%
rename from internal/processing/admin/emoji.go
rename to internal/processing/admin/createemoji.go
diff --git a/internal/processing/admin/getemojis.go b/internal/processing/admin/getemojis.go
new file mode 100644
index 000000000..d44b4d250
--- /dev/null
+++ b/internal/processing/admin/getemojis.go
@@ -0,0 +1,101 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+	"github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *processor) EmojisGet(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
+	if !*user.Admin {
+		return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin")
+	}
+
+	emojis, err := p.db.GetEmojis(ctx, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit)
+	if err != nil && !errors.Is(err, db.ErrNoEntries) {
+		err := fmt.Errorf("EmojisGet: db error: %s", err)
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	count := len(emojis)
+	if count == 0 {
+		return util.EmptyPageableResponse(), nil
+	}
+
+	items := make([]interface{}, 0, count)
+	for _, emoji := range emojis {
+		adminEmoji, err := p.tc.EmojiToAdminAPIEmoji(ctx, emoji)
+		if err != nil {
+			err := fmt.Errorf("EmojisGet: error converting emoji to admin model emoji: %s", err)
+			return nil, gtserror.NewErrorInternalError(err)
+		}
+		items = append(items, adminEmoji)
+	}
+
+	filterBuilder := strings.Builder{}
+	filterBuilder.WriteString("filter=")
+
+	switch domain {
+	case "", "local":
+		filterBuilder.WriteString("domain:local")
+	case db.EmojiAllDomains:
+		filterBuilder.WriteString("domain:all")
+	default:
+		filterBuilder.WriteString("domain:")
+		filterBuilder.WriteString(domain)
+	}
+
+	if includeDisabled != includeEnabled {
+		if includeDisabled {
+			filterBuilder.WriteString(",disabled")
+		}
+		if includeEnabled {
+			filterBuilder.WriteString(",enabled")
+		}
+	}
+
+	if shortcode != "" {
+		filterBuilder.WriteString(",shortcode:")
+		filterBuilder.WriteString(shortcode)
+	}
+
+	return util.PackagePageableResponse(util.PageableResponseParams{
+		Items:            items,
+		Path:             "api/v1/admin/custom_emojis",
+		NextMaxIDKey:     "max_shortcode_domain",
+		NextMaxIDValue:   shortcodeDomain(emojis[count-1]),
+		PrevMinIDKey:     "min_shortcode_domain",
+		PrevMinIDValue:   shortcodeDomain(emojis[0]),
+		Limit:            limit,
+		ExtraQueryParams: []string{filterBuilder.String()},
+	})
+}
+
+func shortcodeDomain(emoji *gtsmodel.Emoji) string {
+	return emoji.Shortcode + "@" + emoji.Domain
+}
diff --git a/internal/processing/media/getemoji.go b/internal/processing/media/getemoji.go
index ee33c25eb..83a75eb66 100644
--- a/internal/processing/media/getemoji.go
+++ b/internal/processing/media/getemoji.go
@@ -29,7 +29,7 @@ import (
 )
 
 func (p *processor) GetCustomEmojis(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode) {
-	emojis, err := p.db.GetCustomEmojis(ctx)
+	emojis, err := p.db.GetUseableEmojis(ctx)
 	if err != nil {
 		if err != db.ErrNoEntries {
 			return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error retrieving custom emojis: %s", err))
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index c76b1623b..b616511ea 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -112,6 +112,8 @@ type Processor interface {
 	AdminAccountAction(ctx context.Context, authed *oauth.Auth, form *apimodel.AdminAccountActionRequest) gtserror.WithCode
 	// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form.
 	AdminEmojiCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)
+	// AdminEmojisGet allows admins to view emojis based on various filters.
+	AdminEmojisGet(ctx context.Context, authed *oauth.Auth, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
 	// AdminDomainBlockCreate handles the creation of a new domain block by an admin, using the given form.
 	AdminDomainBlockCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode)
 	// AdminDomainBlocksImport handles the import of multiple domain blocks by an admin, using the given form.
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
index b1a771458..1ad7264ed 100644
--- a/internal/typeutils/converter.go
+++ b/internal/typeutils/converter.go
@@ -67,6 +67,8 @@ type TypeConverter interface {
 	MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (model.Mention, error)
 	// EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API.
 	EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (model.Emoji, error)
+	// EmojiToAdminAPIEmoji converts a gts model emoji into an API representation with extra admin information.
+	EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error)
 	// TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API.
 	TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error)
 	// StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API.
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 09bd5fc7d..7b4c3e8cc 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -363,6 +363,24 @@ func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (mod
 	}, nil
 }
 
+func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error) {
+	emoji, err := c.EmojiToAPIEmoji(ctx, e)
+	if err != nil {
+		return nil, err
+	}
+
+	return &model.AdminEmoji{
+		Emoji:         emoji,
+		ID:            e.ID,
+		Disabled:      *e.Disabled,
+		Domain:        e.Domain,
+		UpdatedAt:     util.FormatISO8601(e.UpdatedAt),
+		TotalFileSize: e.ImageFileSize + e.ImageStaticFileSize,
+		ContentType:   e.ImageContentType,
+		URI:           e.URI,
+	}, nil
+}
+
 func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error) {
 	return model.Tag{
 		Name: t.Name,
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 9dd8ed4e3..f2514e719 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -141,6 +141,36 @@ func (suite *InternalToFrontendTestSuite) TestInstanceToFrontendWithAdminAccount
 	suite.Equal(`{"uri":"https://example.org","title":"example instance","description":"a much longer description","short_description":"a little description","email":"someone@example.org","version":"software-from-hell 0.666","registrations":false,"approval_required":false,"invites_enabled":false,"thumbnail":"","contact_account":{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","username":"some_user","acct":"some_user@example.org","display_name":"some user","locked":true,"bot":false,"created_at":"2020-08-10T12:13:28.000Z","note":"i'm a real son of a gun","url":"http://example.org/@some_user","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":0,"last_status_at":"","emojis":[],"fields":[]},"max_toot_chars":0}`, string(b))
 }
 
+func (suite *InternalToFrontendTestSuite) TestEmojiToFrontend() {
+	emoji, err := suite.typeconverter.EmojiToAPIEmoji(context.Background(), suite.testEmojis["rainbow"])
+	suite.NoError(err)
+
+	b, err := json.Marshal(emoji)
+	suite.NoError(err)
+
+	suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}`, string(b))
+}
+
+func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin1() {
+	emoji, err := suite.typeconverter.EmojiToAdminAPIEmoji(context.Background(), suite.testEmojis["rainbow"])
+	suite.NoError(err)
+
+	b, err := json.Marshal(emoji)
+	suite.NoError(err)
+
+	suite.Equal(`{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"id":"01F8MH9H8E4VG3KDYJR9EGPXCQ","disabled":false,"updated_at":"2021-09-20T10:40:37.000Z","total_file_size":47115,"content_type":"image/png","uri":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ"}`, string(b))
+}
+
+func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin2() {
+	emoji, err := suite.typeconverter.EmojiToAdminAPIEmoji(context.Background(), suite.testEmojis["yell"])
+	suite.NoError(err)
+
+	b, err := json.Marshal(emoji)
+	suite.NoError(err)
+
+	suite.Equal(`{"shortcode":"yell","url":"http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/original/01GD5KP5CQEE1R3X43Y1EHS2CW.png","static_url":"http://localhost:8080/fileserver/01GD5KR15NHTY8FZ01CD4D08XP/emoji/static/01GD5KP5CQEE1R3X43Y1EHS2CW.png","visible_in_picker":false,"id":"01GD5KP5CQEE1R3X43Y1EHS2CW","disabled":false,"domain":"fossbros-anonymous.io","updated_at":"2020-03-18T12:12:00.000Z","total_file_size":21697,"content_type":"image/png","uri":"http://fossbros-anonymous.io/emoji/01GD5KP5CQEE1R3X43Y1EHS2CW"}`, string(b))
+}
+
 func TestInternalToFrontendTestSuite(t *testing.T) {
 	suite.Run(t, new(InternalToFrontendTestSuite))
 }