diff --git a/src/app.jsx b/src/app.jsx
index 8e39b0e0..c9b7c36a 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -27,6 +27,7 @@ import AccountStatuses from './pages/account-statuses';
 import Bookmarks from './pages/bookmarks';
 // import Catchup from './pages/catchup';
 import Favourites from './pages/favourites';
+import Filters from './pages/filters';
 import FollowedHashtags from './pages/followed-hashtags';
 import Following from './pages/following';
 import Hashtag from './pages/hashtag';
@@ -463,7 +464,8 @@ function SecondaryRoutes({ isLoggedIn }) {
             <Route index element={<Lists />} />
             <Route path=":id" element={<List />} />
           </Route>
-          <Route path="/ft" element={<FollowedHashtags />} />
+          <Route path="/fh" element={<FollowedHashtags />} />
+          <Route path="/ft" element={<Filters />} />
           <Route
             path="/catchup"
             element={
diff --git a/src/components/ICONS.jsx b/src/components/ICONS.jsx
index d2cb8edd..c0ad8bd4 100644
--- a/src/components/ICONS.jsx
+++ b/src/components/ICONS.jsx
@@ -78,6 +78,7 @@ export const ICONS = {
   refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
   emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
   filter: () => import('@iconify-icons/mingcute/filter-2-line'),
+  filters: () => import('@iconify-icons/mingcute/filter-line'),
   chart: () => import('@iconify-icons/mingcute/chart-line-line'),
   react: () => import('@iconify-icons/mingcute/react-line'),
   layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx
index aa6aa690..0d4fe98c 100644
--- a/src/components/nav-menu.jsx
+++ b/src/components/nav-menu.jsx
@@ -223,11 +223,15 @@ function NavMenu(props) {
                 <MenuLink to="/f">
                   <Icon icon="heart" size="l" /> <span>Likes</span>
                 </MenuLink>
-                <MenuLink to="/ft">
+                <MenuLink to="/fh">
                   <Icon icon="hashtag" size="l" />{' '}
                   <span>Followed Hashtags</span>
                 </MenuLink>
                 <MenuDivider />
+                <MenuLink to="/ft">
+                  <Icon icon="filters" size="l" />
+                  Filters
+                </MenuLink>
                 <MenuItem
                   onClick={() => {
                     states.showGenericAccounts = {
diff --git a/src/pages/filters.css b/src/pages/filters.css
new file mode 100644
index 00000000..43daff0a
--- /dev/null
+++ b/src/pages/filters.css
@@ -0,0 +1,149 @@
+#filters-page {
+  .filters-list {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+
+    li {
+      padding: 8px 16px;
+      border-bottom: var(--hairline-width) solid var(--outline-color);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    h2 {
+      font-weight: 500;
+      margin: 0;
+      padding: 0;
+      font-size: 1em;
+    }
+  }
+}
+
+#filters-add-edit-modal {
+  .filter-form-row {
+    margin-bottom: 16px;
+
+    + .filter-form-row {
+      margin-top: 16px;
+      border-top: 1px solid var(--outline-color);
+      padding-top: 16px;
+    }
+  }
+
+  main {
+    padding-top: 10px;
+    line-height: 1.5;
+
+    p {
+      margin-block: 1em;
+    }
+  }
+
+  label {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+
+  .filter-form-keywords {
+    margin: 0 -16px 16px;
+  }
+
+  .filter-form-cols {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 16px;
+    flex-wrap: wrap;
+
+    .filter-form-col {
+      flex-basis: 160px;
+      flex-grow: 1;
+
+      > *:first-child {
+        margin-top: 0;
+      }
+      > *:last-child {
+        margin-bottom: 0;
+      }
+    }
+  }
+
+  .filter-keywords {
+    --gap: 16px;
+    margin: 0;
+    padding: 0;
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: var(--gap);
+    padding: var(--gap);
+    overflow-y: auto;
+    min-height: 80px;
+    max-height: 25vh;
+    background-color: var(--bg-faded-blur-color);
+    counter-reset: index;
+    scroll-behavior: smooth;
+
+    li {
+      counter-increment: index;
+      display: flex;
+      gap: 4px;
+      align-items: center;
+      flex-wrap: wrap;
+
+      &:not(:only-child):before {
+        content: counter(index);
+        font-size: 10px;
+        color: var(--text-insignificant-color);
+        align-self: flex-start;
+      }
+
+      input[type='text'] {
+        flex-basis: 160px;
+        flex-grow: 100;
+      }
+
+      .filter-keyword-actions {
+        display: flex;
+        gap: 8px;
+        flex-grow: 1;
+        align-items: center;
+        justify-content: space-between;
+
+        label {
+          font-size: 0.8em;
+          line-height: 1;
+        }
+      }
+    }
+  }
+
+  .filter-keywords-footer {
+    padding: 8px 16px 0;
+    display: flex;
+    justify-content: space-between;
+  }
+
+  input[type='text'] {
+    display: block;
+    width: 100%;
+  }
+
+  .filter-form-footer {
+    display: flex;
+    gap: 16px;
+    justify-content: space-between;
+    align-items: center;
+
+    > span {
+      display: flex;
+      align-items: center;
+    }
+
+    button[type='submit'] {
+      padding-inline: 24px;
+    }
+  }
+}
diff --git a/src/pages/filters.jsx b/src/pages/filters.jsx
new file mode 100644
index 00000000..7d11152d
--- /dev/null
+++ b/src/pages/filters.jsx
@@ -0,0 +1,580 @@
+import './filters.css';
+
+import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
+
+import Icon from '../components/icon';
+import Link from '../components/link';
+import Loader from '../components/loader';
+import MenuConfirm from '../components/menu-confirm';
+import Modal from '../components/modal';
+import NavMenu from '../components/nav-menu';
+import RelativeTime from '../components/relative-time';
+import { api } from '../utils/api';
+import useInterval from '../utils/useInterval';
+import useTitle from '../utils/useTitle';
+
+const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
+const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
+const FILTER_CONTEXT_LABELS = {
+  home: 'Home and lists',
+  notifications: 'Notifications',
+  public: 'Public timelines',
+  thread: 'Conversations',
+  account: 'Profiles',
+};
+
+const EXPIRY_DURATIONS = [
+  0, // forever
+  30 * 60, // 30 minutes
+  60 * 60, // 1 hour
+  6 * 60 * 60, // 6 hours
+  12 * 60 * 60, // 12 hours
+  60 * 60 * 24, // 24 hours
+  60 * 60 * 24 * 7, // 7 days
+  60 * 60 * 24 * 30, // 30 days
+];
+const EXPIRY_DURATIONS_LABELS = {
+  0: 'Never',
+  1800: '30 minutes',
+  3600: '1 hour',
+  21600: '6 hours',
+  43200: '12 hours',
+  86_400: '24 hours',
+  604_800: '7 days',
+  2_592_000: '30 days',
+};
+
+function Filters() {
+  const { masto } = api();
+  useTitle(`Filters`, `/ft`);
+  const [uiState, setUIState] = useState('default');
+  const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
+
+  const [reloadCount, reload] = useReducer((c) => c + 1, 0);
+  const [filters, setFilters] = useState([]);
+  useEffect(() => {
+    setUIState('loading');
+    (async () => {
+      try {
+        const filters = await masto.v2.filters.list();
+        filters.sort((a, b) => a.title.localeCompare(b.title));
+        filters.forEach((filter) => {
+          if (filter.keywords?.length) {
+            filter.keywords.sort((a, b) => a.id - b.id);
+          }
+        });
+        console.log(filters);
+        setFilters(filters);
+        setUIState('default');
+      } catch (e) {
+        console.error(e);
+        setUIState('error');
+      }
+    })();
+  }, [reloadCount]);
+
+  return (
+    <div id="filters-page" class="deck-container" tabIndex="-1">
+      <div class="timeline-deck deck">
+        <header>
+          <div class="header-grid">
+            <div class="header-side">
+              <NavMenu />
+              <Link to="/" class="button plain">
+                <Icon icon="home" size="l" />
+              </Link>
+            </div>
+            <h1>Filters</h1>
+            <div class="header-side">
+              <button
+                type="button"
+                class="plain"
+                onClick={() => {
+                  setShowFiltersAddEditModal(true);
+                }}
+              >
+                <Icon icon="plus" size="l" alt="New filter" />
+              </button>
+            </div>
+          </div>
+        </header>
+        <main>
+          {filters.length > 0 ? (
+            <>
+              <ul class="filters-list">
+                {filters.map((filter) => {
+                  const { id, title, expiresAt, keywords } = filter;
+                  return (
+                    <li key={id}>
+                      <div>
+                        <h2>{title}</h2>
+                        {keywords?.length > 0 && (
+                          <div>
+                            {keywords.map((k) => (
+                              <>
+                                <span class="tag collapsed insignificant">
+                                  {k.wholeWord ? `“${k.keyword}”` : k.keyword}
+                                </span>{' '}
+                              </>
+                            ))}
+                          </div>
+                        )}
+                        <small class="insignificant">
+                          <ExpiryStatus expiresAt={expiresAt} />
+                        </small>
+                      </div>
+                      <button
+                        type="button"
+                        class="plain"
+                        onClick={() => {
+                          setShowFiltersAddEditModal({
+                            filter,
+                          });
+                        }}
+                      >
+                        <Icon icon="pencil" size="l" alt="Edit filter" />
+                      </button>
+                    </li>
+                  );
+                })}
+              </ul>
+              {filters.length > 1 && (
+                <footer class="ui-state">
+                  <small class="insignificant">
+                    {filters.length} filter
+                    {filters.length === 1 ? '' : 's'}
+                  </small>
+                </footer>
+              )}
+            </>
+          ) : uiState === 'loading' ? (
+            <p class="ui-state">
+              <Loader />
+            </p>
+          ) : uiState === 'error' ? (
+            <p class="ui-state">Unable to load filters.</p>
+          ) : (
+            <p class="ui-state">No filters yet.</p>
+          )}
+        </main>
+      </div>
+      {!!showFiltersAddEditModal && (
+        <Modal
+          title="Add filter"
+          onClose={() => {
+            setShowFiltersAddEditModal(false);
+          }}
+        >
+          <FiltersAddEdit
+            filter={showFiltersAddEditModal?.filter}
+            onClose={(result) => {
+              if (result.state === 'success') {
+                reload();
+              }
+              setShowFiltersAddEditModal(false);
+            }}
+          />
+        </Modal>
+      )}
+    </div>
+  );
+}
+
+function FiltersAddEdit({ filter, onClose }) {
+  const { masto } = api();
+  const [uiState, setUIState] = useState('default');
+  const editMode = !!filter;
+  const { context, expiresAt, id, keywords, title, filterAction } =
+    filter || {};
+  const hasExpiry = !!expiresAt;
+  const expiresAtDate = hasExpiry && new Date(expiresAt);
+  const [editKeywords, setEditKeywords] = useState(keywords || []);
+  const keywordsRef = useRef();
+
+  // Hacky way of handling removed keywords for both existing and new ones
+  const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
+  const [removedNewKeywordIndices, setRemovedNewKeywordIndices] = useState([]);
+
+  return (
+    <div class="sheet" id="filters-add-edit-modal">
+      {!!onClose && (
+        <button type="button" class="sheet-close" onClick={onClose}>
+          <Icon icon="x" />
+        </button>
+      )}
+      <header>
+        <h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
+      </header>
+      <main>
+        <form
+          onSubmit={(e) => {
+            e.preventDefault();
+            const formData = new FormData(e.target);
+            const title = formData.get('title');
+            const keywordIDs = formData.getAll('keyword_attributes[][id]');
+            const keywordKeywords = formData.getAll(
+              'keyword_attributes[][keyword]',
+            );
+            // const keywordWholeWords = formData.getAll(
+            //   'keyword_attributes[][whole_word]',
+            // );
+            // Not using getAll because it skips the empty checkboxes
+            const keywordWholeWords = [
+              ...keywordsRef.current.querySelectorAll(
+                'input[name="keyword_attributes[][whole_word]"]',
+              ),
+            ].map((i) => i.checked);
+            const keywordsAttributes = keywordKeywords.map((k, i) => ({
+              id: keywordIDs[i] || undefined,
+              keyword: k,
+              wholeWord: keywordWholeWords[i],
+            }));
+            // if (editMode && keywords?.length) {
+            //   // Find which one got deleted and add to keywordsAttributes
+            //   keywords.forEach((k) => {
+            //     if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
+            //       keywordsAttributes.push({
+            //         ...k,
+            //         _destroy: true,
+            //       });
+            //     }
+            //   });
+            // }
+            if (editMode && removedKeywordIDs?.length) {
+              removedKeywordIDs.forEach((id) => {
+                keywordsAttributes.push({
+                  id,
+                  _destroy: true,
+                });
+              });
+            }
+            const context = formData.getAll('context');
+            let expiresIn = formData.get('expires_in');
+            const filterAction = formData.get('filter_action');
+            console.log({
+              title,
+              keywordIDs,
+              keywords: keywordKeywords,
+              wholeWords: keywordWholeWords,
+              keywordsAttributes,
+              context,
+              expiresIn,
+              filterAction,
+            });
+
+            // Required fields
+            if (!title || !context?.length) {
+              return;
+            }
+
+            setUIState('loading');
+
+            (async () => {
+              try {
+                let filterResult;
+
+                if (editMode) {
+                  if (expiresIn === '' || expiresIn === null) {
+                    // No value
+                    // Preserve existing expiry if not specified
+                    // Seconds from now to expiresAtDate
+                    // Other clients don't do this
+                    expiresIn = Math.floor((expiresAtDate - new Date()) / 1000);
+                  } else if (expiresIn === '0' || expiresIn === 0) {
+                    // 0 = Never
+                    expiresIn = null;
+                  } else {
+                    expiresIn = +expiresIn;
+                  }
+                  filterResult = await masto.v2.filters.$select(id).update({
+                    title,
+                    context,
+                    expiresIn,
+                    keywordsAttributes,
+                    filterAction,
+                  });
+                } else {
+                  expiresIn = +expiresIn || null;
+                  filterResult = await masto.v2.filters.create({
+                    title,
+                    context,
+                    expiresIn,
+                    keywordsAttributes,
+                    filterAction,
+                  });
+                }
+                console.log({ filterResult });
+                setUIState('default');
+                onClose?.({
+                  state: 'success',
+                  filter: filterResult,
+                });
+              } catch (error) {
+                console.error(error);
+                setUIState('error');
+                alert(
+                  editMode
+                    ? 'Unable to edit filter'
+                    : 'Unable to create filter',
+                );
+              }
+            })();
+          }}
+        >
+          <div class="filter-form-row">
+            <label>
+              <b>Title</b>
+              <input
+                type="text"
+                name="title"
+                defaultValue={title}
+                disabled={uiState === 'loading'}
+                dir="auto"
+                required
+              />
+            </label>
+          </div>
+          <div class="filter-form-keywords" ref={keywordsRef}>
+            {editKeywords.length ? (
+              <ul class="filter-keywords">
+                {editKeywords.map((k, index) => {
+                  const { id, keyword, wholeWord } = k;
+                  const removed =
+                    removedKeywordIDs.includes(id) ||
+                    removedNewKeywordIndices.includes(index);
+                  if (removed) return null;
+                  return (
+                    <li key={`${index}-${id}`}>
+                      <input
+                        type="hidden"
+                        name="keyword_attributes[][id]"
+                        value={id}
+                      />
+                      <input
+                        name="keyword_attributes[][keyword]"
+                        type="text"
+                        defaultValue={keyword}
+                        disabled={uiState === 'loading'}
+                        required
+                      />
+                      <div class="filter-keyword-actions">
+                        <label>
+                          <input
+                            name="keyword_attributes[][whole_word]"
+                            type="checkbox"
+                            value={id} // Hacky way to map checkbox boolean to the keyword id
+                            defaultChecked={wholeWord}
+                            disabled={uiState === 'loading'}
+                          />{' '}
+                          Whole word
+                        </label>
+                        <button
+                          type="button"
+                          class="light danger small"
+                          disabled={uiState === 'loading'}
+                          onClick={() => {
+                            if (id) {
+                              removedKeywordIDs.push(id);
+                              setRemovedKeywordIDs([...removedKeywordIDs]);
+                            } else {
+                              // If no id, remove by index
+                              removedNewKeywordIndices.push(index);
+                              setRemovedNewKeywordIndices([
+                                ...removedNewKeywordIndices,
+                              ]);
+                            }
+                          }}
+                        >
+                          <Icon icon="x" />
+                        </button>
+                      </div>
+                    </li>
+                  );
+                })}
+              </ul>
+            ) : (
+              <div class="filter-keywords">
+                <div class="insignificant">No keywords. Add one.</div>
+              </div>
+            )}
+            <footer class="filter-keywords-footer">
+              <button
+                type="button"
+                class="light"
+                onClick={() => {
+                  setEditKeywords([
+                    ...editKeywords,
+                    {
+                      keyword: '',
+                      wholeWord: true,
+                    },
+                  ]);
+                  setTimeout(() => {
+                    // Focus last input
+                    const fields =
+                      keywordsRef.current.querySelectorAll(
+                        'input[type="text"]',
+                      );
+                    fields[fields.length - 1]?.focus?.();
+                  }, 10);
+                }}
+              >
+                Add keyword
+              </button>{' '}
+              {editKeywords?.length > 1 && (
+                <small class="insignificant">
+                  {editKeywords.length} keyword
+                  {editKeywords.length === 1 ? '' : 's'}
+                </small>
+              )}
+            </footer>
+          </div>
+          <div class="filter-form-cols">
+            <div class="filter-form-col">
+              <div>
+                <b>Filter from…</b>
+              </div>
+              {FILTER_CONTEXT.map((ctx) => (
+                <div>
+                  <label
+                    class={
+                      FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
+                        ? 'insignificant'
+                        : ''
+                    }
+                  >
+                    <input
+                      type="checkbox"
+                      name="context"
+                      value={ctx}
+                      defaultChecked={!!context ? context.includes(ctx) : true}
+                      disabled={uiState === 'loading'}
+                    />{' '}
+                    {FILTER_CONTEXT_LABELS[ctx]}
+                    {FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
+                  </label>{' '}
+                </div>
+              ))}
+              <p>
+                <small class="insignificant">* Not implemented yet</small>
+              </p>
+            </div>
+            <div class="filter-form-col">
+              {editMode && (
+                <>
+                  Status:{' '}
+                  <b>
+                    <ExpiryStatus expiresAt={expiresAt} showNeverExpires />
+                  </b>
+                </>
+              )}
+              <div>
+                <label for="filters-expires_in">
+                  {editMode ? 'Change expiry' : 'Expiry'}
+                </label>
+                <select
+                  id="filters-expires_in"
+                  name="expires_in"
+                  disabled={uiState === 'loading'}
+                  defaultValue={editMode ? undefined : 0}
+                >
+                  {editMode && <option></option>}
+                  {EXPIRY_DURATIONS.map((v) => (
+                    <option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
+                  ))}
+                </select>
+              </div>
+              <p>
+                Filtered post will be…
+                <br />
+                <label class="ib">
+                  <input
+                    type="radio"
+                    name="filter_action"
+                    value="warn"
+                    defaultChecked={filterAction === 'warn' || !editMode}
+                    disabled={uiState === 'loading'}
+                  />{' '}
+                  minimized
+                </label>{' '}
+                <label class="ib">
+                  <input
+                    type="radio"
+                    name="filter_action"
+                    value="hide"
+                    defaultChecked={filterAction === 'hide'}
+                    disabled={uiState === 'loading'}
+                  />{' '}
+                  hidden
+                </label>
+              </p>
+            </div>
+          </div>
+          <footer class="filter-form-footer">
+            <span>
+              <button type="submit" disabled={uiState === 'loading'}>
+                {editMode ? 'Save' : 'Create'}
+              </button>{' '}
+              <Loader abrupt hidden={uiState !== 'loading'} />
+            </span>
+            {editMode && (
+              <MenuConfirm
+                disabled={uiState === 'loading'}
+                align="end"
+                menuItemClassName="danger"
+                confirmLabel="Delete this filter?"
+                onClick={() => {
+                  setUIState('loading');
+                  (async () => {
+                    try {
+                      await masto.v2.filters.$select(id).remove();
+                      setUIState('default');
+                      onClose?.({
+                        state: 'success',
+                      });
+                    } catch (e) {
+                      console.error(e);
+                      setUIState('error');
+                      alert('Unable to delete filter.');
+                    }
+                  })();
+                }}
+              >
+                <button
+                  type="button"
+                  class="light danger"
+                  onClick={() => {}}
+                  disabled={uiState === 'loading'}
+                >
+                  Delete…
+                </button>
+              </MenuConfirm>
+            )}
+          </footer>
+        </form>
+      </main>
+    </div>
+  );
+}
+
+function ExpiryStatus({ expiresAt, showNeverExpires }) {
+  const hasExpiry = !!expiresAt;
+  const expiresAtDate = hasExpiry && new Date(expiresAt);
+  const expired = hasExpiry && expiresAtDate <= new Date();
+
+  // If less than a minute left, re-render interval every second, else every minute
+  const [_, rerender] = useReducer((c) => c + 1, 0);
+  useInterval(rerender, expired || 30_000);
+
+  return expired ? (
+    'Expired'
+  ) : hasExpiry ? (
+    <>
+      Expiring <RelativeTime datetime={expiresAtDate} />
+    </>
+  ) : (
+    showNeverExpires && 'Never expires'
+  );
+}
+
+export default Filters;
diff --git a/src/pages/followed-hashtags.jsx b/src/pages/followed-hashtags.jsx
index a36f9418..2465f181 100644
--- a/src/pages/followed-hashtags.jsx
+++ b/src/pages/followed-hashtags.jsx
@@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
 
 function FollowedHashtags() {
   const { masto, instance } = api();
-  useTitle(`Followed Hashtags`, `/ft`);
+  useTitle(`Followed Hashtags`, `/fh`);
   const [uiState, setUIState] = useState('default');
 
   const [followedHashtags, setFollowedHashtags] = useState([]);